From fc20f9bde75834e408e2ad770015df596a121ccc Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Wed, 20 May 2026 00:15:43 +0300 Subject: [PATCH 1/6] review: apply CODEBASE_REVIEW.md fixes across all 5 sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surgical and architectural fixes against the codebase review: Security - Gemini API key moved from URL query to x-goog-api-key header - ZipShareHelper.sanitizeFileName rejects ".." and leading dots - ArtworkTransportSanitizer enforces sourceBytesLimit before decode - WearCommandReceiver.openSongFile scheme guard (no File() for cloud URIs) - MusicService.shouldRejectWearController dead-return removed - MusicService.onTaskRemoved always calls super first - MusicService AudioDeviceCallback uses Main-looper Handler Concurrency - signatureMimeCache, codecInfoCache → ConcurrentHashMap - DualPlayerEngine listener lists → CopyOnWriteArrayList - ThemeStateHolder individualAlbumColorSchemes LRU guarded by lock - ArtistImageRepository pendingFetches/failedFetches → concurrent sets Data layer - SyncWorker: 64-bit FNV-1a hash for synthetic Telegram IDs - SyncWorker: exponential backoff + Result.retry for transient failures - M3uManager: UTF-8 charset, BOM strip, 1M-line cap - DailyMixStateHolder: LocalDate compare (DAY_OF_YEAR boundary bug) - MIGRATION_16_17: differentiate duplicate-column from real failures Media stack - MediaFileHttpServerService: latch.await tightened 10min → 2min - DualPlayerEngine: pauseAtEndOfMediaItems applied to both players - DualPlayerEngine: resolvedUriCache gets 15-min TTL Architecture — DataStore split - New playbackStore (separate Preferences DataStore) - 11 playback keys end-to-end migrated with consumer reads + dual-write on setters: persistent_shuffle_enabled, is_shuffle_on, repeat_mode, is_crossfade_enabled, crossfade_duration, hi_fi_mode_enabled, global_transition_settings, playback_queue_snapshot, keep_playing_in_background, replaygain_enabled, replaygain_use_album_gain, disable_cast_autoplay Architecture — Singleton lifecycle - 8 of 9 Singleton StateHolders inject @AppScope: AiStateHolder, SearchStateHolder, LibraryStateHolder, LyricsStateHolder, CastStateHolder, CastTransferStateHolder, SleepTimerStateHolder, ConnectivityStateHolder, MusicRepositoryImpl Architecture — PlayerViewModel decomposition (first slices) - hasGeminiApiKey, hasActiveAiProviderApiKey, AiUiSnapshot flows extracted to AiStateHolder - observeSong cached per-songId Architecture — LibraryScreen extraction - 8 files extracted to presentation/screens/library/: WatchTransferProgressDialog, LibrarySyncIndicators, FolderItems, FolderSortHelpers, LibraryTabGridItem, ArtistListItem, AlbumListItem, AlbumGridItemRedesigned - LibraryScreen.kt: 3,730 → 2,831 lines (-24%) Compose stability - PlaylistArtCollage / PlaylistCover / SearchResultPlaylistItem: List → ImmutableList - LibraryScreen.previewSongs → toPersistentList - MarqueeText LaunchedEffect keyed on text - SmartImage allowHardware=true default - Theme.kt SideEffect → LaunchedEffect gated on icon-mode - EditSongSheet derivedStateOf keyed on density - HomeScreen rotationIndex hoisted out of conditional Build / deps / CI - lint.checkReleaseBuilds=true with abortOnError=false - ABI splits include x86_64 - libs.versions.toml: removed unused deps (pytorch, tensorflow-lite, spleeter, compose-dnd, duktape, google-genai); consolidated duplicate version keys (accompanist, junitJupiter, mediarouter) Testing - Robolectric infra: robolectric:4.14 + isIncludeAndroidResources=true - New test files (~100 cases): ZipShareHelperSanitizationTest, ArtworkTransportSanitizerTest, SyncWorkerHashTest, FileDeletionUtilsTest, FolderSortHelpersTest, AudioSignatureDetectionTest, CastSessionSecurityTest expansions, CastHttpRouteAuthTest, WearPlaybackCommandFuzzTest, CrashHandlerRobolectricTest, MusicServiceConstantsRobolectricTest - REFACTOR_NOTES.md documents remaining architectural work Wear OS - backup_rules.xml + data_extraction_rules.xml added - @AndroidEntryPoint guards --- REFACTOR_NOTES.md | 247 +++++ app/build.gradle.kts | 31 +- .../data/ai/provider/GeminiAiClient.kt | 12 +- .../pixelplay/data/database/MusicDao.kt | 29 +- .../data/database/PixelPlayDatabase.kt | 45 +- .../pixelplay/data/gdrive/GDriveConstants.kt | 12 + .../data/paging/MediaStorePagingSource.kt | 13 +- .../pixelplay/data/playlist/M3uManager.kt | 30 +- .../preferences/UserPreferencesRepository.kt | 215 +++- .../data/repository/ArtistImageRepository.kt | 13 +- .../data/repository/LyricsRepositoryImpl.kt | 32 +- .../data/repository/MusicRepositoryImpl.kt | 23 +- .../pixelplay/data/service/MusicService.kt | 23 +- .../data/service/auto/AutoMediaBrowseTree.kt | 29 +- .../data/service/cast/CastOptionsProvider.kt | 5 + .../service/http/AudioSignatureDetection.kt | 117 +++ .../http/MediaFileHttpServerService.kt | 138 +-- .../data/service/player/CastPlayer.kt | 69 +- .../data/service/player/DualPlayerEngine.kt | 43 +- .../data/service/wear/WearCommandReceiver.kt | 20 +- .../data/telegram/TelegramClientManager.kt | 8 +- .../pixelplay/data/worker/SyncWorker.kt | 106 +- .../com/theveloper/pixelplay/di/AppModule.kt | 30 +- .../com/theveloper/pixelplay/di/Qualifiers.kt | 19 + .../presentation/components/EditSongSheet.kt | 7 +- .../presentation/components/MarqueeText.kt | 7 +- .../components/PlaylistArtCollage.kt | 3 +- .../components/PlaylistContainer.kt | 8 +- .../presentation/components/PlaylistCover.kt | 2 +- .../presentation/components/SmartImage.kt | 6 +- .../presentation/screens/HomeScreen.kt | 12 +- .../presentation/screens/LibraryMediaTabs.kt | 3 + .../presentation/screens/LibraryScreen.kt | 991 +----------------- .../presentation/screens/LibrarySongsTab.kt | 7 +- .../presentation/screens/SearchScreen.kt | 8 +- .../library/AlbumGridItemRedesigned.kt | 258 +++++ .../screens/library/AlbumListItem.kt | 270 +++++ .../screens/library/ArtistListItem.kt | 112 ++ .../screens/library/FolderItems.kt | 109 ++ .../screens/library/FolderSortHelpers.kt | 73 ++ .../screens/library/LibrarySyncIndicators.kt | 195 ++++ .../screens/library/LibraryTabGridItem.kt | 96 ++ .../library/WatchTransferProgressDialog.kt | 175 ++++ .../presentation/viewmodel/AiStateHolder.kt | 83 +- .../viewmodel/ArtistDetailViewModel.kt | 14 +- .../presentation/viewmodel/CastStateHolder.kt | 22 +- .../viewmodel/CastTransferStateHolder.kt | 28 +- .../viewmodel/ConnectivityStateHolder.kt | 23 +- .../viewmodel/DailyMixStateHolder.kt | 15 +- .../viewmodel/LibraryStateHolder.kt | 81 +- .../viewmodel/LyricsStateHolder.kt | 37 +- .../presentation/viewmodel/MainViewModel.kt | 17 +- .../viewmodel/MultiSelectionStateHolder.kt | 37 +- .../presentation/viewmodel/PlayerUiState.kt | 4 +- .../presentation/viewmodel/PlayerViewModel.kt | 146 ++- .../viewmodel/PlaylistSelectionStateHolder.kt | 36 +- .../viewmodel/SearchStateHolder.kt | 54 +- .../viewmodel/SleepTimerStateHolder.kt | 17 +- .../viewmodel/ThemeStateHolder.kt | 49 +- .../pixelplay/ui/glancewidget/WidgetUtils.kt | 17 +- .../pixelplay/ui/theme/ColorRoles.kt | 7 +- .../theveloper/pixelplay/ui/theme/Theme.kt | 12 +- .../utils/ArtworkTransportSanitizer.kt | 4 + .../pixelplay/utils/ZipShareHelper.kt | 28 +- .../UserPreferencesRepositoryTest.kt | 21 +- .../repository/MusicRepositoryImplTest.kt | 5 +- .../MusicServiceConstantsRobolectricTest.kt | 42 + .../http/AudioSignatureDetectionTest.kt | 162 +++ .../service/http/CastHttpRouteAuthTest.kt | 190 ++++ .../service/http/CastSessionSecurityTest.kt | 87 ++ .../wear/WearPlaybackCommandFuzzTest.kt | Bin 0 -> 5715 bytes .../data/worker/SyncWorkerHashTest.kt | 94 ++ .../screens/library/FolderSortHelpersTest.kt | 161 +++ .../viewmodel/LyricsStateHolderTest.kt | 5 +- .../utils/ArtworkTransportSanitizerTest.kt | 75 ++ .../utils/CrashHandlerRobolectricTest.kt | 117 +++ .../pixelplay/utils/FileDeletionUtilsTest.kt | 67 ++ .../utils/ZipShareHelperSanitizationTest.kt | 104 ++ gradle/libs.versions.toml | 50 +- wear/src/main/AndroidManifest.xml | 2 + wear/src/main/res/xml/wear_backup_rules.xml | 20 + .../res/xml/wear_data_extraction_rules.xml | 37 + 82 files changed, 4081 insertions(+), 1540 deletions(-) create mode 100644 REFACTOR_NOTES.md create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/service/http/AudioSignatureDetection.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/AlbumGridItemRedesigned.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/AlbumListItem.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/ArtistListItem.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/FolderItems.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/FolderSortHelpers.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/LibrarySyncIndicators.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/LibraryTabGridItem.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/WatchTransferProgressDialog.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/MusicServiceConstantsRobolectricTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/http/AudioSignatureDetectionTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/http/CastHttpRouteAuthTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/service/wear/WearPlaybackCommandFuzzTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/worker/SyncWorkerHashTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/presentation/screens/library/FolderSortHelpersTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/utils/ArtworkTransportSanitizerTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/utils/CrashHandlerRobolectricTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/utils/FileDeletionUtilsTest.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/utils/ZipShareHelperSanitizationTest.kt create mode 100644 wear/src/main/res/xml/wear_backup_rules.xml create mode 100644 wear/src/main/res/xml/wear_data_extraction_rules.xml diff --git a/REFACTOR_NOTES.md b/REFACTOR_NOTES.md new file mode 100644 index 000000000..94bff09a5 --- /dev/null +++ b/REFACTOR_NOTES.md @@ -0,0 +1,247 @@ +# Outstanding architectural refactors + +Tracks the multi-PR architectural items surfaced by the +`CODEBASE_REVIEW.md` audit that are too large to land surgically. Each +entry includes scope, blast radius, the target file structure, and a +sequenced plan so a follow-up session can pick up safely. Surgical +sub-tasks that were already landed are noted under the "delivered" +bullet of each section. + +## 1. PlayerViewModel decomposition + +**Status:** Not started. The file is currently ~5,100 lines and mixes +seven cohesive but distinct concerns. + +**Target structure (under `presentation/viewmodel/player/`):** + +1. `PlaybackController` — owns `MediaController` lifecycle, `Player.Listener`, + `playPause`/`seekTo`/`nextSong`/`previousSong`/`toggleShuffle`/ + `cycleRepeatMode`/`playSongs`/`playSongsShuffled`/`playExternalUri`/ + `loadAndPlaySong`/`playAlbum`/`playArtist`/`buildResolvedPlaybackMediaItem`, + plus `updateCurrentPlaybackQueueFromPlayer` / + `refreshPlaybackAudioMetadata`. Roughly 1,500 lines. +2. `CastController` — Cast routes, sessions, transfer back, remote queue + alignment, route volume, route discovery callbacks, `castSongUiSyncJob`. + Roughly 700 lines. +3. `LibraryFacade` (or just inject `LibraryStateHolder` directly into + `LibraryScreen` and friends) — library tab navigation, sort options, + folder navigation, storage filter, daily mix triggers. Roughly 700 lines. +4. `AiPlaylistController` — sheet visibility, generation triggers, + `hasActiveAiProviderApiKey` combine. Roughly 400 lines. +5. `LyricsAndMetadataController` — lyrics callbacks, sync offset, manual + search, metadata edit, write-permission flow, delete-permission flow. + Roughly 800 lines. +6. The residual `PlayerViewModel` should be ~600–800 lines and own: + sheet visibility/expansion, predictive-back fractions, queue-source + name, toast event flow, and routing to the controllers above. + +**Sequence (safe, incremental):** + +- Step 1 — extract `LibraryFacade` first, because most callers are + already injecting `LibraryStateHolder` and the facade is a thin + delegate. Replace `playerViewModel.libraryStateHolder.foo` with + direct `libraryStateHolder.foo` at call sites. +- Step 2 — move the Cast wiring (700 lines) into a `CastController` + injected into `PlayerViewModel`. No screens currently depend on + PlayerViewModel for Cast except `CastBottomSheet`. +- Step 3 — extract `LyricsAndMetadataController`. Permission flows + require coordination between Activity (for `IntentSender`) and VM — + keep a thin permission-bridge interface to avoid `Activity` leaks. +- Step 4 — extract `AiPlaylistController`. Already mostly delegated to + `AiStateHolder`; this step removes the API-key combine duplication. +- Step 5 — extract `PlaybackController` last; it is the largest and + the most central. + +**Blast radius:** every screen that injects `PlayerViewModel` (~30 +composables). Tests: every `*ViewModelTest` plus `PlaybackStateHolderTest`. + +**Delivered surgically so far:** flow consolidation +(`fullPlayerSlice`/`playerConfigSlice`), `currentSongArtists` typed as +`ImmutableList`, `imageLoader` hoist, `resolveSelectedAlbumSongs` +parallelized, `EotStateHolder` interaction documented. + +## 2. LibraryScreen extraction + +**Status:** First extraction landed. +`WatchTransferProgressDialog` moved to +`presentation/screens/library/WatchTransferProgressDialog.kt`. The +file is still ~3,600 lines. + +**Target structure (continue under `presentation/screens/library/`):** + +1. `WatchTransferProgressDialog.kt` — **landed.** +2. `LibraryNavigationPill.kt` — `LibraryNavigationPill` + + `LibraryTabSwitcherSheet` + `LibraryTabGridItem` + + `rememberLibraryNavigationPillTitleStyle`. ~430 lines. +3. `LibraryFoldersTab.kt` — `LibraryFoldersTab` + `FolderPlaylistItem` + + `FolderListItem` + `flattenFolders`/`sortMusicFoldersByOption`/ + `sortSongsForFolderView`/`collectAllSongs` + + `isDescendantFolderPath`. ~425 lines. +4. `LibraryAlbumItems.kt` — `AlbumGridItemRedesigned`/`AlbumListItem`/ + `ArtistListItem`. ~490 lines. +5. `LibrarySyncIndicators.kt` — `LibrarySyncOverlay` + + `LibraryInlineSyncIndicator` + `CompactLibraryPagerIndicator`. + ~150 lines. +6. `rememberLibrarySelectionState.kt` — the ~200-line multi-selection + wiring block from the screen body. +7. Per-tab `LibrarySongsTabPage` / `LibraryAlbumsTabPage` etc. so the + `HorizontalPager` `when (tabId)` block shrinks to ~50 lines. + +**Sequence:** items above in order. Each step compiles independently +and the screen file shrinks monotonically. + +**Estimated total reduction:** ~1,700 lines moved out, leaving the +screen file around ~2,000 lines focused on `Scaffold`/`TopBar`/sheets/ +pager wiring. + +## 3. DataStore split + +**Status:** Partial. Three sibling repositories exist — +`ThemePreferencesRepository`, `EqualizerPreferencesRepository`, +`PlaylistPreferencesRepository` — but all of them and the AI repo +still share `Context.dataStore by preferencesDataStore(name = "settings")`. +117 keys live in one file. Every write to any key fires re-evaluation +on every flow subscribed to any other key. + +**Target structure (under `data/preferences/`):** + +- `theme.preferences_pb` — already separated logically; needs a + dedicated DataStore file +- `playback.preferences_pb` — sleep timer prefs, cross-fade prefs, + transition prefs, shuffle/repeat persistence +- `library.preferences_pb` — sort options, last-storage-filter, hide- + local-media, library tab order +- `equalizer.preferences_pb` — already separated logically +- `ai.preferences_pb` — non-secret AI prefs (provider, model, prompt, + safe-token-limit); secrets stay in EncryptedSharedPreferences +- `dev.preferences_pb` — feature flags, debug toggles +- `settings.preferences_pb` — true "general settings" that don't fit + the above (locale, theme mode etc.) + +**Sequence (one domain at a time, safe migration):** + +For each domain: +1. Add `Context.DataStore by preferencesDataStore(name = "")`. +2. Add an `@Named("")` qualifier so DI doesn't collide on + `DataStore`. +3. In the repository, read all old keys from the legacy store on first + launch, write into the new store, then remove from the legacy + store. Mark migration done via a one-time flag in the new store. +4. Update all flows in the repository to read/write the new store. +5. Verify no other repository references those keys via the legacy + store name. + +**Blast radius:** all StateHolders / ViewModels that collect prefs +flows. Tests: `*PreferencesRepositoryTest` and instrumentation tests +that cover real DataStore migration. + +**Delivered surgically so far:** AI API keys moved to +EncryptedSharedPreferences with one-time migration from legacy +DataStore. `LyricsRepositoryImpl` and `LibraryStateHolder` now batch +their multi-flow reads via `awaitAll` so the cold-flow startup cost +overlaps instead of stacking sequentially. + +## 4. Singleton StateHolder lifecycle reanchoring + +**Status:** Partial. 9 singleton state holders take a `scope` parameter +via `initialize(scope: CoroutineScope)` from `PlayerViewModel.init`, +and unregister system callbacks in `onCleared()`. On any process +recreation where a new `PlayerViewModel` is instantiated against the +same Application singleton, there is a window between +`onCleared` (sets `isInitialized = false`) and the next `initialize` +where the holder is in a deinitialized state, and a stale `scope` +field can be used by subsequent ProcessLifecycleOwner callbacks. + +Affected: +- `ConnectivityStateHolder` +- `CastTransferStateHolder` +- `CastStateHolder` +- `SearchStateHolder` +- `AiStateHolder` +- `LibraryStateHolder` +- `SleepTimerStateHolder` +- `QueueUndoStateHolder` +- `PlaylistDismissUndoStateHolder` + +**Target:** Each holder injects `@AppScope` directly and uses it as +the primary scope. `initialize(scope)` is replaced with +`bind(callbacks)` which only wires up callbacks/listeners but uses +the always-alive `@AppScope` for `launch`. `onCleared` becomes +optional and only un-binds callbacks. + +**Sequence:** + +1. Migrate the easiest first: `SearchStateHolder`, `AiStateHolder`, + `QueueUndoStateHolder`, `PlaylistDismissUndoStateHolder` — these + already only `launch` into the captured scope and have no system + listeners. Switch their captured scope to `@AppScope`. +2. `LibraryStateHolder` — same pattern, but it has flow collectors + (`startObservingLibraryData`); make sure those are cancellable + via a controller-scope `Job` even though the parent scope lives + for the app. +3. `ConnectivityStateHolder`, `CastStateHolder`, `CastTransferStateHolder`, + `SleepTimerStateHolder` — these register system callbacks. Move + registration into `initialize()` but use `@AppScope` for any + `.launch{}` calls and add a kill-switch flow so `onCleared` can + pause without truly unregistering. + +**Blast radius:** every singleton holder + every test that mocks them. + +**Delivered surgically so far:** `MusicRepositoryImpl` switched from a +private `CoroutineScope(Dispatchers.IO + SupervisorJob())` to use +`@AppScope`. System-service lookups in `ConnectivityStateHolder`, +`SleepTimerStateHolder`, and `CastStateHolder` are now `by lazy` so +they don't run on the first-frame critical path during singleton-graph +construction. + +## 5. Test coverage expansion + +**Status:** Partial. New tests added: + +- `ZipShareHelperSanitizationTest` — 13 cases covering path-traversal, + leading-dot defang, length cap. +- `ArtworkTransportSanitizerTest` — oversized-input rejection, null/ + empty short-circuits, config sanity. +- `SyncWorkerHashTest` — FNV-1a determinism, avalanche, and + zero-collision check on a 5000-input corpus. +- `FileDeletionUtilsTest` — `canDeleteFile` and `getFileInfo` paths + exercising real files via JUnit `TemporaryFolder`. + +Still missing per the review: + +- `MusicService` unit tests — MediaSession callbacks, foreground-service + lifecycle, sleep timer integration, Cast switching, Wear command + handling. Requires Robolectric or instrumentation; service has + ~4,500 lines and 35+ DI dependencies. +- `MediaFileHttpServerService` HTTP-route tests — Ktor `testApplication` + block for `/song/`, `/art/`, auth-token validation, Range + header parsing. Estimated 200-400 lines of test code. +- `WearCommandReceiver` JSON-fuzz tests — malformed `MessageEvent` + payloads, missing fields, type mismatches. +- Turbine-based StateFlow emission tests for `PlayerViewModel`, + `PlaybackStateHolder`, `LyricsStateHolder`, `ThemeStateHolder`. +- Compose UI tests for recomposition counts via `composeTestRule`. + Per `app/performance_analysis.md`, recomposition counts are a + critical performance metric and there are zero UI tests today. + +## 6. CastBottomSheet flow consolidation + +**Status:** Per-frame allocations already wrapped in `remember()` in +the recent perf commit. Full consolidation into a +`CastBottomSheetSlice` flow requires combining across 3 different +StateHolders (cast/connectivity/playback). Kotlin's `combine()` only +supports up to 5 args so the combine would need to be nested, which +the original review flagged as a smell. Plausible target: move the +13 fields into `CastTransferStateHolder` as a single derived flow, +since most of them are already fed from there. + +## 7. HTTP server self-signed HTTPS + +**Status:** Not started. Token theft from sniffing Cast traffic +(which runs in plaintext to the Default Media Receiver) remains +mitigated only by the IP allowlist + auth-token. A configurable +HTTPS option with a per-session self-signed cert and a pin in +`MediaInfo` would eliminate the LAN sniffing risk. Out of scope for +surgical fixes because the receiver-side cert pinning is unsupported +by the Default Media Receiver, so this needs a custom Cast receiver +app first. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d29493347..29d024736 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -66,6 +66,21 @@ android { versionName = (project.findProperty("APP_VERSION_NAME") as? String) ?: "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + // Telegram TDLib credentials are externalized via BuildConfig so the + // built APK can carry credentials specific to this app rather than + // being hardcoded to Telegram Desktop's public credentials (which + // violates Telegram TOS and can get user accounts flagged). Override + // by setting TELEGRAM_API_ID / TELEGRAM_API_HASH in local.properties. + // The fallback values keep the build working for OSS contributors. + val telegramApiId = (project.findProperty("TELEGRAM_API_ID") as? String) + ?: keystoreProperties.getProperty("TELEGRAM_API_ID") + ?: "2040" + val telegramApiHash = (project.findProperty("TELEGRAM_API_HASH") as? String) + ?: keystoreProperties.getProperty("TELEGRAM_API_HASH") + ?: "b18441a1ff607e10a989891a5462e627" + buildConfigField("int", "TELEGRAM_API_ID", telegramApiId) + buildConfigField("String", "TELEGRAM_API_HASH", "\"$telegramApiHash\"") } signingConfigs { @@ -112,11 +127,16 @@ android { testOptions { unitTests.isReturnDefaultValues = true + unitTests.isIncludeAndroidResources = true // Needed by Robolectric unitTests.all { it.useJUnitPlatform() } } lint { - checkReleaseBuilds = false + // Run lint on release so NewApi / MissingPermission / LeakedClosure + // are surfaced, but never block a build — release CI surfaces these + // as warnings rather than failing the assemble. + checkReleaseBuilds = true + abortOnError = false } splits { @@ -124,7 +144,10 @@ android { isEnable = enableAbiSplits reset() if (enableAbiSplits) { - include("arm64-v8a", "armeabi-v7a") + // x86_64 is required for many Chromebook installs and the + // Android Studio emulator. arm64-v8a / armeabi-v7a are the + // primary phone targets. + include("arm64-v8a", "armeabi-v7a", "x86_64") isUniversalApk = false } } @@ -298,6 +321,10 @@ dependencies { testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.junit) testImplementation(libs.androidx.room.testing) + testImplementation(libs.ktor.server.test.host) + // Robolectric for Android-component unit tests (MediaSession callbacks, + // FileProvider, Context-bound helpers) without an emulator. + testImplementation(libs.robolectric) testImplementation(kotlin("test")) // Testing (Instrumentation) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt index 51116de0e..7e8fa0317 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/ai/provider/GeminiAiClient.kt @@ -72,16 +72,20 @@ class GeminiAiClient(private val apiKey: String) : AiClient { } override suspend fun getAvailableModels(apiKey: String): List { - // Models are usually fetched via HTTP as the SDK doesn't expose a listing method + // Models are usually fetched via HTTP as the SDK doesn't expose a listing method. + // The API key is sent via the x-goog-api-key header instead of as a URL query + // parameter so it cannot leak to HTTP logs, MITM proxies, or + // okhttp3.HttpLoggingInterceptor traces. return withContext(Dispatchers.IO) { try { - val url = "https://generativelanguage.googleapis.com/v1beta/models?key=$apiKey" + val url = "https://generativelanguage.googleapis.com/v1beta/models" val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection - + connection.requestMethod = "GET" + connection.setRequestProperty("x-goog-api-key", apiKey) connection.connectTimeout = 10000 connection.readTimeout = 10000 - + val responseCode = connection.responseCode if (responseCode == 200) { val response = connection.inputStream.bufferedReader().use { it.readText() } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt index 55a850cf0..5c7310c7d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/MusicDao.kt @@ -15,10 +15,15 @@ import kotlinx.coroutines.flow.combine private val SONG_SEARCH_QUERY_TOKEN_REGEX = Regex("""[\p{L}\p{N}]+""") private const val EMPTY_SONG_SEARCH_MATCH_QUERY = "pixelplayemptyquery*" +// Defensive cap on individual token length. Without a cap, the FTS query +// builder can be fed multi-kilobyte pasted strings that turn the search +// into an effectively unbounded SQLite scan. +private const val MAX_FTS_TOKEN_LENGTH = 64 + private fun buildSongTitleSearchMatchQuery(query: String): String { val tokens = SONG_SEARCH_QUERY_TOKEN_REGEX .findAll(query) - .map { it.value.trim() } + .map { it.value.trim().take(MAX_FTS_TOKEN_LENGTH) } .filter { it.isNotEmpty() } .take(6) .toList() @@ -31,7 +36,7 @@ private fun buildSongTitleSearchMatchQuery(query: String): String { private fun buildSongSearchMatchQuery(query: String): String { val tokens = SONG_SEARCH_QUERY_TOKEN_REGEX .findAll(query) - .map { it.value.trim() } + .map { it.value.trim().take(MAX_FTS_TOKEN_LENGTH) } .filter { it.isNotEmpty() } .take(6) .toList() @@ -1832,13 +1837,27 @@ interface MusicDao { companion object { /** - * SQLite has a limit on the number of variables per statement (default 999, higher in newer versions). + * SQLite per-statement variable limit. Android's bundled SQLite raised + * this to 32766 from API 31 onward (Room 2.6+). We pick the larger + * value when the runtime supports it so cross-ref inserts use far + * fewer chunks during initial sync (~33x fewer transactions on large + * libraries). + * * Each SongArtistCrossRef insert uses 3 variables (songId, artistId, isPrimary). - * The batch size is calculated so that batchSize * 3 <= SQLITE_MAX_VARIABLE_NUMBER. */ - private const val SQLITE_MAX_VARIABLE_NUMBER = 999 // Increase if you know your SQLite version supports more + private const val SQLITE_MAX_VARIABLE_NUMBER_LEGACY = 999 + private const val SQLITE_MAX_VARIABLE_NUMBER_MODERN = 32_000 + private val SQLITE_MAX_VARIABLE_NUMBER: Int = + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + SQLITE_MAX_VARIABLE_NUMBER_MODERN + } else { + SQLITE_MAX_VARIABLE_NUMBER_LEGACY + } private const val CROSS_REF_FIELDS_PER_OBJECT = 3 val CROSS_REF_BATCH_SIZE: Int = SQLITE_MAX_VARIABLE_NUMBER / CROSS_REF_FIELDS_PER_OBJECT + // Single-column `IN (…)` deletions only consume one variable per row, + // so the chunk size for those can be ~3x larger than the cross-ref insert. + val DELETE_IN_BATCH_SIZE: Int = SQLITE_MAX_VARIABLE_NUMBER /** * Batch size for song inserts during incremental sync. diff --git a/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt b/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt index d5e0df383..e8cda15d5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/database/PixelPlayDatabase.kt @@ -219,16 +219,19 @@ abstract class PixelPlayDatabase : RoomDatabase() { val MIGRATION_16_17 = object : Migration(16, 17) { override fun migrate(db: SupportSQLiteDatabase) { - try { - db.execSQL("ALTER TABLE songs ADD COLUMN telegram_chat_id INTEGER DEFAULT NULL") - } catch (e: Exception) { - // Column might already exist - } - try { - db.execSQL("ALTER TABLE songs ADD COLUMN telegram_file_id INTEGER DEFAULT NULL") - } catch (e: Exception) { - // Column might already exist - } + // SQLite signals "column already exists" as `SQLiteException` with + // message "duplicate column name". Any other ALTER failure is a + // real schema problem — let it propagate so Room sees the + // migration as failed rather than silently shipping a missing + // column that later crashes every query. + addColumnIgnoringDuplicate( + db, + "ALTER TABLE songs ADD COLUMN telegram_chat_id INTEGER DEFAULT NULL" + ) + addColumnIgnoringDuplicate( + db, + "ALTER TABLE songs ADD COLUMN telegram_file_id INTEGER DEFAULT NULL" + ) // Fix for album_art_themes schema mismatch if user is coming from version 16 (where the schema might be broken) // We re-apply the DROP and RECREATE strategy here to ensure everyone ends up with the correct schema. @@ -769,6 +772,28 @@ abstract class PixelPlayDatabase : RoomDatabase() { } } + /** + * Execute an `ALTER TABLE … ADD COLUMN` statement, swallowing only + * the "duplicate column name" failure SQLite raises when the column + * already exists on the table. Any other SQL error propagates so + * Room marks the migration as failed instead of shipping a missing + * column that crashes later queries. + */ + private fun addColumnIgnoringDuplicate( + db: SupportSQLiteDatabase, + statement: String + ) { + try { + db.execSQL(statement) + } catch (e: android.database.SQLException) { + val msg = e.message?.lowercase().orEmpty() + if ("duplicate column name" in msg || "already exists" in msg) { + return + } + throw e + } + } + private fun recreateSongsTable(db: SupportSQLiteDatabase) { val songsTableExists = tableExists(db, "songs") val columns = if (songsTableExists) getTableColumns(db, "songs") else emptySet() diff --git a/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveConstants.kt b/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveConstants.kt index 32f1a9573..34da185f5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveConstants.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/gdrive/GDriveConstants.kt @@ -4,6 +4,18 @@ object GDriveConstants { // TODO: Replace with your Google Cloud Console OAuth2 Web Client ID const val WEB_CLIENT_ID = "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com" + // Principle of least privilege: request `drive.file` (only files the + // user explicitly opens via the Picker or files the app itself created) + // instead of `drive.readonly` (read access to the entire Drive). The + // music-folder use case is satisfied by a user-picked folder under + // drive.file. NOTE: the actual scope granted is decided by the OAuth + // configuration on the Web Client (above); this constant documents the + // intent and is what the in-app authorization flow should request. + const val SCOPE_DRIVE_FILE = "https://www.googleapis.com/auth/drive.file" + @Deprecated( + "Use SCOPE_DRIVE_FILE — drive.readonly grants access to the user's entire Drive.", + replaceWith = ReplaceWith("SCOPE_DRIVE_FILE") + ) const val SCOPE_DRIVE_READONLY = "https://www.googleapis.com/auth/drive.readonly" const val TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token" const val DRIVE_API_BASE = "https://www.googleapis.com/drive/v3" diff --git a/app/src/main/java/com/theveloper/pixelplay/data/paging/MediaStorePagingSource.kt b/app/src/main/java/com/theveloper/pixelplay/data/paging/MediaStorePagingSource.kt index 2fda86c0b..2ffb0190e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/paging/MediaStorePagingSource.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/paging/MediaStorePagingSource.kt @@ -86,8 +86,15 @@ class MediaStorePagingSource( val songs = mutableListOf() if (ids.isEmpty()) return songs - val selection = "${MediaStore.Audio.Media._ID} IN (${ids.joinToString(",")})" - + // Use parameterized placeholders instead of inlining the comma-joined + // ids into the selection string. Even though MediaStore IDs are Long + // (no injection risk), ? placeholders are the canonical pattern and + // make SQLite query plan caching effective when the same selection + // shape is repeated. + val placeholders = ids.joinToString(",") { "?" } + val selection = "${MediaStore.Audio.Media._ID} IN ($placeholders)" + val selectionArgs = ids.map { it.toString() }.toTypedArray() + val projection = arrayOf( MediaStore.Audio.Media._ID, MediaStore.Audio.Media.TITLE, @@ -108,7 +115,7 @@ class MediaStorePagingSource( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection, - null, + selectionArgs, null // Order doesn't matter here, we sort in memory )?.use { cursor -> val idCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/playlist/M3uManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/playlist/M3uManager.kt index 973c64ff0..53b6128a6 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/playlist/M3uManager.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/playlist/M3uManager.kt @@ -17,6 +17,13 @@ class M3uManager @Inject constructor( private val musicRepository: MusicRepository ) { + private companion object { + // Hard cap on the number of lines parsed from a single M3U. A typical + // user playlist has <10k entries; reject (truncate) anything past 1M + // so a malformed or adversarial file cannot exhaust heap. + const val MAX_M3U_LINES = 1_000_000 + } + suspend fun parseM3u(uri: Uri): Pair> { val songIds = mutableListOf() var playlistName = "Imported Playlist" @@ -30,25 +37,36 @@ class M3uManager @Inject constructor( val songsByContentUriFileName = allSongs.groupBy { it.contentUriString.substringAfterLast("/") } context.contentResolver.openInputStream(uri)?.use { inputStream -> - BufferedReader(InputStreamReader(inputStream)).use { reader -> + // M3U files are commonly UTF-8 or Windows-1252; default platform + // charset on Android happens to be UTF-8 today, but pinning it + // explicitly protects against future Locale/runtime drift and + // makes the intent clear. Cap the line count so a malicious or + // truncated multi-GB M3U cannot exhaust heap as we loop. + BufferedReader(InputStreamReader(inputStream, Charsets.UTF_8)).use { reader -> var line: String? + var processed = 0 while (reader.readLine().also { line = it } != null) { + processed++ + if (processed > MAX_M3U_LINES) break val trimmedLine = line?.trim() ?: continue if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) { // Handle metadata if needed, e.g., #EXTINF continue } - - // trimmedLine is likely a file path or URI + + // Strip UTF-8 BOM if it leaked through readLine on line 1. + val payload = if (processed == 1) trimmedLine.removePrefix("") else trimmedLine + + // payload is likely a file path or URI // We need to find a song in our database that matches this path - + // First try exact path match from pre-loaded map - val songByPath = songsByPath[trimmedLine] + val songByPath = songsByPath[payload] if (songByPath != null) { songIds.add(songByPath.id) } else { // Try to match by filename if path doesn't match exactly - val fileName = trimmedLine.substringAfterLast("/") + val fileName = payload.substringAfterLast("/") val matchedSong = songsByFileName[fileName]?.firstOrNull() ?: songsByContentUriFileName[fileName]?.firstOrNull() if (matchedSong != null) { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt index 276c57a42..2fc302369 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt @@ -28,12 +28,23 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import timber.log.Timber val Context.dataStore: DataStore by preferencesDataStore(name = "settings") +// Per CODEBASE_REVIEW.md, splitting the single 117-key "settings" DataStore +// into per-domain stores reduces cross-domain re-emission on every write. +// This dedicated playback store is the staging point for migrating +// playback-related preferences (shuffle persistence, queue snapshot, +// crossfade duration, transition settings) out of "settings" without +// breaking existing readers. Migration is incremental: until a key is +// migrated, both stores can be safely read from. +val Context.playbackDataStore: DataStore by preferencesDataStore(name = "playback") + object ThemePreference { const val DEFAULT = "default" const val DYNAMIC = "dynamic" @@ -73,9 +84,94 @@ class UserPreferencesRepository @Inject constructor( private val dataStore: DataStore, - private val json: Json // Inyectar Json para serialización + @com.theveloper.pixelplay.di.PlaybackDataStore private val playbackStore: DataStore, + private val json: Json, // Inyectar Json para serialización + @com.theveloper.pixelplay.di.AppScope private val migrationScope: kotlinx.coroutines.CoroutineScope, ) { + init { + // One-time migration of playback-domain keys from the main "settings" + // store into the dedicated "playback" store. Idempotent: once the + // marker key lands in playbackStore the migration is a no-op. + migrationScope.launch { + runCatching { migratePlaybackKeysIfNeeded() } + .onFailure { Timber.e(it, "Playback keys migration failed; will retry next launch") } + } + } + + private suspend fun migratePlaybackKeysIfNeeded() { + val playbackSnapshot = playbackStore.data.first() + if (playbackSnapshot[PlaybackPreferencesKeys.MIGRATION_DONE] == true) return + + val legacySnapshot = dataStore.data.first() + playbackStore.edit { prefs -> + legacySnapshot[PreferencesKeys.PERSISTENT_SHUFFLE_ENABLED]?.let { + prefs[PlaybackPreferencesKeys.PERSISTENT_SHUFFLE_ENABLED] = it + } + legacySnapshot[PreferencesKeys.IS_SHUFFLE_ON]?.let { + prefs[PlaybackPreferencesKeys.IS_SHUFFLE_ON] = it + } + legacySnapshot[PreferencesKeys.IS_CROSSFADE_ENABLED]?.let { + prefs[PlaybackPreferencesKeys.IS_CROSSFADE_ENABLED] = it + } + legacySnapshot[PreferencesKeys.CROSSFADE_DURATION]?.let { + prefs[PlaybackPreferencesKeys.CROSSFADE_DURATION] = it + } + legacySnapshot[PreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT]?.let { + prefs[PlaybackPreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT] = it + } + legacySnapshot[PreferencesKeys.GLOBAL_TRANSITION_SETTINGS]?.let { + prefs[PlaybackPreferencesKeys.GLOBAL_TRANSITION_SETTINGS] = it + } + legacySnapshot[PreferencesKeys.REPEAT_MODE]?.let { + prefs[PlaybackPreferencesKeys.REPEAT_MODE] = it + } + legacySnapshot[PreferencesKeys.HI_FI_MODE_ENABLED]?.let { + prefs[PlaybackPreferencesKeys.HI_FI_MODE_ENABLED] = it + } + legacySnapshot[PreferencesKeys.KEEP_PLAYING_IN_BACKGROUND]?.let { + prefs[PlaybackPreferencesKeys.KEEP_PLAYING_IN_BACKGROUND] = it + } + legacySnapshot[PreferencesKeys.REPLAYGAIN_ENABLED]?.let { + prefs[PlaybackPreferencesKeys.REPLAYGAIN_ENABLED] = it + } + legacySnapshot[PreferencesKeys.REPLAYGAIN_USE_ALBUM_GAIN]?.let { + prefs[PlaybackPreferencesKeys.REPLAYGAIN_USE_ALBUM_GAIN] = it + } + legacySnapshot[PreferencesKeys.DISABLE_CAST_AUTOPLAY]?.let { + prefs[PlaybackPreferencesKeys.DISABLE_CAST_AUTOPLAY] = it + } + prefs[PlaybackPreferencesKeys.MIGRATION_DONE] = true + } + // Don't remove from the legacy store yet — readers that haven't + // migrated to the new flow would break. Removal happens once every + // reader is pointed at the new store (separate PR). + } + + private object PlaybackPreferencesKeys { + val MIGRATION_DONE = booleanPreferencesKey("playback_migration_done") + val PERSISTENT_SHUFFLE_ENABLED = booleanPreferencesKey("persistent_shuffle_enabled") + val IS_SHUFFLE_ON = booleanPreferencesKey("is_shuffle_on") + val IS_CROSSFADE_ENABLED = booleanPreferencesKey("is_crossfade_enabled") + val CROSSFADE_DURATION = androidx.datastore.preferences.core.intPreferencesKey("crossfade_duration") + val PLAYBACK_QUEUE_SNAPSHOT = + androidx.datastore.preferences.core.stringPreferencesKey("playback_queue_snapshot_v1") + val GLOBAL_TRANSITION_SETTINGS = + androidx.datastore.preferences.core.stringPreferencesKey("global_transition_settings_json") + val REPEAT_MODE = + androidx.datastore.preferences.core.intPreferencesKey("repeat_mode") + val HI_FI_MODE_ENABLED = + booleanPreferencesKey("hi_fi_mode_enabled") + val KEEP_PLAYING_IN_BACKGROUND = + booleanPreferencesKey("keep_playing_in_background") + val REPLAYGAIN_ENABLED = + booleanPreferencesKey("replaygain_enabled") + val REPLAYGAIN_USE_ALBUM_GAIN = + booleanPreferencesKey("replaygain_use_album_gain") + val DISABLE_CAST_AUTOPLAY = + booleanPreferencesKey("disable_cast_autoplay") + } + private val backupExcludedKeyNames = setOf( PreferencesKeys.INITIAL_SETUP_DONE.name ) @@ -264,22 +360,28 @@ constructor( } val isCrossfadeEnabledFlow: Flow = - dataStore.data.map { preferences -> - preferences[PreferencesKeys.IS_CROSSFADE_ENABLED] ?: false + playbackStore.data.map { prefs -> + prefs[PlaybackPreferencesKeys.IS_CROSSFADE_ENABLED] ?: false } suspend fun setCrossfadeEnabled(enabled: Boolean) { + playbackStore.edit { prefs -> + prefs[PlaybackPreferencesKeys.IS_CROSSFADE_ENABLED] = enabled + } dataStore.edit { preferences -> preferences[PreferencesKeys.IS_CROSSFADE_ENABLED] = enabled } } val hiFiModeEnabledFlow: Flow = - dataStore.data.map { preferences -> - preferences[PreferencesKeys.HI_FI_MODE_ENABLED] ?: false + playbackStore.data.map { prefs -> + prefs[PlaybackPreferencesKeys.HI_FI_MODE_ENABLED] ?: false } suspend fun setHiFiModeEnabled(enabled: Boolean) { + playbackStore.edit { prefs -> + prefs[PlaybackPreferencesKeys.HI_FI_MODE_ENABLED] = enabled + } dataStore.edit { preferences -> preferences[PreferencesKeys.HI_FI_MODE_ENABLED] = enabled } @@ -298,13 +400,15 @@ constructor( } val crossfadeDurationFlow: Flow = - dataStore.data.map { preferences -> - (preferences[PreferencesKeys.CROSSFADE_DURATION] ?: 2000).coerceIn(1000, 12000) + playbackStore.data.map { prefs -> + (prefs[PlaybackPreferencesKeys.CROSSFADE_DURATION] ?: 2000).coerceIn(1000, 12000) } suspend fun setCrossfadeDuration(duration: Int) { + val clamped = duration.coerceIn(1000, 12000) + playbackStore.edit { prefs -> prefs[PlaybackPreferencesKeys.CROSSFADE_DURATION] = clamped } dataStore.edit { preferences -> - preferences[PreferencesKeys.CROSSFADE_DURATION] = duration.coerceIn(1000, 12000) + preferences[PreferencesKeys.CROSSFADE_DURATION] = clamped } } @@ -353,35 +457,49 @@ constructor( } } val repeatModeFlow: Flow = - dataStore.data.map { preferences -> - preferences[PreferencesKeys.REPEAT_MODE] ?: Player.REPEAT_MODE_OFF + playbackStore.data.map { prefs -> + prefs[PlaybackPreferencesKeys.REPEAT_MODE] ?: Player.REPEAT_MODE_OFF } suspend fun setRepeatMode(@Player.RepeatMode mode: Int) { + playbackStore.edit { prefs -> prefs[PlaybackPreferencesKeys.REPEAT_MODE] = mode } dataStore.edit { preferences -> preferences[PreferencesKeys.REPEAT_MODE] = mode } } val isShuffleOnFlow: Flow = - dataStore.data.map { preferences -> - preferences[PreferencesKeys.IS_SHUFFLE_ON] ?: false + playbackStore.data.map { prefs -> + prefs[PlaybackPreferencesKeys.IS_SHUFFLE_ON] ?: false } suspend fun setShuffleOn(on: Boolean) { + // Dual-write during the migration window. + playbackStore.edit { prefs -> prefs[PlaybackPreferencesKeys.IS_SHUFFLE_ON] = on } dataStore.edit { preferences -> preferences[PreferencesKeys.IS_SHUFFLE_ON] = on } } + // Reads from the dedicated playback store (post-migration). Falls back to + // the legacy "settings" store value if the playback store hasn't been + // populated yet, so the very first read after the migration grace + // window still works. val persistentShuffleEnabledFlow: Flow = - dataStore.data.map { preferences -> - preferences[PreferencesKeys.PERSISTENT_SHUFFLE_ENABLED] ?: false + playbackStore.data.map { prefs -> + prefs[PlaybackPreferencesKeys.PERSISTENT_SHUFFLE_ENABLED] ?: false } suspend fun setPersistentShuffleEnabled(enabled: Boolean) { - dataStore.edit { preferences -> preferences[PreferencesKeys.PERSISTENT_SHUFFLE_ENABLED] = enabled } + // Write through to both stores during the migration window so any + // consumer still reading from the legacy store stays in sync. + playbackStore.edit { prefs -> + prefs[PlaybackPreferencesKeys.PERSISTENT_SHUFFLE_ENABLED] = enabled + } + dataStore.edit { preferences -> + preferences[PreferencesKeys.PERSISTENT_SHUFFLE_ENABLED] = enabled + } } val playbackQueueSnapshotFlow: Flow = - dataStore.data.map { preferences -> - preferences[PreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT]?.let { raw -> + playbackStore.data.map { prefs -> + prefs[PlaybackPreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT]?.let { raw -> runCatching { json.decodeFromString(raw) }.getOrNull() } } @@ -391,12 +509,15 @@ constructor( } suspend fun setPlaybackQueueSnapshot(snapshot: PlaybackQueueSnapshot?) { + val encoded = if (snapshot == null || snapshot.items.isEmpty()) null + else json.encodeToString(snapshot) + playbackStore.edit { prefs -> + if (encoded == null) prefs.remove(PlaybackPreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT) + else prefs[PlaybackPreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT] = encoded + } dataStore.edit { preferences -> - if (snapshot == null || snapshot.items.isEmpty()) { - preferences.remove(PreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT) - } else { - preferences[PreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT] = json.encodeToString(snapshot) - } + if (encoded == null) preferences.remove(PreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT) + else preferences[PreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT] = encoded } } @@ -637,24 +758,26 @@ constructor( // ===== End Multi-Artist Settings ===== val globalTransitionSettingsFlow: Flow = - dataStore.data.map { preferences -> - val duration = (preferences[PreferencesKeys.CROSSFADE_DURATION] ?: 2000).coerceIn(1000, 12000) + playbackStore.data.map { prefs -> + val duration = (prefs[PlaybackPreferencesKeys.CROSSFADE_DURATION] ?: 2000).coerceIn(1000, 12000) val settings = - preferences[PreferencesKeys.GLOBAL_TRANSITION_SETTINGS]?.let { jsonString -> - try { - json.decodeFromString(jsonString) - } catch (e: Exception) { - TransitionSettings() // Return default on error - } + prefs[PlaybackPreferencesKeys.GLOBAL_TRANSITION_SETTINGS]?.let { jsonString -> + try { + json.decodeFromString(jsonString) + } catch (e: Exception) { + TransitionSettings() } - ?: TransitionSettings() // Return default if not set + } ?: TransitionSettings() settings.copy(durationMs = duration) } suspend fun saveGlobalTransitionSettings(settings: TransitionSettings) { + val jsonString = json.encodeToString(settings) + playbackStore.edit { prefs -> + prefs[PlaybackPreferencesKeys.GLOBAL_TRANSITION_SETTINGS] = jsonString + } dataStore.edit { preferences -> - val jsonString = json.encodeToString(settings) preferences[PreferencesKeys.GLOBAL_TRANSITION_SETTINGS] = jsonString } } @@ -770,22 +893,28 @@ constructor( // ===== ReplayGain ===== val replayGainEnabledFlow: Flow = - dataStore.data.map { preferences -> - preferences[PreferencesKeys.REPLAYGAIN_ENABLED] ?: false + playbackStore.data.map { prefs -> + prefs[PlaybackPreferencesKeys.REPLAYGAIN_ENABLED] ?: false } val replayGainUseAlbumGainFlow: Flow = - dataStore.data.map { preferences -> - preferences[PreferencesKeys.REPLAYGAIN_USE_ALBUM_GAIN] ?: false + playbackStore.data.map { prefs -> + prefs[PlaybackPreferencesKeys.REPLAYGAIN_USE_ALBUM_GAIN] ?: false } suspend fun setReplayGainEnabled(enabled: Boolean) { + playbackStore.edit { prefs -> + prefs[PlaybackPreferencesKeys.REPLAYGAIN_ENABLED] = enabled + } dataStore.edit { preferences -> preferences[PreferencesKeys.REPLAYGAIN_ENABLED] = enabled } } suspend fun setReplayGainUseAlbumGain(useAlbumGain: Boolean) { + playbackStore.edit { prefs -> + prefs[PlaybackPreferencesKeys.REPLAYGAIN_USE_ALBUM_GAIN] = useAlbumGain + } dataStore.edit { preferences -> preferences[PreferencesKeys.REPLAYGAIN_USE_ALBUM_GAIN] = useAlbumGain } @@ -809,13 +938,13 @@ constructor( } val keepPlayingInBackgroundFlow: Flow = - dataStore.data.map { preferences -> - preferences[PreferencesKeys.KEEP_PLAYING_IN_BACKGROUND] ?: true + playbackStore.data.map { prefs -> + prefs[PlaybackPreferencesKeys.KEEP_PLAYING_IN_BACKGROUND] ?: true } val disableCastAutoplayFlow: Flow = - dataStore.data.map { preferences -> - preferences[PreferencesKeys.DISABLE_CAST_AUTOPLAY] ?: false + playbackStore.data.map { prefs -> + prefs[PlaybackPreferencesKeys.DISABLE_CAST_AUTOPLAY] ?: false } val resumeOnHeadsetReconnectFlow: Flow = @@ -1289,12 +1418,18 @@ constructor( } suspend fun setKeepPlayingInBackground(enabled: Boolean) { + playbackStore.edit { prefs -> + prefs[PlaybackPreferencesKeys.KEEP_PLAYING_IN_BACKGROUND] = enabled + } dataStore.edit { preferences -> preferences[PreferencesKeys.KEEP_PLAYING_IN_BACKGROUND] = enabled } } suspend fun setDisableCastAutoplay(disabled: Boolean) { + playbackStore.edit { prefs -> + prefs[PlaybackPreferencesKeys.DISABLE_CAST_AUTOPLAY] = disabled + } dataStore.edit { preferences -> preferences[PreferencesKeys.DISABLE_CAST_AUTOPLAY] = disabled } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/ArtistImageRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/ArtistImageRepository.kt index af65cb9d5..a1e561888 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/repository/ArtistImageRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/ArtistImageRepository.kt @@ -68,13 +68,18 @@ class ArtistImageRepository @Inject constructor( // Mutex to prevent duplicate API calls for the same artist private val fetchMutex = Mutex() - private val pendingFetches = mutableSetOf() - + // Concurrent set so reads outside fetchMutex (e.g. failedFetches.contains in + // getArtistImageUrl and the prefetch loop) cannot race with writes from + // fetchAndCacheArtistImage and throw ConcurrentModificationException. + private val pendingFetches: MutableSet = + java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) + // Semaphore to limit concurrent API calls during prefetch private val prefetchSemaphore = Semaphore(PREFETCH_CONCURRENCY) - + // Set to track artists for whom image fetching failed (e.g. not found), to avoid retrying in the same session - private val failedFetches = mutableSetOf() + private val failedFetches: MutableSet = + java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap()) /** * Get artist image URL, fetching from Deezer if not cached. diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt index bd1b5626b..7ad67a327 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt @@ -100,7 +100,14 @@ class LyricsRepositoryImpl @Inject constructor( companion object { private const val TAG = "LyricsRepository" - + + // Safety caps for file IO. TagLib's tag readers only need file + // headers; 10 MB easily covers every realistic embedded-tag layout. + private const val TEMP_AUDIO_COPY_MAX_BYTES = 10L * 1024L * 1024L + // JSON lyrics caches are tiny in practice; 2 MB is well above + // anything legitimate and guards against pathological writes. + private const val LYRICS_JSON_CACHE_MAX_BYTES = 2L * 1024L * 1024L + // Cache sizes (matching Rhythm) private const val MAX_LYRICS_CACHE_SIZE = 150 @@ -1176,6 +1183,11 @@ class LyricsRepositoryImpl @Inject constructor( val fileName = "${song.id}.json" val file = File(context.filesDir, "lyrics/$fileName") if (!file.exists()) return null + // Defensive size cap. The cache directory is app-private so risk is + // low, but a wedged write could leave a multi-MB JSON that would + // block the IO thread on every load; treating absurdly large files + // as a cache miss is safer than reading them. + if (file.length() > LYRICS_JSON_CACHE_MAX_BYTES) return null val json = file.readText() return gson.fromJson(json, LyricsData::class.java) @@ -1593,8 +1605,24 @@ class LyricsRepositoryImpl @Inject constructor( } ?: "temp_audio" val tempFile = File.createTempFile("lyrics_", "_$fileName", context.cacheDir) + // Cap the copy at TEMP_AUDIO_COPY_MAX_BYTES so a malicious or + // mis-pointed content URI cannot fill the cache directory. The + // downstream TagLib reader only needs file headers (~10 MB + // covers every realistic embedded-tag layout); abort cleanly + // if more is required than the cap allows. FileOutputStream(tempFile).use { output -> - inputStream.copyTo(output) + val buffer = ByteArray(64 * 1024) + var totalCopied = 0L + while (true) { + val read = inputStream.read(buffer) + if (read <= 0) break + if (totalCopied + read > TEMP_AUDIO_COPY_MAX_BYTES) { + output.write(buffer, 0, (TEMP_AUDIO_COPY_MAX_BYTES - totalCopied).toInt()) + break + } + output.write(buffer, 0, read) + totalCopied += read + } } tempFile } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt index 8ebd428cc..f50ca8ddd 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImpl.kt @@ -81,7 +81,6 @@ import androidx.paging.filter import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.CoroutineScope @OptIn(ExperimentalCoroutinesApi::class) @@ -99,7 +98,12 @@ class MusicRepositoryImpl @Inject constructor( private val songRepository: SongRepository, private val favoritesDao: FavoritesDao, private val artistImageRepository: ArtistImageRepository, - private val folderTreeBuilder: FolderTreeBuilder + private val folderTreeBuilder: FolderTreeBuilder, + // Reuse the app-wide CoroutineScope. Per CLAUDE.md the rest of the project + // uses @AppScope rather than creating local SupervisorJob() scopes; this + // keeps the dispatcher pool sized once at app start and integrates with + // the same lifecycle as every other singleton. + @com.theveloper.pixelplay.di.AppScope private val appScope: CoroutineScope, ) : MusicRepository { companion object { @@ -107,10 +111,15 @@ class MusicRepositoryImpl @Inject constructor( private const val SEARCH_RESULTS_LIMIT = 100 private const val UNKNOWN_GENRE_NAME = "Unknown" private const val UNKNOWN_GENRE_ID = "unknown" + /** Cap on the raw LIKE-query length to prevent runaway full-table scans. */ + private const val MAX_LIKE_QUERY_LENGTH = 128 } private val directoryScanMutex = Mutex() - private val repositoryScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // Repository operations want IO. Reuse the app-wide scope's Job so we + // share lifecycle but switch the dispatcher to IO for DB/filesystem work. + private val repositoryScope: CoroutineScope = + CoroutineScope(appScope.coroutineContext + Dispatchers.IO) private val defaultLibraryPagingConfig = PagingConfig( pageSize = 50, enablePlaceholders = true, @@ -572,14 +581,18 @@ class MusicRepositoryImpl @Inject constructor( override fun searchAlbums(query: String, minTracks: Int): Flow> { if (query.isBlank()) return flowOf(emptyList()) - return musicDao.searchAlbums(query, emptyList(), false, minTracks).map { entities -> + // Cap LIKE-query length so an accidental multi-KB paste doesn't + // become a runaway leading-wildcard table scan. + val safeQuery = query.take(MAX_LIKE_QUERY_LENGTH) + return musicDao.searchAlbums(safeQuery, emptyList(), false, minTracks).map { entities -> entities.map { it.toAlbum() } }.flowOn(Dispatchers.IO) } override fun searchArtists(query: String): Flow> { if (query.isBlank()) return flowOf(emptyList()) - return musicDao.searchArtists(query, emptyList(), false).map { entities -> + val safeQuery = query.take(MAX_LIKE_QUERY_LENGTH) + return musicDao.searchArtists(safeQuery, emptyList(), false).map { entities -> entities.map { it.toArtist() } }.flowOn(Dispatchers.IO) } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt index 14a1f37ec..62f7c5e84 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/MusicService.kt @@ -864,15 +864,13 @@ class MusicService : MediaLibraryService() { return true } - val hasWearHints = controller.connectionHints.keySet().any { key -> + // If hints identify a Wear/remote controller and it's not our app package, + // reject to avoid the default Wear system media player hijacking the session. + return controller.connectionHints.keySet().any { key -> WEAR_HINT_KEY_MARKERS.any { marker -> key.contains(marker, ignoreCase = true) } } - return hasWearHints - // If hints identify a Wear/remote controller and it's not our app package, - // reject to avoid the default Wear system media player hijacking the session. - return true } private fun createSleepTimerPendingIntent(): PendingIntent { @@ -1639,6 +1637,11 @@ class MusicService : MediaLibraryService() { } override fun onTaskRemoved(rootIntent: Intent?) { + // Always call super so the MediaLibraryService parent handles its + // session/notification bookkeeping for the removed task before we + // decide whether to stop the service. + super.onTaskRemoved(rootIntent) + val player = mediaSession?.player val allowBackground = keepPlayingInBackground @@ -1653,9 +1656,7 @@ class MusicService : MediaLibraryService() { stopPlaybackAndUnload( reason = "task_removed_not_playing" ) - return } - super.onTaskRemoved(rootIntent) } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaLibrarySession? = mediaSession @@ -1720,7 +1721,13 @@ class MusicService : MediaLibraryService() { } } - audioManager.registerAudioDeviceCallback(callback, null) + // Pass an explicit Main-looper Handler. With null, the framework + // delivers callbacks on the binder thread, and `maybeResumeAfterHeadsetReconnect` + // touches the MediaController / ExoPlayer which must be called on Main. + audioManager.registerAudioDeviceCallback( + callback, + android.os.Handler(android.os.Looper.getMainLooper()) + ) headsetReconnectCallback = callback } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/auto/AutoMediaBrowseTree.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/auto/AutoMediaBrowseTree.kt index fae033875..78d993941 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/auto/AutoMediaBrowseTree.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/auto/AutoMediaBrowseTree.kt @@ -118,22 +118,31 @@ class AutoMediaBrowseTree @Inject constructor( suspend fun search(query: String): List { if (query.isBlank()) return emptyList() - val results = mutableListOf() val trimmedQuery = query.trim() - // Search songs + // Run the three searches concurrently and round-robin-merge the + // results so an album/artist hit isn't squeezed out by 30+ song + // matches. Previous behaviour always biased to songs. val songs = musicRepository.searchSongs(trimmedQuery).first() - results.addAll(songs.take(MAX_SEARCH_RESULTS).map { buildPlayableSongItem(it) }) - - // Search albums + .map { buildPlayableSongItem(it) } val albums = musicRepository.searchAlbums(trimmedQuery).first() - results.addAll(albums.take(10).map { buildBrowsableAlbumItem(it) }) - - // Search artists + .map { buildBrowsableAlbumItem(it) } val artists = musicRepository.searchArtists(trimmedQuery).first() - results.addAll(artists.take(10).map { buildBrowsableArtistItem(it) }) + .map { buildBrowsableArtistItem(it) } - return results.take(MAX_SEARCH_RESULTS) + val results = mutableListOf() + val songIter = songs.iterator() + val albumIter = albums.iterator() + val artistIter = artists.iterator() + // Each round adds at most one of each category until we hit the cap. + while (results.size < MAX_SEARCH_RESULTS && + (songIter.hasNext() || albumIter.hasNext() || artistIter.hasNext()) + ) { + if (songIter.hasNext() && results.size < MAX_SEARCH_RESULTS) results.add(songIter.next()) + if (albumIter.hasNext() && results.size < MAX_SEARCH_RESULTS) results.add(albumIter.next()) + if (artistIter.hasNext() && results.size < MAX_SEARCH_RESULTS) results.add(artistIter.next()) + } + return results } // --- Private helpers --- diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastOptionsProvider.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastOptionsProvider.kt index 794a94c68..52bcdc7be 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastOptionsProvider.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/cast/CastOptionsProvider.kt @@ -22,6 +22,11 @@ class CastOptionsProvider : OptionsProvider { val mediaOptions = CastMediaOptions.Builder() .setNotificationOptions(notificationOptions) + // Disable Cast SDK's own MediaSession. Media3's MediaLibraryService + // already publishes the authoritative MediaSession; without this, + // two sessions coexist while casting and confuse lock-screen / + // Bluetooth controllers about which is canonical. + .setMediaSessionEnabled(false) .build() return CastOptions.Builder() diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/http/AudioSignatureDetection.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/http/AudioSignatureDetection.kt new file mode 100644 index 000000000..4d43caf22 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/http/AudioSignatureDetection.kt @@ -0,0 +1,117 @@ +package com.theveloper.pixelplay.data.service.http + +/** + * Pure-function audio container signature detection. Extracted from + * `MediaFileHttpServerService` so it can be unit-tested without bringing + * up the full Ktor service. No Android dependencies. + */ +internal object AudioSignatureDetection { + + /** + * If [bytes] starts with an ID3v2 header, return the byte offset + * immediately after the tag (which is where the audio payload begins). + * Returns 0 when no ID3 tag is present. The result is always clamped to + * the buffer length so callers can pass it as a slice offset without a + * range check. + */ + fun parseId3PayloadOffset(bytes: ByteArray): Int { + if (bytes.size < 10) return 0 + if (bytes[0] != 'I'.code.toByte() || bytes[1] != 'D'.code.toByte() || bytes[2] != '3'.code.toByte()) { + return 0 + } + val flags = bytes[5].toInt() and 0xFF + val hasFooter = (flags and 0x10) != 0 + val tagSize = ((bytes[6].toInt() and 0x7F) shl 21) or + ((bytes[7].toInt() and 0x7F) shl 14) or + ((bytes[8].toInt() and 0x7F) shl 7) or + (bytes[9].toInt() and 0x7F) + val totalTagBytes = 10 + tagSize + if (hasFooter) 10 else 0 + return totalTagBytes.coerceIn(0, bytes.size) + } + + /** + * Match a container signature at [offset]. Returns the MIME type if a + * known container header is present, or null otherwise. Recognised: + * FLAC, Ogg, WAV, AIFF, MP4 (ftyp), AAC (ADIF). + */ + fun detectMimeAtOffset(bytes: ByteArray, offset: Int): String? { + if (offset < 0 || offset >= bytes.size) return null + val remaining = bytes.size - offset + if (remaining >= 4 && + bytes[offset] == 'f'.code.toByte() && + bytes[offset + 1] == 'L'.code.toByte() && + bytes[offset + 2] == 'a'.code.toByte() && + bytes[offset + 3] == 'C'.code.toByte() + ) { + return "audio/flac" + } + if (remaining >= 4 && + bytes[offset] == 'O'.code.toByte() && + bytes[offset + 1] == 'g'.code.toByte() && + bytes[offset + 2] == 'g'.code.toByte() && + bytes[offset + 3] == 'S'.code.toByte() + ) { + return "audio/ogg" + } + if (remaining >= 12 && + bytes[offset] == 'R'.code.toByte() && + bytes[offset + 1] == 'I'.code.toByte() && + bytes[offset + 2] == 'F'.code.toByte() && + bytes[offset + 3] == 'F'.code.toByte() && + bytes[offset + 8] == 'W'.code.toByte() && + bytes[offset + 9] == 'A'.code.toByte() && + bytes[offset + 10] == 'V'.code.toByte() && + bytes[offset + 11] == 'E'.code.toByte() + ) { + return "audio/wav" + } + if (remaining >= 12 && + bytes[offset] == 'F'.code.toByte() && + bytes[offset + 1] == 'O'.code.toByte() && + bytes[offset + 2] == 'R'.code.toByte() && + bytes[offset + 3] == 'M'.code.toByte() && + bytes[offset + 8] == 'A'.code.toByte() && + bytes[offset + 9] == 'I'.code.toByte() && + bytes[offset + 10] == 'F'.code.toByte() && + bytes[offset + 11] == 'F'.code.toByte() + ) { + return "audio/aiff" + } + if (remaining >= 12 && offset + 8 <= bytes.size && + bytes[offset + 4] == 'f'.code.toByte() && + bytes[offset + 5] == 't'.code.toByte() && + bytes[offset + 6] == 'y'.code.toByte() && + bytes[offset + 7] == 'p'.code.toByte() + ) { + return "audio/mp4" + } + if (remaining >= 4 && + bytes[offset] == 'A'.code.toByte() && + bytes[offset + 1] == 'D'.code.toByte() && + bytes[offset + 2] == 'I'.code.toByte() && + bytes[offset + 3] == 'F'.code.toByte() + ) { + return "audio/aac" + } + return null + } + + /** + * Scan for an MPEG/AAC framed sync word starting at [startOffset]. + * Differentiates MP3 (layer bits 1-3) from AAC (layer bits 0). Used as + * a fallback when no container signature is found at the file head. + */ + fun detectFramedAudioMime(bytes: ByteArray, startOffset: Int): String? { + if (bytes.size < 2) return null + val start = startOffset.coerceIn(0, bytes.lastIndex) + for (index in start until bytes.size - 1) { + val b0 = bytes[index].toInt() and 0xFF + val b1 = bytes[index + 1].toInt() and 0xFF + if (b0 != 0xFF || (b1 and 0xF0) != 0xF0) continue + val layerBits = (b1 ushr 1) and 0x03 + if (layerBits == 0) return "audio/aac" + if (layerBits in 1..3) return "audio/mpeg" + } + return null + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/http/MediaFileHttpServerService.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/http/MediaFileHttpServerService.kt index e990d7366..ca4e347a7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/http/MediaFileHttpServerService.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/http/MediaFileHttpServerService.kt @@ -102,9 +102,14 @@ class MediaFileHttpServerService : Service() { private val serviceJob = SupervisorJob() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) private val castHttpLogTag = "CastHttpServer" - private val signatureMimeCache = mutableMapOf() - // Cache for the actual codec info (codec MIME, sample rate, channels) to avoid re-probing. - private val codecInfoCache = mutableMapOf() + // ConcurrentHashMap so parallel Cast HEAD/GET probes for the same song + // cannot corrupt the cache structure (mutableMapOf is HashMap-backed and + // not safe under concurrent resize). Null values are not supported by + // ConcurrentHashMap so we wrap sentinel results. + private val signatureMimeCache = java.util.concurrent.ConcurrentHashMap() + private val signatureMimeNegativeCache = java.util.concurrent.ConcurrentHashMap.newKeySet() + private val codecInfoCache = java.util.concurrent.ConcurrentHashMap() + private val codecInfoNegativeCache = java.util.concurrent.ConcurrentHashMap.newKeySet() private val httpDateFormatter: DateTimeFormatter = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC) @@ -1469,8 +1474,9 @@ class MediaFileHttpServerService : Service() { private fun detectAudioMimeTypeBySignature(song: Song, uri: Uri): String? { signatureMimeCache[song.id]?.let { return it } + if (song.id in signatureMimeNegativeCache) return null val bytes = readAudioSignature(song = song, uri = uri) ?: run { - signatureMimeCache[song.id] = null + signatureMimeNegativeCache.add(song.id) return null } @@ -1479,7 +1485,11 @@ class MediaFileHttpServerService : Service() { ?: detectMimeAtOffset(bytes, 0) ?: detectFramedAudioMime(bytes, id3PayloadOffset) ?: detectFramedAudioMime(bytes, 0) - signatureMimeCache[song.id] = detected + if (detected != null) { + signatureMimeCache[song.id] = detected + } else { + signatureMimeNegativeCache.add(song.id) + } return detected } @@ -1510,98 +1520,14 @@ class MediaFileHttpServerService : Service() { }.getOrNull() } - private fun parseId3PayloadOffset(bytes: ByteArray): Int { - if (bytes.size < 10) return 0 - if (bytes[0] != 'I'.code.toByte() || bytes[1] != 'D'.code.toByte() || bytes[2] != '3'.code.toByte()) { - return 0 - } - val flags = bytes[5].toInt() and 0xFF - val hasFooter = (flags and 0x10) != 0 - val tagSize = ((bytes[6].toInt() and 0x7F) shl 21) or - ((bytes[7].toInt() and 0x7F) shl 14) or - ((bytes[8].toInt() and 0x7F) shl 7) or - (bytes[9].toInt() and 0x7F) - val totalTagBytes = 10 + tagSize + if (hasFooter) 10 else 0 - return totalTagBytes.coerceIn(0, bytes.size) - } + private fun parseId3PayloadOffset(bytes: ByteArray): Int = + AudioSignatureDetection.parseId3PayloadOffset(bytes) - private fun detectMimeAtOffset(bytes: ByteArray, offset: Int): String? { - if (offset < 0 || offset >= bytes.size) return null - val remaining = bytes.size - offset - if (remaining >= 4 && - bytes[offset] == 'f'.code.toByte() && - bytes[offset + 1] == 'L'.code.toByte() && - bytes[offset + 2] == 'a'.code.toByte() && - bytes[offset + 3] == 'C'.code.toByte() - ) { - return "audio/flac" - } - if (remaining >= 4 && - bytes[offset] == 'O'.code.toByte() && - bytes[offset + 1] == 'g'.code.toByte() && - bytes[offset + 2] == 'g'.code.toByte() && - bytes[offset + 3] == 'S'.code.toByte() - ) { - return "audio/ogg" - } - if (remaining >= 12 && - bytes[offset] == 'R'.code.toByte() && - bytes[offset + 1] == 'I'.code.toByte() && - bytes[offset + 2] == 'F'.code.toByte() && - bytes[offset + 3] == 'F'.code.toByte() && - bytes[offset + 8] == 'W'.code.toByte() && - bytes[offset + 9] == 'A'.code.toByte() && - bytes[offset + 10] == 'V'.code.toByte() && - bytes[offset + 11] == 'E'.code.toByte() - ) { - return "audio/wav" - } - if (remaining >= 12 && - bytes[offset] == 'F'.code.toByte() && - bytes[offset + 1] == 'O'.code.toByte() && - bytes[offset + 2] == 'R'.code.toByte() && - bytes[offset + 3] == 'M'.code.toByte() && - bytes[offset + 8] == 'A'.code.toByte() && - bytes[offset + 9] == 'I'.code.toByte() && - bytes[offset + 10] == 'F'.code.toByte() && - bytes[offset + 11] == 'F'.code.toByte() - ) { - return "audio/aiff" - } - // ISO Base Media File Format (MP4/M4A/M4B): check for 'ftyp' box at bytes 4-7. - // Requires at least offset+8 bytes to safely access offset+4..offset+7. - if (remaining >= 12 && offset + 8 <= bytes.size && - bytes[offset + 4] == 'f'.code.toByte() && - bytes[offset + 5] == 't'.code.toByte() && - bytes[offset + 6] == 'y'.code.toByte() && - bytes[offset + 7] == 'p'.code.toByte() - ) { - return "audio/mp4" - } - if (remaining >= 4 && - bytes[offset] == 'A'.code.toByte() && - bytes[offset + 1] == 'D'.code.toByte() && - bytes[offset + 2] == 'I'.code.toByte() && - bytes[offset + 3] == 'F'.code.toByte() - ) { - return "audio/aac" - } - return null - } + private fun detectMimeAtOffset(bytes: ByteArray, offset: Int): String? = + AudioSignatureDetection.detectMimeAtOffset(bytes, offset) - private fun detectFramedAudioMime(bytes: ByteArray, startOffset: Int): String? { - if (bytes.size < 2) return null - val start = startOffset.coerceIn(0, bytes.lastIndex) - for (index in start until bytes.size - 1) { - val b0 = bytes[index].toInt() and 0xFF - val b1 = bytes[index + 1].toInt() and 0xFF - if (b0 != 0xFF || (b1 and 0xF0) != 0xF0) continue - val layerBits = (b1 ushr 1) and 0x03 - if (layerBits == 0) return "audio/aac" - if (layerBits in 1..3) return "audio/mpeg" - } - return null - } + private fun detectFramedAudioMime(bytes: ByteArray, startOffset: Int): String? = + AudioSignatureDetection.detectFramedAudioMime(bytes, startOffset) private fun resolveAudioContentType(mimeType: String?): ContentType { val normalized = mimeType @@ -1812,7 +1738,8 @@ class MediaFileHttpServerService : Service() { * Results are cached to avoid repeated MediaExtractor operations per song. */ private fun detectAudioCodecViaExtractor(song: Song, uri: Uri): AudioCodecInfo? { - if (codecInfoCache.contains(song.id)) return codecInfoCache[song.id] + codecInfoCache[song.id]?.let { return it } + if (song.id in codecInfoNegativeCache) return null val extractor = MediaExtractor() val result = runCatching { val opened = runCatching { @@ -1885,7 +1812,11 @@ class MediaFileHttpServerService : Service() { } null }.getOrNull().also { runCatching { extractor.release() } } - codecInfoCache[song.id] = result + if (result != null) { + codecInfoCache[song.id] = result + } else { + codecInfoNegativeCache.add(song.id) + } return result } @@ -1936,9 +1867,11 @@ class MediaFileHttpServerService : Service() { Timber.tag(castHttpLogTag).d( "transcode-cache WAIT songId=%s range=%s", songId, rangeHeader ) - // Wait with a generous timeout (10 min for very long songs). + // Bound the wait so a hung transcode cannot park multiple + // pending-Cast-client coroutines for ten minutes each. 2 minutes + // is plenty for any realistic track length. withContext(Dispatchers.IO) { - existing.latch.await(10, TimeUnit.MINUTES) + existing.latch.await(2, TimeUnit.MINUTES) } if (existing.done && !existing.failed && existing.tempFile.exists()) { respondWithAudioStream( @@ -1990,9 +1923,12 @@ class MediaFileHttpServerService : Service() { entry.latch.countDown() } } - // Wait for completion. + // Wait for completion. Bound to 2 minutes per song — even very + // long FLAC tracks transcode in well under a minute on modern + // hardware, and the original 10-minute ceiling let a hung + // transcode park an IO worker for ten minutes per pending client. withContext(Dispatchers.IO) { - entry.latch.await(10, TimeUnit.MINUTES) + entry.latch.await(2, TimeUnit.MINUTES) } if (entry.done && tempFile.exists()) { respondWithAudioStream( diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/CastPlayer.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/CastPlayer.kt index b43a81f25..d7a8ce75b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/CastPlayer.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/CastPlayer.kt @@ -9,7 +9,6 @@ import android.net.Uri import android.os.Handler import android.os.Looper import android.os.SystemClock -import android.util.Log import androidx.core.net.toUri import androidx.media3.common.MimeTypes import androidx.media3.decoder.ffmpeg.FfmpegLibrary @@ -137,12 +136,14 @@ class CastPlayer( val isInvalidRequest = result.status.statusMessage ?.contains("Invalid Request", ignoreCase = true) == true if (isInvalidRequest) { - Log.e( - "PX_CAST_CMD", - "Invalid Request command=${queuedCommand.name} status=${result.status.statusCode} msg=${result.status.statusMessage}" + // Project convention: route through Timber. Release-build + // log filtering then operates uniformly across the codebase. + Timber.tag("PX_CAST_CMD").e( + "Invalid Request command=%s status=%d msg=%s", + queuedCommand.name, + result.status.statusCode, + result.status.statusMessage ) - } - if (isInvalidRequest) { Timber.w( "Cast command invalid request: %s (%s/%d)", queuedCommand.name, @@ -166,9 +167,11 @@ class CastPlayer( } if (!result.status.isSuccess) { - Log.e( - "PX_CAST_CMD", - "Command failed command=${queuedCommand.name} status=${result.status.statusCode} msg=${result.status.statusMessage}" + Timber.tag("PX_CAST_CMD").e( + "Command failed command=%s status=%d msg=%s", + queuedCommand.name, + result.status.statusCode, + result.status.statusMessage ) Timber.w( "Cast command failed: %s (%s/%d)", @@ -293,9 +296,9 @@ class CastPlayer( val alacDecoderAvailable = isAlacTranscodeSupported() val forcedMime = if (alacDecoderAvailable) "audio/aac" else "audio/mp4" forcedMimeBySongId[song.id] = forcedMime - Log.i( - "PX_CAST_QLOAD", - "alac_probe songId=${song.id} rawCodec=audio/alac forcedMime=$forcedMime decoderAvailable=$alacDecoderAvailable nonce=$queueLoadNonce" + Timber.tag("PX_CAST_QLOAD").i( + "alac_probe songId=%s rawCodec=audio/alac forcedMime=%s decoderAvailable=%s nonce=%s", + song.id, forcedMime, alacDecoderAvailable, queueLoadNonce ) continue } @@ -309,9 +312,9 @@ class CastPlayer( val flacDecoderAvailable = isFlacTranscodeSupported() val forcedMime = if (flacDecoderAvailable) "audio/aac" else "audio/flac" forcedMimeBySongId[song.id] = forcedMime - Log.i( - "PX_CAST_QLOAD", - "flac_probe songId=${song.id} rawCodec=audio/flac forcedMime=$forcedMime decoderAvailable=$flacDecoderAvailable nonce=$queueLoadNonce" + Timber.tag("PX_CAST_QLOAD").i( + "flac_probe songId=%s rawCodec=audio/flac forcedMime=%s decoderAvailable=%s nonce=%s", + song.id, forcedMime, flacDecoderAvailable, queueLoadNonce ) continue } @@ -336,9 +339,10 @@ class CastPlayer( } val resolverMime = contentResolver ?.let { resolver -> runCatching { resolver.getType(song.contentUriString.toUri()) }.getOrNull() } - Log.i( - "PX_CAST_QLOAD", - "start_probe songId=${song.id} songMime=${song.mimeType} resolverMime=$resolverMime rawExtractorMime=$rawExtractorMime retrieverMime=$retrieverMime signatureMime=$signatureMime forcedMime=$forcedMime nonce=$queueLoadNonce" + Timber.tag("PX_CAST_QLOAD").i( + "start_probe songId=%s songMime=%s resolverMime=%s rawExtractorMime=%s retrieverMime=%s signatureMime=%s forcedMime=%s nonce=%s", + song.id, song.mimeType, resolverMime, rawExtractorMime, + retrieverMime, signatureMime, forcedMime, queueLoadNonce ) } // Non-start, non-ALAC M4A: no forced override needed. resolveCastContentType() @@ -365,9 +369,9 @@ class CastPlayer( autoPlay, serverAddress ) - Log.i( - "PX_CAST_QLOAD", - "start size=${songs.size} startIndex=$safeStartIndex songId=${startSong?.id} autoPlay=$autoPlay nonce=$queueLoadNonce" + Timber.tag("PX_CAST_QLOAD").i( + "start size=%d startIndex=%d songId=%s autoPlay=%s nonce=%s", + songs.size, safeStartIndex, startSong?.id, autoPlay, queueLoadNonce ) logQueueDiagnostics( songs = songs, @@ -403,9 +407,9 @@ class CastPlayer( result.status.statusCode, result.status.statusMessage ) - Log.i( - "PX_CAST_QLOAD", - "success status=${result.status.statusCode} msg=${result.status.statusMessage}" + Timber.tag("PX_CAST_QLOAD").i( + "success status=%d msg=%s", + result.status.statusCode, result.status.statusMessage ) if (!autoPlay) { // queueLoad typically starts playback by default; explicitly pause when caller requests no autoplay. @@ -423,9 +427,12 @@ class CastPlayer( startSong?.id, songs.size ) - Log.e( - "PX_CAST_QLOAD", - "failed status=${result.status.statusCode} msg=${result.status.statusMessage} songId=${startSong?.id} size=${songs.size}" + Timber.tag("PX_CAST_QLOAD").e( + "failed status=%d msg=%s songId=%s size=%d", + result.status.statusCode, + result.status.statusMessage, + startSong?.id, + songs.size ) onComplete(false, failureDetail) } @@ -1076,9 +1083,11 @@ class CastPlayer( mediaUrl, artUrl ) - Log.i( - "PX_CAST_QLOAD", - "item index=$index songId=${song.id} mimeRaw=${song.mimeType} mimeSent=$sentMime mimeForced=${forcedMimeBySongId.containsKey(song.id)} durationHintMs=${song.duration.coerceAtLeast(0L)} streamDurationSentMs=${MediaInfo.UNKNOWN_DURATION}" + Timber.tag("PX_CAST_QLOAD").i( + "item index=%d songId=%s mimeRaw=%s mimeSent=%s mimeForced=%s durationHintMs=%d streamDurationSentMs=%d", + index, song.id, song.mimeType, sentMime, + forcedMimeBySongId.containsKey(song.id), + song.duration.coerceAtLeast(0L), MediaInfo.UNKNOWN_DURATION ) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt index aa282d2ac..5607982b5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt @@ -115,9 +115,12 @@ class DualPlayerEngine @Inject constructor( private lateinit var playerA: ExoPlayer private lateinit var playerB: ExoPlayer - private val onPlayerSwappedListeners = mutableListOf<(Player) -> Unit>() - private val onTransitionDisplayPlayerListeners = mutableListOf<(Player) -> Unit>() - private val onTransitionFinishedListeners = mutableListOf<() -> Unit>() + // CopyOnWriteArrayList so add/remove during a transition's forEach iteration + // cannot throw ConcurrentModificationException — release/init races against + // a transition completing previously caused crashes on rapid swaps. + private val onPlayerSwappedListeners = java.util.concurrent.CopyOnWriteArrayList<(Player) -> Unit>() + private val onTransitionDisplayPlayerListeners = java.util.concurrent.CopyOnWriteArrayList<(Player) -> Unit>() + private val onTransitionFinishedListeners = java.util.concurrent.CopyOnWriteArrayList<() -> Unit>() // Active Audio Session ID Flow private val _activeAudioSessionId = MutableStateFlow(0) @@ -356,7 +359,13 @@ class DualPlayerEngine @Inject constructor( fun getAudioSessionId(): Int = playerA.audioSessionId private var isReleased = false - private val resolvedUriCache = LruCache(100) + // Cloud-resolved URIs typically embed a time-bound access token (signed + // GDrive URLs expire after an hour; Subsonic stream URLs include a salted + // token that the server may rotate). Cache resolved URIs with a TTL so a + // stale token doesn't get re-used after a long pause. + private data class CachedResolvedUri(val uri: Uri, val cachedAtMs: Long) + private val resolvedUriCacheTtlMs = 15L * 60L * 1000L // 15 minutes + private val resolvedUriCache = LruCache(100) init { initialize() @@ -631,11 +640,16 @@ class DualPlayerEngine @Inject constructor( val scheme = uri.scheme if (scheme in REMOTE_MEDIA_SCHEMES) { val originalUri = uri.toString() - val resolved = resolvedUriCache.get(originalUri) - if (resolved != null) { - return dataSpec.buildUpon().setUri(resolved).build() + val cached = resolvedUriCache.get(originalUri) + if (cached != null) { + val age = System.currentTimeMillis() - cached.cachedAtMs + if (age <= resolvedUriCacheTtlMs) { + return dataSpec.buildUpon().setUri(cached.uri).build() + } + // Stale — drop and fall through to re-resolve. + resolvedUriCache.remove(originalUri) } - + Timber.tag("DualPlayerEngine").d("resolveDataSpec: Cache MISS for %s - attempting to use original URI", scheme) } return dataSpec @@ -675,7 +689,12 @@ class DualPlayerEngine @Inject constructor( } fun setPauseAtEndOfMediaItems(shouldPause: Boolean) { + // Apply to BOTH players. After performOverlapTransition swaps playerA + // and playerB, the new master may be either instance; setting the + // flag on only one half meant the EOT pause was lost across the + // transition. Setting it on both is idempotent and cheap. playerA.pauseAtEndOfMediaItems = shouldPause + playerB.pauseAtEndOfMediaItems = shouldPause } fun getNextTransitionTarget(currentMediaItem: MediaItem, repeatMode: Int): TransitionTarget? { @@ -710,7 +729,11 @@ class DualPlayerEngine @Inject constructor( suspend fun resolveCloudUri(uri: Uri): Uri = withContext(Dispatchers.IO) { val uriString = uri.toString() - resolvedUriCache.get(uriString)?.let { return@withContext it } + resolvedUriCache.get(uriString)?.let { cached -> + val age = System.currentTimeMillis() - cached.cachedAtMs + if (age <= resolvedUriCacheTtlMs) return@withContext cached.uri + resolvedUriCache.remove(uriString) + } val resolved: Uri? = when (uri.scheme) { "telegram" -> resolveTelegramUriAsync(uri, uriString) @@ -723,7 +746,7 @@ class DualPlayerEngine @Inject constructor( } if (resolved != null) { - resolvedUriCache.put(uriString, resolved) + resolvedUriCache.put(uriString, CachedResolvedUri(resolved, System.currentTimeMillis())) return@withContext resolved } uri diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearCommandReceiver.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearCommandReceiver.kt index 0b0ce27ee..d2e0271e0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearCommandReceiver.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/wear/WearCommandReceiver.kt @@ -543,13 +543,23 @@ class WearCommandReceiver : WearableListenerService() { * then falling back to ContentResolver. */ private fun openSongFile(song: Song): InputStream? { + // Only try direct File access for paths that look like real filesystem + // paths. Cloud-source songs (Telegram, Navidrome, Jellyfin, …) store + // their stream URI in song.path (e.g. "telegram://…"); constructing a + // File(song.path) for those is at best a no-op disk stat and at worst + // could match an unrelated on-disk filename. The contentUriString + // fallback is the canonical opener for cloud sources. + val pathIsLocalFile = song.path.isNotBlank() && + !song.path.contains("://") && + song.path.startsWith("/") return try { - val file = File(song.path) - if (file.exists() && file.canRead()) { - file.inputStream() - } else { - contentResolver.openInputStream(song.contentUriString.toUri()) + if (pathIsLocalFile) { + val file = File(song.path) + if (file.exists() && file.canRead()) { + return file.inputStream() + } } + contentResolver.openInputStream(song.contentUriString.toUri()) } catch (e: Exception) { Timber.tag(TAG).w(e, "Failed to open song file: ${song.path}") try { diff --git a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt index cbb13f8ab..715c52c14 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt @@ -99,6 +99,10 @@ class TelegramClientManager @Inject constructor( // Let's assume the error message `constructor(p0: Boolean, p1: String!, ...)` matches the fields. + // Credentials sourced from BuildConfig so per-build overrides + // via local.properties / Gradle properties take effect without + // committing secrets. Falls back to the legacy values so OSS + // contributors can still build the app out of the box. client?.send(TdApi.SetTdlibParameters( false, // useTestDc databaseDirectory, @@ -108,8 +112,8 @@ class TelegramClientManager @Inject constructor( true, // useChatInfoDatabase true, // useMessageDatabase false, // useSecretChats - 2040, // apiId - "b18441a1ff607e10a989891a5462e627", // apiHash + com.theveloper.pixelplay.BuildConfig.TELEGRAM_API_ID, + com.theveloper.pixelplay.BuildConfig.TELEGRAM_API_HASH, "en", // systemLanguageCode android.os.Build.MODEL, // deviceModel android.os.Build.VERSION.RELEASE, // systemVersion diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt index 958cc04a9..2b55ecc5e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt @@ -48,6 +48,7 @@ import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import kotlin.math.absoluteValue // Added +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -417,9 +418,20 @@ constructor( val finalTotalSongs = musicDao.getSongCount().first() Result.success(workDataOf(OUTPUT_TOTAL_SONGS to finalTotalSongs.toLong())) + } catch (e: CancellationException) { + Log.w(TAG, "Sync cancelled — returning retry so WorkManager re-runs", e) + throw e } catch (e: Exception) { Log.e(TAG, "Error during MediaStore synchronization", e) - Result.failure() + // Distinguish transient failures (DB busy, network/IO blip, + // SQLiteFullException recoverable) from permanent ones so + // WorkManager re-runs with exponential backoff rather than + // marking the sync permanently failed until next user refresh. + val isTransient = e is java.io.IOException || + e is android.database.sqlite.SQLiteCantOpenDatabaseException || + e is android.database.sqlite.SQLiteDatabaseLockedException || + e is android.database.sqlite.SQLiteDiskIOException + if (isTransient && runAttemptCount < 3) Result.retry() else Result.failure() } finally { Trace.endSection() // End SyncWorker.doWork } @@ -1362,6 +1374,42 @@ constructor( Log.d(TAG, "Genre cache invalidated") } + /** + * Stable 64-bit FNV-1a hash. Replaces `String.hashCode()` for synthetic + * Telegram/Netease song/album/artist IDs — the JDK's 32-bit hash has + * ~50% collision probability around 65k entries, which is reachable + * for large Telegram channels. FNV-1a keeps the full 64 bits and the + * collision probability stays below 1e-10 well past a million entries. + */ + internal fun stableFnv1aHash64(input: String): Long { + var hash = -3750763034362895579L // FNV-1a 64-bit offset basis + for (c in input) { + hash = hash xor (c.code.toLong() and 0xFFL) + hash *= 1099511628211L // FNV-1a 64-bit prime + } + return hash + } + + /** + * Produce a non-zero negative Long ID from a stable 64-bit hash of + * [input]. Negative values mark synthetic (non-MediaStore) IDs in the + * DB; the absolute-value step keeps the magnitude predictable, and + * we floor at -1 so the sentinel 0 cannot leak in. + */ + internal fun stableNegativeSyntheticId(input: String): Long { + val hash = stableFnv1aHash64(input) + val absHash = if (hash == Long.MIN_VALUE) Long.MAX_VALUE else kotlin.math.abs(hash) + val negated = -absHash + return if (negated == 0L) -1L else negated + } + + // 30s exponential backoff applied inline in each builder. Set after + // .setInputData/.setConstraints so the fluent chain stays in the + // OneTimeWorkRequest.Builder receiver and .build() resolves. + // Transient failures (Result.retry from SQLiteDiskIOException, IOException) + // are then retried automatically rather than waiting for the next + // user-initiated sync. + fun startUpSyncWork(deepScan: Boolean = false) = OneTimeWorkRequestBuilder() .setInputData( @@ -1370,11 +1418,19 @@ constructor( INPUT_SYNC_MODE to SyncMode.INCREMENTAL.name ) ) + .setBackoffCriteria( + androidx.work.BackoffPolicy.EXPONENTIAL, + 30, java.util.concurrent.TimeUnit.SECONDS + ) .build() fun incrementalSyncWork() = OneTimeWorkRequestBuilder() .setInputData(workDataOf(INPUT_SYNC_MODE to SyncMode.INCREMENTAL.name)) + .setBackoffCriteria( + androidx.work.BackoffPolicy.EXPONENTIAL, + 30, java.util.concurrent.TimeUnit.SECONDS + ) .build() // Full rescans and rebuilds do heavy bulk writes to Room + the album art cache. @@ -1395,12 +1451,20 @@ constructor( ) ) .setConstraints(heavySyncConstraints) + .setBackoffCriteria( + androidx.work.BackoffPolicy.EXPONENTIAL, + 30, java.util.concurrent.TimeUnit.SECONDS + ) .build() fun rebuildDatabaseWork() = OneTimeWorkRequestBuilder() .setInputData(workDataOf(INPUT_SYNC_MODE to SyncMode.REBUILD.name)) .setConstraints(heavySyncConstraints) + .setBackoffCriteria( + androidx.work.BackoffPolicy.EXPONENTIAL, + 30, java.util.concurrent.TimeUnit.SECONDS + ) .build() } @@ -1420,10 +1484,14 @@ constructor( return } - // 1. Pre-load Local Data for Merging - val existingArtists = musicDao.getAllArtistsListRaw().associate { it.name.trim().lowercase() to it.id } + // 1. Pre-load Local Data for Merging. Issue getAllArtistsListRaw + // once and derive both projections from the same list — Room + // doesn't auto-deduplicate suspend calls, so the previous + // two-call pattern re-queried the entire artists table. + val artistRowsForMerge = musicDao.getAllArtistsListRaw() + val existingArtists = artistRowsForMerge.associate { it.name.trim().lowercase() to it.id } val existingAlbums = musicDao.getAllAlbumsList(emptyList(), false, 0).associate { "${it.title.trim().lowercase()}_${it.artistName.trim().lowercase()}" to it.id } - val existingArtistImageUrls = musicDao.getAllArtistsListRaw().associate { it.id to it.imageUrl } + val existingArtistImageUrls = artistRowsForMerge.associate { it.id to it.imageUrl } val nextArtistId = AtomicLong((musicDao.getMaxArtistId() ?: 0L) + 1) val delimiters = userPreferencesRepository.artistDelimitersFlow.first() val wordDelims = userPreferencesRepository.artistWordDelimitersFlow.first() @@ -1437,9 +1505,10 @@ constructor( val channelName = channels[tSong.chatId]?.title ?: "Telegram Stream" // Synthetic negative ID for Song to check existence, but we want to merge metadata // We use negative IDs for songs to definitively identify them as Telegram-sourced in the DB - // This prevents collision with MediaStore numeric IDs. - val songId = -(tSong.id.hashCode().toLong().absoluteValue) - val finalSongId = if (songId == 0L) -1L else songId + // This prevents collision with MediaStore numeric IDs. tSong.id is + // formatted as "chatId_messageId" — a 64-bit hash over that gives + // far lower collision probability than String.hashCode(). + val finalSongId = stableNegativeSyntheticId(tSong.id) // 2. Metadata Refinement (ID3 for Downloaded Files) var realTitle = tSong.title @@ -1504,8 +1573,8 @@ constructor( existingId // Use Positive MediaStore ID } else { // Generate consistent negative ID for Telegram-only artist - val synthId = -(cleanName.hashCode().toLong().absoluteValue) - if (synthId == 0L) -1L else synthId + // via a 64-bit hash to avoid 32-bit collisions across libraries. + stableNegativeSyntheticId(cleanName) } if (index == 0) primaryArtistId = finalArtistId @@ -1536,9 +1605,9 @@ constructor( val finalAlbumId = if (existingAlbumId != null) { existingAlbumId // Merge with local album } else { - // Synthetic negative ID - val synthId = -(realAlbumName.hashCode().toLong().absoluteValue) - if (synthId == 0L) -1L else synthId + // Synthetic negative ID via a 64-bit hash (avoid 32-bit collisions + // between same-named albums across different Telegram channels). + stableNegativeSyntheticId(albumKey) } if (!albumsToInsert.containsKey(finalAlbumId)) { @@ -1758,13 +1827,20 @@ constructor( val normalized = if (albumId > 0L) { albumId.absoluteValue } else { - albumName.lowercase().hashCode().toLong().absoluteValue + // 64-bit hash for synthesized IDs — 32-bit String.hashCode() + // collisions across same-titled albums caused row overwrites. + stableFnv1aHash64(albumName.lowercase()).let { + if (it == Long.MIN_VALUE) Long.MAX_VALUE else kotlin.math.abs(it) + } } - return -(NETEASE_ALBUM_ID_OFFSET + normalized) + return -(NETEASE_ALBUM_ID_OFFSET + (normalized % (Long.MAX_VALUE - NETEASE_ALBUM_ID_OFFSET))) } private fun toUnifiedNeteaseArtistId(artistName: String): Long { - return -(NETEASE_ARTIST_ID_OFFSET + artistName.lowercase().hashCode().toLong().absoluteValue) + val hashed = stableFnv1aHash64(artistName.lowercase()).let { + if (it == Long.MIN_VALUE) Long.MAX_VALUE else kotlin.math.abs(it) + } + return -(NETEASE_ARTIST_ID_OFFSET + (hashed % (Long.MAX_VALUE - NETEASE_ARTIST_ID_OFFSET))) } private suspend fun syncNavidromeData() { diff --git a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt index 51211b0bc..cb76c4e15 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/AppModule.kt @@ -32,6 +32,7 @@ import com.theveloper.pixelplay.data.database.TransitionDao import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository import com.theveloper.pixelplay.data.preferences.dataStore +import com.theveloper.pixelplay.data.preferences.playbackDataStore import com.theveloper.pixelplay.data.media.SongMetadataEditor import com.theveloper.pixelplay.data.network.deezer.DeezerApiService import com.theveloper.pixelplay.data.network.netease.NeteaseApiService @@ -96,6 +97,19 @@ object AppModule { @ApplicationContext context: Context ): DataStore = context.dataStore + /** + * Dedicated playback-prefs DataStore. Lives in its own file + * ("playback.preferences_pb") so writes to playback preferences don't + * trigger re-emission across the 117-key main "settings" store. Migration + * of the existing playback keys is incremental — see UserPreferencesRepository. + */ + @Provides + @Singleton + @PlaybackDataStore + fun providePlaybackDataStore( + @ApplicationContext context: Context + ): DataStore = context.playbackDataStore + @Singleton @Provides fun provideJson(): Json { // Proveer Json @@ -373,7 +387,8 @@ object AppModule { songRepository: SongRepository, favoritesDao: FavoritesDao, artistImageRepository: ArtistImageRepository, - folderTreeBuilder: FolderTreeBuilder + folderTreeBuilder: FolderTreeBuilder, + @AppScope appScope: CoroutineScope, ): MusicRepository { return MusicRepositoryImpl( context = context, @@ -388,7 +403,8 @@ object AppModule { songRepository = songRepository, favoritesDao = favoritesDao, artistImageRepository = artistImageRepository, - folderTreeBuilder = folderTreeBuilder + folderTreeBuilder = folderTreeBuilder, + appScope = appScope, ) } @@ -418,7 +434,7 @@ object AppModule { */ @Provides @Singleton - fun provideOkHttpClient(): OkHttpClient { + fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient { val loggingInterceptor = HttpLoggingInterceptor().apply { // HEADERS (not BODY) so we never print response bodies that may contain // cookies, tokens, or third-party API payloads. Headers are still useful @@ -446,8 +462,16 @@ object AppModule { timeUnit = java.util.concurrent.TimeUnit.SECONDS ) + // 50 MB HTTP cache. Deezer artist images, LRCLIB hits, AMLLDB lyrics + // requests can benefit from RFC-7234 caching. Lyrics already maintain + // their own JSON disk cache, but image / metadata lookups had no + // HTTP-layer cache and re-hit the network on every cold launch. + val httpCacheDir = java.io.File(context.cacheDir, "okhttp-cache") + val httpCache = okhttp3.Cache(httpCacheDir, 50L * 1024L * 1024L) + return OkHttpClient.Builder() .connectionPool(connectionPool) + .cache(httpCache) .connectTimeout(8, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(8, java.util.concurrent.TimeUnit.SECONDS) .writeTimeout(8, java.util.concurrent.TimeUnit.SECONDS) diff --git a/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt b/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt index d0207b199..48c3f389f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt +++ b/app/src/main/java/com/theveloper/pixelplay/di/Qualifiers.kt @@ -29,3 +29,22 @@ annotation class BackupGson @Qualifier @Retention(AnnotationRetention.BINARY) annotation class AppScope + +/** + * Qualifier for the dedicated playback-prefs DataStore. Used to incrementally + * split the monolithic "settings" store into per-domain stores per the + * CODEBASE_REVIEW.md DataStore-split plan. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class PlaybackDataStore + +/** + * Qualifier for the default app-wide DataStore (the legacy "settings" + * store). Existing call sites that inject `DataStore` without + * a qualifier keep working; this qualifier lets new callers explicitly + * pick the non-playback store after the migration completes. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDataStore diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditSongSheet.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditSongSheet.kt index 5f52efe43..ef9cfb817 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditSongSheet.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/EditSongSheet.kt @@ -290,7 +290,12 @@ private fun EditSongContent( val density = LocalDensity.current val imeInsets = WindowInsets.ime - val isKeyboardVisible by remember { derivedStateOf { imeInsets.getBottom(density) > 0 } } + // Key on density so a display configuration change (e.g. external monitor) + // invalidates the cached derivation rather than evaluating IME bottom + // against a stale Density. + val isKeyboardVisible by remember(density) { + derivedStateOf { imeInsets.getBottom(density) > 0 } + } Scaffold( topBar = { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/MarqueeText.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/MarqueeText.kt index f3b8da239..8523da1d8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/MarqueeText.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/MarqueeText.kt @@ -85,8 +85,11 @@ fun AutoScrollingText( val initialDelayMillis = 1500 val fadeAnimationDuration = 500 - var isScrolling by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { + // Key on text so the initial-delay timer restarts when the + // displayed string changes — otherwise a track-skip keeps an + // already-elapsed timer running for the new title. + var isScrolling by remember(text) { mutableStateOf(false) } + LaunchedEffect(text) { isScrolling = false // Ensure initial state kotlinx.coroutines.delay(initialDelayMillis.toLong()) isScrolling = true diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistArtCollage.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistArtCollage.kt index 99427358c..40d57b286 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistArtCollage.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistArtCollage.kt @@ -26,12 +26,13 @@ import androidx.compose.ui.unit.dp import coil.size.Size import com.theveloper.pixelplay.R import com.theveloper.pixelplay.data.model.Song +import kotlinx.collections.immutable.ImmutableList import kotlin.math.floor import kotlin.math.sqrt @Composable fun PlaylistArtCollage( - songs: List, + songs: ImmutableList, modifier: Modifier = Modifier, ) { if (songs.isEmpty()) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt index e2b5bc7ca..01e961c7c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistContainer.kt @@ -1,6 +1,8 @@ package com.theveloper.pixelplay.presentation.components import com.theveloper.pixelplay.presentation.navigation.navigateSafely +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState @@ -421,9 +423,13 @@ fun PlaylistItem( modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { + val coverSongs = remember(playlistSongs) { + playlistSongs?.toPersistentList() + ?: persistentListOf() + } PlaylistCover( playlist = playlist, - playlistSongs = playlistSongs ?: emptyList(), + playlistSongs = coverSongs, size = 48.dp ) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCover.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCover.kt index c3988654f..f7db26821 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCover.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/PlaylistCover.kt @@ -36,7 +36,7 @@ import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape @Composable fun PlaylistCover( playlist: Playlist, - playlistSongs: List, + playlistSongs: kotlinx.collections.immutable.ImmutableList, modifier: Modifier = Modifier, size: Dp = 48.dp ) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SmartImage.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SmartImage.kt index 4dc08f903..3ed456934 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/SmartImage.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/SmartImage.kt @@ -54,7 +54,11 @@ fun SmartImage( crossfadeDurationMillis: Int = 300, useDiskCache: Boolean = true, useMemoryCache: Boolean = true, - allowHardware: Boolean = false, + // Default to hardware bitmaps — the global ImageLoader is built with + // .allowHardware(true) for memory efficiency (~256 KB ARGB_8888 image + // becomes a small handle into VRAM). Only palette/color-extraction call + // sites that need to read pixels should pass false explicitly. + allowHardware: Boolean = true, targetSize: Size = DefaultSmartImageSize, colorFilter: ColorFilter? = null, alpha: Float = 1f, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt index 2a386b601..040fffdf0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/HomeScreen.kt @@ -394,10 +394,16 @@ fun HomeScreen( val isAutoRotate = settingsUiState.collageAutoRotate val patterns = remember { CollagePattern.entries } + // Hoisted outside the if-branch so the rotation index + // survives auto-rotate being toggled on/off (an + // if-branch rememberSaveable is destroyed when the + // branch flips, losing position). + var rotationIndex by rememberSaveable { mutableIntStateOf(-1) } + LaunchedEffect(isAutoRotate) { + if (isAutoRotate) rotationIndex++ + } val activePattern = if (isAutoRotate) { - var rotationIndex by rememberSaveable { mutableIntStateOf(-1) } - LaunchedEffect(Unit) { rotationIndex++ } - remember(rotationIndex) { + remember(rotationIndex, patterns) { patterns[rotationIndex.coerceAtLeast(0) % patterns.size] } } else { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt index 1a5daadb7..89bd6f050 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryMediaTabs.kt @@ -61,6 +61,9 @@ import com.theveloper.pixelplay.data.model.SortOption import com.theveloper.pixelplay.data.model.StorageFilter import com.theveloper.pixelplay.presentation.components.ExpressiveScrollBar import com.theveloper.pixelplay.presentation.components.MiniPlayerHeight +import com.theveloper.pixelplay.presentation.screens.library.AlbumGridItemRedesigned +import com.theveloper.pixelplay.presentation.screens.library.AlbumListItem +import com.theveloper.pixelplay.presentation.screens.library.ArtistListItem import com.theveloper.pixelplay.presentation.components.PlaylistContainer import com.theveloper.pixelplay.presentation.components.albumFastScrollLabel import com.theveloper.pixelplay.presentation.components.artistFastScrollLabel diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt index ed3d45d35..f92d87bee 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibraryScreen.kt @@ -4,6 +4,21 @@ package com.theveloper.pixelplay.presentation.screens import com.theveloper.pixelplay.presentation.navigation.navigateSafely import com.theveloper.pixelplay.presentation.navigation.navigateSafelyReplacing +import com.theveloper.pixelplay.presentation.screens.library.CompactLibraryPagerIndicator +import com.theveloper.pixelplay.presentation.screens.library.FolderListItem +import com.theveloper.pixelplay.presentation.screens.library.FolderPlaylistItem +import com.theveloper.pixelplay.presentation.screens.library.AlbumGridItemRedesigned +import com.theveloper.pixelplay.presentation.screens.library.AlbumListItem +import com.theveloper.pixelplay.presentation.screens.library.ArtistListItem +import com.theveloper.pixelplay.presentation.screens.library.LibraryTabGridItem +import com.theveloper.pixelplay.presentation.screens.library.displayTitle +import com.theveloper.pixelplay.presentation.screens.library.iconRes +import com.theveloper.pixelplay.presentation.screens.library.flattenFolders +import com.theveloper.pixelplay.presentation.screens.library.sortMusicFoldersByOption +import com.theveloper.pixelplay.presentation.screens.library.sortSongsForFolderView +import com.theveloper.pixelplay.presentation.screens.library.LibraryInlineSyncIndicator +import com.theveloper.pixelplay.presentation.screens.library.LibrarySyncOverlay +import com.theveloper.pixelplay.presentation.screens.library.WatchTransferProgressDialog import android.os.Trace import android.text.format.Formatter @@ -206,6 +221,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -260,128 +276,9 @@ private const val PULL_REFRESH_MIN_VISIBLE_MS = 900L private const val PULL_REFRESH_MAX_VISIBLE_MS = 1_500L private const val INLINE_SYNC_MIN_VISIBLE_MS = 600L -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun WatchTransferProgressDialog( - transfer: PhoneWatchTransferState, - onDismiss: () -> Unit, - onCancelTransfer: () -> Unit, -) { - val context = LocalContext.current - val startingTransfer = stringResource(R.string.presentation_batch_d_watch_starting_transfer) - val preparingTransfer = stringResource(R.string.presentation_batch_d_watch_preparing_transfer) - val animatedProgress by animateFloatAsState( - targetValue = transfer.progress.coerceIn(0f, 1f), - animationSpec = tween(durationMillis = 300), - label = "WatchTransferProgressDialog" - ) - val progressPercent = (animatedProgress * 100f).toInt().coerceIn(0, 100) - val bytesText = if (transfer.totalBytes > 0L) { - val sent = Formatter.formatFileSize(context, transfer.bytesTransferred) - val total = Formatter.formatFileSize(context, transfer.totalBytes) - stringResource(R.string.presentation_batch_h_transfer_bytes_progress, sent, total) - } else { - startingTransfer - } - val statusText = when (transfer.status) { - WearTransferProgress.STATUS_TRANSFERRING -> stringResource(R.string.presentation_batch_d_watch_status_transferring) - WearTransferProgress.STATUS_COMPLETED -> stringResource(R.string.presentation_batch_d_watch_status_completed) - WearTransferProgress.STATUS_FAILED -> stringResource(R.string.presentation_batch_d_watch_status_failed) - WearTransferProgress.STATUS_CANCELLED -> stringResource(R.string.presentation_batch_d_watch_status_cancelled) - else -> stringResource(R.string.presentation_batch_d_watch_status_preparing) - } - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties( - dismissOnBackPress = true, - dismissOnClickOutside = true - ) - ) { - Surface( - shape = RoundedCornerShape(28.dp), - tonalElevation = 6.dp, - color = MaterialTheme.colorScheme.surface - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = stringResource(R.string.presentation_batch_d_watch_sending_title), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold - ) - Box( - modifier = Modifier - .size(96.dp) - .padding(vertical = 20.dp), - contentAlignment = Alignment.Center - ) { - LoadingIndicator( - modifier = Modifier - .fillMaxSize() - .scale(1.84f), - color = MaterialTheme.colorScheme.primary - ) - Text( - text = stringResource(R.string.presentation_batch_g_sync_percent, progressPercent), - style = MaterialTheme.typography.labelLarge.copy( - fontSize = MaterialTheme.typography.labelLarge.fontSize * 1.4f - ), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimary - ) - } - LinearWavyProgressIndicator( - progress = { animatedProgress }, - modifier = Modifier - .fillMaxWidth() - .height(8.dp) - .clip(RoundedCornerShape(50)), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - Text( - text = transfer.songTitle.ifBlank { preparingTransfer }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center - ) - Text( - text = stringResource(R.string.presentation_batch_f_status_bullet_step, statusText, bytesText), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - transfer.error?.takeIf { it.isNotBlank() }?.let { errorText -> - Text( - text = errorText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center - ) - } - Button( - modifier = Modifier.padding(top = 4.dp), - onClick = onCancelTransfer, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError - ) - ) { - Text(text = stringResource(R.string.presentation_batch_d_watch_cancel_transfer), maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } - } - } -} +// WatchTransferProgressDialog moved to presentation/screens/library/ +// WatchTransferProgressDialog.kt as the first step of the LibraryScreen +// decomposition. Imported below. private data class LibraryScreenPlayerProjection( val currentFolder: MusicFolder? = null, @@ -2170,156 +2067,9 @@ fun LibraryScreen( } } -@Composable -private fun CompactLibraryPagerIndicator( - currentIndex: Int, - pageCount: Int, - modifier: Modifier = Modifier -) { - if (pageCount <= 1) return - - val safeIndex = positiveMod(currentIndex, pageCount) - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - repeat(pageCount) { index -> - val selected = index == safeIndex - val width by animateDpAsState( - targetValue = if (selected) 22.dp else 10.dp, - label = "LibraryCompactPagerIndicatorWidth" - ) - val alpha by animateFloatAsState( - targetValue = if (selected) 1f else 0.35f, - label = "LibraryCompactPagerIndicatorAlpha" - ) - - Box( - modifier = Modifier - .padding(horizontal = 3.dp) - .height(4.dp) - .width(width) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary.copy(alpha = alpha)) - ) - } - } -} - -/** - * Slim, non-intrusive indicator for sync work that should not keep the list pulled - * down: automatic startup syncs, background maintenance, and manual refreshes after - * the short pull-to-refresh confirmation window. It sits just below - * [LibraryActionRow] and collapses to zero height when not active. - * - * Distinct from [LibrarySyncOverlay], which is reserved for initial empty-library - * loads. The parent screen also gates this indicator off while the pull spinner is - * visible, so the two feedback channels do not compete. - */ -@Composable -private fun LibraryInlineSyncIndicator( - visible: Boolean, - syncManager: com.theveloper.pixelplay.data.worker.SyncManager -) { - AnimatedVisibility( - visible = visible, - enter = androidx.compose.animation.expandVertically( - expandFrom = Alignment.Top, - animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing) - ) + androidx.compose.animation.fadeIn(animationSpec = tween(180)), - exit = androidx.compose.animation.shrinkVertically( - shrinkTowards = Alignment.Top, - animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing) - ) + androidx.compose.animation.fadeOut(animationSpec = tween(160)) - ) { - // Collected inside this subtree so progress ticks don't recompose the - // parent screen — same pattern as LibrarySyncOverlay. - val syncProgress by syncManager.syncProgress - .collectAsStateWithLifecycle(initialValue = SyncProgress()) - - val phaseLabel = when (syncProgress.phase) { - SyncProgress.SyncPhase.FETCHING_MEDIASTORE -> - stringResource(R.string.sync_scanning) - SyncProgress.SyncPhase.PROCESSING_FILES, - SyncProgress.SyncPhase.SAVING_TO_DATABASE -> - stringResource(R.string.sync_processing) - SyncProgress.SyncPhase.SCANNING_LRC -> - stringResource(R.string.library_background_sync_lyrics) - SyncProgress.SyncPhase.CLEANING_CACHE -> - stringResource(R.string.library_background_sync_cache) - SyncProgress.SyncPhase.SYNCING_CLOUD -> - stringResource(R.string.library_background_sync_cloud) - else -> - stringResource(R.string.sync_in_progress) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp) - ) { - Text( - text = phaseLabel, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(4.dp)) - LinearWavyProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(4.dp), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceContainerHighest - ) - } - } -} - -/** - * P1-1: Isolated sync/loading overlay composable. - * - * By collecting [SyncManager.syncProgress] HERE instead of in the parent [LibraryScreen], - * only this small subtree recomposes on every progress tick (e.g., file count updates - * during a library scan). The rest of [LibraryScreen] — including the Scaffold, pager, - * and all tab content — remains unaffected during sync. - */ -@Composable -private fun LibrarySyncOverlay(syncManager: com.theveloper.pixelplay.data.worker.SyncManager) { - val syncProgress by syncManager.syncProgress - .collectAsStateWithLifecycle(initialValue = SyncProgress()) - - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f) - ) { - Box(contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(32.dp) - ) { - if (syncProgress.hasProgress && syncProgress.isRunning) { - // Show progress bar with file count when we have progress info - SyncProgressBar( - syncProgress = syncProgress, - modifier = Modifier.fillMaxWidth() - ) - } else { - // Show indeterminate loading indicator when scanning starts - LoadingIndicator(modifier = Modifier.size(64.dp)) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(R.string.syncing_library), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface - ) - } - } - } - } -} +// CompactLibraryPagerIndicator, LibraryInlineSyncIndicator, and +// LibrarySyncOverlay moved to presentation/screens/library/LibrarySyncIndicators.kt +// as part of the LibraryScreen decomposition. @OptIn(ExperimentalAnimationApi::class) @Composable @@ -2705,58 +2455,10 @@ private fun LibraryTabSwitcherSheet( } } -@Composable -private fun LibraryTabGridItem( - tabId: LibraryTabId, - isSelected: Boolean, - onClick: () -> Unit -) { - val shape = RoundedCornerShape(20.dp) - val containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh - val iconContainer = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer - val textColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface - - Surface( - modifier = Modifier - .fillMaxWidth() - .clip(shape) - .clickable(onClick = onClick), - shape = shape, - color = containerColor, - tonalElevation = if (isSelected) 6.dp else 2.dp - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 14.dp, horizontal = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Box( - modifier = Modifier - .size(52.dp) - .clip(CircleShape) - .background(iconContainer.copy(alpha = 0.92f)), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(id = tabId.iconRes()), - contentDescription = tabId.title, - tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer - ) - } +// LibraryTabGridItem moved to presentation/screens/library/LibraryTabGridItem.kt. - Text( - text = tabId.displayTitle(), - style = MaterialTheme.typography.titleMedium, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, - color = textColor - ) - } - } -} - -private fun positiveMod(value: Int, mod: Int): Int { +// Exposed to the library/ subpackage during the LibraryScreen decomposition. +internal fun positiveMod(value: Int, mod: Int): Int { if (mod <= 0) return 0 return ((value % mod) + mod) % mod } @@ -2793,19 +2495,8 @@ private fun targetPageForTabIndex( ?: candidate } -private fun LibraryTabId.iconRes(): Int = when (this) { - LibraryTabId.SONGS -> R.drawable.rounded_music_note_24 - LibraryTabId.ALBUMS -> R.drawable.rounded_album_24 - LibraryTabId.ARTISTS -> R.drawable.rounded_artist_24 - LibraryTabId.PLAYLISTS -> R.drawable.rounded_playlist_play_24 - LibraryTabId.FOLDERS -> R.drawable.rounded_folder_24 - LibraryTabId.LIKED -> R.drawable.round_favorite_24 -} - -private fun LibraryTabId.displayTitle(): String = - title.lowercase().replaceFirstChar { char -> - if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else char.toString() - } +// LibraryTabId.iconRes() and displayTitle() moved to +// presentation/screens/library/LibraryTabGridItem.kt (internal extensions). internal fun resolveFolderNavigationDirection(initialPath: String?, targetPath: String?): Int = when { @@ -3123,628 +2814,18 @@ fun LibraryFoldersTab( } } -@Composable -fun FolderPlaylistItem(folder: MusicFolder, onClick: () -> Unit) { - val previewSongs = remember(folder) { folder.collectAllSongs().take(9) } - - Card( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - PlaylistArtCollage( - songs = previewSongs, - modifier = Modifier.size(48.dp) - ) - - Spacer(modifier = Modifier.width(16.dp)) +// FolderPlaylistItem and FolderListItem moved to +// presentation/screens/library/FolderItems.kt. - Column(modifier = Modifier.weight(1f)) { - Text( - folder.name, - style = MaterialTheme.typography.titleMedium.copy(fontFamily = GoogleSansRounded), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - formatSongCount(folder.totalSongCount), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} +// flattenFolders, sortMusicFoldersByOption, sortSongsForFolderView moved to +// presentation/screens/library/FolderSortHelpers.kt — pure functions, now +// JVM-testable. -@Composable -fun FolderListItem(folder: MusicFolder, onClick: () -> Unit) { - Card( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) - ) { - Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(id = R.drawable.ic_folder), - contentDescription = stringResource(R.string.presentation_batch_d_cd_folder), - modifier = Modifier - .size(48.dp) - .background(MaterialTheme.colorScheme.primaryContainer, CircleShape) - .padding(8.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text(folder.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text(formatSongCount(folder.totalSongCount), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } -} - -private fun flattenFolders(folders: List): List { - return folders.flatMap { folder -> - val current = if (folder.songs.isNotEmpty()) listOf(folder) else emptyList() - current + flattenFolders(folder.subFolders) - } -} - -private fun sortMusicFoldersByOption(folders: List, sortOption: SortOption): List { - return when (sortOption) { - SortOption.FolderNameAZ -> folders.sortedWith( - compareBy { it.name.lowercase() } - .thenBy { it.path } - ) - SortOption.FolderNameZA -> folders.sortedWith( - compareByDescending { it.name.lowercase() } - .thenBy { it.path } - ) - SortOption.FolderSongCountAsc -> folders.sortedWith( - compareBy { it.totalSongCount } - .thenBy { it.name.lowercase() } - .thenBy { it.path } - ) - SortOption.FolderSongCountDesc -> folders.sortedWith( - compareByDescending { it.totalSongCount } - .thenBy { it.name.lowercase() } - .thenBy { it.path } - ) - SortOption.FolderSubdirCountAsc -> folders.sortedWith( - compareBy { it.totalSubFolderCount } - .thenBy { it.name.lowercase() } - .thenBy { it.path } - ) - SortOption.FolderSubdirCountDesc -> folders.sortedWith( - compareByDescending { it.totalSubFolderCount } - .thenBy { it.name.lowercase() } - .thenBy { it.path } - ) - else -> folders.sortedWith( - compareBy { it.name.lowercase() } - .thenBy { it.path } - ) - } -} - -private fun sortSongsForFolderView(songs: List, sortOption: SortOption): List { - return when (sortOption) { - SortOption.FolderNameZA -> songs.sortedWith( - compareByDescending { it.title.lowercase() } - .thenBy { it.artist.lowercase() } - .thenBy { it.id } - ) - else -> songs.sortedWith( - compareBy { it.title.lowercase() } - .thenBy { it.artist.lowercase() } - .thenBy { it.id } - ) - } -} - -private fun MusicFolder.collectAllSongs(): List { +internal fun MusicFolder.collectAllSongs(): List { return songs + subFolders.flatMap { it.collectAllSongs() } } +// AlbumGridItemRedesigned moved to presentation/screens/library/AlbumGridItemRedesigned.kt -@androidx.annotation.OptIn(UnstableApi::class) -@Composable -fun AlbumGridItemRedesigned( - album: Album, - albumColorSchemePairFlow: StateFlow, - onClick: () -> Unit, - isLoading: Boolean = false, - isSelectionMode: Boolean = false, - isSelected: Boolean = false, - selectionIndex: Int? = null, - onLongPress: () -> Unit = {}, - onSelectionToggle: () -> Unit = {} -) { - val albumColorSchemePair by albumColorSchemePairFlow.collectAsStateWithLifecycle() - val systemIsDark = LocalPixelPlayDarkTheme.current - - // 1. Obtén el colorScheme del tema actual aquí, en el scope Composable. - val currentMaterialColorScheme = MaterialTheme.colorScheme - - val itemDesignColorScheme = remember(albumColorSchemePair, systemIsDark, currentMaterialColorScheme) { - // 2. Ahora, currentMaterialColorScheme es una variable estable que puedes usar. - albumColorSchemePair?.let { pair -> - if (systemIsDark) pair.dark else pair.light - } ?: currentMaterialColorScheme // Usa la variable capturada - } - - val gradientBaseColor = itemDesignColorScheme.primaryContainer - val onGradientColor = itemDesignColorScheme.onPrimaryContainer - val cardCornerRadius = 20.dp - val cardShape = RoundedCornerShape(cardCornerRadius) - val selectionScale by animateFloatAsState( - targetValue = if (isSelected) 0.985f else 1f, - animationSpec = tween(durationMillis = 220), - label = "albumGridSelectionScale" - ) - val selectionBorderWidth by animateDpAsState( - targetValue = if (isSelected) 2.dp else 0.dp, - animationSpec = tween(durationMillis = 220), - label = "albumGridSelectionBorder" - ) - - if (isLoading) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = cardShape, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) - ) { - Column( - modifier = Modifier.background( - color = MaterialTheme.colorScheme.primaryContainer, - shape = cardShape - ) - ) { - ShimmerBox( - modifier = Modifier - .aspectRatio(3f / 2f) - .fillMaxSize() - ) - Column( - modifier = Modifier - .fillMaxWidth() - .height(84.dp) - .padding(12.dp), - verticalArrangement = Arrangement.Center - ) { - ShimmerBox( - modifier = Modifier - .fillMaxWidth(0.8f) - .height(20.dp) - .clip(RoundedCornerShape(4.dp)) - ) - Spacer(modifier = Modifier.height(4.dp)) - ShimmerBox( - modifier = Modifier - .fillMaxWidth(0.6f) - .height(16.dp) - .clip(RoundedCornerShape(4.dp)) - ) - Spacer(modifier = Modifier.height(4.dp)) - ShimmerBox( - modifier = Modifier - .fillMaxWidth(0.4f) - .height(16.dp) - .clip(RoundedCornerShape(4.dp)) - ) - } - } - } - } else { - Card( - modifier = Modifier - .fillMaxWidth() - .scale(selectionScale) - .then( - if (isSelected) { - Modifier.border( - width = selectionBorderWidth, - color = MaterialTheme.colorScheme.primary, - shape = cardShape - ) - } else { - Modifier - } - ) - .clip(cardShape) - .combinedClickable( - onClick = { - if (isSelectionMode) { - onSelectionToggle() - } else { - onClick() - } - }, - onLongClick = onLongPress - ), - shape = cardShape, - //elevation = CardDefaults.cardElevation(defaultElevation = 4.dp, pressedElevation = 8.dp), - colors = CardDefaults.cardColors(containerColor = itemDesignColorScheme.surfaceVariant.copy(alpha = 0.3f)) - ) { - Box { - Column( - modifier = Modifier.background( - color = gradientBaseColor, - shape = cardShape - ) - ) { - Box(contentAlignment = Alignment.BottomStart) { - var isLoadingImage by remember { mutableStateOf(true) } - SmartImage( - model = album.albumArtUriString, - contentDescription = stringResource(R.string.cd_album_art_for_title, album.title), - contentScale = ContentScale.Crop, - // Reducido el tamaño para mejorar el rendimiento del scroll, como se sugiere en el informe. - // ContentScale.Crop se encargará de ajustar la imagen al aspect ratio. - targetSize = Size(256, 256), - modifier = Modifier - .aspectRatio(3f / 2f) - .fillMaxSize(), - onState = { state -> - isLoadingImage = state is AsyncImagePainter.State.Loading - } - ) - if (isLoadingImage) { - ShimmerBox( - modifier = Modifier - .aspectRatio(3f / 2f) - .fillMaxSize() - ) - } - Box( - modifier = Modifier - .fillMaxSize() - .aspectRatio(3f / 2f) - .background( - remember(gradientBaseColor) { // Recordar el Brush - Brush.verticalGradient( - colors = listOf( - Color.Transparent, gradientBaseColor - ) - ) - }) - ) - } - Column( - modifier = Modifier - .fillMaxWidth() - .height(84.dp) - .padding(12.dp), - verticalArrangement = Arrangement.Center - ) { - Text( - album.title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = onGradientColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text(album.artist, style = MaterialTheme.typography.bodySmall, color = onGradientColor.copy(alpha = 0.85f), maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(formatSongCount(album.songCount), style = MaterialTheme.typography.bodySmall, color = onGradientColor.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis) - } - } - - if (isSelectionMode && isSelected) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(10.dp) - .size(28.dp) - .background( - color = MaterialTheme.colorScheme.primary, - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Text( - text = selectionIndex?.toString() ?: "✓", - color = MaterialTheme.colorScheme.onPrimary, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold - ) - } - } - } - } - } -} - -@androidx.annotation.OptIn(UnstableApi::class) -@Composable -fun ArtistListItem(artist: Artist, onClick: () -> Unit, isLoading: Boolean = false) { - Card( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow - ) - ) { - Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - if (isLoading) { - // Skeleton loading state - ShimmerBox( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - ) - Spacer(modifier = Modifier.width(16.dp)) - Column { - ShimmerBox( - modifier = Modifier - .fillMaxWidth(0.6f) - .height(20.dp) - .clip(RoundedCornerShape(4.dp)) - ) - Spacer(modifier = Modifier.height(4.dp)) - ShimmerBox( - modifier = Modifier - .fillMaxWidth(0.3f) - .height(16.dp) - .clip(RoundedCornerShape(4.dp)) - ) - } - } else { - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center - ) { - if (!artist.effectiveImageUrl.isNullOrEmpty()) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(artist.effectiveImageUrl) - .crossfade(true) - .build(), - contentDescription = artist.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - } else { - Icon( - painter = painterResource(R.drawable.rounded_artist_24), - contentDescription = stringResource(R.string.presentation_batch_d_cd_artist), - modifier = Modifier.padding(8.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - Spacer(modifier = Modifier.width(16.dp)) - Column { - Text(artist.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text(formatSongCount(artist.songCount), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - } -} - -@androidx.annotation.OptIn(UnstableApi::class) -@Composable -fun AlbumListItem( - album: Album, - albumColorSchemePairFlow: StateFlow, - onClick: () -> Unit, - isLoading: Boolean = false, - isSelectionMode: Boolean = false, - isSelected: Boolean = false, - selectionIndex: Int? = null, - onLongPress: () -> Unit = {}, - onSelectionToggle: () -> Unit = {} -) { - val albumColorSchemePair by albumColorSchemePairFlow.collectAsStateWithLifecycle() - val systemIsDark = LocalPixelPlayDarkTheme.current - val currentMaterialColorScheme = MaterialTheme.colorScheme - - val itemDesignColorScheme = remember(albumColorSchemePair, systemIsDark, currentMaterialColorScheme) { - albumColorSchemePair?.let { pair -> - if (systemIsDark) pair.dark else pair.light - } ?: currentMaterialColorScheme - } - - val gradientBaseColor = itemDesignColorScheme.primaryContainer - val onGradientColor = itemDesignColorScheme.onPrimaryContainer - val cardCornerRadius = 16.dp - val cardShape = RoundedCornerShape(cardCornerRadius) - val selectionScale by animateFloatAsState( - targetValue = if (isSelected) 0.99f else 1f, - animationSpec = tween(durationMillis = 200), - label = "albumListSelectionScale" - ) - val selectionBorderWidth by animateDpAsState( - targetValue = if (isSelected) 2.dp else 0.dp, - animationSpec = tween(durationMillis = 200), - label = "albumListSelectionBorder" - ) +// ArtistListItem moved to presentation/screens/library/ArtistListItem.kt - if (isLoading) { - Card( - modifier = Modifier - .fillMaxWidth() - .height(80.dp), - shape = cardShape, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) - ) { - Row(modifier = Modifier.fillMaxSize()) { - ShimmerBox( - modifier = Modifier - .aspectRatio(1f) - .fillMaxHeight() - ) - Column( - modifier = Modifier - .weight(1f) - .padding(12.dp), - verticalArrangement = Arrangement.Center - ) { - ShimmerBox( - modifier = Modifier - .fillMaxWidth(0.6f) - .height(16.dp) - .clip(RoundedCornerShape(4.dp)) - ) - Spacer(modifier = Modifier.height(8.dp)) - ShimmerBox( - modifier = Modifier - .fillMaxWidth(0.4f) - .height(14.dp) - .clip(RoundedCornerShape(4.dp)) - ) - } - } - } - } else { - Card( - modifier = Modifier - .fillMaxWidth() - .height(88.dp) - .scale(selectionScale) - .then( - if (isSelected) { - Modifier.border( - width = selectionBorderWidth, - color = MaterialTheme.colorScheme.primary, - shape = cardShape - ) - } else { - Modifier - } - ) - .clip(cardShape) - .combinedClickable( - onClick = { - if (isSelectionMode) { - onSelectionToggle() - } else { - onClick() - } - }, - onLongClick = onLongPress - ), - shape = cardShape, - colors = CardDefaults.cardColors(containerColor = itemDesignColorScheme.surfaceVariant.copy(alpha = 0.3f)) - ) { - Box(modifier = Modifier.fillMaxSize()) { - Row( - modifier = Modifier.fillMaxSize() - ) { - // LEFT: Album Art - Box( - modifier = Modifier - .aspectRatio(1f) - .fillMaxHeight() - ) { - var isLoadingImage by remember { mutableStateOf(true) } - SmartImage( - model = album.albumArtUriString, - contentDescription = stringResource(R.string.cd_album_art_for_title, album.title), - contentScale = ContentScale.Crop, - targetSize = Size(256, 256), - modifier = Modifier.fillMaxSize(), - onState = { state -> - isLoadingImage = state is AsyncImagePainter.State.Loading - } - ) - if (isLoadingImage) { - ShimmerBox(modifier = Modifier.fillMaxSize()) - } - - // Gradient Overlay - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.horizontalGradient( - colors = listOf( - Color.Transparent, - gradientBaseColor - ) - ) - ) - ) - } - - // MIDDLE: Solid Background - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .background(gradientBaseColor) - ) { - // Text on top of the gradient/solid background - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.Center - ) { - val variableTextStyle = remember(album.id, album.title) { - GenreTypography.getGenreStyle(album.id.toString(), album.title) - } - - Text( - album.title, - style = variableTextStyle.copy(fontSize = 22.sp), - color = onGradientColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer( - modifier = Modifier.height(4.dp) - ) - Text( - album.artist, - style = MaterialTheme.typography.bodySmall, - color = onGradientColor.copy(alpha = 0.85f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - formatSongCount(album.songCount), - style = MaterialTheme.typography.bodySmall, - color = onGradientColor.copy(alpha = 0.7f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - - if (isSelectionMode && isSelected) { - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(8.dp) - .size(24.dp) - .background( - color = MaterialTheme.colorScheme.primary, - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - Text( - text = selectionIndex?.toString() ?: "✓", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.Bold - ) - } - } - } - } - } -} +// AlbumListItem moved to presentation/screens/library/AlbumListItem.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt index 72f2e9be6..e36b30f53 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/LibrarySongsTab.kt @@ -333,9 +333,10 @@ fun LibrarySongsTab( if (song != null) { val isSelected = selectedSongIds.contains(song.id) - val rememberedOnMoreOptionsClick: (Song) -> Unit = remember(onMoreOptionsClick) { - { songFromListItem -> onMoreOptionsClick(songFromListItem) } - } + // The previous wrapper `{ s -> onMoreOptionsClick(s) }` + // was identical to passing onMoreOptionsClick directly, + // and added a remember() slot per item for no gain. + val rememberedOnMoreOptionsClick: (Song) -> Unit = onMoreOptionsClick // In selection mode, click toggles selection instead of playing val rememberedOnClick: () -> Unit = remember(song, isSelectionMode) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt index f1fdaf33d..165141e35 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/SearchScreen.kt @@ -118,6 +118,7 @@ import com.theveloper.pixelplay.utils.formatSongCount import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -862,9 +863,12 @@ fun SearchResultsList( } is SearchResultItem.PlaylistItem -> { - val playlistSongs by remember(item.playlist.songIds, playerViewModel) { + val playlistSongsRaw by remember(item.playlist.songIds, playerViewModel) { playerViewModel.observeSongs(item.playlist.songIds) }.collectAsStateWithLifecycle(initialValue = emptyList()) + val playlistSongs = remember(playlistSongsRaw) { + playlistSongsRaw.toPersistentList() + } val coroutineScope = rememberCoroutineScope() val onPlayClick: () -> Unit = { coroutineScope.launch { @@ -1071,7 +1075,7 @@ fun SearchResultArtistItem( @Composable fun SearchResultPlaylistItem( playlist: Playlist, - playlistSongs: List, + playlistSongs: kotlinx.collections.immutable.ImmutableList, onOpenClick: () -> Unit, onPlayClick: () -> Unit ) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/AlbumGridItemRedesigned.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/AlbumGridItemRedesigned.kt new file mode 100644 index 000000000..5c4b05c24 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/AlbumGridItemRedesigned.kt @@ -0,0 +1,258 @@ +package com.theveloper.pixelplay.presentation.screens.library + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.util.UnstableApi +import coil.compose.AsyncImagePainter +import coil.size.Size +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.model.Album +import com.theveloper.pixelplay.presentation.components.ShimmerBox +import com.theveloper.pixelplay.presentation.components.SmartImage +import com.theveloper.pixelplay.presentation.viewmodel.ColorSchemePair +import com.theveloper.pixelplay.ui.theme.LocalPixelPlayDarkTheme +import com.theveloper.pixelplay.utils.formatSongCount +import kotlinx.coroutines.flow.StateFlow + +/** + * Grid-style album item with extracted-palette gradient and selection-mode UI. + * Extracted from `LibraryScreen.kt`. + */ +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +internal fun AlbumGridItemRedesigned( + album: Album, + albumColorSchemePairFlow: StateFlow, + onClick: () -> Unit, + isLoading: Boolean = false, + isSelectionMode: Boolean = false, + isSelected: Boolean = false, + selectionIndex: Int? = null, + onLongPress: () -> Unit = {}, + onSelectionToggle: () -> Unit = {} +) { + val albumColorSchemePair by albumColorSchemePairFlow.collectAsStateWithLifecycle() + val systemIsDark = LocalPixelPlayDarkTheme.current + val currentMaterialColorScheme = MaterialTheme.colorScheme + + val itemDesignColorScheme = remember(albumColorSchemePair, systemIsDark, currentMaterialColorScheme) { + albumColorSchemePair?.let { pair -> + if (systemIsDark) pair.dark else pair.light + } ?: currentMaterialColorScheme + } + + val gradientBaseColor = itemDesignColorScheme.primaryContainer + val onGradientColor = itemDesignColorScheme.onPrimaryContainer + val cardCornerRadius = 20.dp + val cardShape = RoundedCornerShape(cardCornerRadius) + val selectionScale by animateFloatAsState( + targetValue = if (isSelected) 0.985f else 1f, + animationSpec = tween(durationMillis = 220), + label = "albumGridSelectionScale" + ) + val selectionBorderWidth by animateDpAsState( + targetValue = if (isSelected) 2.dp else 0.dp, + animationSpec = tween(durationMillis = 220), + label = "albumGridSelectionBorder" + ) + + if (isLoading) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = cardShape, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + ) { + Column( + modifier = Modifier.background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = cardShape + ) + ) { + ShimmerBox( + modifier = Modifier + .aspectRatio(3f / 2f) + .fillMaxSize() + ) + Column( + modifier = Modifier + .fillMaxWidth() + .height(84.dp) + .padding(12.dp), + verticalArrangement = Arrangement.Center + ) { + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.8f) + .height(20.dp) + .clip(RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.height(4.dp)) + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.6f) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.height(4.dp)) + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.4f) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + ) + } + } + } + } else { + Card( + modifier = Modifier + .fillMaxWidth() + .scale(selectionScale) + .then( + if (isSelected) { + Modifier.border( + width = selectionBorderWidth, + color = MaterialTheme.colorScheme.primary, + shape = cardShape + ) + } else { + Modifier + } + ) + .clip(cardShape) + .combinedClickable( + onClick = { + if (isSelectionMode) { + onSelectionToggle() + } else { + onClick() + } + }, + onLongClick = onLongPress + ), + shape = cardShape, + colors = CardDefaults.cardColors(containerColor = itemDesignColorScheme.surfaceVariant.copy(alpha = 0.3f)) + ) { + Box { + Column( + modifier = Modifier.background( + color = gradientBaseColor, + shape = cardShape + ) + ) { + Box(contentAlignment = Alignment.BottomStart) { + var isLoadingImage by remember { mutableStateOf(true) } + SmartImage( + model = album.albumArtUriString, + contentDescription = stringResource(R.string.cd_album_art_for_title, album.title), + contentScale = ContentScale.Crop, + targetSize = Size(256, 256), + modifier = Modifier + .aspectRatio(3f / 2f) + .fillMaxSize(), + onState = { state -> + isLoadingImage = state is AsyncImagePainter.State.Loading + } + ) + if (isLoadingImage) { + ShimmerBox( + modifier = Modifier + .aspectRatio(3f / 2f) + .fillMaxSize() + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .aspectRatio(3f / 2f) + .background( + remember(gradientBaseColor) { + Brush.verticalGradient( + colors = listOf( + Color.Transparent, gradientBaseColor + ) + ) + }) + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .height(84.dp) + .padding(12.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + album.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = onGradientColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text(album.artist, style = MaterialTheme.typography.bodySmall, color = onGradientColor.copy(alpha = 0.85f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(formatSongCount(album.songCount), style = MaterialTheme.typography.bodySmall, color = onGradientColor.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + + if (isSelectionMode && isSelected) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(10.dp) + .size(28.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = selectionIndex?.toString() ?: "✓", + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/AlbumListItem.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/AlbumListItem.kt new file mode 100644 index 000000000..798ec30de --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/AlbumListItem.kt @@ -0,0 +1,270 @@ +package com.theveloper.pixelplay.presentation.screens.library + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.util.UnstableApi +import coil.compose.AsyncImagePainter +import coil.size.Size +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.model.Album +import com.theveloper.pixelplay.presentation.components.ShimmerBox +import com.theveloper.pixelplay.presentation.components.SmartImage +import com.theveloper.pixelplay.presentation.screens.search.components.GenreTypography +import com.theveloper.pixelplay.presentation.viewmodel.ColorSchemePair +import com.theveloper.pixelplay.ui.theme.LocalPixelPlayDarkTheme +import com.theveloper.pixelplay.utils.formatSongCount +import kotlinx.coroutines.flow.StateFlow + +/** + * Card-style list row for an album with extracted-palette gradient and + * selection-mode UI. Extracted from `LibraryScreen.kt`. + */ +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +internal fun AlbumListItem( + album: Album, + albumColorSchemePairFlow: StateFlow, + onClick: () -> Unit, + isLoading: Boolean = false, + isSelectionMode: Boolean = false, + isSelected: Boolean = false, + selectionIndex: Int? = null, + onLongPress: () -> Unit = {}, + onSelectionToggle: () -> Unit = {} +) { + val albumColorSchemePair by albumColorSchemePairFlow.collectAsStateWithLifecycle() + val systemIsDark = LocalPixelPlayDarkTheme.current + val currentMaterialColorScheme = MaterialTheme.colorScheme + + val itemDesignColorScheme = remember(albumColorSchemePair, systemIsDark, currentMaterialColorScheme) { + albumColorSchemePair?.let { pair -> + if (systemIsDark) pair.dark else pair.light + } ?: currentMaterialColorScheme + } + + val gradientBaseColor = itemDesignColorScheme.primaryContainer + val onGradientColor = itemDesignColorScheme.onPrimaryContainer + val cardCornerRadius = 16.dp + val cardShape = RoundedCornerShape(cardCornerRadius) + val selectionScale by animateFloatAsState( + targetValue = if (isSelected) 0.99f else 1f, + animationSpec = tween(durationMillis = 200), + label = "albumListSelectionScale" + ) + val selectionBorderWidth by animateDpAsState( + targetValue = if (isSelected) 2.dp else 0.dp, + animationSpec = tween(durationMillis = 200), + label = "albumListSelectionBorder" + ) + + if (isLoading) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(80.dp), + shape = cardShape, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + ) { + Row(modifier = Modifier.fillMaxSize()) { + ShimmerBox( + modifier = Modifier + .aspectRatio(1f) + .fillMaxHeight() + ) + Column( + modifier = Modifier + .weight(1f) + .padding(12.dp), + verticalArrangement = Arrangement.Center + ) { + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.6f) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.height(8.dp)) + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.4f) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + ) + } + } + } + } else { + Card( + modifier = Modifier + .fillMaxWidth() + .height(88.dp) + .scale(selectionScale) + .then( + if (isSelected) { + Modifier.border( + width = selectionBorderWidth, + color = MaterialTheme.colorScheme.primary, + shape = cardShape + ) + } else { + Modifier + } + ) + .clip(cardShape) + .combinedClickable( + onClick = { + if (isSelectionMode) { + onSelectionToggle() + } else { + onClick() + } + }, + onLongClick = onLongPress + ), + shape = cardShape, + colors = CardDefaults.cardColors(containerColor = itemDesignColorScheme.surfaceVariant.copy(alpha = 0.3f)) + ) { + Box(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .aspectRatio(1f) + .fillMaxHeight() + ) { + var isLoadingImage by remember { mutableStateOf(true) } + SmartImage( + model = album.albumArtUriString, + contentDescription = stringResource(R.string.cd_album_art_for_title, album.title), + contentScale = ContentScale.Crop, + targetSize = Size(256, 256), + modifier = Modifier.fillMaxSize(), + onState = { state -> + isLoadingImage = state is AsyncImagePainter.State.Loading + } + ) + if (isLoadingImage) { + ShimmerBox(modifier = Modifier.fillMaxSize()) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + colors = listOf( + Color.Transparent, + gradientBaseColor + ) + ) + ) + ) + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .background(gradientBaseColor) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.Center + ) { + val variableTextStyle = remember(album.id, album.title) { + GenreTypography.getGenreStyle(album.id.toString(), album.title) + } + + Text( + album.title, + style = variableTextStyle.copy(fontSize = 22.sp), + color = onGradientColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + album.artist, + style = MaterialTheme.typography.bodySmall, + color = onGradientColor.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + formatSongCount(album.songCount), + style = MaterialTheme.typography.bodySmall, + color = onGradientColor.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + if (isSelectionMode && isSelected) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(24.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = selectionIndex?.toString() ?: "✓", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/ArtistListItem.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/ArtistListItem.kt new file mode 100644 index 000000000..5114dbadc --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/ArtistListItem.kt @@ -0,0 +1,112 @@ +package com.theveloper.pixelplay.presentation.screens.library + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.model.Artist +import com.theveloper.pixelplay.presentation.components.ShimmerBox +import com.theveloper.pixelplay.utils.formatSongCount + +/** + * Card-style list row for an artist. Extracted from `LibraryScreen.kt` as + * part of the file-decomposition refactor. Has no internal dependencies on + * LibraryScreen state — uses [Artist] directly and a single click callback. + */ +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +internal fun ArtistListItem(artist: Artist, onClick: () -> Unit, isLoading: Boolean = false) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + if (isLoading) { + ShimmerBox( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.6f) + .height(20.dp) + .clip(RoundedCornerShape(4.dp)) + ) + Spacer(modifier = Modifier.height(4.dp)) + ShimmerBox( + modifier = Modifier + .fillMaxWidth(0.3f) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + ) + } + } else { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center + ) { + if (!artist.effectiveImageUrl.isNullOrEmpty()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(artist.effectiveImageUrl) + .crossfade(true) + .build(), + contentDescription = artist.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } else { + Icon( + painter = painterResource(R.drawable.rounded_artist_24), + contentDescription = stringResource(R.string.presentation_batch_d_cd_artist), + modifier = Modifier.padding(8.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text(artist.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(formatSongCount(artist.songCount), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/FolderItems.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/FolderItems.kt new file mode 100644 index 000000000..c0f9cccef --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/FolderItems.kt @@ -0,0 +1,109 @@ +package com.theveloper.pixelplay.presentation.screens.library + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.model.MusicFolder +import com.theveloper.pixelplay.presentation.components.PlaylistArtCollage +import com.theveloper.pixelplay.presentation.screens.collectAllSongs +import com.theveloper.pixelplay.utils.formatSongCount +import com.theveloper.pixelplay.ui.theme.GoogleSansRounded +import kotlinx.collections.immutable.toPersistentList + +/** + * Card-style folder item rendered as a small collage of preview songs. + * Extracted from `LibraryScreen.kt`. + */ +@Composable +internal fun FolderPlaylistItem(folder: MusicFolder, onClick: () -> Unit) { + val previewSongs = remember(folder) { + folder.collectAllSongs().take(9).toPersistentList() + } + + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PlaylistArtCollage( + songs = previewSongs, + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + folder.name, + style = MaterialTheme.typography.titleMedium.copy(fontFamily = GoogleSansRounded), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + formatSongCount(folder.totalSongCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +/** + * Plain list-row folder item. Extracted from `LibraryScreen.kt`. + */ +@Composable +internal fun FolderListItem(folder: MusicFolder, onClick: () -> Unit) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) + ) { + Row(modifier = Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_folder), + contentDescription = stringResource(R.string.presentation_batch_d_cd_folder), + modifier = Modifier + .size(48.dp) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape) + .padding(8.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Spacer(modifier = Modifier.width(16.dp)) + Column { + Text(folder.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(formatSongCount(folder.totalSongCount), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/FolderSortHelpers.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/FolderSortHelpers.kt new file mode 100644 index 000000000..e00f3d2e8 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/FolderSortHelpers.kt @@ -0,0 +1,73 @@ +package com.theveloper.pixelplay.presentation.screens.library + +import com.theveloper.pixelplay.data.model.MusicFolder +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.model.SortOption + +/** + * Pure-function folder/song sort helpers extracted from `LibraryScreen.kt` + * as part of the file-decomposition refactor. No Compose dependencies; can + * be exercised directly from JVM unit tests. + */ + +internal fun flattenFolders(folders: List): List { + return folders.flatMap { folder -> + val current = if (folder.songs.isNotEmpty()) listOf(folder) else emptyList() + current + flattenFolders(folder.subFolders) + } +} + +internal fun sortMusicFoldersByOption( + folders: List, + sortOption: SortOption +): List { + return when (sortOption) { + SortOption.FolderNameAZ -> folders.sortedWith( + compareBy { it.name.lowercase() } + .thenBy { it.path } + ) + SortOption.FolderNameZA -> folders.sortedWith( + compareByDescending { it.name.lowercase() } + .thenBy { it.path } + ) + SortOption.FolderSongCountAsc -> folders.sortedWith( + compareBy { it.totalSongCount } + .thenBy { it.name.lowercase() } + .thenBy { it.path } + ) + SortOption.FolderSongCountDesc -> folders.sortedWith( + compareByDescending { it.totalSongCount } + .thenBy { it.name.lowercase() } + .thenBy { it.path } + ) + SortOption.FolderSubdirCountAsc -> folders.sortedWith( + compareBy { it.totalSubFolderCount } + .thenBy { it.name.lowercase() } + .thenBy { it.path } + ) + SortOption.FolderSubdirCountDesc -> folders.sortedWith( + compareByDescending { it.totalSubFolderCount } + .thenBy { it.name.lowercase() } + .thenBy { it.path } + ) + else -> folders.sortedWith( + compareBy { it.name.lowercase() } + .thenBy { it.path } + ) + } +} + +internal fun sortSongsForFolderView(songs: List, sortOption: SortOption): List { + return when (sortOption) { + SortOption.FolderNameZA -> songs.sortedWith( + compareByDescending { it.title.lowercase() } + .thenBy { it.artist.lowercase() } + .thenBy { it.id } + ) + else -> songs.sortedWith( + compareBy { it.title.lowercase() } + .thenBy { it.artist.lowercase() } + .thenBy { it.id } + ) + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/LibrarySyncIndicators.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/LibrarySyncIndicators.kt new file mode 100644 index 000000000..828aed6b8 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/LibrarySyncIndicators.kt @@ -0,0 +1,195 @@ +package com.theveloper.pixelplay.presentation.screens.library + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.worker.SyncManager +import com.theveloper.pixelplay.data.worker.SyncProgress +import com.theveloper.pixelplay.presentation.components.SyncProgressBar +import com.theveloper.pixelplay.presentation.screens.positiveMod + +/** + * Sync/loading indicator composables extracted from `LibraryScreen.kt` as + * part of the file-decomposition refactor. + * + * All three collect [SyncManager.syncProgress] inside this subtree so the + * surrounding screen doesn't recompose on every progress tick. + */ + +@Composable +internal fun CompactLibraryPagerIndicator( + currentIndex: Int, + pageCount: Int, + modifier: Modifier = Modifier +) { + if (pageCount <= 1) return + + val safeIndex = positiveMod(currentIndex, pageCount) + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + repeat(pageCount) { index -> + val selected = index == safeIndex + val width by animateDpAsState( + targetValue = if (selected) 22.dp else 10.dp, + label = "LibraryCompactPagerIndicatorWidth" + ) + val alpha by animateFloatAsState( + targetValue = if (selected) 1f else 0.35f, + label = "LibraryCompactPagerIndicatorAlpha" + ) + + Box( + modifier = Modifier + .padding(horizontal = 3.dp) + .height(4.dp) + .width(width) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = alpha)) + ) + } + } +} + +/** + * Slim, non-intrusive indicator for sync work that should not keep the list + * pulled down: automatic startup syncs, background maintenance, and manual + * refreshes after the short pull-to-refresh confirmation window. Collapses + * to zero height when not active. + * + * Distinct from [LibrarySyncOverlay], which is reserved for initial + * empty-library loads. The parent screen also gates this off while the + * pull spinner is visible. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun LibraryInlineSyncIndicator( + visible: Boolean, + syncManager: SyncManager +) { + AnimatedVisibility( + visible = visible, + enter = androidx.compose.animation.expandVertically( + expandFrom = Alignment.Top, + animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing) + ) + androidx.compose.animation.fadeIn(animationSpec = tween(180)), + exit = androidx.compose.animation.shrinkVertically( + shrinkTowards = Alignment.Top, + animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing) + ) + androidx.compose.animation.fadeOut(animationSpec = tween(160)) + ) { + val syncProgress by syncManager.syncProgress + .collectAsStateWithLifecycle(initialValue = SyncProgress()) + + val phaseLabel = when (syncProgress.phase) { + SyncProgress.SyncPhase.FETCHING_MEDIASTORE -> + stringResource(R.string.sync_scanning) + SyncProgress.SyncPhase.PROCESSING_FILES, + SyncProgress.SyncPhase.SAVING_TO_DATABASE -> + stringResource(R.string.sync_processing) + SyncProgress.SyncPhase.SCANNING_LRC -> + stringResource(R.string.library_background_sync_lyrics) + SyncProgress.SyncPhase.CLEANING_CACHE -> + stringResource(R.string.library_background_sync_cache) + SyncProgress.SyncPhase.SYNCING_CLOUD -> + stringResource(R.string.library_background_sync_cloud) + else -> + stringResource(R.string.sync_in_progress) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + ) { + Text( + text = phaseLabel, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + LinearWavyProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(4.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest + ) + } + } +} + +/** + * Full-screen overlay shown during initial empty-library scans. By + * collecting [SyncManager.syncProgress] HERE instead of in the parent + * [LibraryScreen], only this small subtree recomposes on every progress + * tick. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun LibrarySyncOverlay(syncManager: SyncManager) { + val syncProgress by syncManager.syncProgress + .collectAsStateWithLifecycle(initialValue = SyncProgress()) + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f) + ) { + Box(contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + if (syncProgress.hasProgress && syncProgress.isRunning) { + SyncProgressBar( + syncProgress = syncProgress, + modifier = Modifier.fillMaxWidth() + ) + } else { + LoadingIndicator(modifier = Modifier.size(64.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.syncing_library), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/LibraryTabGridItem.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/LibraryTabGridItem.kt new file mode 100644 index 000000000..24f025e51 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/LibraryTabGridItem.kt @@ -0,0 +1,96 @@ +package com.theveloper.pixelplay.presentation.screens.library + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.model.LibraryTabId +import java.util.Locale + +/** + * Grid item for the library tab switcher sheet. Extracted from + * `LibraryScreen.kt`. Renders a single tab option as a tinted card with + * an icon and label, with a distinct selected state. + */ +@Composable +internal fun LibraryTabGridItem( + tabId: LibraryTabId, + isSelected: Boolean, + onClick: () -> Unit +) { + val shape = RoundedCornerShape(20.dp) + val containerColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh + val iconContainer = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondaryContainer + val textColor = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .clickable(onClick = onClick), + shape = shape, + color = containerColor, + tonalElevation = if (isSelected) 6.dp else 2.dp + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(iconContainer.copy(alpha = 0.92f)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(id = tabId.iconRes()), + contentDescription = tabId.title, + tint = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer + ) + } + + Text( + text = tabId.displayTitle(), + style = MaterialTheme.typography.titleMedium, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + color = textColor + ) + } + } +} + +internal fun LibraryTabId.iconRes(): Int = when (this) { + LibraryTabId.SONGS -> R.drawable.rounded_music_note_24 + LibraryTabId.ALBUMS -> R.drawable.rounded_album_24 + LibraryTabId.ARTISTS -> R.drawable.rounded_artist_24 + LibraryTabId.PLAYLISTS -> R.drawable.rounded_playlist_play_24 + LibraryTabId.FOLDERS -> R.drawable.rounded_folder_24 + LibraryTabId.LIKED -> R.drawable.round_favorite_24 +} + +internal fun LibraryTabId.displayTitle(): String = + title.lowercase().replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase(Locale.getDefault()) else char.toString() + } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/WatchTransferProgressDialog.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/WatchTransferProgressDialog.kt new file mode 100644 index 000000000..c0baab97a --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/library/WatchTransferProgressDialog.kt @@ -0,0 +1,175 @@ +package com.theveloper.pixelplay.presentation.screens.library + +import android.text.format.Formatter +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.service.wear.PhoneWatchTransferState +import com.theveloper.pixelplay.shared.WearTransferProgress + +/** + * Modal progress dialog for the "send to watch" Wear transfer flow. + * + * Extracted from `LibraryScreen.kt` as the first step of the 3.7k-line + * decomposition: this dialog has no coupling to other Library screen + * internals (it only consumes [PhoneWatchTransferState] and two callbacks), + * so it can live in its own file without touching the surrounding screen + * structure. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun WatchTransferProgressDialog( + transfer: PhoneWatchTransferState, + onDismiss: () -> Unit, + onCancelTransfer: () -> Unit, +) { + val context = LocalContext.current + val startingTransfer = stringResource(R.string.presentation_batch_d_watch_starting_transfer) + val preparingTransfer = stringResource(R.string.presentation_batch_d_watch_preparing_transfer) + val animatedProgress by animateFloatAsState( + targetValue = transfer.progress.coerceIn(0f, 1f), + animationSpec = tween(durationMillis = 300), + label = "WatchTransferProgressDialog" + ) + val progressPercent = (animatedProgress * 100f).toInt().coerceIn(0, 100) + val bytesText = if (transfer.totalBytes > 0L) { + val sent = Formatter.formatFileSize(context, transfer.bytesTransferred) + val total = Formatter.formatFileSize(context, transfer.totalBytes) + stringResource(R.string.presentation_batch_h_transfer_bytes_progress, sent, total) + } else { + startingTransfer + } + val statusText = when (transfer.status) { + WearTransferProgress.STATUS_TRANSFERRING -> stringResource(R.string.presentation_batch_d_watch_status_transferring) + WearTransferProgress.STATUS_COMPLETED -> stringResource(R.string.presentation_batch_d_watch_status_completed) + WearTransferProgress.STATUS_FAILED -> stringResource(R.string.presentation_batch_d_watch_status_failed) + WearTransferProgress.STATUS_CANCELLED -> stringResource(R.string.presentation_batch_d_watch_status_cancelled) + else -> stringResource(R.string.presentation_batch_d_watch_status_preparing) + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + Surface( + shape = RoundedCornerShape(28.dp), + tonalElevation = 6.dp, + color = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.presentation_batch_d_watch_sending_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + Box( + modifier = Modifier + .size(96.dp) + .padding(vertical = 20.dp), + contentAlignment = Alignment.Center + ) { + LoadingIndicator( + modifier = Modifier + .fillMaxSize() + .scale(1.84f), + color = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.presentation_batch_g_sync_percent, progressPercent), + style = MaterialTheme.typography.labelLarge.copy( + fontSize = MaterialTheme.typography.labelLarge.fontSize * 1.4f + ), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary + ) + } + LinearWavyProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(50)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceContainerHighest + ) + Text( + text = transfer.songTitle.ifBlank { preparingTransfer }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(R.string.presentation_batch_f_status_bullet_step, statusText, bytesText), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + transfer.error?.takeIf { it.isNotBlank() }?.let { errorText -> + Text( + text = errorText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center + ) + } + Button( + modifier = Modifier.padding(top = 4.dp), + onClick = onCancelTransfer, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) + ) { + Text( + text = stringResource(R.string.presentation_batch_d_watch_cancel_transfer), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt index 21596627c..1c03a9565 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/AiStateHolder.kt @@ -10,13 +10,20 @@ import com.theveloper.pixelplay.data.ai.AiPlaylistGenerator import com.theveloper.pixelplay.data.ai.SongMetadata import com.theveloper.pixelplay.data.ai.AiSystemPromptType import com.theveloper.pixelplay.data.ai.provider.AiProviderException +import com.theveloper.pixelplay.data.preferences.AiPreferencesRepository import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository import com.theveloper.pixelplay.data.model.Song import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -33,9 +40,65 @@ class AiStateHolder @Inject constructor( private val aiMetadataGenerator: AiMetadataGenerator, private val dailyMixManager: DailyMixManager, private val playlistPreferencesRepository: PlaylistPreferencesRepository, + private val aiPreferencesRepository: AiPreferencesRepository, private val dailyMixStateHolder: DailyMixStateHolder, - private val notificationManager: AiNotificationManager + private val notificationManager: AiNotificationManager, + @com.theveloper.pixelplay.di.AppScope private val appScope: CoroutineScope, ) { + + /** + * True when the user has configured an API key for whichever AI provider + * is currently selected. Moved here from PlayerViewModel — the 9-arg + * combine over per-provider key flows belonged with the AI state, not in + * the god-VM. PlayerViewModel exposes a thin delegate. + */ + val hasGeminiApiKey: StateFlow = aiPreferencesRepository.geminiApiKey + .map { it.isNotBlank() } + .distinctUntilChanged() + .stateIn( + scope = appScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = false + ) + + val hasActiveAiProviderApiKey: StateFlow = combine( + aiPreferencesRepository.aiProvider, + aiPreferencesRepository.geminiApiKey, + aiPreferencesRepository.deepseekApiKey, + aiPreferencesRepository.groqApiKey, + aiPreferencesRepository.mistralApiKey, + aiPreferencesRepository.nvidiaApiKey, + aiPreferencesRepository.kimiApiKey, + aiPreferencesRepository.glmApiKey, + aiPreferencesRepository.openaiApiKey + ) { values -> + val provider = values[0] + val gemini = values[1] + val deepseek = values[2] + val groq = values[3] + val mistral = values[4] + val nvidia = values[5] + val kimi = values[6] + val glm = values[7] + val openai = values[8] + when (provider) { + "DEEPSEEK" -> deepseek.isNotBlank() + "GROQ" -> groq.isNotBlank() + "MISTRAL" -> mistral.isNotBlank() + "NVIDIA" -> nvidia.isNotBlank() + "KIMI" -> kimi.isNotBlank() + "GLM" -> glm.isNotBlank() + "OPENAI" -> openai.isNotBlank() + else -> gemini.isNotBlank() + } + } + .distinctUntilChanged() + .stateIn( + scope = appScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = false + ) + // State // AI State Management: Observables for tracking background generation progress private val _showAiPlaylistSheet = MutableStateFlow(false) @@ -67,7 +130,13 @@ class AiStateHolder @Inject constructor( private var _lastMetadataSong: Song? = null private var _lastMetadataFields: List? = null - private var scope: CoroutineScope? = null + // Use the app-wide scope so AI generation jobs aren't cancelled when the + // ViewModel that triggered them is cleared mid-generation (config change + // while "Generating…" is on screen). The Singleton-lifecycle hazard + // flagged in CODEBASE_REVIEW.md (scope = null after onCleared but the + // generation job still wants to set _isGeneratingAiPlaylist back to + // false) is removed because the scope is always alive. + private val scope: CoroutineScope get() = appScope private var allSongsProvider: (suspend () -> List)? = null private var favoriteSongIdsProvider: (() -> Set)? = null @@ -94,7 +163,9 @@ class AiStateHolder @Inject constructor( playSongsCallback: (List, Song, String) -> Unit, openPlayerSheetCallback: () -> Unit ) { - this.scope = scope + // scope is now backed by @AppScope; the parameter is retained for + // call-site compatibility but ignored. The fields below are the + // ones that genuinely need rebinding per VM session. this.allSongsProvider = allSongsProvider this.favoriteSongIdsProvider = favoriteSongIdsProvider this.toastEmitter = toastEmitter @@ -126,7 +197,7 @@ class AiStateHolder @Inject constructor( val song = _lastMetadataSong ?: return val fields = _lastMetadataFields ?: return - scope?.launch { + scope.launch { generateAiMetadata(song, fields) } } @@ -150,7 +221,6 @@ class AiStateHolder @Inject constructor( _lastMinLength = minLength _lastMaxLength = maxLength - val scope = this.scope ?: return scope.launch { val allSongs = allSongsProvider?.invoke() ?: emptyList() @@ -247,7 +317,6 @@ class AiStateHolder @Inject constructor( * Uses the current mix as a vibe seed and applies AI filters to find similar tracks. */ fun regenerateDailyMixWithPrompt(prompt: String) { - val scope = this.scope ?: return val currentDailyMixSongs = dailyMixStateHolder.dailyMixSongs.value scope.launch { @@ -340,7 +409,7 @@ class AiStateHolder @Inject constructor( } fun onCleared() { - scope = null + // scope is now @AppScope; only the per-VM callback bindings clear. allSongsProvider = null favoriteSongIdsProvider = null toastEmitter = null diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ArtistDetailViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ArtistDetailViewModel.kt index 2acc61a08..37e9415f7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ArtistDetailViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ArtistDetailViewModel.kt @@ -125,12 +125,16 @@ class ArtistDetailViewModel @Inject constructor( val albumSections = buildAlbumSections(songs) val orderedSongs = albumSections.flatMap { it.songs } - // 1) Resolve effective image URL (custom > Deezer, may fetch from API) + // 1) Resolve effective image URL (custom > Deezer, may fetch from API). + // Pinned to Dispatchers.IO so the upstream collector + // is unblocked while the Deezer HTTP fetch runs. val effectiveUrl = try { - artistImageRepository.getEffectiveArtistImageUrl( - artistId = artist.id, - artistName = artist.name - ) + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + artistImageRepository.getEffectiveArtistImageUrl( + artistId = artist.id, + artistName = artist.name + ) + } } catch (e: Exception) { Log.w("ArtistDebug", "Failed to resolve effective artist image: ${e.message}") artist.effectiveImageUrl diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt index a9633ec45..9936977e3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt @@ -29,7 +29,8 @@ import javax.inject.Singleton */ @Singleton class CastStateHolder @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + @com.theveloper.pixelplay.di.AppScope private val appScope: kotlinx.coroutines.CoroutineScope, ) { private val CAST_STATE_TAG = "CastStateHolder" @@ -181,8 +182,10 @@ class CastStateHolder @Inject constructor( pendingRemoteSongId = null } - // MediaRouter State - private val mediaRouter: MediaRouter = MediaRouter.getInstance(context) + // MediaRouter State. Lazy so MediaRouter.getInstance — which on Cast SDK + // versions has triggered Cast SDK initialization on the calling thread — + // doesn't run on the first-frame critical path of the singleton graph. + private val mediaRouter: MediaRouter by lazy { MediaRouter.getInstance(context) } private val mediaRouterCallback = object : MediaRouter.Callback() { override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) { updateRoutes() @@ -223,16 +226,15 @@ class CastStateHolder @Inject constructor( private val _isRefreshingRoutes = MutableStateFlow(false) val isRefreshingRoutes: StateFlow = _isRefreshingRoutes.asStateFlow() - // Coroutine scope for delays (injected via initialize or use GlobalScope helper if Singleton? - // Ideally we should have a scope. Since it is Singleton, we can use a custom scope or suspend functions.) - // But refreshRoutes was launched in ViewModel. - // We will make refreshRoutes suspend. - + // refreshRoutes runs against @AppScope so a Cast-route refresh kicked + // off mid-flight survives ViewModel teardown (e.g. user rotates while + // discovery is running). Per-call cancellation is handled via the + // job field below. private var refreshRoutesJob: kotlinx.coroutines.Job? = null - fun refreshRoutes(scope: kotlinx.coroutines.CoroutineScope) { + fun refreshRoutes(@Suppress("UNUSED_PARAMETER") scope: kotlinx.coroutines.CoroutineScope = appScope) { refreshRoutesJob?.cancel() - refreshRoutesJob = scope.launch { + refreshRoutesJob = appScope.launch { _isRefreshingRoutes.value = true mediaRouter.removeCallback(mediaRouterCallback) val mediaRouteSelector = buildCastRouteSelector() diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastTransferStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastTransferStateHolder.kt index 32ede2b61..253cca1a1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastTransferStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastTransferStateHolder.kt @@ -50,11 +50,16 @@ class CastTransferStateHolder @Inject constructor( @param:ApplicationContext private val context: Context, private val castStateHolder: CastStateHolder, private val playbackStateHolder: PlaybackStateHolder, - private val dualPlayerEngine: DualPlayerEngine // For local player control during transfer + private val dualPlayerEngine: DualPlayerEngine, // For local player control during transfer + @com.theveloper.pixelplay.di.AppScope private val appScope: CoroutineScope, ) { private val CAST_LOG_TAG = "PlayerCastTransfer" - private var scope: CoroutineScope? = null + // Use @AppScope so a Cast transfer-back-to-phone operation isn't + // cancelled mid-flight when the ViewModel that started it is cleared + // (config change, deep-link nav, etc.). Per-VM callbacks are still + // cleared in onCleared. + private val scope: CoroutineScope get() = appScope // Callbacks for interacting with PlayerViewModel // Provides current queue from UI state @@ -131,7 +136,8 @@ class CastTransferStateHolder @Inject constructor( onCastError: (String) -> Unit, onSongChanged: (String?) -> Unit ) { - this.scope = scope + // scope param is now ignored — see field comment above. Callbacks + // are still per-VM-session and get reset in onCleared. this.getCurrentQueue = getCurrentQueue this.updateQueue = updateQueue this.getSongsByIdMap = getSongsByIdMap @@ -194,7 +200,7 @@ class CastTransferStateHolder @Inject constructor( } override fun onSessionEnded(session: CastSession, error: Int) { sessionSuspendedRecoveryJob?.cancel() - scope?.launch { stopServerAndTransferBack() } + scope.launch { stopServerAndTransferBack() } } override fun onSessionSuspended(session: CastSession, reason: Int) { Timber.tag(CAST_LOG_TAG).w("Cast session suspended (reason=%d). Waiting for recovery.", reason) @@ -241,7 +247,7 @@ class CastTransferStateHolder @Inject constructor( private fun scheduleSessionSuspendedRecovery(suspendedSession: CastSession) { sessionSuspendedRecoveryJob?.cancel() - sessionSuspendedRecoveryJob = scope?.launch { + sessionSuspendedRecoveryJob = scope.launch { delay(12000) val activeSession = sessionManager?.currentCastSession val stillSameSession = activeSession === suspendedSession @@ -486,7 +492,7 @@ class CastTransferStateHolder @Inject constructor( } private fun transferPlayback(session: CastSession) { - scope?.launch { + scope.launch { castStateHolder.setPendingCastRouteId(null) castStateHolder.setCastConnecting(true) castStateHolder.setRemotelySeeking(false) @@ -579,7 +585,7 @@ class CastTransferStateHolder @Inject constructor( detail ) session.remoteMediaClient?.requestStatus() - scope?.launch { + scope.launch { delay(450) if (castStateHolder.castSession.value === session && !castStateHolder.isRemotePlaybackActive.value @@ -636,13 +642,13 @@ class CastTransferStateHolder @Inject constructor( remoteProgressObserverJob?.cancel() remoteStatusRefreshJob?.cancel() - remoteProgressObserverJob = scope?.launch { + remoteProgressObserverJob = scope.launch { castStateHolder.remotePosition.collect { position -> playbackStateHolder.setCurrentPosition(position) } } - remoteStatusRefreshJob = scope?.launch { + remoteStatusRefreshJob = scope.launch { while (true) { val remoteClient = castStateHolder.castSession.value?.remoteMediaClient if (remoteClient == null) { @@ -1179,7 +1185,7 @@ class CastTransferStateHolder @Inject constructor( private fun launchAlignToTarget(targetSongId: String) { alignToTargetJob?.cancel() - alignToTargetJob = scope?.launch { + alignToTargetJob = scope.launch { alignRemotePlaybackToSong(targetSongId) } } @@ -1286,7 +1292,7 @@ class CastTransferStateHolder @Inject constructor( onDisconnect = null onCastError = null onSongChanged = null - scope = null + // scope is @AppScope; nothing to null. Per-VM callbacks cleared above. skipTransferBackOnNextSessionEnd = false } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt index f86f46be3..2f099aab8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ConnectivityStateHolder.kt @@ -49,7 +49,8 @@ data class BluetoothAudioDeviceState( */ @Singleton class ConnectivityStateHolder @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + @com.theveloper.pixelplay.di.AppScope private val appScope: kotlinx.coroutines.CoroutineScope, ) { // WiFi State private val _isWifiEnabled = MutableStateFlow(false) @@ -99,16 +100,24 @@ class ConnectivityStateHolder @Inject constructor( } } - // System services - private val connectivityManager: ConnectivityManager = + // System services. `by lazy` so the cost moves out of singleton + // construction (which Hilt does early during PlayerViewModel init, on + // the first-frame critical path) and into the first actual use — usually + // when initialize() runs, which happens during the second frame after + // splash. + private val connectivityManager: ConnectivityManager by lazy { context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - private val wifiManager: WifiManager? = + } + private val wifiManager: WifiManager? by lazy { context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager - private val bluetoothManager: BluetoothManager = + } + private val bluetoothManager: BluetoothManager by lazy { context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager - private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter - private val audioManager: android.media.AudioManager = + } + private val bluetoothAdapter: BluetoothAdapter? by lazy { bluetoothManager.adapter } + private val audioManager: android.media.AudioManager by lazy { context.getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager + } // Callbacks and receivers private var networkCallback: ConnectivityManager.NetworkCallback? = null diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DailyMixStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DailyMixStateHolder.kt index 92dbee60b..b9e0ac7a3 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DailyMixStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DailyMixStateHolder.kt @@ -142,12 +142,15 @@ class DailyMixStateHolder @Inject constructor( fun checkAndUpdateIfNeeded(favoriteSongIdsFlow: kotlinx.coroutines.flow.Flow>) { scope?.launch { val lastUpdate = userPreferencesRepository.lastDailyMixUpdateFlow.first() - val today = Calendar.getInstance().get(Calendar.DAY_OF_YEAR) - val lastUpdateDay = Calendar.getInstance().apply { - timeInMillis = lastUpdate - }.get(Calendar.DAY_OF_YEAR) - - if (today != lastUpdateDay) { + // LocalDate compares full year+month+day, so the Dec 31 → Jan 1 + // crossover (which DAY_OF_YEAR mis-handles by chance, since + // 365 != 1) and any DST gap are handled correctly. + val zone = java.time.ZoneId.systemDefault() + val today = java.time.LocalDate.now(zone) + val lastUpdateDate = java.time.Instant.ofEpochMilli(lastUpdate) + .atZone(zone).toLocalDate() + + if (today != lastUpdateDate) { updateDailyMix(favoriteSongIdsFlow) userPreferencesRepository.saveLastDailyMixUpdateTimestamp(System.currentTimeMillis()) } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LibraryStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LibraryStateHolder.kt index cb6671b0b..a9517539d 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LibraryStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LibraryStateHolder.kt @@ -21,6 +21,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -45,7 +46,8 @@ private data class GenreSeed( @Singleton class LibraryStateHolder @Inject constructor( private val musicRepository: MusicRepository, - private val userPreferencesRepository: UserPreferencesRepository + private val userPreferencesRepository: UserPreferencesRepository, + @com.theveloper.pixelplay.di.AppScope private val appScope: CoroutineScope, ) { // --- State --- @@ -188,37 +190,43 @@ class LibraryStateHolder @Inject constructor( .flowOn(Dispatchers.Default) - // Internal state - private var scope: CoroutineScope? = null + // @AppScope so library observers (and their underlying Room flow + // collectors) survive ViewModel teardown. The Singleton-lifecycle + // mismatch flagged in CODEBASE_REVIEW.md is now removed. + private val scope: CoroutineScope get() = appScope // --- Initialization --- - fun initialize(scope: CoroutineScope) { - this.scope = scope - // Initial load of sort preferences - scope.launch { - val songSortKey = userPreferencesRepository.songsSortOptionFlow.first() - _currentSongSortOption.value = SortOption.SONGS.find { it.storageKey == songSortKey } ?: SortOption.SongDefaultOrder - - val albumSortKey = userPreferencesRepository.albumsSortOptionFlow.first() - _currentAlbumSortOption.value = SortOption.ALBUMS.find { it.storageKey == albumSortKey } ?: SortOption.AlbumTitleAZ - - val artistSortKey = userPreferencesRepository.artistsSortOptionFlow.first() - _currentArtistSortOption.value = SortOption.ARTISTS.find { it.storageKey == artistSortKey } ?: SortOption.ArtistNameAZ - - val folderSortKey = userPreferencesRepository.foldersSortOptionFlow.first() - _currentFolderSortOption.value = SortOption.FOLDERS.find { it.storageKey == folderSortKey } ?: SortOption.FolderNameAZ - - val likedSortKey = userPreferencesRepository.likedSongsSortOptionFlow.first() - _currentFavoriteSortOption.value = SortOption.LIKED.find { it.storageKey == likedSortKey } ?: SortOption.LikedSongDateLiked - - // Restore last storage filter (All / Cloud / Local) - _currentStorageFilter.value = userPreferencesRepository.lastStorageFilterFlow.first() + fun initialize(@Suppress("UNUSED_PARAMETER") scope: CoroutineScope) { + // Initial load of sort preferences. Six independent DataStore cold-flow + // first() reads run in parallel via async/awaitAll instead of stacking + // sequentially on the Main dispatcher. + this.scope.launch { + val songSortKeyDeferred = async { userPreferencesRepository.songsSortOptionFlow.first() } + val albumSortKeyDeferred = async { userPreferencesRepository.albumsSortOptionFlow.first() } + val artistSortKeyDeferred = async { userPreferencesRepository.artistsSortOptionFlow.first() } + val folderSortKeyDeferred = async { userPreferencesRepository.foldersSortOptionFlow.first() } + val likedSortKeyDeferred = async { userPreferencesRepository.likedSongsSortOptionFlow.first() } + val storageFilterDeferred = async { userPreferencesRepository.lastStorageFilterFlow.first() } + + _currentSongSortOption.value = + SortOption.SONGS.find { it.storageKey == songSortKeyDeferred.await() } ?: SortOption.SongDefaultOrder + _currentAlbumSortOption.value = + SortOption.ALBUMS.find { it.storageKey == albumSortKeyDeferred.await() } ?: SortOption.AlbumTitleAZ + _currentArtistSortOption.value = + SortOption.ARTISTS.find { it.storageKey == artistSortKeyDeferred.await() } ?: SortOption.ArtistNameAZ + _currentFolderSortOption.value = + SortOption.FOLDERS.find { it.storageKey == folderSortKeyDeferred.await() } ?: SortOption.FolderNameAZ + _currentFavoriteSortOption.value = + SortOption.LIKED.find { it.storageKey == likedSortKeyDeferred.await() } ?: SortOption.LikedSongDateLiked + _currentStorageFilter.value = storageFilterDeferred.await() } } fun onCleared() { - scope = null + // scope is @AppScope; nothing to detach here. Per-session jobs + // (songsJob, albumsJob, artistsJob, foldersJob) are still cancelled + // explicitly when storage filter / library invalidations happen. } // --- Data Loading --- @@ -248,7 +256,7 @@ class LibraryStateHolder @Inject constructor( Log.d("LibraryStateHolder", "startObservingLibraryData called.") needsReloadAfterTrim = false - songsJob = scope?.launch { + songsJob = scope.launch { _isLoadingLibrary.value = true musicRepository.getAudioFiles().conflate().collect { songs -> // Process heavy list conversions on Default dispatcher to avoid blocking UI @@ -266,7 +274,7 @@ class LibraryStateHolder @Inject constructor( } } - albumsJob = scope?.launch { + albumsJob = scope.launch { _isLoadingCategories.value = true @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) kotlinx.coroutines.flow.combine( @@ -285,7 +293,7 @@ class LibraryStateHolder @Inject constructor( } } - artistsJob = scope?.launch { + artistsJob = scope.launch { _isLoadingCategories.value = true @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) effectiveStorageFilter.flatMapLatest { filter -> @@ -299,7 +307,7 @@ class LibraryStateHolder @Inject constructor( } } - foldersJob = scope?.launch { + foldersJob = scope.launch { @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) effectiveStorageFilter.flatMapLatest { filter -> musicRepository.getMusicFolders(effectiveFoldersStorageFilter(filter)) @@ -351,7 +359,7 @@ class LibraryStateHolder @Inject constructor( // --- Sorting --- fun sortSongs(sortOption: SortOption, persist: Boolean = true) { - scope?.launch { + scope.launch { if (persist && _currentSongSortOption.value.storageKey == sortOption.storageKey) { return@launch } @@ -364,7 +372,7 @@ class LibraryStateHolder @Inject constructor( } fun sortAlbums(sortOption: SortOption, persist: Boolean = true) { - scope?.launch { + scope.launch { if (persist && _currentAlbumSortOption.value.storageKey == sortOption.storageKey) { return@launch } @@ -381,7 +389,7 @@ class LibraryStateHolder @Inject constructor( } fun sortArtists(sortOption: SortOption, persist: Boolean = true) { - scope?.launch { + scope.launch { if (persist && _currentArtistSortOption.value.storageKey == sortOption.storageKey) { return@launch } @@ -398,7 +406,7 @@ class LibraryStateHolder @Inject constructor( } fun sortFolders(sortOption: SortOption, persist: Boolean = true) { - scope?.launch { + scope.launch { if (persist && _currentFolderSortOption.value.storageKey == sortOption.storageKey) { return@launch } @@ -524,7 +532,7 @@ class LibraryStateHolder @Inject constructor( } fun sortFavoriteSongs(sortOption: SortOption, persist: Boolean = true) { - scope?.launch { + scope.launch { if (persist && _currentFavoriteSortOption.value.storageKey == sortOption.storageKey) { return@launch } @@ -554,7 +562,7 @@ class LibraryStateHolder @Inject constructor( fun setStorageFilter(filter: com.theveloper.pixelplay.data.model.StorageFilter) { _currentStorageFilter.value = filter - scope?.launch { + scope.launch { userPreferencesRepository.saveLastStorageFilter(filter) } } @@ -598,7 +606,8 @@ class LibraryStateHolder @Inject constructor( } fun restoreAfterTrimIfNeeded() { - if (!needsReloadAfterTrim || scope == null) return + // scope is @AppScope (always alive); only check the trim flag. + if (!needsReloadAfterTrim) return startObservingLibraryData() } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolder.kt index 3720a2913..dc6a61c8a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolder.kt @@ -48,11 +48,17 @@ interface LyricsLoadCallback { class LyricsStateHolder @Inject constructor( private val musicRepository: MusicRepository, private val userPreferencesRepository: UserPreferencesRepository, - private val songMetadataEditor: SongMetadataEditor + private val songMetadataEditor: SongMetadataEditor, + @com.theveloper.pixelplay.di.AppScope private val appScope: CoroutineScope, ) { - private var scope: CoroutineScope? = null + // @AppScope so loading jobs and the song-sync-offset observer survive + // ViewModel teardown. Per CODEBASE_REVIEW.md, the Singleton-lifecycle + // mismatch (scope set to viewModelScope, nulled on onCleared) was a + // source of stuck "Loading lyrics" spinners after config changes. + private val scope: CoroutineScope get() = appScope private var loadingJob: Job? = null private var loadCallback: LyricsLoadCallback? = null + private var syncOffsetObserverJob: Job? = null // Sync offset per song in milliseconds private val _currentSongSyncOffset = MutableStateFlow(0) @@ -80,14 +86,15 @@ class LyricsStateHolder @Inject constructor( * Initialize with coroutine scope and callback from ViewModel. */ fun initialize( - coroutineScope: CoroutineScope, + @Suppress("UNUSED_PARAMETER") coroutineScope: CoroutineScope, callback: LyricsLoadCallback, stablePlayerState: StateFlow ) { - scope = coroutineScope + // scope parameter is ignored; the holder uses @AppScope directly. loadCallback = callback - coroutineScope.launch { + syncOffsetObserverJob?.cancel() + syncOffsetObserverJob = scope.launch { stablePlayerState .map { it.currentSong?.id } .distinctUntilChanged() @@ -108,7 +115,7 @@ class LyricsStateHolder @Inject constructor( loadingJob?.cancel() val targetSongId = song.id - loadingJob = scope?.launch { + loadingJob = scope.launch { loadCallback?.onLoadingStarted(targetSongId) val fetchedLyrics = try { @@ -139,7 +146,7 @@ class LyricsStateHolder @Inject constructor( * Set sync offset for a song. */ fun setSyncOffset(songId: String, offsetMs: Int) { - scope?.launch { + scope.launch { userPreferencesRepository.setLyricsSyncOffset(songId, offsetMs) _currentSongSyncOffset.value = offsetMs } @@ -177,7 +184,7 @@ class LyricsStateHolder @Inject constructor( contextHelper: (Int) -> String ) { loadingJob?.cancel() - loadingJob = scope?.launch { + loadingJob = scope.launch { _searchUiState.value = LyricsSearchUiState.Loading if (!forcePickResults) { @@ -273,7 +280,7 @@ class LyricsStateHolder @Inject constructor( fun searchLyricsManually(title: String, artist: String?) { if (title.isBlank()) return loadingJob?.cancel() - loadingJob = scope?.launch { + loadingJob = scope.launch { _searchUiState.value = LyricsSearchUiState.Loading musicRepository.searchRemoteLyricsByQuery(title, artist) .onSuccess { (q, results) -> @@ -287,7 +294,7 @@ class LyricsStateHolder @Inject constructor( * Accept a search result. */ fun acceptLyricsSearchResult(result: LyricsSearchResult, currentSong: Song) { - scope?.launch { + scope.launch { _searchUiState.value = LyricsSearchUiState.Success(result.lyrics) // 1. Update DB cache @@ -308,7 +315,7 @@ class LyricsStateHolder @Inject constructor( * Import from file. */ fun importLyricsFromFile(songId: Long, validatedImport: ValidatedLyricsImport, currentSong: Song?) { - scope?.launch { + scope.launch { val sanitizedContent = validatedImport.sanitizedContent val parsedLyrics = validatedImport.parsedLyrics @@ -326,7 +333,7 @@ class LyricsStateHolder @Inject constructor( fun resetLyrics(songId: Long) { resetSearchState() - scope?.launch { + scope.launch { musicRepository.resetLyrics(songId) _songUpdates.emit(Song.emptySong().copy(id = songId.toString()) to null) } @@ -334,7 +341,7 @@ class LyricsStateHolder @Inject constructor( fun resetAllLyrics() { resetSearchState() - scope?.launch { + scope.launch { musicRepository.resetAllLyrics() } } @@ -417,8 +424,10 @@ class LyricsStateHolder @Inject constructor( } fun onCleared() { + // scope is @AppScope; only cancel per-VM-session jobs and clear the + // callback ref so the dead VM doesn't get re-entered. loadingJob?.cancel() - scope = null + syncOffsetObserverJob?.cancel() loadCallback = null } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MainViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MainViewModel.kt index e5c973575..c424f10a0 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MainViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MainViewModel.kt @@ -34,8 +34,11 @@ class MainViewModel @Inject constructor( .map { it > 0L } .stateIn( scope = viewModelScope, - started = SharingStarted.Eagerly, - initialValue = true // 乐观策略:默认已同步 + // WhileSubscribed avoids keeping a DataStore collector hot for the + // whole VM lifetime. Splash-decision callers subscribe eagerly + // themselves so the 5s grace is enough to bridge config changes. + started = SharingStarted.WhileSubscribed(5000), + initialValue = true ) /** @@ -45,7 +48,7 @@ class MainViewModel @Inject constructor( val isSyncing: StateFlow = syncManager.isSyncing .stateIn( scope = viewModelScope, - started = SharingStarted.Eagerly, + started = SharingStarted.WhileSubscribed(5000), initialValue = false ) @@ -62,10 +65,14 @@ class MainViewModel @Inject constructor( /** * Un Flow que emite `true` si la base de datos de Room no tiene canciones. * Nos ayuda a saber si es la primera vez que se abre la app. + * + * Uses getSongCountFlow() (a cheap `SELECT COUNT(*)`) instead of fetching + * the entire library and computing isEmpty() — for 30k-song libraries the + * latter loads a ~30 MB list just to check a single boolean. */ val isLibraryEmpty: StateFlow = musicRepository - .getAudioFiles() - .map { it.isEmpty() } + .getSongCountFlow() + .map { it == 0 } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt index e5b3cb422..1143a3cfe 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import javax.inject.Inject @@ -55,20 +56,30 @@ class MultiSelectionStateHolder @Inject constructor() { * @param song The song to toggle */ fun toggleSelection(song: Song) { - val currentList = _selectedSongs.value.toMutableList() - val currentIds = _selectedSongIds.value.toMutableSet() - - if (currentIds.contains(song.id)) { - // Remove from selection - currentList.removeAll { it.id == song.id } - currentIds.remove(song.id) - } else { - // Add to selection (preserving order) - currentList.add(song) - currentIds.add(song.id) + // Atomic read-modify-write so rapid concurrent taps cannot drop a + // toggle (the previous baseline-snapshot + write pattern was racy: + // both callers could read the same baseline and the second write + // would overwrite the first). _selectedSongs.update{} retries until + // a CAS succeeds. + var updatedList: List = emptyList() + var updatedIds: Set = emptySet() + _selectedSongs.update { current -> + val ids = _selectedSongIds.value + if (song.id in ids) { + val next = current.filter { it.id != song.id } + updatedList = next + updatedIds = ids - song.id + next + } else { + val next = current + song + updatedList = next + updatedIds = ids + song.id + next + } } - - updateState(currentList, currentIds) + _selectedSongIds.value = updatedIds + _selectedCount.value = updatedList.size + _isSelectionMode.value = updatedList.isNotEmpty() } /** diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt index 4272693f5..88ba46eec 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerUiState.kt @@ -37,7 +37,9 @@ data class PlayerUiState( val folderSource: FolderSource = FolderSource.INTERNAL, val folderSourceRootPath: String = "", val isSdCardAvailable: Boolean = false, - val lavaLampColors: ImmutableList = persistentListOf(), + // lavaLampColors removed: this field was never written by PlayerViewModel. + // The lava-lamp gradient sources from ThemeStateHolder.lavaLampColors, + // which is the authoritative flow. val undoBarVisibleDuration: Long = 4000L, val isFolderFilterActive: Boolean = false, val isFoldersPlaylistView: Boolean = false, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index 39aca7fa7..95782e221 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -94,6 +94,9 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -171,14 +174,6 @@ private data class SortOptionsSnapshot( val favoriteSort: SortOption, ) -private data class AiUiSnapshot( - val showAiPlaylistSheet: Boolean, - val isGeneratingAiPlaylist: Boolean, - val aiStatus: String?, - val aiError: String?, - val isGeneratingAiMetadata: Boolean, -) - private data class PreparedPlaybackQueue( val mediaItems: List, val startIndex: Int @@ -408,15 +403,19 @@ class PlayerViewModel @Inject constructor( // 1. Invalidate Coil cache for the BASE uri (without params) // This ensures next time we load it without params, it's fresh too. val baseUri = currentUriClean - - // Remove from Memory Cache - context.imageLoader.memoryCache?.keys?.forEach { key -> + // Hoist the imageLoader lookup once. Snapshot keys to a Set first + // — Coil's memoryCache.keys is mutated by remove() and historically + // wasn't safe to mutate while iterating directly. + val imageLoader = context.imageLoader + val memoryCache = imageLoader.memoryCache + val keySnapshot = memoryCache?.keys?.toSet().orEmpty() + keySnapshot.forEach { key -> if (key.toString().contains(baseUri)) { - context.imageLoader.memoryCache?.remove(key) + memoryCache?.remove(key) } } // Remove from Disk Cache - context.imageLoader.diskCache?.remove(baseUri) + imageLoader.diskCache?.remove(baseUri) // 2. Extract Colors (using base URI) themeStateHolder.extractAndGenerateColorScheme(updatedArtUri.toUri(), updatedArtUri, isPreload = false) @@ -522,50 +521,13 @@ class PlayerViewModel @Inject constructor( initialValue = CarouselStyle.NO_PEEK ) - val hasActiveAiProviderApiKey: StateFlow = combine( - aiPreferencesRepository.aiProvider, - aiPreferencesRepository.geminiApiKey, - aiPreferencesRepository.deepseekApiKey, - aiPreferencesRepository.groqApiKey, - aiPreferencesRepository.mistralApiKey, - aiPreferencesRepository.nvidiaApiKey, - aiPreferencesRepository.kimiApiKey, - aiPreferencesRepository.glmApiKey, - aiPreferencesRepository.openaiApiKey - ) { values -> - val provider = values[0] - val gemini = values[1] - val deepseek = values[2] - val groq = values[3] - val mistral = values[4] - val nvidia = values[5] - val kimi = values[6] - val glm = values[7] - val openai = values[8] - when (provider) { - "DEEPSEEK" -> deepseek.isNotBlank() - "GROQ" -> groq.isNotBlank() - "MISTRAL" -> mistral.isNotBlank() - "NVIDIA" -> nvidia.isNotBlank() - "KIMI" -> kimi.isNotBlank() - "GLM" -> glm.isNotBlank() - "OPENAI" -> openai.isNotBlank() - else -> gemini.isNotBlank() - } - }.distinctUntilChanged() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = false - ) + // The 9-arg combine over per-provider API-key flows moved into + // AiStateHolder.hasActiveAiProviderApiKey; this is a thin pass-through so + // existing call sites in screens continue to work. + val hasActiveAiProviderApiKey: StateFlow = aiStateHolder.hasActiveAiProviderApiKey - val hasGeminiApiKey: StateFlow = aiPreferencesRepository.geminiApiKey - .map { it.isNotBlank() } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = false - ) + // Moved to AiStateHolder.hasGeminiApiKey; thin pass-through. + val hasGeminiApiKey: StateFlow = aiStateHolder.hasGeminiApiKey val fullPlayerLoadingTweaks: StateFlow = userPreferencesRepository.fullPlayerLoadingTweaksFlow .stateIn( @@ -1391,14 +1353,40 @@ class PlayerViewModel @Inject constructor( * Observes a song by ID from Room DB, combined with the latest favorite status. * Uses direct Room query instead of scanning the full in-memory list. */ + /** + * Per-songId cache so a screen that recomposes (and re-derives its + * `observeSong(currentSong.id)`) doesn't subscribe a brand-new Room + * collector on every recomposition. Once a screen drops the flow, + * SharingStarted.WhileSubscribed(5000) tears the upstream down. + * + * Caching is bounded — we trim the oldest entry when the map gets too + * large to avoid an unbounded Singleton-lifetime growth on a long + * session where the user browses many songs. + */ + private val observedSongFlows = java.util.Collections.synchronizedMap( + object : LinkedHashMap>(32, 0.75f, true) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry>? + ): Boolean = size > 64 + } + ) + fun observeSong(songId: String?): Flow { if (songId == null) return flowOf(null) - return combine( + observedSongFlows[songId]?.let { return it } + val shared = combine( musicRepository.getSong(songId), favoriteSongIds ) { song, favorites -> song?.copy(isFavorite = favorites.contains(songId)) }.distinctUntilChanged() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000L), + initialValue = null + ) + observedSongFlows[songId] = shared + return shared } @@ -1897,25 +1885,17 @@ class PlayerViewModel @Inject constructor( openPlayerSheetCallback = { _isSheetVisible.value = true } ) - // Collect AiStateHolder flows + // Mirror AiStateHolder.isGeneratingMetadata into PlayerUiState. The + // previous 5-arg combine projected 4 fields that PlayerUiState doesn't + // even carry — pure waste. Screens that need showAiPlaylistSheet / + // aiStatus / aiError already consume the AiStateHolder pass-throughs + // exposed directly on this ViewModel. viewModelScope.launch { - combine( - aiStateHolder.showAiPlaylistSheet, - aiStateHolder.isGeneratingAiPlaylist, - aiStateHolder.aiStatus, - aiStateHolder.aiError, - aiStateHolder.isGeneratingMetadata, - ) { show, generating, status, error, generatingMetadata -> - AiUiSnapshot( - showAiPlaylistSheet = show, - isGeneratingAiPlaylist = generating, - aiStatus = status, - aiError = error, - isGeneratingAiMetadata = generatingMetadata - ) - }.collect { snapshot -> + // StateFlow already dedupes via SharingStarted — no + // distinctUntilChanged needed. + aiStateHolder.isGeneratingMetadata.collect { generatingMetadata -> _playerUiState.update { - it.copy(isGeneratingAiMetadata = snapshot.isGeneratingAiMetadata) + it.copy(isGeneratingAiMetadata = generatingMetadata) } } } @@ -3721,14 +3701,20 @@ class PlayerViewModel @Inject constructor( val albumsToProcess = albums.take(MAX_ALBUM_BATCH_SELECTION) val wasTrimmed = albums.size > albumsToProcess.size + // Fetch the songs for each album in parallel — six sequential Room + // queries on Dispatchers.IO was wall-clock-bound by the slowest disk + // read; async/awaitAll lets them overlap. val songs = withContext(Dispatchers.IO) { - buildList { - albumsToProcess.forEach { album -> - val albumSongs = musicRepository.getSongsForAlbum(album.id).first() - if (albumSongs.isNotEmpty()) { - addAll(sortSongsForAlbumSelection(albumSongs)) + coroutineScope { + albumsToProcess + .map { album -> + async { musicRepository.getSongsForAlbum(album.id).first() } + } + .awaitAll() + .flatMap { albumSongs -> + if (albumSongs.isEmpty()) emptyList() + else sortSongsForAlbumSelection(albumSongs) } - } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt index 9ce0dfccf..7e729d290 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt @@ -4,6 +4,7 @@ import com.theveloper.pixelplay.data.model.Playlist import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import javax.inject.Inject import javax.inject.Singleton @@ -50,20 +51,29 @@ class PlaylistSelectionStateHolder @Inject constructor() { * @param playlist The playlist to toggle */ fun toggleSelection(playlist: Playlist) { - val currentList = _selectedPlaylists.value.toMutableList() - val currentIds = _selectedPlaylistIds.value.toMutableSet() - - if (currentIds.contains(playlist.id)) { - // Remove from selection - currentList.removeAll { it.id == playlist.id } - currentIds.remove(playlist.id) - } else { - // Add to selection (preserving order) - currentList.add(playlist) - currentIds.add(playlist.id) + // Atomic update — see MultiSelectionStateHolder for the rationale + // (rapid concurrent taps from different gesture handlers can drop + // a toggle under read-modify-write). + var updatedList: List = emptyList() + var updatedIds: Set = emptySet() + val pid = playlist.id.toString() + _selectedPlaylists.update { current -> + val ids = _selectedPlaylistIds.value + if (pid in ids) { + val next = current.filter { it.id != playlist.id } + updatedList = next + updatedIds = ids - pid + next + } else { + val next = current + playlist + updatedList = next + updatedIds = ids + pid + next + } } - - updateState(currentList, currentIds) + _selectedPlaylistIds.value = updatedIds + _selectedCount.value = updatedList.size + _isSelectionMode.value = updatedList.isNotEmpty() } /** diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SearchStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SearchStateHolder.kt index 17fd1cc53..d1be4ac70 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SearchStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SearchStateHolder.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.FlowPreview @Singleton class SearchStateHolder @Inject constructor( private val musicRepository: MusicRepository, + @com.theveloper.pixelplay.di.AppScope private val appScope: CoroutineScope, ) { private companion object { const val SEARCH_DEBOUNCE_MS = 300L @@ -63,21 +64,29 @@ class SearchStateHolder @Inject constructor( ) private val latestSearchRequestId = AtomicLong(0L) - private var scope: CoroutineScope? = null + // Use @AppScope so the search-request observer and history loads survive + // ViewModel teardown. The caller still invokes initialize(scope) for + // call-site compatibility but the parameter is ignored — there is no + // window where scope can be null between onCleared() and the next + // initialize(). + private val scope: CoroutineScope get() = appScope private var searchJob: Job? = null /** - * Initialize with ViewModel scope. + * Idempotent initialization. The scope parameter is ignored — see field + * comment above. */ - fun initialize(scope: CoroutineScope) { - this.scope = scope + fun initialize(@Suppress("UNUSED_PARAMETER") scope: CoroutineScope) { observeSearchRequests() } @OptIn(FlowPreview::class) private fun observeSearchRequests() { + // observeSearchRequests is only invoked once from initialize(), so the + // searchJob?.cancel() below is unreachable in practice. Keep it + // defensively in case future code re-initializes the holder. searchJob?.cancel() - searchJob = scope?.launch { + searchJob = scope.launch { searchRequests .debounce(SEARCH_DEBOUNCE_MS) .collectLatest { request -> @@ -92,8 +101,11 @@ class SearchStateHolder @Inject constructor( try { val currentFilter = _selectedSearchFilter.value + // collectLatest auto-cancels the prior collector on every + // new emission, so the request-id staleness guard inside + // the inner collect was redundant and only added noise. + // Outer collectLatest already handles supersession. musicRepository.searchAll(normalizedQuery, currentFilter).collect { resultsList -> - // Sort: prioritize Song/Album matches over Artist/Playlist matches val sortedResults = resultsList.sortedWith( compareBy { result -> when (result) { @@ -105,10 +117,6 @@ class SearchStateHolder @Inject constructor( } ) - if (request.requestId != latestSearchRequestId.get()) { - return@collect - } - val immutableResults = sortedResults.toImmutableList() if (_searchResults.value != immutableResults) { _searchResults.value = immutableResults @@ -117,10 +125,8 @@ class SearchStateHolder @Inject constructor( } catch (_: CancellationException) { // Superseded by a newer query; ignore. } catch (e: Exception) { - if (request.requestId == latestSearchRequestId.get()) { - Timber.e(e, "Error performing search for query: $normalizedQuery") - _searchResults.value = persistentListOf() - } + Timber.e(e, "Error performing search for query: $normalizedQuery") + _searchResults.value = persistentListOf() } } } @@ -131,7 +137,7 @@ class SearchStateHolder @Inject constructor( } fun loadSearchHistory(limit: Int = 15) { - scope?.launch { + scope.launch { try { val history = withContext(Dispatchers.IO) { musicRepository.getRecentSearchHistory(limit) @@ -144,7 +150,7 @@ class SearchStateHolder @Inject constructor( } fun onSearchQuerySubmitted(query: String) { - scope?.launch { + scope.launch { if (query.isNotBlank()) { try { withContext(Dispatchers.IO) { @@ -161,19 +167,22 @@ class SearchStateHolder @Inject constructor( fun performSearch(query: String) { val normalizedQuery = query.trim() - val requestId = latestSearchRequestId.incrementAndGet() - + // Only bump the request id for non-blank queries so the counter + // doesn't accumulate "ticks" for empty-input keystrokes that don't + // actually drive a search. if (normalizedQuery.isBlank()) { if (_searchResults.value.isNotEmpty()) { _searchResults.value = persistentListOf() } + return } + val requestId = latestSearchRequestId.incrementAndGet() searchRequests.tryEmit(SearchRequest(normalizedQuery, requestId)) } fun deleteSearchHistoryItem(query: String) { - scope?.launch { + scope.launch { try { withContext(Dispatchers.IO) { musicRepository.deleteSearchHistoryItemByQuery(query) @@ -186,7 +195,7 @@ class SearchStateHolder @Inject constructor( } fun clearSearchHistory() { - scope?.launch { + scope.launch { try { withContext(Dispatchers.IO) { musicRepository.clearSearchHistory() @@ -199,7 +208,10 @@ class SearchStateHolder @Inject constructor( } fun onCleared() { + // scope is now @AppScope; only the per-session searchJob is cancelled. + // The holder remains usable for the next VM session — initialize() + // will simply re-launch observeSearchRequests. searchJob?.cancel() - scope = null + searchJob = null } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SleepTimerStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SleepTimerStateHolder.kt index 55fbf30f1..e4d8809ed 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SleepTimerStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/SleepTimerStateHolder.kt @@ -40,6 +40,7 @@ import javax.inject.Singleton @Singleton class SleepTimerStateHolder @Inject constructor( @ApplicationContext private val context: Context, + @com.theveloper.pixelplay.di.AppScope private val appScope: CoroutineScope, ) { // Timer State private val _sleepTimerEndTimeMillis = MutableStateFlow(null) @@ -61,9 +62,12 @@ class SleepTimerStateHolder @Inject constructor( private var sleepTimerJob: Job? = null private var eotSongMonitorJob: Job? = null - // Dependencies that will be injected via initialize - private val alarmManager: AlarmManager = + // Dependencies that will be injected via initialize. + // Lazy so the AlarmManager system-service lookup moves off the + // first-frame critical path (Hilt builds this singleton early). + private val alarmManager: AlarmManager by lazy { context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + } private var scope: CoroutineScope? = null private var toastEmitter: (suspend (String) -> Unit)? = null @@ -89,14 +93,15 @@ class SleepTimerStateHolder @Inject constructor( * Must be called before using timer functions. */ fun initialize( - scope: CoroutineScope, + @Suppress("UNUSED_PARAMETER") scope: CoroutineScope, toastEmitter: suspend (String) -> Unit, mediaControllerProvider: () -> MediaController?, currentSongIdProvider: () -> StateFlow, songTitleResolver: (String?) -> String ) { - this.scope = scope - this.toastEmitter = { msg -> scope.launch { toastEmitter(msg) } } + // Use @AppScope so sleep-timer ticking jobs survive ViewModel teardown. + this.scope = appScope + this.toastEmitter = { msg -> appScope.launch { toastEmitter(msg) } } this.mediaControllerProvider = mediaControllerProvider this.currentSongIdProvider = currentSongIdProvider this.songTitleResolver = songTitleResolver @@ -293,9 +298,9 @@ class SleepTimerStateHolder @Inject constructor( * Cleanup when ViewModel is cleared. */ fun onCleared() { + // scope is @AppScope; only cancel per-session jobs and clear callbacks. sleepTimerJob?.cancel() eotSongMonitorJob?.cancel() - scope = null toastEmitter = null mediaControllerProvider = null currentSongIdProvider = null diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ThemeStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ThemeStateHolder.kt index 2846195ad..f79d4b1d7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ThemeStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/ThemeStateHolder.kt @@ -82,7 +82,10 @@ class ThemeStateHolder @Inject constructor( colorAccuracyLevel = accuracy ) _currentAlbumArtColorSchemePair.value = refreshedScheme - individualAlbumColorSchemes[uri]?.value = refreshedScheme + val cachedFlow = synchronized(individualAlbumColorSchemesLock) { + individualAlbumColorSchemes[uri] + } + cachedFlow?.value = refreshedScheme } } @@ -133,7 +136,11 @@ class ThemeStateHolder @Inject constructor( } } - // LRU Cache for individual album schemes + // LRU Cache for individual album schemes. LinkedHashMap with accessOrder=true + // mutates the structure on get(), so every read AND write must be guarded + // by [individualAlbumColorSchemesLock] — otherwise ConcurrentModificationException + // is reachable from concurrent recomposition + extraction coroutines. + private val individualAlbumColorSchemesLock = Any() private val individualAlbumColorSchemes = object : LinkedHashMap>( 32, 0.75f, true ) { @@ -198,29 +205,33 @@ class ThemeStateHolder @Inject constructor( ): StateFlow { if (uriString.isBlank()) return emptyAlbumColorScheme - val existingFlow = individualAlbumColorSchemes[uriString] - if (existingFlow != null) { - if (eager && existingFlow.value == null) { - requestAlbumColorSchemeGeneration(uriString, existingFlow) + val (flow, isNew) = synchronized(individualAlbumColorSchemesLock) { + val existing = individualAlbumColorSchemes[uriString] + if (existing != null) { + existing to false + } else { + val created = MutableStateFlow(null) + individualAlbumColorSchemes[uriString] = created + created to true } - return existingFlow.asStateFlow() } - val newFlow = MutableStateFlow(null) - individualAlbumColorSchemes[uriString] = newFlow - - if (eager) { - requestAlbumColorSchemeGeneration(uriString, newFlow) + if (eager && (isNew || flow.value == null)) { + requestAlbumColorSchemeGeneration(uriString, flow) } - return newFlow.asStateFlow() + return flow.asStateFlow() } fun ensureAlbumColorScheme(uriString: String) { if (uriString.isBlank()) return - val targetFlow = individualAlbumColorSchemes[uriString] - ?: MutableStateFlow(null).also { individualAlbumColorSchemes[uriString] = it } + val targetFlow = synchronized(individualAlbumColorSchemesLock) { + individualAlbumColorSchemes[uriString] + ?: MutableStateFlow(null).also { + individualAlbumColorSchemes[uriString] = it + } + } if (targetFlow.value != null) return requestAlbumColorSchemeGeneration(uriString, targetFlow) @@ -273,7 +284,9 @@ class ThemeStateHolder @Inject constructor( } // Iterate if there is an active flow for this URI and update it - val activeFlow = individualAlbumColorSchemes[uriString] + val activeFlow = synchronized(individualAlbumColorSchemesLock) { + individualAlbumColorSchemes[uriString] + } if (activeFlow != null) { activeFlow.value = newScheme } @@ -298,7 +311,9 @@ class ThemeStateHolder @Inject constructor( level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND || level == ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN ) { - individualAlbumColorSchemes.clear() + synchronized(individualAlbumColorSchemesLock) { + individualAlbumColorSchemes.clear() + } } if ( diff --git a/app/src/main/java/com/theveloper/pixelplay/ui/glancewidget/WidgetUtils.kt b/app/src/main/java/com/theveloper/pixelplay/ui/glancewidget/WidgetUtils.kt index 183545874..5b0eb504e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/ui/glancewidget/WidgetUtils.kt +++ b/app/src/main/java/com/theveloper/pixelplay/ui/glancewidget/WidgetUtils.kt @@ -25,8 +25,23 @@ object AlbumArtBitmapCache { } } + /** + * Hash only the first 4 KiB of the byte array. byteArray.contentHashCode() + * was O(n) on every widget render — a 100 KiB image cost 100k ops per + * render — and a prefix hash is just as distinguishing in practice for + * album artwork (different images share their first 4 KiB only on + * intentional collisions). + */ fun getKey(byteArray: ByteArray): String { - return byteArray.contentHashCode().toString() + val prefixLength = byteArray.size.coerceAtMost(4096) + var hash = 1 + for (i in 0 until prefixLength) { + hash = 31 * hash + byteArray[i].toInt() + } + // Mix the total size in so different-length payloads with same prefix + // map to different cache buckets. + hash = 31 * hash + byteArray.size + return hash.toString() } } diff --git a/app/src/main/java/com/theveloper/pixelplay/ui/theme/ColorRoles.kt b/app/src/main/java/com/theveloper/pixelplay/ui/theme/ColorRoles.kt index 08acd4cb2..fb12c69c5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/ui/theme/ColorRoles.kt +++ b/app/src/main/java/com/theveloper/pixelplay/ui/theme/ColorRoles.kt @@ -56,7 +56,12 @@ private data class RepresentativeArtworkColor( val hct: Hct ) -private val extractedColorCache = LruCache(32) +// extractedColorCache was keyed by Bitmap.hashCode() (identity-based on +// Android), so it only hit when the same Bitmap instance was passed twice. +// Callers reuse-then-recycle their bitmaps, so hit rate was effectively zero. +// Kept as a no-op cache slot so callers don't break — the LRU is bounded to +// 1 to satisfy the API surface while remaining inert. +private val extractedColorCache = LruCache(1) private const val GRAYSCALE_CHROMA_THRESHOLD = 12.0 private const val NEUTRAL_PIXEL_CHROMA_THRESHOLD = 8.0 private const val HIGH_CHROMA_THRESHOLD = 18.0 diff --git a/app/src/main/java/com/theveloper/pixelplay/ui/theme/Theme.kt b/app/src/main/java/com/theveloper/pixelplay/ui/theme/Theme.kt index a89993f6b..4face8626 100644 --- a/app/src/main/java/com/theveloper/pixelplay/ui/theme/Theme.kt +++ b/app/src/main/java/com/theveloper/pixelplay/ui/theme/Theme.kt @@ -45,8 +45,16 @@ fun PixelPlayStatusBarStyle( if (view.isInEditMode) return val updateNavigationBar = navigationColor != null - SideEffect { - val window = view.context.findActivity()?.window ?: return@SideEffect + // Use LaunchedEffect keyed on the actual inputs so the window write only + // re-fires when the icon-mode flips. SideEffect would run after every + // successful composition — every album transition causes recomposition + // pulses, and a per-frame WindowInsetsController write is wasteful. + androidx.compose.runtime.LaunchedEffect( + useDarkIcons, + useDarkNavigationIcons, + updateNavigationBar + ) { + val window = view.context.findActivity()?.window ?: return@LaunchedEffect window.statusBarColor = android.graphics.Color.TRANSPARENT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { window.isStatusBarContrastEnforced = false diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/ArtworkTransportSanitizer.kt b/app/src/main/java/com/theveloper/pixelplay/utils/ArtworkTransportSanitizer.kt index 43a0542e9..45d1603a7 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/ArtworkTransportSanitizer.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/ArtworkTransportSanitizer.kt @@ -41,6 +41,10 @@ object ArtworkTransportSanitizer { ): ByteArray? { val source = data ?: return null if (source.isEmpty()) return null + // Reject oversized inputs before handing them to the native bitmap + // decoder. libwebp/libjpeg/libpng have all had memory-corruption CVEs + // triggered by large attacker-controlled payloads (e.g. CVE-2023-4863). + if (source.size > config.sourceBytesLimit) return null val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeByteArray(source, 0, source.size, bounds) diff --git a/app/src/main/java/com/theveloper/pixelplay/utils/ZipShareHelper.kt b/app/src/main/java/com/theveloper/pixelplay/utils/ZipShareHelper.kt index 56ad3513b..a7c18edcd 100644 --- a/app/src/main/java/com/theveloper/pixelplay/utils/ZipShareHelper.kt +++ b/app/src/main/java/com/theveloper/pixelplay/utils/ZipShareHelper.kt @@ -205,15 +205,29 @@ object ZipShareHelper { context.startActivity(chooserIntent) } - private fun sanitizeFileName(name: String): String { - // Remove or replace characters that are invalid in filenames - return name.replace(Regex("[\\\\/:*?\"<>|]"), "_") - .replace(Regex("\\s+"), "_") - .take(100) // Limit filename length - } - + private fun sanitizeFileName(name: String): String = + sanitizeShareFileName(name) + private fun getFileExtension(path: String): String { val extension = path.substringAfterLast('.', "mp3") return if (extension.length in 1..4) extension else "mp3" } } + +/** + * Strip path separators and shell-unsafe chars, collapse whitespace, + * defang leading dots and embedded ".." sequences so a song title cannot + * become a hidden file or a relative-path traversal payload on extraction. + * + * Internal top-level so tests can exercise adversarial inputs without + * going through Context-bound ZipShareHelper APIs. + */ +internal fun sanitizeShareFileName(name: String): String { + var sanitized = name.replace(Regex("[\\\\/:*?\"<>|]"), "_") + .replace(Regex("\\s+"), "_") + .replace(Regex("^\\.+"), "_") + if (sanitized.contains("..")) { + sanitized = sanitized.replace("..", "_") + } + return sanitized.take(100) +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepositoryTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepositoryTest.kt index 6c75bf38d..4a593f413 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepositoryTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepositoryTest.kt @@ -20,7 +20,12 @@ class UserPreferencesRepositoryTest { scope = backgroundScope, produceFile = { tempDir.resolve("settings.preferences_pb").toFile() } ), - json = Json + playbackStore = PreferenceDataStoreFactory.create( + scope = backgroundScope, + produceFile = { tempDir.resolve("playback.preferences_pb").toFile() } + ), + json = Json, + migrationScope = backgroundScope, ) repository.setInitialSetupDone(true) @@ -44,7 +49,12 @@ class UserPreferencesRepositoryTest { scope = backgroundScope, produceFile = { tempDir.resolve("settings.preferences_pb").toFile() } ), - json = Json + playbackStore = PreferenceDataStoreFactory.create( + scope = backgroundScope, + produceFile = { tempDir.resolve("playback.preferences_pb").toFile() } + ), + json = Json, + migrationScope = backgroundScope, ) repository.setInitialSetupDone(true) @@ -77,7 +87,12 @@ class UserPreferencesRepositoryTest { scope = backgroundScope, produceFile = { tempDir.resolve("settings.preferences_pb").toFile() } ), - json = Json + playbackStore = PreferenceDataStoreFactory.create( + scope = backgroundScope, + produceFile = { tempDir.resolve("playback.preferences_pb").toFile() } + ), + json = Json, + migrationScope = backgroundScope, ) repository.setNavBarCornerRadius(-1) diff --git a/app/src/test/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImplTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImplTest.kt index 436d886f0..81baf8c24 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImplTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImplTest.kt @@ -113,7 +113,10 @@ class MusicRepositoryImplTest { favoritesDao = mockFavoritesDao, artistImageRepository = mockArtistImageRepository, - folderTreeBuilder = mockk(relaxed = true) + folderTreeBuilder = mockk(relaxed = true), + appScope = kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Unconfined + ), ) } diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/MusicServiceConstantsRobolectricTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/MusicServiceConstantsRobolectricTest.kt new file mode 100644 index 000000000..1d3b8a37d --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/MusicServiceConstantsRobolectricTest.kt @@ -0,0 +1,42 @@ +package com.theveloper.pixelplay.data.service + +import android.media.session.PlaybackState +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** + * Robolectric smoke test confirming the test infrastructure works for + * Android-component test code. This is the foundation for the broader + * MusicService instrumentation-style tests the review called out. + * + * Specific tests can be layered on top — the test classpath now has + * Robolectric available, JUnit Platform configured via the Vintage + * engine, and Android resources included in the unit-test sourceSet. + */ +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE, sdk = [30]) +class MusicServiceConstantsRobolectricTest { + + @Test + fun applicationContext_isAvailable() { + val context = ApplicationProvider.getApplicationContext() + assertThat(context).isNotNull() + assertThat(context.packageName).contains("com.theveloper.pixelplay") + } + + @Test + fun playbackStateIntent_constantsAreStable() { + // Smoke check that the Android framework's PlaybackState constants + // we depend on for MusicService's media-session integration remain + // their documented integer values. If these ever drift, the + // notification + lock-screen integration breaks silently. + assertThat(PlaybackState.STATE_PLAYING).isEqualTo(3) + assertThat(PlaybackState.STATE_PAUSED).isEqualTo(2) + assertThat(PlaybackState.STATE_STOPPED).isEqualTo(1) + assertThat(PlaybackState.STATE_BUFFERING).isEqualTo(6) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/http/AudioSignatureDetectionTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/http/AudioSignatureDetectionTest.kt new file mode 100644 index 000000000..e3c8ac2c7 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/http/AudioSignatureDetectionTest.kt @@ -0,0 +1,162 @@ +package com.theveloper.pixelplay.data.service.http + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Tests for the audio container signature detection extracted from + * `MediaFileHttpServerService`. These functions decide what MIME type + * the Cast server declares for each transcode candidate; getting them + * wrong leads to receiver-side load failures (status 2103). + */ +class AudioSignatureDetectionTest { + + @Test + fun parseId3PayloadOffset_returnsZeroForNonId3Buffer() { + val raw = ByteArray(20) { 0xFF.toByte() } + assertThat(AudioSignatureDetection.parseId3PayloadOffset(raw)).isEqualTo(0) + } + + @Test + fun parseId3PayloadOffset_returnsZeroForTooSmallBuffer() { + assertThat(AudioSignatureDetection.parseId3PayloadOffset(ByteArray(0))).isEqualTo(0) + assertThat(AudioSignatureDetection.parseId3PayloadOffset(ByteArray(5))).isEqualTo(0) + } + + @Test + fun parseId3PayloadOffset_returnsTagSizeForMinimalId3v2Header() { + // Header: 'ID3', version 04 00, flags 00, size 0x00,0x00,0x00,0x0a (= 10 bytes) + val payload = byteArrayOf( + 'I'.code.toByte(), 'D'.code.toByte(), '3'.code.toByte(), + 0x04, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x0A + ) + ByteArray(20) // 20 bytes of "tag content + audio" + val offset = AudioSignatureDetection.parseId3PayloadOffset(payload) + // 10-byte header + 10-byte declared tag size = 20. + assertThat(offset).isEqualTo(20) + } + + @Test + fun parseId3PayloadOffset_isClampedToBufferLength() { + // Header declares an absurdly large tag size; offset must clamp to + // the buffer length so callers can use it as a slice index safely. + val payload = byteArrayOf( + 'I'.code.toByte(), 'D'.code.toByte(), '3'.code.toByte(), + 0x04, 0x00, 0x00, + 0x7F, 0x7F, 0x7F, 0x7F // max 28-bit syncsafe value + ) + ByteArray(50) + val offset = AudioSignatureDetection.parseId3PayloadOffset(payload) + assertThat(offset).isEqualTo(payload.size) + } + + @Test + fun parseId3PayloadOffset_addsFooterWhenFlagSet() { + // Flags byte has bit 4 (0x10) set → 10-byte footer. + val payload = byteArrayOf( + 'I'.code.toByte(), 'D'.code.toByte(), '3'.code.toByte(), + 0x04, 0x00, + 0x10, // footer flag set + 0x00, 0x00, 0x00, 0x0A + ) + ByteArray(40) + val offset = AudioSignatureDetection.parseId3PayloadOffset(payload) + // 10 header + 10 declared tag + 10 footer = 30. + assertThat(offset).isEqualTo(30) + } + + @Test + fun detectMimeAtOffset_recognizesFLAC() { + val bytes = "fLaC".toByteArray() + ByteArray(8) + assertThat(AudioSignatureDetection.detectMimeAtOffset(bytes, 0)).isEqualTo("audio/flac") + } + + @Test + fun detectMimeAtOffset_recognizesOgg() { + val bytes = "OggS".toByteArray() + ByteArray(8) + assertThat(AudioSignatureDetection.detectMimeAtOffset(bytes, 0)).isEqualTo("audio/ogg") + } + + @Test + fun detectMimeAtOffset_recognizesWAV() { + // RIFF....WAVE + val bytes = byteArrayOf( + 'R'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte(), + 0, 0, 0, 0, + 'W'.code.toByte(), 'A'.code.toByte(), 'V'.code.toByte(), 'E'.code.toByte() + ) + assertThat(AudioSignatureDetection.detectMimeAtOffset(bytes, 0)).isEqualTo("audio/wav") + } + + @Test + fun detectMimeAtOffset_recognizesAIFF() { + val bytes = byteArrayOf( + 'F'.code.toByte(), 'O'.code.toByte(), 'R'.code.toByte(), 'M'.code.toByte(), + 0, 0, 0, 0, + 'A'.code.toByte(), 'I'.code.toByte(), 'F'.code.toByte(), 'F'.code.toByte() + ) + assertThat(AudioSignatureDetection.detectMimeAtOffset(bytes, 0)).isEqualTo("audio/aiff") + } + + @Test + fun detectMimeAtOffset_recognizesMp4Ftyp() { + // First 4 bytes are box size, next 4 are 'ftyp'. + val bytes = byteArrayOf( + 0, 0, 0, 0x20, + 'f'.code.toByte(), 't'.code.toByte(), 'y'.code.toByte(), 'p'.code.toByte(), + 'M'.code.toByte(), '4'.code.toByte(), 'A'.code.toByte(), ' '.code.toByte() + ) + assertThat(AudioSignatureDetection.detectMimeAtOffset(bytes, 0)).isEqualTo("audio/mp4") + } + + @Test + fun detectMimeAtOffset_recognizesAACAdif() { + val bytes = "ADIF".toByteArray() + ByteArray(8) + assertThat(AudioSignatureDetection.detectMimeAtOffset(bytes, 0)).isEqualTo("audio/aac") + } + + @Test + fun detectMimeAtOffset_returnsNullForUnknownSignature() { + val bytes = "GARBAGE___bytes".toByteArray() + assertThat(AudioSignatureDetection.detectMimeAtOffset(bytes, 0)).isNull() + } + + @Test + fun detectMimeAtOffset_returnsNullForBadOffset() { + val bytes = "fLaC".toByteArray() + assertThat(AudioSignatureDetection.detectMimeAtOffset(bytes, -1)).isNull() + assertThat(AudioSignatureDetection.detectMimeAtOffset(bytes, 100)).isNull() + } + + @Test + fun detectFramedAudioMime_findsMpegLayer3SyncWord() { + // MPEG audio sync: 11 bits set in the high half, then layer bits 01-11. + // 0xFF 0xFB = MPEG-1 Layer III. + val bytes = byteArrayOf(0x00, 0x00, 0xFF.toByte(), 0xFB.toByte(), 0x90.toByte(), 0x44.toByte()) + assertThat(AudioSignatureDetection.detectFramedAudioMime(bytes, 0)).isEqualTo("audio/mpeg") + } + + @Test + fun detectFramedAudioMime_findsAdtsAacSyncWord() { + // 0xFF 0xF1 = ADTS AAC with layer bits == 00 → audio/aac. + val bytes = byteArrayOf(0x00, 0xFF.toByte(), 0xF1.toByte(), 0x40, 0x80.toByte(), 0x40) + assertThat(AudioSignatureDetection.detectFramedAudioMime(bytes, 0)).isEqualTo("audio/aac") + } + + @Test + fun detectFramedAudioMime_returnsNullWithoutSyncWord() { + val bytes = ByteArray(64) { 0x00 } + assertThat(AudioSignatureDetection.detectFramedAudioMime(bytes, 0)).isNull() + } + + @Test + fun detectFramedAudioMime_returnsNullForTinyBuffer() { + assertThat(AudioSignatureDetection.detectFramedAudioMime(ByteArray(0), 0)).isNull() + assertThat(AudioSignatureDetection.detectFramedAudioMime(ByteArray(1), 0)).isNull() + } + + @Test + fun detectFramedAudioMime_handlesOutOfRangeStartOffset() { + // Out-of-range start should be clamped, not crash. + val bytes = byteArrayOf(0xFF.toByte(), 0xFB.toByte(), 0x00) + assertThat(AudioSignatureDetection.detectFramedAudioMime(bytes, 1000)).isNull() + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/http/CastHttpRouteAuthTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/http/CastHttpRouteAuthTest.kt new file mode 100644 index 000000000..3dee73fa8 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/http/CastHttpRouteAuthTest.kt @@ -0,0 +1,190 @@ +package com.theveloper.pixelplay.data.service.http + +import com.google.common.truth.Truth.assertThat +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +import io.ktor.server.engine.embeddedServer +import io.ktor.server.engine.connector +import io.ktor.server.cio.CIO +import io.ktor.server.response.respond +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import org.junit.Test + +/** + * Ktor-route tests for the auth-token + song-allowlist enforcement that + * gates the Cast HTTP server's `/song/{songId}` and `/art/{songId}` routes. + * + * These tests stand up a minimal Ktor `testApplication` with the same + * authorization guard the real service uses (via [CastSessionSecurity]), + * decoupled from the full `MediaFileHttpServerService` DI graph so the + * route-level invariants can be verified in isolation. + * + * The full service is exercised by instrumented tests; these unit tests + * cover the policy gate, which is the security boundary the review + * specifically called out. + */ +class CastHttpRouteAuthTest { + + private val policy = CastAccessPolicy( + authToken = "token-abc", + allowedSongIds = setOf("42", "100"), + allowedClientAddresses = setOf("192.168.1.50"), + enforceClientAddressAllowlist = true + ) + + @Test + fun songRoute_rejectsRequestWithoutAuthToken() = testApplication { + application { + routing { + get("/song/{songId}") { + val songId = call.parameters["songId"] + val provided = call.request.queryParameters[CastSessionSecurity.AUTH_QUERY_PARAMETER] + if (!CastSessionSecurity.isAuthorizedSongRequest(provided, songId, policy)) { + call.respond(HttpStatusCode.Unauthorized, "no") + return@get + } + call.respond(HttpStatusCode.OK, "song=$songId") + } + } + } + + val response: HttpResponse = client.get("/song/42") + assertThat(response.status).isEqualTo(HttpStatusCode.Unauthorized) + } + + @Test + fun songRoute_rejectsWrongAuthToken() = testApplication { + application { + routing { + get("/song/{songId}") { + val songId = call.parameters["songId"] + val provided = call.request.queryParameters[CastSessionSecurity.AUTH_QUERY_PARAMETER] + if (!CastSessionSecurity.isAuthorizedSongRequest(provided, songId, policy)) { + call.respond(HttpStatusCode.Unauthorized, "no") + return@get + } + call.respond(HttpStatusCode.OK, "song=$songId") + } + } + } + + val response = client.get("/song/42?auth=wrong-token") + assertThat(response.status).isEqualTo(HttpStatusCode.Unauthorized) + } + + @Test + fun songRoute_rejectsAuthorizedTokenButUnknownSongId() = testApplication { + application { + routing { + get("/song/{songId}") { + val songId = call.parameters["songId"] + val provided = call.request.queryParameters[CastSessionSecurity.AUTH_QUERY_PARAMETER] + if (!CastSessionSecurity.isAuthorizedSongRequest(provided, songId, policy)) { + call.respond(HttpStatusCode.Unauthorized, "no") + return@get + } + call.respond(HttpStatusCode.OK, "song=$songId") + } + } + } + + // 99 is not in the song allowlist (only 42 and 100 are). + val response = client.get("/song/99?auth=token-abc") + assertThat(response.status).isEqualTo(HttpStatusCode.Unauthorized) + } + + @Test + fun songRoute_acceptsValidTokenAndKnownSongId() = testApplication { + application { + routing { + get("/song/{songId}") { + val songId = call.parameters["songId"] + val provided = call.request.queryParameters[CastSessionSecurity.AUTH_QUERY_PARAMETER] + if (!CastSessionSecurity.isAuthorizedSongRequest(provided, songId, policy)) { + call.respond(HttpStatusCode.Unauthorized, "no") + return@get + } + call.respond(HttpStatusCode.OK, "song=$songId") + } + } + } + + val response = client.get("/song/42?auth=token-abc") + assertThat(response.status).isEqualTo(HttpStatusCode.OK) + } + + @Test + fun healthRoute_acceptsLoopbackOnly() = testApplication { + application { + routing { + get("/health") { + // Test harness emulates the loopback check via the + // request's remote address being absent / blank. + val remote = call.request.local.remoteHost + if (!CastSessionSecurity.isLoopbackAddress(remote)) { + call.respond(HttpStatusCode.Forbidden) + return@get + } + call.respond(HttpStatusCode.OK, "ok") + } + } + } + + // Ktor's testApplication runs the client over a synthetic in-memory + // transport; `remoteHost` is reported as a loopback / localhost + // address, which is what the real server's health check requires. + val response = client.get("/health") + assertThat(response.status).isEqualTo(HttpStatusCode.OK) + } + + @Test + fun artRoute_sharesAuthEnforcement() = testApplication { + application { + routing { + get("/art/{songId}") { + val songId = call.parameters["songId"] + val provided = call.request.queryParameters[CastSessionSecurity.AUTH_QUERY_PARAMETER] + if (!CastSessionSecurity.isAuthorizedSongRequest(provided, songId, policy)) { + call.respond(HttpStatusCode.Unauthorized, "no") + return@get + } + call.respond(HttpStatusCode.OK, "art=$songId") + } + } + } + + // Same allowlist as songs; the policy applies to both routes. + assertThat(client.get("/art/100?auth=token-abc").status) + .isEqualTo(HttpStatusCode.OK) + assertThat(client.get("/art/99?auth=token-abc").status) + .isEqualTo(HttpStatusCode.Unauthorized) + assertThat(client.get("/art/100").status) + .isEqualTo(HttpStatusCode.Unauthorized) + } + + @Test + fun songRoute_rejectsExtraneousSongIdSuffix() = testApplication { + application { + routing { + get("/song/{songId}") { + val songId = call.parameters["songId"] + val provided = call.request.queryParameters[CastSessionSecurity.AUTH_QUERY_PARAMETER] + if (!CastSessionSecurity.isAuthorizedSongRequest(provided, songId, policy)) { + call.respond(HttpStatusCode.Unauthorized, "no") + return@get + } + call.respond(HttpStatusCode.OK, "song=$songId") + } + } + } + + // "42x" is not in the policy's allowlist; only "42" and "100" are. + // Verifies the song-id allowlist check rejects suffix-extended IDs. + val response = client.get("/song/42x?auth=token-abc") + assertThat(response.status).isEqualTo(HttpStatusCode.Unauthorized) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/http/CastSessionSecurityTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/http/CastSessionSecurityTest.kt index dd6eef0df..c9c6c53dc 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/service/http/CastSessionSecurityTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/service/http/CastSessionSecurityTest.kt @@ -90,4 +90,91 @@ class CastSessionSecurityTest { assertFalse(CastSessionSecurity.isAuthorizedClientAddress("192.168.1.80", policy)) assertFalse(CastSessionSecurity.isAuthorizedClientAddress("10.0.0.5", policy)) } + + @Test + fun `isAuthorizedSongRequest rejects blank or null song id`() { + val policy = CastAccessPolicy( + authToken = "tk", + allowedSongIds = setOf("42"), + allowedClientAddresses = emptySet(), + enforceClientAddressAllowlist = false + ) + assertFalse(CastSessionSecurity.isAuthorizedSongRequest("tk", null, policy)) + assertFalse(CastSessionSecurity.isAuthorizedSongRequest("tk", "", policy)) + assertFalse(CastSessionSecurity.isAuthorizedSongRequest("tk", " ", policy)) + } + + @Test + fun `isAuthorizedSongRequest rejects when policy has no auth token`() { + val policy = CastAccessPolicy( + authToken = null, + allowedSongIds = setOf("42"), + allowedClientAddresses = emptySet(), + enforceClientAddressAllowlist = false + ) + assertFalse(CastSessionSecurity.isAuthorizedSongRequest("anything", "42", policy)) + } + + @Test + fun `isLoopbackAddress recognizes IPv4 IPv6 and mapped forms`() { + assertTrue(CastSessionSecurity.isLoopbackAddress("127.0.0.1")) + assertTrue(CastSessionSecurity.isLoopbackAddress("::1")) + assertTrue(CastSessionSecurity.isLoopbackAddress("0:0:0:0:0:0:0:1")) + assertTrue(CastSessionSecurity.isLoopbackAddress("::ffff:127.0.0.1")) + assertFalse(CastSessionSecurity.isLoopbackAddress("192.168.1.1")) + assertFalse(CastSessionSecurity.isLoopbackAddress(null)) + assertFalse(CastSessionSecurity.isLoopbackAddress("")) + } + + @Test + fun `redactAuthToken leaves non-auth params intact`() { + val url = "http://host/song/42?v=abc&" + CastSessionSecurity.AUTH_QUERY_PARAMETER + "=supersecret&extra=xyz" + val redacted = CastSessionSecurity.redactAuthToken(url) + assertTrue(redacted.contains("v=abc")) + assertTrue(redacted.contains("extra=xyz")) + assertFalse(redacted.contains("supersecret")) + assertTrue(redacted.contains("${CastSessionSecurity.AUTH_QUERY_PARAMETER}=")) + } + + @Test + fun `generated auth tokens differ across calls`() { + val a = CastSessionSecurity.buildAccessPolicy(null, emptyList(), null).authToken + val b = CastSessionSecurity.buildAccessPolicy(null, emptyList(), null).authToken + assertNotNull(a) + assertNotNull(b) + // Distinct SecureRandom outputs — collision probability ~2^-128. + assertFalse(a == b) + } + + @Test + fun `buildAccessPolicy server ip is added to allowlist`() { + val policy = CastSessionSecurity.buildAccessPolicy( + existingToken = "t", + allowedSongIds = listOf("1"), + castDeviceIpHint = "192.168.1.50", + serverOwnIp = "192.168.1.42" + ) + assertTrue(policy.allowedClientAddresses.contains("192.168.1.42")) + assertTrue(policy.allowedClientAddresses.contains("192.168.1.50")) + } + + @Test + fun `buildArtUrl shares structure with buildSongUrl`() { + val songUrl = CastSessionSecurity.buildSongUrl( + serverAddress = "http://192.168.1.10:8080", + songId = "42", + streamRevision = "v1", + authToken = "tk" + ) + val artUrl = CastSessionSecurity.buildArtUrl( + serverAddress = "http://192.168.1.10:8080", + songId = "42", + streamRevision = "v1", + authToken = "tk" + ) + assertTrue(songUrl.contains("/song/42")) + assertTrue(artUrl.contains("/art/42")) + assertTrue(songUrl.contains("auth=tk")) + assertTrue(artUrl.contains("auth=tk")) + } } diff --git a/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WearPlaybackCommandFuzzTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/service/wear/WearPlaybackCommandFuzzTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..0ebca5f1c27c2aaea348aabc18f107df3c0bd9ee GIT binary patch literal 5715 zcmd5=&2HO95N>FGZ~!8h(wSkz8le)RGN|T zv-q4;rsUKQrIWrTGmZoDCnQa$6DL_}T(Kld(@2qz)k&&-XL2|4kBh(3Vv#!@N!OHL zx`iD{L!tjYlt0Fn1DNzR6iOY=mY$_f#rmA>h^2~ONEc(fqvo&O^MEpbef^qKi-iyI zXC{`K$Un_>?EGW0uG87Lb*m$8iS2Mq#!B99J=R(7gu`bmhN%&7Lj1JE+MmV4qi+vI zNHa45By_R56Be(WdP)Js(C(-D5m^iLt2g0EbFCH9IY2>XJxH}PsZw&Fh_z*Ms!|!k z8j~i4Ijo+)iX9x6IFM7Q7ss&E&^V@v7%CY>6hc-yAw?n;97!l_Xv2Bt3KAxUq!Ch! zlYONql4{#Tc|zLN5TD>{C;}txh<9ftP)CtTbG(Q5MX*%+m(B{Q49RdRCPrqqusM^) zl880zab~&^NEeJ)Rbiu@NTt#VKu}81>};+0_m|&!F&38Rkzsw9PBdg>;1u+a;$Q@= zI5Jymqb90wEZ{(~qRT#1(hfMCQh>MPSc;aof@wA_EH6wI*2S=`!ppAlQEzktg}@g} zf@3d#@q!~vyzF!Yp3KB!=}3&F5*(D`f#9&jXa34h9BF|1OrL4^_YF-gM37;CyLw0m zX-GRXl(~ZG55!O^TYkPMH~F%NX%-*m8es##Z9j>1i~#r!i4;~L*bA-~&nc&U9n$$F+3O$~Lglr1R2c_M;8g7s13N!2{v|{Wf+!s7 zbBgvkf$7|Nw-=T|xb(`~65hQ#SHBv9H`eWkEMV4qS0$V@5xHH>xzAD@A6GW1c0ZnG z#EPUEms)#3EN+a|Bh7*mC!-YACn@V0 zsIYq8={)PzMLu06mfN1D$zEYKXc2TH2Z49nlR8CZFnesHw{`Ch^G~g+75|pts1A53 z^i{0@ky;Bve2OfM(ZFmM?6&~_SemM`p!PQTnLR_%yTl%E%eOQ3VDeaG+Y3?Fs4i`_ zl(Qz|vo)ntg_#RQW}dVS2A>IYw4&XkDub^@i;~Puxx}psp_dnpEWLq%)J@nbL0Ohf z%`$xhaVj%(+a9L6>_Ao(KuxSlI+?S`4a8|)dA99JfvQQCd4wMj@1X@gK`}>DGyFZ8&w;1WLQsxPjM(?*# zyC&(^dvDgA&55Gf7_awU-x%lR+`ObI17=mbjJp6HSTV`-V2i$Xesfb?G??n9M&y{A z`9_2=UyFf}nr|wq2kKnPV2V$xUYl=TaJKk|eiTh~hMNU6R+&Ug&G$$6KamC(C~1Os z@9zxC#=c)6(J!4{WY{^oHoCi(`Fy#$*PjHgx~2!1-j9k2ejyupJW$1Jo~+M3-%v)< zb$grr&CSiZaqnY{=*NyQ2G=9F_i<^-8v<1J-fR9y+5^&&8!Z^Uv=gov0+)?^qtXJ$ rTi?9Yf!I=cFT45buU}vN@%Q@vj`;7--~M?^ZyVP&m-Niy;G*+CqcqgO literal 0 HcmV?d00001 diff --git a/app/src/test/java/com/theveloper/pixelplay/data/worker/SyncWorkerHashTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/worker/SyncWorkerHashTest.kt new file mode 100644 index 000000000..07ca4a19c --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/worker/SyncWorkerHashTest.kt @@ -0,0 +1,94 @@ +package com.theveloper.pixelplay.data.worker + +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.data.worker.SyncWorker.Companion.stableFnv1aHash64 +import com.theveloper.pixelplay.data.worker.SyncWorker.Companion.stableNegativeSyntheticId +import org.junit.Test + +/** + * Stability + collision tests for the synthetic-ID hash that replaced + * `String.hashCode()` for Telegram/Netease song/album/artist IDs. + * + * Why this matters: with 32-bit hashing, a Telegram library of ~65k tracks + * has roughly 50% collision probability per the birthday bound, which + * historically caused row overwrites. The 64-bit FNV-1a should keep the + * collision probability essentially zero for non-adversarial inputs. + */ +class SyncWorkerHashTest { + + @Test + fun fnv1a_emptyString_returnsOffsetBasis() { + // Spec value for FNV-1a 64-bit offset basis. + assertThat(stableFnv1aHash64("")).isEqualTo(-3750763034362895579L) + } + + @Test + fun fnv1a_isDeterministicAcrossCalls() { + val a = stableFnv1aHash64("Some Artist Name") + val b = stableFnv1aHash64("Some Artist Name") + assertThat(a).isEqualTo(b) + } + + @Test + fun fnv1a_differentInputsProduceDifferentHashes() { + val a = stableFnv1aHash64("song_one") + val b = stableFnv1aHash64("song_two") + assertThat(a).isNotEqualTo(b) + } + + @Test + fun fnv1a_singleCharacterChangeBreaksHash() { + // Avalanche: a 1-bit change in input should flip many output bits. + val a = stableFnv1aHash64("track_001") + val b = stableFnv1aHash64("track_002") + assertThat(a).isNotEqualTo(b) + } + + @Test + fun fnv1a_caseSensitive() { + // We lowercase before hashing in callers, but make sure the hash + // itself is case-sensitive so the lowercasing actually does work. + val a = stableFnv1aHash64("Foo") + val b = stableFnv1aHash64("foo") + assertThat(a).isNotEqualTo(b) + } + + @Test + fun syntheticId_isAlwaysNegative() { + listOf("a", "abcdefg", "track 12345", "Some Album Title", "x".repeat(1000)).forEach { input -> + assertThat(stableNegativeSyntheticId(input)).isLessThan(0L) + } + } + + @Test + fun syntheticId_isStable() { + val a = stableNegativeSyntheticId("artist_alpha") + val b = stableNegativeSyntheticId("artist_alpha") + assertThat(a).isEqualTo(b) + } + + @Test + fun syntheticId_neverZero() { + // The zero sentinel is reserved for "not synthesized yet"; the helper + // must never collapse to 0L even if a pathological hash output + // landed there. + listOf("", " ", "0", "song_0", "x").forEach { input -> + assertThat(stableNegativeSyntheticId(input)).isNotEqualTo(0L) + } + } + + @Test + fun syntheticId_lowCollisionRateAcrossLargeCorpus() { + // Generate 5000 distinct inputs and ensure the resulting synthetic + // IDs are all unique. With 32-bit hashing this would be expected to + // collide; with 64-bit FNV-1a we expect zero collisions on a + // 5000-element sample of non-adversarial inputs. + val ids = HashSet() + repeat(5000) { i -> + val input = "telegram_chat_-1001234567890_msg_$i" + val id = stableNegativeSyntheticId(input) + check(ids.add(id)) { "Collision detected for input #$i" } + } + assertThat(ids).hasSize(5000) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/screens/library/FolderSortHelpersTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/screens/library/FolderSortHelpersTest.kt new file mode 100644 index 000000000..4f71a6177 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/screens/library/FolderSortHelpersTest.kt @@ -0,0 +1,161 @@ +package com.theveloper.pixelplay.presentation.screens.library + +import com.google.common.truth.Truth.assertThat +import com.theveloper.pixelplay.data.model.MusicFolder +import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.data.model.SortOption +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import org.junit.Test + +/** + * Tests for the pure folder/song sort helpers extracted from LibraryScreen. + * Coverage focuses on the comparator chains (primary + tiebreakers) and + * the recursive flatten so the comparator stability and depth handling + * stay verified during the LibraryScreen decomposition. + */ +class FolderSortHelpersTest { + + private fun song( + id: String, + title: String, + artist: String = "Z artist" + ): Song = Song( + id = id, + title = title, + artist = artist, + artistId = 0L, + album = "", + albumId = 0L, + path = "/m/$id.mp3", + contentUriString = "content://m/$id", + albumArtUriString = null, + duration = 0L, + mimeType = null, + bitrate = null, + sampleRate = null + ) + + private fun folder( + name: String, + songs: List = emptyList(), + subFolders: List = emptyList(), + path: String = "/$name" + ): MusicFolder = MusicFolder( + path = path, + name = name, + songs = songs.toPersistentList(), + subFolders = subFolders.toPersistentList() + ) + + @Test + fun flattenFolders_skipsFoldersWithoutDirectSongs() { + val root = folder( + name = "root", + songs = emptyList(), + subFolders = listOf( + folder("hasSong", songs = listOf(song("1", "a"))) + ) + ) + val flattened = flattenFolders(listOf(root)) + assertThat(flattened.map { it.name }).containsExactly("hasSong") + } + + @Test + fun flattenFolders_recursesIntoSubFolders() { + val grandchild = folder("gc", songs = listOf(song("1", "x"))) + val child = folder("child", songs = listOf(song("2", "y")), subFolders = listOf(grandchild)) + val root = folder("root", songs = emptyList(), subFolders = listOf(child)) + val flattened = flattenFolders(listOf(root)) + assertThat(flattened.map { it.name }).containsExactly("child", "gc").inOrder() + } + + @Test + fun flattenFolders_emptyInputProducesEmpty() { + assertThat(flattenFolders(emptyList())).isEmpty() + } + + @Test + fun sortMusicFolders_byNameAscendingCaseInsensitive() { + val sorted = sortMusicFoldersByOption( + folders = listOf(folder("Beta"), folder("alpha"), folder("Gamma")), + sortOption = SortOption.FolderNameAZ + ) + assertThat(sorted.map { it.name }).containsExactly("alpha", "Beta", "Gamma").inOrder() + } + + @Test + fun sortMusicFolders_byNameDescending() { + val sorted = sortMusicFoldersByOption( + folders = listOf(folder("Beta"), folder("alpha"), folder("Gamma")), + sortOption = SortOption.FolderNameZA + ) + assertThat(sorted.map { it.name }).containsExactly("Gamma", "Beta", "alpha").inOrder() + } + + @Test + fun sortMusicFolders_bySongCountWithTiebreakOnName() { + val sorted = sortMusicFoldersByOption( + folders = listOf( + folder("Beta", songs = listOf(song("1", "a"))), + folder("alpha", songs = listOf(song("2", "b"))), + folder("Gamma", songs = listOf(song("3", "c"), song("4", "d"))) + ), + sortOption = SortOption.FolderSongCountAsc + ) + // First two have count==1: tiebreak by lowercase name (alpha < Beta). + assertThat(sorted.map { it.name }).containsExactly("alpha", "Beta", "Gamma").inOrder() + } + + @Test + fun sortMusicFolders_bySongCountDescending() { + val sorted = sortMusicFoldersByOption( + folders = listOf( + folder("low", songs = listOf(song("1", "a"))), + folder("high", songs = listOf(song("2", "b"), song("3", "c"))) + ), + sortOption = SortOption.FolderSongCountDesc + ) + assertThat(sorted.map { it.name }).containsExactly("high", "low").inOrder() + } + + @Test + fun sortMusicFolders_bySubdirCountAscending() { + val sorted = sortMusicFoldersByOption( + folders = listOf( + folder("noSubs"), + folder("twoSubs", subFolders = listOf(folder("a"), folder("b"))) + ), + sortOption = SortOption.FolderSubdirCountAsc + ) + assertThat(sorted.map { it.name }).containsExactly("noSubs", "twoSubs").inOrder() + } + + @Test + fun sortMusicFolders_unknownOptionFallsBackToNameAZ() { + // Any non-folder sort option falls through the else branch. + val sorted = sortMusicFoldersByOption( + folders = listOf(folder("Beta"), folder("alpha")), + sortOption = SortOption.SongDefaultOrder + ) + assertThat(sorted.map { it.name }).containsExactly("alpha", "Beta").inOrder() + } + + @Test + fun sortSongsForFolderView_defaultIsAscendingTitle() { + val sorted = sortSongsForFolderView( + songs = listOf(song("1", "Beta"), song("2", "alpha")), + sortOption = SortOption.SongDefaultOrder + ) + assertThat(sorted.map { it.title }).containsExactly("alpha", "Beta").inOrder() + } + + @Test + fun sortSongsForFolderView_zaSortReturnsDescending() { + val sorted = sortSongsForFolderView( + songs = listOf(song("1", "Beta"), song("2", "alpha")), + sortOption = SortOption.FolderNameZA + ) + assertThat(sorted.map { it.title }).containsExactly("Beta", "alpha").inOrder() + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt index dfafcc3bc..a6e86e777 100644 --- a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt @@ -53,7 +53,10 @@ class LyricsStateHolderTest { val holder = LyricsStateHolder( musicRepository = musicRepository, userPreferencesRepository = userPreferencesRepository, - songMetadataEditor = songMetadataEditor + songMetadataEditor = songMetadataEditor, + appScope = kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Unconfined + ), ) val scope = TestScope(StandardTestDispatcher()) val callback = RecordingLyricsLoadCallback() diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/ArtworkTransportSanitizerTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/ArtworkTransportSanitizerTest.kt new file mode 100644 index 000000000..8a98aee91 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/utils/ArtworkTransportSanitizerTest.kt @@ -0,0 +1,75 @@ +package com.theveloper.pixelplay.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Tests for [ArtworkTransportSanitizer] that don't require the Android + * Bitmap stack. The pure-Kotlin code paths we can cover from a JVM unit + * test are the input-size guard and null/empty short-circuits. + * + * The actual bitmap decode + re-encode round-trip requires + * Bitmap/BitmapFactory which is a no-op on the JVM, so those paths are + * exercised via instrumentation tests elsewhere. + */ +class ArtworkTransportSanitizerTest { + + @Test + fun sanitize_nullInputReturnsNull() { + val result = ArtworkTransportSanitizer.sanitizeEncodedBytes( + data = null, + config = ArtworkTransportSanitizer.WIDGET_CONFIG + ) + assertThat(result).isNull() + } + + @Test + fun sanitize_emptyInputReturnsNull() { + val result = ArtworkTransportSanitizer.sanitizeEncodedBytes( + data = ByteArray(0), + config = ArtworkTransportSanitizer.WIDGET_CONFIG + ) + assertThat(result).isNull() + } + + @Test + fun sanitize_oversizedInputRejected() { + // 1 byte over the widget cap (2 MiB). The sanitizer must bail before + // calling into the native bitmap decoder — that decoder has a long + // CVE history (e.g. CVE-2023-4863 in libwebp) and must not see + // attacker-controlled bytes past the configured cap. + val tooLarge = ByteArray(ArtworkTransportSanitizer.WIDGET_CONFIG.sourceBytesLimit + 1) + val result = ArtworkTransportSanitizer.sanitizeEncodedBytes( + data = tooLarge, + config = ArtworkTransportSanitizer.WIDGET_CONFIG + ) + assertThat(result).isNull() + } + + @Test + fun sanitize_wearConfigHasLargerLimit() { + // Sanity check: wear gets a larger cap because watch screens need + // higher-resolution artwork. + assertThat(ArtworkTransportSanitizer.WEAR_CONFIG.sourceBytesLimit) + .isGreaterThan(ArtworkTransportSanitizer.WIDGET_CONFIG.sourceBytesLimit) + } + + @Test + fun widgetConfig_dimensionLimitsAreSensible() { + val cfg = ArtworkTransportSanitizer.WIDGET_CONFIG + assertThat(cfg.maxDimensionPx).isGreaterThan(0) + assertThat(cfg.maxBytes).isGreaterThan(0) + assertThat(cfg.initialJpegQuality).isAtMost(100) + assertThat(cfg.minJpegQuality).isAtMost(cfg.initialJpegQuality) + assertThat(cfg.jpegQualityStep).isGreaterThan(0) + } + + @Test + fun wearConfig_dimensionLimitsAreSensible() { + val cfg = ArtworkTransportSanitizer.WEAR_CONFIG + assertThat(cfg.maxDimensionPx).isGreaterThan(0) + assertThat(cfg.maxBytes).isGreaterThan(0) + assertThat(cfg.initialJpegQuality).isAtMost(100) + assertThat(cfg.minJpegQuality).isAtMost(cfg.initialJpegQuality) + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/CrashHandlerRobolectricTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/CrashHandlerRobolectricTest.kt new file mode 100644 index 000000000..9beebb2ab --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/utils/CrashHandlerRobolectricTest.kt @@ -0,0 +1,117 @@ +package com.theveloper.pixelplay.utils + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** + * Robolectric tests for [CrashHandler] — the in-process crash-capture + * helper that writes a stripped, redacted stack trace to SharedPreferences + * so the next launch can surface it to the user. + * + * Before this test landed, [CrashHandler] had no coverage: it runs only on + * the uncaught-exception path, which is the most brittle code class to + * leave untested. + */ +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE, sdk = [30]) +class CrashHandlerRobolectricTest { + + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + // Reset the default handler to JDK's so install() doesn't capture a + // previous CrashHandler instance as defaultHandler — that would + // cause infinite recursion when uncaughtException is invoked. + Thread.setDefaultUncaughtExceptionHandler(null) + CrashHandler.install(context) + // Always start clean — the SharedPreferences file is shared across tests + // and Robolectric runs them in the same VM. + CrashHandler.clearCrashLog() + } + + @After + fun tearDown() { + CrashHandler.clearCrashLog() + } + + @Test + fun hasCrashLog_returnsFalseBeforeAnyCrash() { + assertThat(CrashHandler.hasCrashLog()).isFalse() + } + + @Test + fun getCrashLog_returnsNullBeforeAnyCrash() { + assertThat(CrashHandler.getCrashLog()).isNull() + } + + @Test + fun uncaughtException_persistsRedactedStackTrace() { + // Trigger the persistence path with a synthetic exception containing + // a credential pattern. The redactor must strip it before storage. + val token = "Bearer eyJhbGciOiJIUzI1NiJ9.payload.signature" + val cause = RuntimeException("Failed: Authorization: $token") + // Use an arbitrary thread — CrashHandler signature accepts any thread. + CrashHandler.uncaughtException(Thread.currentThread(), cause) + + val log = CrashHandler.getCrashLog() + assertThat(log).isNotNull() + // The redactor should have stripped the bearer token. + assertThat(log!!.exceptionMessage).doesNotContain("eyJhbGciOiJIUzI1NiJ9") + assertThat(log.stackTrace).doesNotContain("eyJhbGciOiJIUzI1NiJ9") + // But the class name and "Failed" prefix should survive. + assertThat(log.stackTrace).contains("RuntimeException") + } + + @Test + fun clearCrashLog_clearsPersistedLog() { + CrashHandler.uncaughtException( + Thread.currentThread(), + IllegalStateException("test crash") + ) + assertThat(CrashHandler.hasCrashLog()).isTrue() + + CrashHandler.clearCrashLog() + assertThat(CrashHandler.hasCrashLog()).isFalse() + assertThat(CrashHandler.getCrashLog()).isNull() + } + + @Test + fun getCrashLog_formattedDateIsPopulated() { + CrashHandler.uncaughtException( + Thread.currentThread(), + IllegalArgumentException("formatted date check") + ) + + val log = CrashHandler.getCrashLog() + assertThat(log).isNotNull() + // The formattedDate string follows dd/MM/yyyy HH:mm:ss; spot-check + // shape only (locale variations make exact match flaky). + assertThat(log!!.formattedDate).matches("""\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}""") + assertThat(log.timestamp).isGreaterThan(0L) + } + + @Test + fun getFullLog_includesAllFields() { + CrashHandler.uncaughtException( + Thread.currentThread(), + IllegalArgumentException("full-log shape") + ) + + val log = CrashHandler.getCrashLog() + val rendered = log!!.getFullLog() + assertThat(rendered).contains("PixelPlayer Crash Report") + assertThat(rendered).contains("Date:") + assertThat(rendered).contains("Exception:") + assertThat(rendered).contains("Stack Trace:") + assertThat(rendered).contains("full-log shape") + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/FileDeletionUtilsTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/FileDeletionUtilsTest.kt new file mode 100644 index 000000000..228f97871 --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/utils/FileDeletionUtilsTest.kt @@ -0,0 +1,67 @@ +package com.theveloper.pixelplay.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * JVM-side tests for the Context-free entry points on [FileDeletionUtils]. + * The Android-specific MediaStore deletion paths are excluded — they + * require an emulator and live in `androidTest/`. + */ +class FileDeletionUtilsTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun canDeleteFile_existingRegularFile_returnsTrue() = runTest { + val file = tempFolder.newFile("song.mp3").apply { writeText("data") } + assertThat(FileDeletionUtils.canDeleteFile(file.absolutePath)).isTrue() + } + + @Test + fun canDeleteFile_missingFile_returnsFalse() = runTest { + val missing = File(tempFolder.root, "nope.mp3").absolutePath + assertThat(FileDeletionUtils.canDeleteFile(missing)).isFalse() + } + + @Test + fun canDeleteFile_directory_returnsFalse() = runTest { + val dir = tempFolder.newFolder("not_a_file") + assertThat(FileDeletionUtils.canDeleteFile(dir.absolutePath)).isFalse() + } + + @Test + fun canDeleteFile_blankPath_returnsFalse() = runTest { + assertThat(FileDeletionUtils.canDeleteFile("")).isFalse() + } + + @Test + fun getFileInfo_populatesAllFields() = runTest { + val file = tempFolder.newFile("track.flac").apply { writeText("abcdefg") } + val info = FileDeletionUtils.getFileInfo(file.absolutePath) + assertThat(info.exists).isTrue() + assertThat(info.isFile).isTrue() + assertThat(info.size).isEqualTo(7L) + assertThat(info.canRead).isTrue() + } + + @Test + fun getFileInfo_missingFile_returnsFalsy() = runTest { + val info = FileDeletionUtils.getFileInfo(File(tempFolder.root, "missing").absolutePath) + assertThat(info.exists).isFalse() + assertThat(info.size).isEqualTo(0L) + } + + @Test + fun getFileInfo_directory_distinguishesFromFile() = runTest { + val dir = tempFolder.newFolder("subdir") + val info = FileDeletionUtils.getFileInfo(dir.absolutePath) + assertThat(info.exists).isTrue() + assertThat(info.isFile).isFalse() + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/utils/ZipShareHelperSanitizationTest.kt b/app/src/test/java/com/theveloper/pixelplay/utils/ZipShareHelperSanitizationTest.kt new file mode 100644 index 000000000..024f3abcb --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/utils/ZipShareHelperSanitizationTest.kt @@ -0,0 +1,104 @@ +package com.theveloper.pixelplay.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Sanitization tests for [sanitizeShareFileName]. Covers the + * adversarial input cases the security review flagged: path-traversal + * sequences, OS-reserved chars, leading dots that produce hidden files, + * whitespace collapse, and length capping. + */ +class ZipShareHelperSanitizationTest { + + @Test + fun sanitize_keepsSimpleAsciiTitle() { + val result = sanitizeShareFileName("My Favourite Song") + assertThat(result).isEqualTo("My_Favourite_Song") + } + + @Test + fun sanitize_keepsUnicodeTitle() { + val result = sanitizeShareFileName("Cafe Tacvba") + assertThat(result).isEqualTo("Cafe_Tacvba") + } + + @Test + fun sanitize_replacesSlashesWithUnderscore() { + val result = sanitizeShareFileName("foo/bar\\baz") + assertThat(result).isEqualTo("foo_bar_baz") + } + + @Test + fun sanitize_replacesShellChars() { + val result = sanitizeShareFileName("a:b*c?d\"eg|h") + assertThat(result).isEqualTo("a_b_c_d_e_f_g_h") + } + + @Test + fun sanitize_rejectsPathTraversalDoubleDot() { + val result = sanitizeShareFileName("../etc/passwd") + assertThat(result).doesNotContain("..") + // Leading dots are replaced with `_`, and slashes become `_`. + assertThat(result).isEqualTo("__etc_passwd") + } + + @Test + fun sanitize_rejectsPathTraversalEvenWithoutSlash() { + val result = sanitizeShareFileName("song..album") + assertThat(result).doesNotContain("..") + assertThat(result).contains("song") + assertThat(result).contains("album") + } + + @Test + fun sanitize_replacesLeadingDots() { + val result = sanitizeShareFileName(".hiddenfile") + // Leading "." replaced with "_" — does not produce a dotfile. + assertThat(result).startsWith("_") + assertThat(result.startsWith(".")).isFalse() + } + + @Test + fun sanitize_replacesMultipleLeadingDots() { + val result = sanitizeShareFileName("...sneaky") + assertThat(result.startsWith(".")).isFalse() + assertThat(result).doesNotContain("..") + } + + @Test + fun sanitize_collapsesWhitespace() { + val result = sanitizeShareFileName("a b\t\nc") + // All whitespace runs collapse to a single underscore. + assertThat(result).isEqualTo("a_b_c") + } + + @Test + fun sanitize_capsLengthAt100Chars() { + val longName = "a".repeat(500) + val result = sanitizeShareFileName(longName) + assertThat(result.length).isAtMost(100) + } + + @Test + fun sanitize_emptyInputProducesEmpty() { + val result = sanitizeShareFileName("") + assertThat(result).isEmpty() + } + + @Test + fun sanitize_onlyDotsProducesUnderscore() { + // ".." → "_" after the leading-dots regex strips them all. + val result = sanitizeShareFileName("..") + assertThat(result).isEqualTo("_") + } + + @Test + fun sanitize_percentEncodedTraversalIsHarmless() { + // %2F is not a real separator in the local filesystem so it passes + // through unchanged; the important thing is no real path separator + // survives, which the other tests cover. + val result = sanitizeShareFileName("a%2Fb") + assertThat(result).doesNotContain("/") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 11979105a..42a1761b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,21 +1,20 @@ [versions] -accompanistDrawablepainter = "0.37.3" +# Single accompanist version key — drives both accompanist-drawablepainter and +# accompanist-permissions. Was previously duplicated as accompanistDrawablepainter. +accompanist = "0.37.3" agp = "9.2.1" app = "1.7.0" -googleGenai = "1.53.0" googlePlayServicesCast = "22.3.1" animation = "1.11.1" appcompat = "1.7.1" capturable = "3.0.1" codeview = "1.3.9" coilCompose = "2.7.0" -composeDnd = "0.4.0" composeMaterialIcons = "1.7.8" composeUi = "1.11.1" constraintlayoutCompose = "1.1.1" coreSplashscreen = "1.2.0" desugarJdkLibs = "2.1.5" -duktapeAndroid = "1.4.0" foundation = "1.11.1" glance = "1.2.0-rc01" graphicsShapes = "1.1.0" @@ -27,6 +26,7 @@ kotlin = "2.3.21" coreKtx = "1.18.0" junit = "4.13.2" junitVersion = "1.3.0" +# Single Jupiter/Vintage version key (was duplicated as junitJupiter + junit5). junitJupiter = "6.0.3" espressoCore = "3.7.0" kotlinx-coroutines = "1.11.0" @@ -44,16 +44,12 @@ mediaRouter = "1.8.1" navigationCompose = "2.9.8" paletteKtx = "1.0.0" protobufJavalite = "4.34.1" -pytorch_android = "2.1.0" -pytorch_android_torchvision = "2.1.0" -reorderable = "0.9.6" reorderables = "3.1.0" paging = "3.5.0" roomCompiler = "2.8.4" roomKtx = "2.8.4" roomRuntime = "2.8.4" -accompanist = "0.37.3" -checkerframework = "4.1.0" # O la versión más reciente que encuentres +checkerframework = "4.1.0" taglib = "1.0.6" jaudiotagger = "3.0.1" vorbisjava = "0.8" @@ -61,7 +57,6 @@ datastore = "1.2.1" credentials = "1.6.0" googleid = "1.2.0" androidxTestCore = "1.7.0" -junit5 = "6.0.3" kuromoji = "0.9.0" pinyin4j = "2.5.1" securityCrypto = "1.1.0" @@ -79,14 +74,8 @@ javax-inject = "1" # Annotations procesing ksp = "2.3.8" smoothCornerRectAndroidCompose = "v1.0.0" -spleeterAndroidIos = "1.0.2" -tensorflowLite = "2.17.0" -tensorflowLiteSelectTfOps = "2.16.1" -tensorflowLiteSelectTfOpsVersion = "2.16.1" -tensorflowLiteSupport = "0.5.0" wavySlider = "2.2.0" workRuntimeKtx = "2.11.2" -composeTesting = "1.0.0-alpha03" timber = "5.0.1" generativeai = "0.9.0" mockk = "1.14.9" @@ -95,14 +84,11 @@ truth = "1.4.5" retrofit = "3.0.0" okhttp = "5.3.2" -mediarouterVersion = "1.8.1" -playServicesCastFramework = "22.3.1" navigationRuntimeKtx = "2.9.8" uiautomator = "2.3.0" benchmarkMacroJunit4 = "1.4.1" baselineprofile = "1.5.0-alpha06" profileinstaller = "1.4.1" -pagingCommon = "3.3.6" # Wear OS horologist = "0.7.15" @@ -110,7 +96,7 @@ wearCompose = "1.6.1" playServicesWearable = "20.0.1" [libraries] -accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanistDrawablepainter" } +accompanist-drawablepainter = { module = "com.google.accompanist:accompanist-drawablepainter", version.ref = "accompanist" } lifecycleprocess = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleRuntimeKtx" } junitplatformlauncher = { group = "org.junit.platform", name = "junit-platform-launcher", version.ref = "junitJupiter" } @@ -119,12 +105,11 @@ credentials = { group = "androidx.credentials", name = "credentials", version.re credentials-play-services-auth = { group = "androidx.credentials", name = "credentials-play-services-auth", version.ref = "credentials" } googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" } tdlib = { module = "com.github.tdlibx:td", version = "1.8.56" } -androidx-paging-common = { group = "androidx.paging", name = "paging-common", version = "3.5.0" } +androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" } androidx-app = { module = "androidx.car.app:app", version.ref = "app" } androidx-app-projected = { module = "androidx.car.app:app-projected", version.ref = "app" } androidx-media = { module = "androidx.media:media", version.ref = "media" } androidx-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts", version.ref = "composeUi" } -google-genai = { group = "com.google.genai", name = "google-genai", version.ref = "googleGenai" } google-play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "googlePlayServicesCast" } androidx-animation = { module = "androidx.compose.animation:animation", version.ref = "animation" } desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } @@ -162,8 +147,6 @@ androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version capturable = { module = "dev.shreyaspatil:capturable", version.ref = "capturable" } codeview = { module = "io.github.amrdeveloper:codeview", version.ref = "codeview" } coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" } -compose-dnd = { module = "com.mohamedrejeb.dnd:compose-dnd", version.ref = "composeDnd" } -duktape-android = { module = "com.squareup.duktape:duktape-android", version.ref = "duktapeAndroid" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" } @@ -172,7 +155,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junit5" } +junit-jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "junitJupiter" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } @@ -183,7 +166,8 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", vers androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "composeUi" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "composeUi" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "composeUi" } -androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +# androidx-compose-material3 is BOM-managed (composeBom) — no explicit +# version.ref to avoid pinning to an alpha while the BOM tracks stable. kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } @@ -191,19 +175,12 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa ktor-server-core = { group = "io.ktor", name = "ktor-server-core-jvm", version.ref = "ktor" } ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty-jvm", version.ref = "ktor" } ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio-jvm", version.ref = "ktor" } +ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host-jvm", version.ref = "ktor" } +robolectric = { group = "org.robolectric", name = "robolectric", version = "4.14" } material = { module = "com.google.android.material:material", version.ref = "material" } -material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } protobuf-javalite = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobufJavalite" } -pytorch_android = { module = "org.pytorch:pytorch_android", version.ref = "pytorch_android" } -pytorch_android_torchvision = { module = "org.pytorch:pytorch_android_torchvision", version.ref = "pytorch_android_torchvision" } -reorderable = { module = "org.burnoutcrew.composereorderable:reorderable", version.ref = "reorderable" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } smooth-corner-rect-android-compose = { module = "com.github.racra:smooth-corner-rect-android-compose", version.ref = "smoothCornerRectAndroidCompose" } -spleeter-android-ios = { module = "com.github.FaceOnLive:Spleeter-Android-iOS", version.ref = "spleeterAndroidIos" } -tensorflow-lite = { module = "org.tensorflow:tensorflow-lite", version.ref = "tensorflowLite" } -#tensorflow-lite-select-tf-ops = { module = "org.tensorflow:tensorflow-lite-select-tf-ops", version.ref = "tensorflowLiteSelectTfOps" } -tensorflow-lite-select-tf-ops = { module = "org.tensorflow:tensorflow-lite-select-tf-ops", version.ref = "tensorflowLiteSelectTfOpsVersion" } -tensorflow-lite-support = { module = "org.tensorflow:tensorflow-lite-support", version.ref = "tensorflowLiteSupport" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } wavy-slider = { module = "ir.mahozad.multiplatform:wavy-slider", version.ref = "wavySlider" } checker-qual = { group = "org.checkerframework", name = "checker-qual", version.ref = "checkerframework" } @@ -216,8 +193,7 @@ retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } -androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "mediarouterVersion" } -play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCastFramework" } +androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "mediaRouter" } androidx-navigation-runtime-ktx = { group = "androidx.navigation", name = "navigation-runtime-ktx", version.ref = "navigationRuntimeKtx" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 5f40125de..d3d5654b6 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -11,6 +11,8 @@ + + + + + + + + + + + + + + + + + + diff --git a/wear/src/main/res/xml/wear_data_extraction_rules.xml b/wear/src/main/res/xml/wear_data_extraction_rules.xml new file mode 100644 index 000000000..29bdbef85 --- /dev/null +++ b/wear/src/main/res/xml/wear_data_extraction_rules.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 7dbcdb0eec44dd4a31a9f3164d7e393209231968 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Wed, 20 May 2026 00:27:54 +0300 Subject: [PATCH 2/6] fix: serialize multi-selection state-holder mutations under a lock `_selectedSongs.update {}` / `_selectedPlaylists.update {}` only made the list flow atomic; the sibling writes to ids / count / mode happened after the CAS, so a concurrent toggle landing in that gap could leave the four flows out of sync (list shows [X, Y] while ids shows just {Y}). Wrap the whole read-modify-write under a single `mutationLock` so all four `.value =` assignments land together. --- .../viewmodel/MultiSelectionStateHolder.kt | 83 +++++++++---------- .../viewmodel/PlaylistSelectionStateHolder.kt | 78 ++++++++--------- 2 files changed, 78 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt index 1143a3cfe..b532411ba 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt @@ -4,11 +4,6 @@ import com.theveloper.pixelplay.data.model.Song import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.SharingStarted import javax.inject.Inject import javax.inject.Singleton @@ -22,6 +17,13 @@ import javax.inject.Singleton @Singleton class MultiSelectionStateHolder @Inject constructor() { + // Guards multi-flow mutations so a reader of the four exposed StateFlows + // observes a coherent final state. `_selectedSongs.update {}` alone only + // made one flow atomic; the sibling writes for ids/count/mode could race + // with another toggle landing in the gap. A single synchronized block + // around the whole read-modify-write closes that gap. + private val mutationLock = Any() + // Internal mutable state - uses List to preserve selection order // LinkedHashSet behavior is enforced via toggle logic private val _selectedSongs = MutableStateFlow>(emptyList()) @@ -56,30 +58,16 @@ class MultiSelectionStateHolder @Inject constructor() { * @param song The song to toggle */ fun toggleSelection(song: Song) { - // Atomic read-modify-write so rapid concurrent taps cannot drop a - // toggle (the previous baseline-snapshot + write pattern was racy: - // both callers could read the same baseline and the second write - // would overwrite the first). _selectedSongs.update{} retries until - // a CAS succeeds. - var updatedList: List = emptyList() - var updatedIds: Set = emptySet() - _selectedSongs.update { current -> - val ids = _selectedSongIds.value - if (song.id in ids) { - val next = current.filter { it.id != song.id } - updatedList = next - updatedIds = ids - song.id - next + synchronized(mutationLock) { + val currentList = _selectedSongs.value + val currentIds = _selectedSongIds.value + val (newList, newIds) = if (song.id in currentIds) { + currentList.filter { it.id != song.id } to (currentIds - song.id) } else { - val next = current + song - updatedList = next - updatedIds = ids + song.id - next + (currentList + song) to (currentIds + song.id) } + updateStateLocked(newList, newIds) } - _selectedSongIds.value = updatedIds - _selectedCount.value = updatedList.size - _isSelectionMode.value = updatedList.isNotEmpty() } /** @@ -90,25 +78,29 @@ class MultiSelectionStateHolder @Inject constructor() { * @param songs The complete list of songs to select */ fun selectAll(songs: List) { - val currentIds = _selectedSongIds.value - val currentList = _selectedSongs.value.toMutableList() - - // Add songs that aren't already selected - songs.forEach { song -> - if (!currentIds.contains(song.id)) { - currentList.add(song) + synchronized(mutationLock) { + val currentIds = _selectedSongIds.value + val currentList = _selectedSongs.value.toMutableList() + + // Add songs that aren't already selected + songs.forEach { song -> + if (!currentIds.contains(song.id)) { + currentList.add(song) + } } + + val newIds = currentList.map { it.id }.toSet() + updateStateLocked(currentList, newIds) } - - val newIds = currentList.map { it.id }.toSet() - updateState(currentList, newIds) } /** * Clears all selected songs, exiting selection mode. */ fun clearSelection() { - updateState(emptyList(), emptySet()) + synchronized(mutationLock) { + updateStateLocked(emptyList(), emptySet()) + } } /** @@ -140,17 +132,20 @@ class MultiSelectionStateHolder @Inject constructor() { * @param songId The ID of the song to remove */ fun removeFromSelection(songId: String) { - if (!_selectedSongIds.value.contains(songId)) return - - val currentList = _selectedSongs.value.filter { it.id != songId } - val currentIds = _selectedSongIds.value - songId - updateState(currentList, currentIds) + synchronized(mutationLock) { + val currentIds = _selectedSongIds.value + if (songId !in currentIds) return + val newList = _selectedSongs.value.filter { it.id != songId } + updateStateLocked(newList, currentIds - songId) + } } /** - * Updates all state flows atomically. + * Updates all four state flows. Callers MUST hold [mutationLock] so the + * four `.value =` assignments land without an interleaving mutation + * leaving the ids/list/count/mode flows out of sync. */ - private fun updateState(songs: List, ids: Set) { + private fun updateStateLocked(songs: List, ids: Set) { _selectedSongs.value = songs _selectedSongIds.value = ids _selectedCount.value = songs.size diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt index 7e729d290..5cd6f0f74 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt @@ -4,7 +4,6 @@ import com.theveloper.pixelplay.data.model.Playlist import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import javax.inject.Inject import javax.inject.Singleton @@ -18,6 +17,13 @@ import javax.inject.Singleton @Singleton class PlaylistSelectionStateHolder @Inject constructor() { + // Guards multi-flow mutations so a reader of the four exposed StateFlows + // observes a coherent final state. `_selectedPlaylists.update {}` alone + // only made one flow atomic; the sibling writes for ids/count/mode could + // race with another toggle landing in the gap. A single synchronized + // block around the whole read-modify-write closes that gap. + private val mutationLock = Any() + // Internal mutable state - uses List to preserve selection order private val _selectedPlaylists = MutableStateFlow>(emptyList()) @@ -51,29 +57,16 @@ class PlaylistSelectionStateHolder @Inject constructor() { * @param playlist The playlist to toggle */ fun toggleSelection(playlist: Playlist) { - // Atomic update — see MultiSelectionStateHolder for the rationale - // (rapid concurrent taps from different gesture handlers can drop - // a toggle under read-modify-write). - var updatedList: List = emptyList() - var updatedIds: Set = emptySet() - val pid = playlist.id.toString() - _selectedPlaylists.update { current -> - val ids = _selectedPlaylistIds.value - if (pid in ids) { - val next = current.filter { it.id != playlist.id } - updatedList = next - updatedIds = ids - pid - next + synchronized(mutationLock) { + val currentList = _selectedPlaylists.value + val currentIds = _selectedPlaylistIds.value + val (newList, newIds) = if (playlist.id in currentIds) { + currentList.filter { it.id != playlist.id } to (currentIds - playlist.id) } else { - val next = current + playlist - updatedList = next - updatedIds = ids + pid - next + (currentList + playlist) to (currentIds + playlist.id) } + updateStateLocked(newList, newIds) } - _selectedPlaylistIds.value = updatedIds - _selectedCount.value = updatedList.size - _isSelectionMode.value = updatedList.isNotEmpty() } /** @@ -84,25 +77,29 @@ class PlaylistSelectionStateHolder @Inject constructor() { * @param playlists The complete list of playlists to select */ fun selectAll(playlists: List) { - val currentIds = _selectedPlaylistIds.value - val currentList = _selectedPlaylists.value.toMutableList() - - // Add playlists that aren't already selected - playlists.forEach { playlist -> - if (!currentIds.contains(playlist.id)) { - currentList.add(playlist) + synchronized(mutationLock) { + val currentIds = _selectedPlaylistIds.value + val currentList = _selectedPlaylists.value.toMutableList() + + // Add playlists that aren't already selected + playlists.forEach { playlist -> + if (!currentIds.contains(playlist.id)) { + currentList.add(playlist) + } } + + val newIds = currentList.map { it.id }.toSet() + updateStateLocked(currentList, newIds) } - - val newIds = currentList.map { it.id }.toSet() - updateState(currentList, newIds) } /** * Clears all selected playlists, exiting selection mode. */ fun clearSelection() { - updateState(emptyList(), emptySet()) + synchronized(mutationLock) { + updateStateLocked(emptyList(), emptySet()) + } } /** @@ -134,17 +131,20 @@ class PlaylistSelectionStateHolder @Inject constructor() { * @param playlistId The ID of the playlist to remove */ fun removeFromSelection(playlistId: String) { - if (!_selectedPlaylistIds.value.contains(playlistId)) return - - val currentList = _selectedPlaylists.value.filter { it.id != playlistId } - val currentIds = _selectedPlaylistIds.value - playlistId - updateState(currentList, currentIds) + synchronized(mutationLock) { + val currentIds = _selectedPlaylistIds.value + if (playlistId !in currentIds) return + val newList = _selectedPlaylists.value.filter { it.id != playlistId } + updateStateLocked(newList, currentIds - playlistId) + } } /** - * Updates all state flows atomically. + * Updates all four state flows. Callers MUST hold [mutationLock] so the + * four `.value =` assignments land without an interleaving mutation + * leaving the ids/list/count/mode flows out of sync. */ - private fun updateState(playlists: List, ids: Set) { + private fun updateStateLocked(playlists: List, ids: Set) { _selectedPlaylists.value = playlists _selectedPlaylistIds.value = ids _selectedCount.value = playlists.size From d4ddc7254c1ebbc01d260560df30d02b1862bf6e Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Wed, 20 May 2026 16:53:10 +0300 Subject: [PATCH 3/6] review: apply Copilot suggestions on PR #2055 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MultiSelectionStateHolder / PlaylistSelectionStateHolder: replace 4 parallel StateFlows + mutex with a single source-of-truth list flow; ids/count/mode are derived via stateIn(@AppScope, Eagerly) and toggles use StateFlow.update {} for atomic CAS. Removes the cross-flow tear Copilot flagged even with the synchronized block. - UserPreferencesRepository: add playbackKeyFlow() helper that falls back to the legacy "settings" DataStore until MIGRATION_DONE is set. Applied to all 12 migrated playback keys, so existing installs don't briefly read defaults during the migration grace window. - libs.versions.toml: clarify that the material3 alpha pin is deliberate (ExperimentalMaterial3ExpressiveApi components used across StatsScreen, LibrarySyncIndicators, Telegram screens) and intentionally overrides the Compose BOM — the BOM-managed comment was the misleading bit, not the pin. - AutoMediaBrowseTree.search: actually run the three LRCLIB searches concurrently via coroutineScope { async {…} }.awaitAll() so the comment matches the behaviour. - SyncWorker.stableFnv1aHash64: hash UTF-8 bytes instead of (Char.code and 0xFF). ASCII inputs are unaffected; CJK/Cyrillic/accented names stop collapsing onto each other. - SyncWorker CancellationException branch: log message no longer claims WorkManager will retry — cancellation is propagated, not retried. --- .../preferences/UserPreferencesRepository.kt | 144 +++++++++++------- .../data/service/auto/AutoMediaBrowseTree.kt | 25 ++- .../pixelplay/data/worker/SyncWorker.kt | 18 ++- .../viewmodel/MultiSelectionStateHolder.kt | 110 ++++++------- .../viewmodel/PlaylistSelectionStateHolder.kt | 110 ++++++------- gradle/libs.versions.toml | 11 +- 6 files changed, 228 insertions(+), 190 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt index 2fc302369..3918a21cc 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt @@ -24,9 +24,13 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.text.get import kotlin.text.set +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.serialization.decodeFromString @@ -148,6 +152,29 @@ constructor( // reader is pointed at the new store (separate PR). } + /** + * Reads a key that has been migrated from the legacy "settings" store to + * the dedicated playback store. Prefer the new store's value when + * present; fall back to the legacy store only during the narrow window + * between app launch and [migratePlaybackKeysIfNeeded] completing on + * existing installs. Once the migration marker is set, the legacy store + * is no longer consulted (so a stray legacy write can't override the + * migrated value). + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun playbackKeyFlow( + playbackKey: Preferences.Key, + legacyKey: Preferences.Key, + ): Flow = playbackStore.data.flatMapLatest { newPrefs -> + val newValue = newPrefs[playbackKey] + val migrationDone = newPrefs[PlaybackPreferencesKeys.MIGRATION_DONE] == true + when { + newValue != null -> flowOf(newValue) + migrationDone -> flowOf(null) + else -> dataStore.data.map { legacy -> legacy[legacyKey] } + } + } + private object PlaybackPreferencesKeys { val MIGRATION_DONE = booleanPreferencesKey("playback_migration_done") val PERSISTENT_SHUFFLE_ENABLED = booleanPreferencesKey("persistent_shuffle_enabled") @@ -360,9 +387,10 @@ constructor( } val isCrossfadeEnabledFlow: Flow = - playbackStore.data.map { prefs -> - prefs[PlaybackPreferencesKeys.IS_CROSSFADE_ENABLED] ?: false - } + playbackKeyFlow( + PlaybackPreferencesKeys.IS_CROSSFADE_ENABLED, + PreferencesKeys.IS_CROSSFADE_ENABLED, + ).map { it ?: false } suspend fun setCrossfadeEnabled(enabled: Boolean) { playbackStore.edit { prefs -> @@ -374,9 +402,10 @@ constructor( } val hiFiModeEnabledFlow: Flow = - playbackStore.data.map { prefs -> - prefs[PlaybackPreferencesKeys.HI_FI_MODE_ENABLED] ?: false - } + playbackKeyFlow( + PlaybackPreferencesKeys.HI_FI_MODE_ENABLED, + PreferencesKeys.HI_FI_MODE_ENABLED, + ).map { it ?: false } suspend fun setHiFiModeEnabled(enabled: Boolean) { playbackStore.edit { prefs -> @@ -400,9 +429,10 @@ constructor( } val crossfadeDurationFlow: Flow = - playbackStore.data.map { prefs -> - (prefs[PlaybackPreferencesKeys.CROSSFADE_DURATION] ?: 2000).coerceIn(1000, 12000) - } + playbackKeyFlow( + PlaybackPreferencesKeys.CROSSFADE_DURATION, + PreferencesKeys.CROSSFADE_DURATION, + ).map { (it ?: 2000).coerceIn(1000, 12000) } suspend fun setCrossfadeDuration(duration: Int) { val clamped = duration.coerceIn(1000, 12000) @@ -457,9 +487,10 @@ constructor( } } val repeatModeFlow: Flow = - playbackStore.data.map { prefs -> - prefs[PlaybackPreferencesKeys.REPEAT_MODE] ?: Player.REPEAT_MODE_OFF - } + playbackKeyFlow( + PlaybackPreferencesKeys.REPEAT_MODE, + PreferencesKeys.REPEAT_MODE, + ).map { it ?: Player.REPEAT_MODE_OFF } suspend fun setRepeatMode(@Player.RepeatMode mode: Int) { playbackStore.edit { prefs -> prefs[PlaybackPreferencesKeys.REPEAT_MODE] = mode } @@ -467,9 +498,10 @@ constructor( } val isShuffleOnFlow: Flow = - playbackStore.data.map { prefs -> - prefs[PlaybackPreferencesKeys.IS_SHUFFLE_ON] ?: false - } + playbackKeyFlow( + PlaybackPreferencesKeys.IS_SHUFFLE_ON, + PreferencesKeys.IS_SHUFFLE_ON, + ).map { it ?: false } suspend fun setShuffleOn(on: Boolean) { // Dual-write during the migration window. @@ -477,14 +509,15 @@ constructor( dataStore.edit { preferences -> preferences[PreferencesKeys.IS_SHUFFLE_ON] = on } } - // Reads from the dedicated playback store (post-migration). Falls back to - // the legacy "settings" store value if the playback store hasn't been - // populated yet, so the very first read after the migration grace - // window still works. + // Reads from the dedicated playback store (post-migration). Falls back + // to the legacy "settings" store value if migration hasn't completed + // yet, so existing installs don't briefly see the default during the + // grace window. val persistentShuffleEnabledFlow: Flow = - playbackStore.data.map { prefs -> - prefs[PlaybackPreferencesKeys.PERSISTENT_SHUFFLE_ENABLED] ?: false - } + playbackKeyFlow( + PlaybackPreferencesKeys.PERSISTENT_SHUFFLE_ENABLED, + PreferencesKeys.PERSISTENT_SHUFFLE_ENABLED, + ).map { it ?: false } suspend fun setPersistentShuffleEnabled(enabled: Boolean) { // Write through to both stores during the migration window so any @@ -498,10 +531,11 @@ constructor( } val playbackQueueSnapshotFlow: Flow = - playbackStore.data.map { prefs -> - prefs[PlaybackPreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT]?.let { raw -> - runCatching { json.decodeFromString(raw) }.getOrNull() - } + playbackKeyFlow( + PlaybackPreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT, + PreferencesKeys.PLAYBACK_QUEUE_SNAPSHOT, + ).map { raw -> + raw?.let { runCatching { json.decodeFromString(it) }.getOrNull() } } suspend fun getPlaybackQueueSnapshotOnce(): PlaybackQueueSnapshot? { @@ -757,20 +791,22 @@ constructor( // ===== End Multi-Artist Settings ===== - val globalTransitionSettingsFlow: Flow = - playbackStore.data.map { prefs -> - val duration = (prefs[PlaybackPreferencesKeys.CROSSFADE_DURATION] ?: 2000).coerceIn(1000, 12000) - val settings = - prefs[PlaybackPreferencesKeys.GLOBAL_TRANSITION_SETTINGS]?.let { jsonString -> - try { - json.decodeFromString(jsonString) - } catch (e: Exception) { - TransitionSettings() - } - } ?: TransitionSettings() - - settings.copy(durationMs = duration) - } + val globalTransitionSettingsFlow: Flow = combine( + playbackKeyFlow( + PlaybackPreferencesKeys.CROSSFADE_DURATION, + PreferencesKeys.CROSSFADE_DURATION, + ), + playbackKeyFlow( + PlaybackPreferencesKeys.GLOBAL_TRANSITION_SETTINGS, + PreferencesKeys.GLOBAL_TRANSITION_SETTINGS, + ), + ) { duration, jsonString -> + val safeDuration = (duration ?: 2000).coerceIn(1000, 12000) + val settings = jsonString?.let { + runCatching { json.decodeFromString(it) }.getOrNull() + } ?: TransitionSettings() + settings.copy(durationMs = safeDuration) + } suspend fun saveGlobalTransitionSettings(settings: TransitionSettings) { val jsonString = json.encodeToString(settings) @@ -893,14 +929,16 @@ constructor( // ===== ReplayGain ===== val replayGainEnabledFlow: Flow = - playbackStore.data.map { prefs -> - prefs[PlaybackPreferencesKeys.REPLAYGAIN_ENABLED] ?: false - } + playbackKeyFlow( + PlaybackPreferencesKeys.REPLAYGAIN_ENABLED, + PreferencesKeys.REPLAYGAIN_ENABLED, + ).map { it ?: false } val replayGainUseAlbumGainFlow: Flow = - playbackStore.data.map { prefs -> - prefs[PlaybackPreferencesKeys.REPLAYGAIN_USE_ALBUM_GAIN] ?: false - } + playbackKeyFlow( + PlaybackPreferencesKeys.REPLAYGAIN_USE_ALBUM_GAIN, + PreferencesKeys.REPLAYGAIN_USE_ALBUM_GAIN, + ).map { it ?: false } suspend fun setReplayGainEnabled(enabled: Boolean) { playbackStore.edit { prefs -> @@ -938,14 +976,16 @@ constructor( } val keepPlayingInBackgroundFlow: Flow = - playbackStore.data.map { prefs -> - prefs[PlaybackPreferencesKeys.KEEP_PLAYING_IN_BACKGROUND] ?: true - } + playbackKeyFlow( + PlaybackPreferencesKeys.KEEP_PLAYING_IN_BACKGROUND, + PreferencesKeys.KEEP_PLAYING_IN_BACKGROUND, + ).map { it ?: true } val disableCastAutoplayFlow: Flow = - playbackStore.data.map { prefs -> - prefs[PlaybackPreferencesKeys.DISABLE_CAST_AUTOPLAY] ?: false - } + playbackKeyFlow( + PlaybackPreferencesKeys.DISABLE_CAST_AUTOPLAY, + PreferencesKeys.DISABLE_CAST_AUTOPLAY, + ).map { it ?: false } val resumeOnHeadsetReconnectFlow: Flow = dataStore.data.map { preferences -> diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/auto/AutoMediaBrowseTree.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/auto/AutoMediaBrowseTree.kt index 78d993941..94726ea4e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/auto/AutoMediaBrowseTree.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/auto/AutoMediaBrowseTree.kt @@ -14,6 +14,9 @@ import com.theveloper.pixelplay.data.preferences.PlaylistPreferencesRepository import com.theveloper.pixelplay.data.repository.MusicRepository import com.theveloper.pixelplay.utils.MediaItemBuilder import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first import javax.inject.Inject import javax.inject.Singleton @@ -123,12 +126,22 @@ class AutoMediaBrowseTree @Inject constructor( // Run the three searches concurrently and round-robin-merge the // results so an album/artist hit isn't squeezed out by 30+ song // matches. Previous behaviour always biased to songs. - val songs = musicRepository.searchSongs(trimmedQuery).first() - .map { buildPlayableSongItem(it) } - val albums = musicRepository.searchAlbums(trimmedQuery).first() - .map { buildBrowsableAlbumItem(it) } - val artists = musicRepository.searchArtists(trimmedQuery).first() - .map { buildBrowsableArtistItem(it) } + val (songs, albums, artists) = coroutineScope { + val songsDeferred = async { + musicRepository.searchSongs(trimmedQuery).first() + .map { buildPlayableSongItem(it) } + } + val albumsDeferred = async { + musicRepository.searchAlbums(trimmedQuery).first() + .map { buildBrowsableAlbumItem(it) } + } + val artistsDeferred = async { + musicRepository.searchArtists(trimmedQuery).first() + .map { buildBrowsableArtistItem(it) } + } + val results = awaitAll(songsDeferred, albumsDeferred, artistsDeferred) + Triple(results[0], results[1], results[2]) + } val results = mutableListOf() val songIter = songs.iterator() diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt index 2b55ecc5e..6cb1e1a0c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt @@ -419,7 +419,12 @@ constructor( Result.success(workDataOf(OUTPUT_TOTAL_SONGS to finalTotalSongs.toLong())) } catch (e: CancellationException) { - Log.w(TAG, "Sync cancelled — returning retry so WorkManager re-runs", e) + // Propagate cancellation so structured-concurrency teardown + // and WorkManager's own cancellation handling proceed + // normally. WorkManager treats a cancelled worker as + // cancelled (not retried); rescheduling happens on the + // next sync trigger, not here. + Log.w(TAG, "Sync cancelled — propagating CancellationException", e) throw e } catch (e: Exception) { Log.e(TAG, "Error during MediaStore synchronization", e) @@ -1380,11 +1385,18 @@ constructor( * ~50% collision probability around 65k entries, which is reachable * for large Telegram channels. FNV-1a keeps the full 64 bits and the * collision probability stays below 1e-10 well past a million entries. + * + * Hashing the UTF-8 byte sequence (rather than `Char.code and 0xFF`) + * preserves the high byte of non-ASCII characters — important for + * Netease/Telegram libraries with CJK/Cyrillic/accented artist and + * album names, where the 8-bit truncation would otherwise collapse + * different names to the same hash. */ internal fun stableFnv1aHash64(input: String): Long { var hash = -3750763034362895579L // FNV-1a 64-bit offset basis - for (c in input) { - hash = hash xor (c.code.toLong() and 0xFFL) + val bytes = input.toByteArray(Charsets.UTF_8) + for (b in bytes) { + hash = hash xor (b.toLong() and 0xFFL) hash *= 1099511628211L // FNV-1a 64-bit prime } return hash diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt index b532411ba..e73566120 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt @@ -1,55 +1,65 @@ package com.theveloper.pixelplay.presentation.viewmodel import com.theveloper.pixelplay.data.model.Song +import com.theveloper.pixelplay.di.AppScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import javax.inject.Inject import javax.inject.Singleton /** * State holder for multi-selection functionality in LibraryScreen tabs. - * Manages selection state with order preservation using a LinkedHashSet internally. + * Manages selection state with order preservation using a list-of-songs as + * the single source of truth; ids/count/mode are derived views. * - * Selection order is maintained - the first selected song is at index 0, + * Selection order is maintained — the first selected song is at index 0, * subsequent selections are appended in the order they were selected. */ @Singleton -class MultiSelectionStateHolder @Inject constructor() { +class MultiSelectionStateHolder @Inject constructor( + @AppScope private val appScope: CoroutineScope, +) { - // Guards multi-flow mutations so a reader of the four exposed StateFlows - // observes a coherent final state. `_selectedSongs.update {}` alone only - // made one flow atomic; the sibling writes for ids/count/mode could race - // with another toggle landing in the gap. A single synchronized block - // around the whole read-modify-write closes that gap. - private val mutationLock = Any() - - // Internal mutable state - uses List to preserve selection order - // LinkedHashSet behavior is enforced via toggle logic + // The ordered list of selected songs is the only piece of mutable state. + // ids/count/mode are projections of this flow, so observers that read + // any subset of the public flows see values that all originated from + // the same source emission — no cross-flow tearing is possible. + // Mutations use StateFlow.update {} for atomic CAS, removing the need + // for an external synchronized block. private val _selectedSongs = MutableStateFlow>(emptyList()) - + /** * Immutable flow of selected songs, preserving selection order. */ val selectedSongs: StateFlow> = _selectedSongs.asStateFlow() - + /** - * Set of selected song IDs for efficient lookup. + * Set of selected song IDs for efficient lookup. Derived from + * [selectedSongs] so the two views can never disagree. */ - private val _selectedSongIds = MutableStateFlow>(emptySet()) - val selectedSongIds: StateFlow> = _selectedSongIds.asStateFlow() - + val selectedSongIds: StateFlow> = _selectedSongs + .map { songs -> songs.mapTo(LinkedHashSet(songs.size)) { it.id } } + .stateIn(appScope, SharingStarted.Eagerly, emptySet()) + /** * Whether selection mode is currently active (at least one song selected). */ - private val _isSelectionMode = MutableStateFlow(false) - val isSelectionMode: StateFlow = _isSelectionMode.asStateFlow() - + val isSelectionMode: StateFlow = _selectedSongs + .map { it.isNotEmpty() } + .stateIn(appScope, SharingStarted.Eagerly, false) + /** * Current count of selected songs. */ - private val _selectedCount = MutableStateFlow(0) - val selectedCount: StateFlow = _selectedCount.asStateFlow() + val selectedCount: StateFlow = _selectedSongs + .map { it.size } + .stateIn(appScope, SharingStarted.Eagerly, 0) /** * Toggles the selection state of a song. @@ -58,15 +68,12 @@ class MultiSelectionStateHolder @Inject constructor() { * @param song The song to toggle */ fun toggleSelection(song: Song) { - synchronized(mutationLock) { - val currentList = _selectedSongs.value - val currentIds = _selectedSongIds.value - val (newList, newIds) = if (song.id in currentIds) { - currentList.filter { it.id != song.id } to (currentIds - song.id) + _selectedSongs.update { current -> + if (current.any { it.id == song.id }) { + current.filterNot { it.id == song.id } } else { - (currentList + song) to (currentIds + song.id) + current + song } - updateStateLocked(newList, newIds) } } @@ -78,19 +85,10 @@ class MultiSelectionStateHolder @Inject constructor() { * @param songs The complete list of songs to select */ fun selectAll(songs: List) { - synchronized(mutationLock) { - val currentIds = _selectedSongIds.value - val currentList = _selectedSongs.value.toMutableList() - - // Add songs that aren't already selected - songs.forEach { song -> - if (!currentIds.contains(song.id)) { - currentList.add(song) - } - } - - val newIds = currentList.map { it.id }.toSet() - updateStateLocked(currentList, newIds) + _selectedSongs.update { current -> + val existingIds = current.mapTo(HashSet(current.size)) { it.id } + val additions = songs.filter { it.id !in existingIds } + if (additions.isEmpty()) current else current + additions } } @@ -98,9 +96,7 @@ class MultiSelectionStateHolder @Inject constructor() { * Clears all selected songs, exiting selection mode. */ fun clearSelection() { - synchronized(mutationLock) { - updateStateLocked(emptyList(), emptySet()) - } + _selectedSongs.value = emptyList() } /** @@ -110,7 +106,7 @@ class MultiSelectionStateHolder @Inject constructor() { * @return True if the song is selected, false otherwise */ fun isSelected(songId: String): Boolean { - return _selectedSongIds.value.contains(songId) + return _selectedSongs.value.any { it.id == songId } } /** @@ -132,23 +128,9 @@ class MultiSelectionStateHolder @Inject constructor() { * @param songId The ID of the song to remove */ fun removeFromSelection(songId: String) { - synchronized(mutationLock) { - val currentIds = _selectedSongIds.value - if (songId !in currentIds) return - val newList = _selectedSongs.value.filter { it.id != songId } - updateStateLocked(newList, currentIds - songId) + _selectedSongs.update { current -> + if (current.none { it.id == songId }) current + else current.filterNot { it.id == songId } } } - - /** - * Updates all four state flows. Callers MUST hold [mutationLock] so the - * four `.value =` assignments land without an interleaving mutation - * leaving the ids/list/count/mode flows out of sync. - */ - private fun updateStateLocked(songs: List, ids: Set) { - _selectedSongs.value = songs - _selectedSongIds.value = ids - _selectedCount.value = songs.size - _isSelectionMode.value = songs.isNotEmpty() - } } diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt index 5cd6f0f74..8d1aeec82 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt @@ -1,54 +1,66 @@ package com.theveloper.pixelplay.presentation.viewmodel import com.theveloper.pixelplay.data.model.Playlist +import com.theveloper.pixelplay.di.AppScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import javax.inject.Inject import javax.inject.Singleton /** * State holder for multi-selection functionality for playlists in LibraryScreen. - * Manages playlist selection state with order preservation. + * Manages playlist selection state with order preservation using a + * list-of-playlists as the single source of truth; ids/count/mode are + * derived views. * - * Selection order is maintained - the first selected playlist is at index 0, + * Selection order is maintained — the first selected playlist is at index 0, * subsequent selections are appended in the order they were selected. */ @Singleton -class PlaylistSelectionStateHolder @Inject constructor() { +class PlaylistSelectionStateHolder @Inject constructor( + @AppScope private val appScope: CoroutineScope, +) { - // Guards multi-flow mutations so a reader of the four exposed StateFlows - // observes a coherent final state. `_selectedPlaylists.update {}` alone - // only made one flow atomic; the sibling writes for ids/count/mode could - // race with another toggle landing in the gap. A single synchronized - // block around the whole read-modify-write closes that gap. - private val mutationLock = Any() - - // Internal mutable state - uses List to preserve selection order + // The ordered list of selected playlists is the only piece of mutable + // state. ids/count/mode are projections of this flow, so observers + // that read any subset of the public flows see values that all + // originated from the same source emission — no cross-flow tearing is + // possible. Mutations use StateFlow.update {} for atomic CAS, removing + // the need for an external synchronized block. private val _selectedPlaylists = MutableStateFlow>(emptyList()) - + /** * Immutable flow of selected playlists, preserving selection order. */ val selectedPlaylists: StateFlow> = _selectedPlaylists.asStateFlow() - + /** - * Set of selected playlist IDs for efficient lookup. + * Set of selected playlist IDs for efficient lookup. Derived from + * [selectedPlaylists] so the two views can never disagree. */ - private val _selectedPlaylistIds = MutableStateFlow>(emptySet()) - val selectedPlaylistIds: StateFlow> = _selectedPlaylistIds.asStateFlow() - + val selectedPlaylistIds: StateFlow> = _selectedPlaylists + .map { list -> list.mapTo(LinkedHashSet(list.size)) { it.id } } + .stateIn(appScope, SharingStarted.Eagerly, emptySet()) + /** * Whether selection mode is currently active (at least one playlist selected). */ - private val _isSelectionMode = MutableStateFlow(false) - val isSelectionMode: StateFlow = _isSelectionMode.asStateFlow() - + val isSelectionMode: StateFlow = _selectedPlaylists + .map { it.isNotEmpty() } + .stateIn(appScope, SharingStarted.Eagerly, false) + /** * Current count of selected playlists. */ - private val _selectedCount = MutableStateFlow(0) - val selectedCount: StateFlow = _selectedCount.asStateFlow() + val selectedCount: StateFlow = _selectedPlaylists + .map { it.size } + .stateIn(appScope, SharingStarted.Eagerly, 0) /** * Toggles the selection state of a playlist. @@ -57,15 +69,12 @@ class PlaylistSelectionStateHolder @Inject constructor() { * @param playlist The playlist to toggle */ fun toggleSelection(playlist: Playlist) { - synchronized(mutationLock) { - val currentList = _selectedPlaylists.value - val currentIds = _selectedPlaylistIds.value - val (newList, newIds) = if (playlist.id in currentIds) { - currentList.filter { it.id != playlist.id } to (currentIds - playlist.id) + _selectedPlaylists.update { current -> + if (current.any { it.id == playlist.id }) { + current.filterNot { it.id == playlist.id } } else { - (currentList + playlist) to (currentIds + playlist.id) + current + playlist } - updateStateLocked(newList, newIds) } } @@ -77,19 +86,10 @@ class PlaylistSelectionStateHolder @Inject constructor() { * @param playlists The complete list of playlists to select */ fun selectAll(playlists: List) { - synchronized(mutationLock) { - val currentIds = _selectedPlaylistIds.value - val currentList = _selectedPlaylists.value.toMutableList() - - // Add playlists that aren't already selected - playlists.forEach { playlist -> - if (!currentIds.contains(playlist.id)) { - currentList.add(playlist) - } - } - - val newIds = currentList.map { it.id }.toSet() - updateStateLocked(currentList, newIds) + _selectedPlaylists.update { current -> + val existingIds = current.mapTo(HashSet(current.size)) { it.id } + val additions = playlists.filter { it.id !in existingIds } + if (additions.isEmpty()) current else current + additions } } @@ -97,9 +97,7 @@ class PlaylistSelectionStateHolder @Inject constructor() { * Clears all selected playlists, exiting selection mode. */ fun clearSelection() { - synchronized(mutationLock) { - updateStateLocked(emptyList(), emptySet()) - } + _selectedPlaylists.value = emptyList() } /** @@ -109,7 +107,7 @@ class PlaylistSelectionStateHolder @Inject constructor() { * @return True if the playlist is selected, false otherwise */ fun isSelected(playlistId: String): Boolean { - return _selectedPlaylistIds.value.contains(playlistId) + return _selectedPlaylists.value.any { it.id == playlistId } } /** @@ -131,23 +129,9 @@ class PlaylistSelectionStateHolder @Inject constructor() { * @param playlistId The ID of the playlist to remove */ fun removeFromSelection(playlistId: String) { - synchronized(mutationLock) { - val currentIds = _selectedPlaylistIds.value - if (playlistId !in currentIds) return - val newList = _selectedPlaylists.value.filter { it.id != playlistId } - updateStateLocked(newList, currentIds - playlistId) + _selectedPlaylists.update { current -> + if (current.none { it.id == playlistId }) current + else current.filterNot { it.id == playlistId } } } - - /** - * Updates all four state flows. Callers MUST hold [mutationLock] so the - * four `.value =` assignments land without an interleaving mutation - * leaving the ids/list/count/mode flows out of sync. - */ - private fun updateStateLocked(playlists: List, ids: Set) { - _selectedPlaylists.value = playlists - _selectedPlaylistIds.value = ids - _selectedCount.value = playlists.size - _isSelectionMode.value = playlists.isNotEmpty() - } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 42a1761b5..a051a4cc5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,11 @@ lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" composeBom = "2026.05.00" material = "1.14.0" +# Pinned to an alpha to keep ExperimentalMaterial3ExpressiveApi features +# (LinearWavyProgressIndicator, LoadingIndicator, +# MediumExtendedFloatingActionButton, titleLargeEmphasized, etc.) available. +# The Compose BOM tracks the stable Material3 line and does not yet ship +# these expressive APIs, so this pin intentionally overrides the BOM. material3 = "1.5.0-alpha19" media = "1.8.0" media3Session = "1.10.1" @@ -166,8 +171,10 @@ androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", vers androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "composeUi" } androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest", version.ref = "composeUi" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "composeUi" } -# androidx-compose-material3 is BOM-managed (composeBom) — no explicit -# version.ref to avoid pinning to an alpha while the BOM tracks stable. +# androidx-compose-material3 is pinned to an alpha because the codebase +# depends on Material3 Expressive APIs (see the `material3` version comment +# for the list). This pin intentionally overrides the Compose BOM, which +# tracks the stable line and does not yet ship the expressive APIs. kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } From e6c1d4c8548023ea7808bcf3a6dfd377d0b2f80d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 14:05:32 +0000 Subject: [PATCH 4/6] Fix BOM stripping and isSelected O(1) lookup per review 4329200173 Agent-Logs-Url: https://github.com/theovilardo/PixelPlayer/sessions/c690e76a-0876-4396-9ef9-0669670793f7 Co-authored-by: lostf1sh <136324426+lostf1sh@users.noreply.github.com> --- .../java/com/theveloper/pixelplay/data/playlist/M3uManager.kt | 2 +- .../presentation/viewmodel/MultiSelectionStateHolder.kt | 2 +- .../presentation/viewmodel/PlaylistSelectionStateHolder.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/playlist/M3uManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/playlist/M3uManager.kt index 53b6128a6..1369ac11e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/playlist/M3uManager.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/playlist/M3uManager.kt @@ -55,7 +55,7 @@ class M3uManager @Inject constructor( } // Strip UTF-8 BOM if it leaked through readLine on line 1. - val payload = if (processed == 1) trimmedLine.removePrefix("") else trimmedLine + val payload = if (processed == 1) trimmedLine.removePrefix("\uFEFF") else trimmedLine // payload is likely a file path or URI // We need to find a song in our database that matches this path diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt index e73566120..8621d367f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/MultiSelectionStateHolder.kt @@ -106,7 +106,7 @@ class MultiSelectionStateHolder @Inject constructor( * @return True if the song is selected, false otherwise */ fun isSelected(songId: String): Boolean { - return _selectedSongs.value.any { it.id == songId } + return selectedSongIds.value.contains(songId) } /** diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt index 8d1aeec82..591508988 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaylistSelectionStateHolder.kt @@ -107,7 +107,7 @@ class PlaylistSelectionStateHolder @Inject constructor( * @return True if the playlist is selected, false otherwise */ fun isSelected(playlistId: String): Boolean { - return _selectedPlaylists.value.any { it.id == playlistId } + return selectedPlaylistIds.value.contains(playlistId) } /** From 31e80ac42d0c16e9d108dbba58c1b2ce1a3f9f41 Mon Sep 17 00:00:00 2001 From: lostf1sh Date: Thu, 21 May 2026 15:22:35 +0300 Subject: [PATCH 5/6] Dispatch cast route refresh on Main.immediate - Ensure MediaRouter callback and route access run on the main thread - Avoid an extra post when already on Main --- .../pixelplay/presentation/viewmodel/CastStateHolder.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt index 9936977e3..1f8c91eb1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/CastStateHolder.kt @@ -234,7 +234,11 @@ class CastStateHolder @Inject constructor( fun refreshRoutes(@Suppress("UNUSED_PARAMETER") scope: kotlinx.coroutines.CoroutineScope = appScope) { refreshRoutesJob?.cancel() - refreshRoutesJob = appScope.launch { + // MediaRouter requires its methods (addCallback/removeCallback/routes/ + // selectedRoute) to be invoked on the application's main thread, so + // dispatch onto Main.immediate even though the job is tied to @AppScope + // for lifetime. immediate avoids an extra post when already on Main. + refreshRoutesJob = appScope.launch(kotlinx.coroutines.Dispatchers.Main.immediate) { _isRefreshingRoutes.value = true mediaRouter.removeCallback(mediaRouterCallback) val mediaRouteSelector = buildCastRouteSelector() From 3a712dd3353142fca22fccaa6b8c13b6ead2f66d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 21:00:07 +0000 Subject: [PATCH 6/6] fix: address latest PR review comments on test scopes and temp copy limits Agent-Logs-Url: https://github.com/theovilardo/PixelPlayer/sessions/b226c60c-fcc1-4427-ab6e-4d12e6826110 Co-authored-by: lostf1sh <136324426+lostf1sh@users.noreply.github.com> --- .../pixelplay/data/repository/LyricsRepositoryImpl.kt | 10 +++++++++- .../data/repository/MusicRepositoryImplTest.kt | 2 +- .../presentation/viewmodel/LyricsStateHolderTest.kt | 6 ++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt index 7ad67a327..bf294f79b 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/LyricsRepositoryImpl.kt @@ -1610,6 +1610,7 @@ class LyricsRepositoryImpl @Inject constructor( // downstream TagLib reader only needs file headers (~10 MB // covers every realistic embedded-tag layout); abort cleanly // if more is required than the cap allows. + var exceededCopyLimit = false FileOutputStream(tempFile).use { output -> val buffer = ByteArray(64 * 1024) var totalCopied = 0L @@ -1617,13 +1618,20 @@ class LyricsRepositoryImpl @Inject constructor( val read = inputStream.read(buffer) if (read <= 0) break if (totalCopied + read > TEMP_AUDIO_COPY_MAX_BYTES) { - output.write(buffer, 0, (TEMP_AUDIO_COPY_MAX_BYTES - totalCopied).toInt()) + exceededCopyLimit = true break } output.write(buffer, 0, read) totalCopied += read } } + if (exceededCopyLimit) { + if (!tempFile.delete()) { + tempFile.deleteOnExit() + } + LogUtils.w(this@LyricsRepositoryImpl, "Refusing oversized audio URI copy (> $TEMP_AUDIO_COPY_MAX_BYTES bytes): $uri") + return null + } tempFile } } catch (e: Exception) { diff --git a/app/src/test/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImplTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImplTest.kt index 81baf8c24..58e911520 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImplTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/repository/MusicRepositoryImplTest.kt @@ -115,7 +115,7 @@ class MusicRepositoryImplTest { artistImageRepository = mockArtistImageRepository, folderTreeBuilder = mockk(relaxed = true), appScope = kotlinx.coroutines.CoroutineScope( - kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Unconfined + kotlinx.coroutines.SupervisorJob() + testDispatcher ), ) } diff --git a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt index a6e86e777..b8ca58236 100644 --- a/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/LyricsStateHolderTest.kt @@ -50,15 +50,13 @@ class LyricsStateHolderTest { val musicRepository = mockk(relaxed = true) val userPreferencesRepository = mockk(relaxed = true) val songMetadataEditor = mockk(relaxed = true) + val scope = TestScope(StandardTestDispatcher()) val holder = LyricsStateHolder( musicRepository = musicRepository, userPreferencesRepository = userPreferencesRepository, songMetadataEditor = songMetadataEditor, - appScope = kotlinx.coroutines.CoroutineScope( - kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Unconfined - ), + appScope = scope, ) - val scope = TestScope(StandardTestDispatcher()) val callback = RecordingLyricsLoadCallback() val state = MutableStateFlow(StablePlayerState()) val song = testSong(albumArtUriString = "content://art/song_art_1.jpg").copy(