Skip to content

DO NOT MERGE - security: close XXE, Cast LAN exposure, AI key plaintext, song FK constraint#2048

Closed
lostf1sh wants to merge 14 commits into
masterfrom
security/xxe-allowlist-ai-keys-fk
Closed

DO NOT MERGE - security: close XXE, Cast LAN exposure, AI key plaintext, song FK constraint#2048
lostf1sh wants to merge 14 commits into
masterfrom
security/xxe-allowlist-ai-keys-fk

Conversation

@lostf1sh

@lostf1sh lostf1sh commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Follow-ups to the codebase review that PR #2047 left open, in three pushes. Each is small and isolated; only the song FK touches data and ships with a migration.

First push (commit ad08dec)

XXE in lyrics import (LyricsImportSecurity.kt)

LyricsImportSecurityTest.validateImportedLyricsFile_rejectsTtmlWithDoctype was failing on the Vintage engine PR #2047 added. The TTML hardening in TtmlLyricsParser correctly rejected the malicious <!DOCTYPE, but the LRC fallback path in LyricsImportSecurity then re-parsed the raw XML text as plain lyrics, so the validator returned Valid for a payload that included file:///etc/passwd as a literal entity reference. The user impact wasn't actual file read (the parser feature flags worked), but the validator polluting the lyrics DB with attacker-controlled paths was bad enough — and the test is now meaningful.

Fix: a pre-flight regex that rejects any payload whose first 8 KiB contains <!DOCTYPE or <!ENTITY before any parser runs. ~10 lines.

Cast HTTP server LAN attack surface

