DO NOT MERGE - security: close XXE, Cast LAN exposure, AI key plaintext, song FK constraint#2048
Closed
lostf1sh wants to merge 14 commits into
Closed
DO NOT MERGE - security: close XXE, Cast LAN exposure, AI key plaintext, song FK constraint#2048lostf1sh wants to merge 14 commits into
lostf1sh wants to merge 14 commits into
Conversation
…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.
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.
Collaborator
Author
|
@copilot review |
Contributor
Reviewed the latest branch state and CI for this PR. Current head commit |
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
4 tasks
`_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.
Agent-Logs-Url: https://github.com/theovilardo/PixelPlayer/sessions/c690e76a-0876-4396-9ef9-0669670793f7 Co-authored-by: lostf1sh <136324426+lostf1sh@users.noreply.github.com>
- Ensure MediaRouter callback and route access run on the main thread - Avoid an extra post when already on Main
…imits Agent-Logs-Url: https://github.com/theovilardo/PixelPlayer/sessions/b226c60c-fcc1-4427-ab6e-4d12e6826110 Co-authored-by: lostf1sh <136324426+lostf1sh@users.noreply.github.com>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_rejectsTtmlWithDoctypewas failing on the Vintage engine PR #2047 added. The TTML hardening inTtmlLyricsParsercorrectly rejected the malicious<!DOCTYPE, but the LRC fallback path inLyricsImportSecuritythen re-parsed the raw XML text as plain lyrics, so the validator returnedValidfor a payload that includedfile:///etc/passwdas 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
<!DOCTYPEor<!ENTITYbefore 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 of0.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-65—enforceClientAddressAllowlist = trueunconditionally. Previously the field was set tocastAddressVariants.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 providedpins 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) usesEncryptedSharedPreferences. 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
@AppScopecoroutine, gated by a marker key in the encrypted store; clears the legacy DataStore keys after a successful transfer. The public Flow API is preserved (oneMutableStateFlowper provider, hydrated from the encrypted store at construction).backup_rules.xmlanddata_extraction_rules.xmlnow excludeai_prefs.xmland theai_prefs_plain.xmlfallback (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: Longwas INTEGER NOT NULL but its FK action wasON DELETE SET NULL. The first timedeleteOrphanedArtists()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, andMIGRATION_41_42(create-copy-drop-rename) to drop NOT NULL on the column and rebuild the indexes. DomainSong.artistIdstaysLongwith a0Lfallback in the entity→model mapping, keeping the blast radius minimal (SyncWorker.kt:323is the only other call site).Second push (commit a9610e2)
Crash log PII redaction (
CrashHandler.kt, newCrashLogRedactor.kt)CrashHandler.saveCrashLogpersistedthrowable.messageand the raw stack trace verbatim to SharedPreferences, andCrashReportDialogsurfaces them with a share intent. Network and media-stack exceptions routinely embed Bearer tokens, salted Subsonic auth params (t/s/p/salt), JellyfinX-Emby-Tokenheaders, Google API keys (?key=...), NetEaseMUSIC_Ucookies, and Telegram phone numbers — anything the user shared out leaked them.New
CrashLogRedactor(pure Kotlin, no Android deps) covers all those credential shapes.CrashHandlernow 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-keyheaders, sensitive query params (including the full Subsonic salted-token set),MUSIC_Ucookies, Telegram phone numbers, and mixed payloads.Manifest hardening (
AndroidManifest.xml)MEDIA_BUTTONintent-filter fromMusicService.PixelPlayMediaButtonReceiveralready declares the filter and forwards viaUtil.startForegroundService; the duplicate path onMusicServicewas racing with the receiver on headphone-button press.SCHEDULE_EXACT_ALARMis now scoped tomaxSdkVersion=32, andUSE_EXACT_ALARMis added for API 33+ (auto-granted with a Play Console justification — the sleep timer qualifies). The runtimecanScheduleExactAlarms()path inSleepTimerStateHolderandSetupScreencontinues to work unchanged.network_security_config.xmlwas considered but left untouched. Flipping<base-config cleartextTrafficPermitted=\"false\">would break self-hosted Navidrome / Jellyfin users on HTTP RFC1918 servers (CloudStreamSecurity.isPrivateIpv4Literalalready gates the app side, but RFC1918 ranges cannot be expressed as network-security-config XML wildcards).Release workflow tightening (
.github/workflows/*.yml)./gradlewso the pinned Gradle 9.5.1 from the wrapper is used everywhere. Previously CI ran whichevergradlewas preinstalled on the runner, which is a reproducibility hazard against the wrapper-pinned developer build.phone-release.ymldrops itspull_requesttrigger. 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. Onlypush:masterandworkflow_dispatchcan fire it now.phone-debug.ymlandwearos-apk.ymlkeep 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.BackupSectionTestTest asserted exactly 11 backup sections, but
AI_USAGE_LOGSwas added as the 12th (sinceVersion = 4). Updated the count, addedAI_USAGE_LOGSto thefromKeyround-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.validatePayloadTest
rejectsUnsyncedLrcContentexpected an.lrcfile containing only plain text to be rejected withINVALID_LYRICS_CONTENT, but the validator was returningValidbecauseLyricsUtils.parseLyricsemits plain-text Lyrics for any non-empty input. The LRC contract requires at least one synced line —validatePayloadnow skipsValidresults whereformat == LRCandparsedLyrics.syncedis empty/null, letting other normalization candidates (e.g. the TTML-to-enhanced-LRC fallback) still produce a synced result before falling through toINVALID_LYRICS_CONTENT.PlayerViewModelTest.triggerShuffleAllFromTileTest stubbed
_allSongsFlowwith three songs and expectedtriggerShuffleAllFromTileto forward them toprepareShuffledQueueSuspending, but the implementation always calledmusicRepository.getRandomSongs(500)which the test had stubbed to returnemptyList, so the action looped on the syncManager retry path and never reached the queue holder. Fixed by readinglibraryStateHolder.allSongs.valuefirst; on warm starts this skips an unnecessary DB round-trip, and the cold-start path (empty library snapshot → sync + repository sample) is unchanged.LyricsStateHolder.fetchLyricsForSongTest
fetchLyricsForSong_usesStoredLyricsWithoutRemoteFetchfailed withsearchUiStatestuck atLoadingeven afteradvanceUntilIdle. The cause:fetchLyricsForSongwrapped thegetStoredLyricscall inwithContext(Dispatchers.IO), which trapped the work on a real IO thread that theTestScopecannot drain. Removed the redundantwithContext—getStoredLyricsis a suspend function backed by theLyricsRepository→ Room DAO chain, which already executes on Room's IO executor.AudioMetaUtilsTestThe source file had merge artifacts: three
@Testmethods 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 thatAudioMetaUtils.mimeTypeToFormatactually implements.CI wiring
phone-debug.ymlnow runs./gradlew :app:testDebugUnitTestbeforeassembleDebug. 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:
LyricsImportSecurityTest.rejectsTtmlWithDoctype✓,CastSessionSecurityTest(full suite, including new case) ✓,kspDebugKotlinschema validation produces42.jsoncleanly ✓,:app:compileDebugKotlin✓.CrashLogRedactorTest(14 cases) ✓; same run executed:app:processDebugMainManifestso AGP has validated the manifest changes../gradlew :app:testDebugUnitTestreports 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
LyricsImportSecuritylayer rather than just trustingTtmlLyricsParserbecause the parser is one of two normalization candidates — the LRC fallback would still see the raw bytes. Defense in depth.selectIpAddress()on connectivity changes).@AppScope. If it fails (Keystore unavailable on first run), theMIGRATION_DONE_KEYis not set and the next launch retries. Until the marker is set, both stores may contain the key — the encrypted one wins for reads viaapiKeyFlows.MIGRATION_41_42follows the same create-copy-drop-rename pattern already used byrecreateSongsTable. The migration test (PixelPlayDatabaseMigrationTest) is an instrumentation test and isn't wired to includeMIGRATION_40_41orMIGRATION_41_42inALL_MIGRATIONS; that's a pre-existing gap and not part of this PR.CrashLogRedactordeliberately 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.phone-release.ymlis 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.triggerShuffleAllFromTilechange 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.LyricsStateHolderwithContextremoval is safe:MusicRepository.getStoredLyricsis a suspend function and its concrete impl delegates toLyricsRepository.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 forevrc,qcelp, andima/x-ima-adpcmmappings thatAudioMetaUtils.mimeTypeToFormatdoes not currently implement. Those assertions are dropped from the new file. If they should be implemented (Samsung-specific MIME extensions), file a follow-up.