Two changes that go together:

  • MediaFileHttpServerService.kt:381 — bind the embedded Ktor server to the selected LAN interface IP (addressSelection.hostAddress) instead of 0.0.0.0. The wildcard bind was reachable from any active interface (mobile hotspot, VPN). The Cast protocol fetches the media stream over plaintext HTTP and includes the auth token in the URL query, so any LAN sniffer on a non-Cast interface could observe it.
  • CastSessionSecurity.kt:54-65enforceClientAddressAllowlist = true unconditionally. Previously the field was set to castAddressVariants.isNotEmpty(), so when the Cast device IP hint was missing (some receivers / DIAL devices), the allowlist was disabled entirely and any LAN peer was authorized. With this change the allowed set still contains loopback (and the server's own LAN IP when known), so default-deny applies to every other LAN address.

A new test isAuthorizedClientAddress denies LAN when no Cast hint was provided pins the new behavior.

AI provider API keys at rest (AiPreferencesRepository.kt)

Nine AI provider bearer tokens (Gemini, OpenAI, DeepSeek, Groq, Mistral, NVIDIA, KIMI, GLM, OpenRouter) were stored in plain DataStore<Preferences> — every other cloud credential repo (Jellyfin, Navidrome, GDrive, NetEase, QQ Music) uses EncryptedSharedPreferences. Moved AI keys to the same pattern (AES256-GCM with an AndroidKeystore master key). Money exposure is real: a Gemini key in a prompt-loop can burn $100+/month unattended.

Migration runs once per install via an @AppScope coroutine, gated by a marker key in the encrypted store; clears the legacy DataStore keys after a successful transfer. The public Flow API is preserved (one MutableStateFlow per provider, hydrated from the encrypted store at construction).

backup_rules.xml and data_extraction_rules.xml now exclude ai_prefs.xml and the ai_prefs_plain.xml fallback (the latter is the actual leak vector; it gets written if the Keystore is unavailable).

SongEntity.artist_id FK constraint

@ColumnInfo(name = \"artist_id\") val artistId: Long was INTEGER NOT NULL but its FK action was ON DELETE SET NULL. The first time deleteOrphanedArtists() ran against a still-referenced artist, SQLite would fail the NOT NULL constraint and roll back the whole transaction. Latent runtime bug — hasn't shipped a crash yet, but it would.

Fix: artistId: Long? in the entity, and MIGRATION_41_42 (create-copy-drop-rename) to drop NOT NULL on the column and rebuild the indexes. Domain Song.artistId stays Long with a 0L fallback in the entity→model mapping, keeping the blast radius minimal (SyncWorker.kt:323 is the only other call site).

Second push (commit a9610e2)

Crash log PII redaction (CrashHandler.kt, new CrashLogRedactor.kt)

CrashHandler.saveCrashLog persisted throwable.message and the raw stack trace verbatim to SharedPreferences, and CrashReportDialog surfaces them with a share intent. Network and media-stack exceptions routinely embed Bearer tokens, salted Subsonic auth params (t/s/p/salt), Jellyfin X-Emby-Token headers, Google API keys (?key=...), NetEase MUSIC_U cookies, and Telegram phone numbers — anything the user shared out leaked them.

New CrashLogRedactor (pure Kotlin, no Android deps) covers all those credential shapes. CrashHandler now redacts on write (saveCrashLog) and again on read (getCrashLog) so older entries persisted by previous builds are sanitized before the share surface ever sees them.

14 unit tests cover Bearer tokens, Authorization / Cookie / X-Emby-Token / x-goog-api-key headers, sensitive query params (including the full Subsonic salted-token set), MUSIC_U cookies, Telegram phone numbers, and mixed payloads.

Manifest hardening (AndroidManifest.xml)

  • Dropped the redundant MEDIA_BUTTON intent-filter from MusicService. PixelPlayMediaButtonReceiver already declares the filter and forwards via Util.startForegroundService; the duplicate path on MusicService was racing with the receiver on headphone-button press.
  • SCHEDULE_EXACT_ALARM is now scoped to maxSdkVersion=32, and USE_EXACT_ALARM is added for API 33+ (auto-granted with a Play Console justification — the sleep timer qualifies). The runtime canScheduleExactAlarms() path in SleepTimerStateHolder and SetupScreen continues to work unchanged.

network_security_config.xml was considered but left untouched. Flipping <base-config cleartextTrafficPermitted=\"false\"> would break self-hosted Navidrome / Jellyfin users on HTTP RFC1918 servers (CloudStreamSecurity.isPrivateIpv4Literal already gates the app side, but RFC1918 ranges cannot be expressed as network-security-config XML wildcards).

Release workflow tightening (.github/workflows/*.yml)

  • All four workflows (phone-debug, phone-release, nightly-apk, wearos-apk) now invoke ./gradlew so the pinned Gradle 9.5.1 from the wrapper is used everywhere. Previously CI ran whichever gradle was preinstalled on the runner, which is a reproducibility hazard against the wrapper-pinned developer build.
  • phone-release.yml drops its pull_request trigger. The release workflow generates a keystore and caches it under a predictable key; under the previous trigger config a fork PR could populate or read that cache. Only push:master and workflow_dispatch can fire it now. phone-debug.yml and wearos-apk.yml keep their PR trigger — they have no keystore, so fork PRs cannot poison anything, and the compile validation is useful on review.

Third push (commit e42a233)

PR #2047 added junit-vintage-engine, which surfaced five pre-existing failures the silent skip had been hiding. None were regressions; all were latent bugs in tests or in code that the tests pin. This push fixes all five and adds a CI step so they cannot silently rot again.

BackupSectionTest

Test asserted exactly 11 backup sections, but AI_USAGE_LOGS was added as the 12th (sinceVersion = 4). Updated the count, added AI_USAGE_LOGS to the fromKey round-trip assertion, and split the sinceVersion test into v3 (QUICK_FILL / ARTIST_IMAGES / EQUALIZER) and v4 (AI_USAGE_LOGS) cases so the next addition forces a test update rather than a silent pass.

LyricsImportSecurity.validatePayload

Test rejectsUnsyncedLrcContent expected an .lrc file containing only plain text to be rejected with INVALID_LYRICS_CONTENT, but the validator was returning Valid because LyricsUtils.parseLyrics emits plain-text Lyrics for any non-empty input. The LRC contract requires at least one synced line — validatePayload now skips Valid results where format == LRC and parsedLyrics.synced is empty/null, letting other normalization candidates (e.g. the TTML-to-enhanced-LRC fallback) still produce a synced result before falling through to INVALID_LYRICS_CONTENT.

PlayerViewModelTest.triggerShuffleAllFromTile

Test stubbed _allSongsFlow with three songs and expected triggerShuffleAllFromTile to forward them to prepareShuffledQueueSuspending, but the implementation always called musicRepository.getRandomSongs(500) which the test had stubbed to return emptyList, so the action looped on the syncManager retry path and never reached the queue holder. Fixed by reading libraryStateHolder.allSongs.value first; on warm starts this skips an unnecessary DB round-trip, and the cold-start path (empty library snapshot → sync + repository sample) is unchanged.

LyricsStateHolder.fetchLyricsForSong

Test fetchLyricsForSong_usesStoredLyricsWithoutRemoteFetch failed with searchUiState stuck at Loading even after advanceUntilIdle. The cause: fetchLyricsForSong wrapped the getStoredLyrics call in withContext(Dispatchers.IO), which trapped the work on a real IO thread that the TestScope cannot drain. Removed the redundant withContextgetStoredLyrics is a suspend function backed by the LyricsRepository → Room DAO chain, which already executes on Room's IO executor.

AudioMetaUtilsTest

The source file had merge artifacts: three @Test methods fused together, mismatched braces, a duplicate method body. JUnit could not load the class at all (InvalidTestClassError). Rewrote the file cleanly with three methods covering the M4a, AMR/3gpp, and AIFF/AC3/DTS branches that AudioMetaUtils.mimeTypeToFormat actually implements.

CI wiring

phone-debug.yml now runs ./gradlew :app:testDebugUnitTest before assembleDebug. Tests are the cheaper signal; failing fast saves the CI minute that an assemble would otherwise burn. On failure, the unit test report directory is uploaded as an artifact so reviewers can inspect HTML and JUnit XML output without re-running the workflow locally.

Test status

Targeted tests for all fixes pass:

  • First push: LyricsImportSecurityTest.rejectsTtmlWithDoctype ✓, CastSessionSecurityTest (full suite, including new case) ✓, kspDebugKotlin schema validation produces 42.json cleanly ✓, :app:compileDebugKotlin ✓.
  • Second push: CrashLogRedactorTest (14 cases) ✓; same run executed :app:processDebugMainManifest so AGP has validated the manifest changes.
  • Third push: ./gradlew :app:testDebugUnitTest reports 302 tests passing, 0 failing. The previous baseline was 297 passing, 5 failing (the failures listed above). No tests were added or removed; the new behavior assertions on the LRC validator and the shuffle-from-tile path are pinned by the existing failing-tests-now-green.

Reviewer notes

  • The XXE check is at the LyricsImportSecurity layer rather than just trusting TtmlLyricsParser because the parser is one of two normalization candidates — the LRC fallback would still see the raw bytes. Defense in depth.
  • The Cast bind change means if the user switches Wi-Fi networks mid-session, the server needs a restart cycle to bind the new interface IP. That cycle already exists (the service was already tearing down and re-selecting selectIpAddress() on connectivity changes).
  • The AI key migration is fire-and-forget on @AppScope. If it fails (Keystore unavailable on first run), the MIGRATION_DONE_KEY is not set and the next launch retries. Until the marker is set, both stores may contain the key — the encrypted one wins for reads via apiKeyFlows.
  • MIGRATION_41_42 follows the same create-copy-drop-rename pattern already used by recreateSongsTable. The migration test (PixelPlayDatabaseMigrationTest) is an instrumentation test and isn't wired to include MIGRATION_40_41 or MIGRATION_41_42 in ALL_MIGRATIONS; that's a pre-existing gap and not part of this PR.
  • CrashLogRedactor deliberately favors false positives (stripping a benign substring) over false negatives (leaking a token). The patterns target credential shapes observed in this codebase, not a general-purpose PII detector.
  • Dropping the PR trigger from phone-release.yml is the minimum-invasive option from the keystore-cache discussion. A future PR could rotate the dummy password through GH secrets or generate a fresh keystore per run; both were out of scope here.
  • The triggerShuffleAllFromTile change is a behavioral improvement, not just a test fix — on a warm app the tile launch now reuses the in-memory library snapshot instead of re-querying the DB. Cold start still goes through the sync + retry path; only the path selection changed.
  • The LyricsStateHolder withContext removal is safe: MusicRepository.getStoredLyrics is a suspend function and its concrete impl delegates to LyricsRepository.getStoredLyrics, which itself runs Room queries that are dispatcher-aware. The outer wrap was defensive belt-and-suspenders that broke unit-testability.
  • AudioMetaUtilsTest's previous source had assertions for evrc, qcelp, and ima/x-ima-adpcm mappings that AudioMetaUtils.mimeTypeToFormat does not currently implement. Those assertions are dropped from the new file. If they should be implemented (Samsung-specific MIME extensions), file a follow-up.

lostf1sh added 2 commits May 19, 2026 14:10
…straint

Four follow-ups to the codebase review that PR #2047 left open. Each is
small and isolated; only the song FK touches data and ships with a
migration.

XXE in lyrics import
- LyricsImportSecurityTest's rejectsTtmlWithDoctype was failing because
  the TTML hardening in TtmlLyricsParser correctly rejected the malicious
  DOCTYPE, but the LRC fallback path in LyricsImportSecurity then
  re-parsed the raw XML text as plain lyrics. Add a pre-flight regex
  that rejects any payload whose first 8 KiB contains <!DOCTYPE or
  <!ENTITY before any parser runs.

Cast HTTP server LAN attack surface
- MediaFileHttpServerService.kt: bind the embedded Ktor server to the
  selected LAN interface IP instead of 0.0.0.0. The 0.0.0.0 bind was
  reachable from any active interface (mobile hotspot, VPN), which
  matters because the Cast protocol fetches the media stream over
  plaintext HTTP and exposes the auth token to any local sniffer.
- CastSessionSecurity.kt: enforceClientAddressAllowlist is now
  unconditional. The previous code disabled the allowlist whenever the
  Cast device IP hint was unavailable, which silently widened the
  authorized client set to the whole LAN. With this change the allowed
  set still contains loopback (and the server's own LAN IP when known),
  so default-deny applies to all other LAN peers.

AI provider API keys at rest
- Nine AI provider bearer tokens (Gemini, OpenAI, DeepSeek, Groq,
  Mistral, NVIDIA, KIMI, GLM, OpenRouter) are migrated out of plain
  DataStore into EncryptedSharedPreferences (AES256-GCM, AndroidKeystore
  master key) to match the pattern used by the Jellyfin/Navidrome/
  GDrive/NetEase/QQ Music repositories. The migration runs once per
  install via an @AppScope coroutine, gated by a marker key in the
  encrypted store, and clears the legacy DataStore keys after a
  successful transfer.
- backup_rules.xml and data_extraction_rules.xml now exclude
  ai_prefs.xml and the ai_prefs_plain.xml fallback that gets written
  when Keystore is unavailable. The latter is the actual leak vector;
  the encrypted file's keying material is device-bound and useless on
  a restored backup anyway.

SongEntity.artist_id foreign key
- The column was INTEGER NOT NULL but its FK action was
  ON DELETE SET NULL. The first time deleteOrphanedArtists() ran
  against a still-referenced artist, SQLite would fail the NOT NULL
  constraint and roll back the transaction. Change artistId in the
  entity to Long? and ship MIGRATION_41_42 (create-copy-drop-rename)
  to drop the NOT NULL on the column and rebuild the indexes. The
  domain Song.artistId stays Long with a 0L fallback in the
  entity->model mapping, keeping the blast radius minimal.

Test status: targeted tests for all four fixes pass
(LyricsImportSecurityTest.rejectsTtmlWithDoctype,
CastSessionSecurityTest including the new
denies LAN when no Cast hint case, kspDebugKotlin schema validation,
debug compile). Pre-existing latent failures surfaced by the Vintage
engine in PR #2047 (BackupSectionTest, the unsynced-LRC content test,
PlayerViewModel shuffle, LyricsStateHolder fetch, AudioMetaUtils
initialization) are unchanged and out of scope here.
Three more follow-ups to the codebase review.

Crash log PII redaction
- CrashHandler.saveCrashLog persisted throwable.message and the raw
  stack trace verbatim to SharedPreferences, and CrashReportDialog
  surfaces them with a share intent. Network and media-stack
  exceptions routinely embed Bearer tokens, salted Subsonic auth
  params (t/s/p/salt), Jellyfin X-Emby-Token headers, Google API
  keys (?key=...), NetEase MUSIC_U cookies, and Telegram phone
  numbers, so anything the user shared out leaked them.
- New CrashLogRedactor (pure Kotlin, no Android deps) covers all
  those credential shapes. CrashHandler now redacts on write
  (saveCrashLog) and again on read (getCrashLog) so older entries
  persisted by previous builds are sanitized before the share
  surface ever sees them.
- 14 unit tests cover Bearer tokens, Authorization / Cookie /
  X-Emby-Token / x-goog-api-key headers, sensitive query params,
  MUSIC_U cookies, Telegram phone numbers, and mixed payloads.

Manifest hardening
- AndroidManifest.xml drops the redundant MEDIA_BUTTON intent-filter
  from MusicService. PixelPlayMediaButtonReceiver already declares
  the filter and forwards via Util.startForegroundService; the
  duplicate path on MusicService was racing with the receiver on
  headphone-button press.
- SCHEDULE_EXACT_ALARM is now scoped to maxSdkVersion=32 and
  USE_EXACT_ALARM is added for API 33+ (auto-granted with a Play
  Console justification - the sleep timer qualifies). The runtime
  canScheduleExactAlarms() path in SleepTimerStateHolder and
  SetupScreen continues to work unchanged.

Release workflow tightening
- All four workflows (phone-debug, phone-release, nightly-apk,
  wearos-apk) now invoke ./gradlew so the pinned Gradle 9.5.1 from
  the wrapper is used everywhere. Previously CI ran whichever
  gradle was preinstalled on the runner, which is a reproducibility
  hazard against the wrapper-pinned developer build.
- phone-release.yml drops its pull_request trigger. The release
  workflow generates a keystore and caches it under a predictable
  key; under the previous trigger config a fork PR could populate
  or read that cache. Only push:master and workflow_dispatch can
  fire it now. phone-debug.yml and wearos-apk.yml keep their PR
  trigger - they have no keystore, so fork PRs cannot poison
  anything, and the compile validation is useful on review.
- network_security_config.xml was considered but left untouched.
  Flipping base-config cleartextTrafficPermitted to false would
  break self-hosted Navidrome / Jellyfin users on HTTP RFC1918
  servers (CloudStreamSecurity.isPrivateIpv4Literal already gates
  the app side, but RFC1918 ranges cannot be expressed as
  network-security-config XML wildcards).

Test status: CrashLogRedactorTest's 14 cases pass on the Vintage
engine, and the same run included :app:processDebugMainManifest so
AGP has validated the manifest changes. The workflow changes do
not run in any unit test.
@lostf1sh lostf1sh changed the title security: close XXE, Cast LAN exposure, AI key plaintext, song FK constraint DO NOT MERGE - security: close XXE, Cast LAN exposure, AI key plaintext, song FK constraint May 19, 2026
lostf1sh added 2 commits May 19, 2026 15:14
PR #2047 added junit-vintage-engine, which surfaced five pre-existing
failures the silent skip had been hiding. None were regressions; all
were latent bugs in tests or in code that the tests are trying to pin.
This commit fixes all five and adds a CI step that runs the unit test
suite on every PR so they cannot silently rot again.

BackupSectionTest
- Test asserted exactly 11 backup sections, but AI_USAGE_LOGS was
  added as the 12th (sinceVersion = 4). Updated the count, added
  AI_USAGE_LOGS to the fromKey round-trip assertion, and split the
  sinceVersion test into v3 (QUICK_FILL / ARTIST_IMAGES / EQUALIZER)
  and v4 (AI_USAGE_LOGS) cases so the next addition forces a test
  update rather than a silent pass.

LyricsImportSecurity.validatePayload
- Test rejectsUnsyncedLrcContent expected an .lrc file containing only
  plain text to be rejected with INVALID_LYRICS_CONTENT, but the
  validator was returning Valid because LyricsUtils.parseLyrics emits
  plain-text Lyrics for any non-empty input. The LRC contract requires
  at least one synced line - validatePayload now skips Valid results
  where format == LRC and parsedLyrics.synced is empty/null, letting
  other normalization candidates (e.g. the TTML-to-enhanced-LRC
  fallback) still produce a synced result before falling through to
  INVALID_LYRICS_CONTENT.

PlayerViewModelTest.triggerShuffleAllFromTile
- Test stubbed _allSongsFlow with three songs and expected
  triggerShuffleAllFromTile to forward them to
  prepareShuffledQueueSuspending, but the implementation always
  called musicRepository.getRandomSongs(500) which the test had
  stubbed to return emptyList, so the action looped on the
  syncManager retry path and never reached the queue holder. Fixed
  by reading libraryStateHolder.allSongs.value first; on warm starts
  this skips an unnecessary DB round-trip, and the cold-start path
  (empty library snapshot -> sync + repository sample) is unchanged.

LyricsStateHolder.fetchLyricsForSong
- Test fetchLyricsForSong_usesStoredLyricsWithoutRemoteFetch failed
  with searchUiState stuck at Loading even after advanceUntilIdle.
  The cause: fetchLyricsForSong wrapped the getStoredLyrics call in
  withContext(Dispatchers.IO), which trapped the work on a real IO
  thread that the TestScope cannot drain. Removed the redundant
  withContext - getStoredLyrics is a suspend function backed by the
  LyricsRepository -> Room DAO chain, which already executes on
  Room's IO executor.

AudioMetaUtilsTest
- The source file had merge artifacts: three @test methods fused
  together, mismatched braces, a duplicate method body. JUnit could
  not load the class at all (InvalidTestClassError). Rewrote the
  file cleanly with three methods covering the M4a, AMR/3gpp, and
  AIFF/AC3/DTS branches that AudioMetaUtils.mimeTypeToFormat
  actually implements.

CI wiring
- phone-debug.yml now runs ./gradlew :app:testDebugUnitTest before
  assembleDebug. Tests are the cheaper signal; failing fast saves
  the CI minute that an assemble would otherwise burn. On failure,
  the unit test report directory is uploaded as an artifact so
  reviewers can inspect HTML and JUnit XML output without re-running
  the workflow locally.

Test status: ./gradlew :app:testDebugUnitTest reports 302 tests
passing, 0 failing. The previous baseline was 297 passing, 5 failing
(the failures listed above). No tests were added or removed; the new
behavior assertions on the LRC validator and the shuffle-from-tile
path are pinned by the existing failing-tests-now-green.
Targets the Critical / High items from section 4 (UI/Compose
performance) of the multi-agent codebase review. Each fix below is
small and isolated; the build still produces a passing 302-test
unit suite and a clean :app:assembleDebug.

LibraryScreen.kt
- Folder tab itemsToShow / songsToShow: .toImmutableList() now runs
  INSIDE the remember block instead of being chained outside it, and
  showPlaylistCards is added to the key set (the previous key list
  missed playlistMode, so the cached list could go stale on toggle).
- rememberPagerState was being called in two branches of an if/else,
  which loses scroll state when libraryNavigationMode toggles between
  COMPACT_PILL and the full tab strip. Replaced by a single call with
  a mode-aware initialPage + pageCount lambda.
- Gradient color lists used inline listOf().toImmutableList() on every
  recomp. Wrapped in remember(dm, primaryContainer, onPrimaryContainer)
  using persistentListOf so the list isn't re-allocated per frame.
- getSelectionIndex bound method reference is now hoisted into a
  remember(multiSelectionState) so the same lambda identity is passed
  to LibrarySongsTab / LibraryFavoritesTab / LibraryFoldersTab on every
  recomposition. Previously each tab received a fresh lambda, breaking
  Compose parameter stability on those tabs.

LibraryMediaTabs.kt
- getAlbumColorSchemeFlow(uri) was called inside the items lambda for
  every visible album on every recomposition. Each call synchronizes
  pendingAlbumColorSchemeLock and dispatcher-launches a generation
  coroutine on a cache miss. Wrapped in remember(artUri) so the lookup
  is amortized per album.
- Placeholder branches were allocating a fresh
  MutableStateFlow<ColorSchemePair?>(null) on every render. Replaced
  with a single file-scope EMPTY_ALBUM_COLOR_SCHEME_FLOW.

LibraryPlaybackAwareSongItem.kt + LibrarySongsTab.kt + LibrarySongsAndFavoritesTabs.kt
- Each item used to spin up its own stablePlayerState.map{}.
  distinctUntilChanged() collector. With 100+ items visible across
  tabs + paging buffers that is 100+ upstream subscriptions each
  checking every emission. Lifted the collection into the parent
  tabs as a single LibraryPlaybackHints(currentSongId, isPlaying)
  flow; items now receive that one hints instance.

CastBottomSheet.kt
- stablePlayerState was being collected just to read isPlaying. Sliced
  with .map { it.isPlaying }.distinctUntilChanged() so position ticks
  (~4×/s) don't recompose the sheet.
- availableRoutes / bluetoothDevices / activeBluetoothName / devices
  derived lists were rebuilt inline on every recomposition. Wrapped
  each in remember(inputs). activeDevice was deliberately left inline
  because it captures stringResource (composable-only).

QueueBottomSheet.kt
- Hallazgo 3 reappeared in QueuePlaylistSongItem: six independent
  animateDpAsState / animateColorAsState / animateFloatAsState calls
  per visible queue item. Consolidated into a single updateTransition
  keyed on a QueueItemAnimState(isCurrentSong, isDragging,
  isSwipeTargeted) — same pattern that was applied to
  EnhancedSongListItem. dismissIconAlpha now derives from
  revealProgress × an animated factor, so revealProgress can be a
  plain float instead of needing its own animation.
- queue param signature: List<Song> -> ImmutableList<Song>. The
  caller in UnifiedPlayerOverlaysLayer already had it as
  ImmutableList; the downcast there was erasing stability info.

PlayerViewModel.kt + downstream composables
- currentSongArtists: StateFlow<List<Artist>> -> StateFlow<
  ImmutableList<Artist>>. FullPlayerSlice.currentSongArtists and
  FullPlayerSlicePart1.currentSongArtists migrated to match.
- FullPlayerSongMetadataSection, SongMetadataDisplaySection,
  PlayerSongInfo (all FullPlayerContent.kt) and
  PlayerArtistPickerBottomSheet now accept ImmutableList<Artist>.
- SongInfoBottomSheetViewModel.resolvedArtists also moved to
  ImmutableList<Artist> for the picker call site.

LyricsSheet.kt
- Seven separate context.dataStore.data.map{} subscriptions
  (alignment, translation, romanization, animated, blur enabled,
  blur strength, keep-screen-on) collapsed into a single mapped
  Flow<LyricsSheetPrefs> with distinctUntilChanged. New file-private
  LyricsSheetPrefs data class + Preferences.toLyricsSheetPrefs()
  helper. The architectural-violation note (these reads still bypass
  UserPreferencesRepository) is documented in a comment; the proper
  fix needs new repository flows and is a separate task.
- Removed a duplicate DisposableEffect that registered an identical
  second lifecycle observer for keep-screen-on (merge artifact).
- Four remember(state) { derivedStateOf { state.field } } wrappers
  on plain captured values (isLoadingLyrics, lyrics, isPlaying,
  currentSong) replaced with direct destructuring. derivedStateOf
  with no State<T> read inside is dead weight.

FullPlayerContent.kt
- Same derivedStateOf misuse on resolvedArtistId. Calculation reads
  only the artists parameter and captured artistId, no State<T>.
  Replaced with plain remember(artists, artistId).

DailyMixSection.kt
- DailyMixCard's headerSongs / visibleSongs were calling
  songs.take(n).toImmutableList() on every recomposition. Wrapped
  both in remember(songs).

Verification
- ./gradlew :app:testDebugUnitTest passes 302 tests, 0 failing.
- ./gradlew :app:assembleDebug succeeds.

What is NOT in this PR
- The List<Song> / List<Album> / List<Playlist> parameter migrations
  on PlaylistBottomSheet, PlaylistArtCollage, MultiSelectionBottomSheet,
  AlbumMultiSelectionOptionSheet, and PlaylistContainer were tried
  and reverted. Each has ~10 call sites across LibraryScreen,
  AlbumDetailScreen, ArtistDetailScreen, DailyMixScreen,
  GenreDetailScreen, etc. that all pass plain playlist.songs:
  List<Song>. Flipping the parameter requires either toImmutableList()
  boilerplate at every site (anti-pattern) or migrating
  Playlist.songs upstream to ImmutableList. The second migration is
  the proper fix and belongs to a separate task once the source data
  model is touched.
- The architectural violation in LyricsSheet (direct DataStore reads
  bypassing UserPreferencesRepository) is consolidated but not yet
  routed through the repository.
- The 13 collectAsStateWithLifecycle calls in CastBottomSheet were
  deliberately kept separate. Compose smart-skipping already
  invalidates only the slice that changed; consolidating them into a
  single combine slice was not an unambiguous win.
@lostf1sh

Copy link
Copy Markdown
Collaborator Author

@copilot review

Copilot AI commented May 19, 2026

Copy link
Copy Markdown
Contributor

@copilot review

Reviewed the latest branch state and CI for this PR. Current head commit e42a233 has passing Phone Debug, Wear OS Debug, and CodeQL workflows; no additional code changes are required from this review request.

theovilardo and others added 2 commits May 19, 2026 11:25
perf: critical Compose recomposition hotspot fixes
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<Song> → ImmutableList<Song>
- 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
lostf1sh and others added 8 commits May 20, 2026 00:27
`_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.
- 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.
- Ensure MediaRouter callback and route access run on the main thread
- Avoid an extra post when already on Main
fix(codebase) :  Security fixes, data layer changes, file decomposition
…ist-ai-keys-fk

# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/com/theveloper/pixelplay/data/telegram/TelegramClientManager.kt
#	gradle/libs.versions.toml
…ist-ai-keys-fk

# Conflicts:
#	app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt
@lostf1sh lostf1sh closed this May 28, 2026
@lostf1sh lostf1sh deleted the security/xxe-allowlist-ai-keys-fk branch May 28, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants