Skip to content

Commit 6770598

Browse files
committed
Fix critical/high/medium code-review findings
Resolve 93 of 94 verified critical/high/medium findings from the multi-agent code review. Every finding was re-verified against the current code before being fixed. Database & data integrity: - Add MIGRATION_45_46 (schema v46): album_art_themes gets a composite primary key (albumArtUriString, paletteStyle) so each palette style is cached as its own row instead of overwriting one row. Migration output verified to match the generated v46 schema. - Clear playlist_songs when a playlist is deleted (deletePlaylistWithSongs) instead of orphaning rows. - Guard deleteOrphanedArtists against artists still referenced by the NOT NULL songs.artist_id (its FK is SET_NULL), avoiding a transaction abort. - Dedupe the per-playlist default transition rule in the DAO (the unique index treats NULL track ids as distinct), no schema change required. Concurrency & reliability: - Drop main-thread runBlocking Coil artwork fetches in MediaItemBuilder (suspend conversion across MediaItemBuilder / TrustedMediaItemsResolution / MusicService). - Make StateHolder scope release identity-safe so a sibling ViewModel's onCleared cannot null a still-alive PlayerViewModel's scope. - Run long FULL/REBUILD library syncs as a dataSync foreground service so they are not killed in the background. - Jellyfin library re-sync is now gated on a 24h staleness window instead of running on every dashboard/home open. Security & UX: - Surface a warning when Navidrome credentials fall back to unencrypted storage. - Add the missing package declaration to DelimiterConfigScreen. - Move hardcoded user-facing strings to resources; model the Jellyfin sync banner as typed state; one-shot save event for transition settings; remember album color-scheme flows; remove a dead duplicate enum. Verified: :app:compileDebugKotlin, :app:testDebugUnitTest and :app:lintDebug all pass. Deferred: F99 (cloud song/album/artist IDs derived from 32-bit String.hashCode) is not auto-applied. Re-deriving IDs requires a favorites/playlist reconciliation migration and on-device testing; applying it blindly would risk silently orphaning cloud favorites and playlists.
1 parent 804a79c commit 6770598

85 files changed

Lines changed: 3680 additions & 748 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/schemas/com.lostf1sh.pixelplayeross.data.database.PixelPlayerDatabase/46.json

Lines changed: 1999 additions & 0 deletions
Large diffs are not rendered by default.

app/src/main/AndroidManifest.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
1818
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
1919
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
20+
<!-- Long full library rescans run as a dataSync foreground service so they are not killed (F138). -->
21+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
2022
<uses-permission
2123
android:name="android.permission.WRITE_SETTINGS"
2224
tools:ignore="ProtectedPermissions" />
@@ -122,6 +124,12 @@
122124
</intent-filter>
123125
</service>
124126

127+
<!-- Allow WorkManager's foreground service to run as dataSync for long library syncs (F138). -->
128+
<service
129+
android:name="androidx.work.impl.foreground.SystemForegroundService"
130+
android:foregroundServiceType="dataSync"
131+
tools:node="merge" />
132+
125133
<receiver
126134
android:name=".ui.glancewidget.PixelPlayerGlanceWidgetReceiver"
127135
android:exported="false">

app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration.
6464
// ADD THE COMPANION OBJECT
6565
companion object {
6666
const val NOTIFICATION_CHANNEL_ID = "pixelplayer_music_channel"
67+
// Low-importance channel for the background library-sync foreground notification (F138).
68+
const val SYNC_NOTIFICATION_CHANNEL_ID = "pixelplayer_sync_channel"
6769
}
6870

6971
private val appLifecycleObserver = object : DefaultLifecycleObserver {
@@ -94,8 +96,14 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration.
9496
"PixelPlayerOSS Music Playback",
9597
NotificationManager.IMPORTANCE_LOW
9698
)
99+
val syncChannel = NotificationChannel(
100+
SYNC_NOTIFICATION_CHANNEL_ID,
101+
getString(R.string.sync_notification_channel_name),
102+
NotificationManager.IMPORTANCE_LOW
103+
)
97104
val notificationManager = getSystemService(NotificationManager::class.java)
98105
notificationManager.createNotificationChannel(channel)
106+
notificationManager.createNotificationChannel(syncChannel)
99107
}
100108

101109
ProcessLifecycleOwner.get().lifecycle.addObserver(appLifecycleObserver)

app/src/main/java/com/lostf1sh/pixelplayeross/data/database/AlbumArtThemeEntity.kt

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ package com.lostf1sh.pixelplayeross.data.database
22

33
import androidx.room.Embedded
44
import androidx.room.Entity
5-
import androidx.room.Index
6-
import androidx.room.PrimaryKey
75

86
// For simplicity, we store colors as hexadecimal Strings.
97
// Stores the color values for ONE scheme (either light or dark)
@@ -60,12 +58,12 @@ data class StoredColorSchemeValues(
6058

6159
@Entity(
6260
tableName = "album_art_themes",
63-
indices = [
64-
Index(value = ["albumArtUriString", "paletteStyle"])
65-
]
61+
// Composite primary key so each (album art, palette style) variant is cached as its own row.
62+
// Previously the PK was albumArtUriString alone, which collapsed all styles onto one row (F71).
63+
primaryKeys = ["albumArtUriString", "paletteStyle"]
6664
)
6765
data class AlbumArtThemeEntity(
68-
@PrimaryKey val albumArtUriString: String,
66+
val albumArtUriString: String,
6967
val paletteStyle: String,
7068
@Embedded(prefix = "light_") val lightThemeValues: StoredColorSchemeValues,
7169
@Embedded(prefix = "dark_") val darkThemeValues: StoredColorSchemeValues

app/src/main/java/com/lostf1sh/pixelplayeross/data/database/LocalPlaylistDao.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ interface LocalPlaylistDao {
3636
@Query("DELETE FROM playlists WHERE id = :playlistId")
3737
suspend fun deletePlaylist(playlistId: String)
3838

39+
// playlist_songs has no FK/cascade, so deleting a playlist must also clear its song rows in the
40+
// same transaction or they are orphaned forever (F73).
41+
@Transaction
42+
suspend fun deletePlaylistWithSongs(playlistId: String) {
43+
clearPlaylistSongs(playlistId)
44+
deletePlaylist(playlistId)
45+
}
46+
3947
@Insert(onConflict = OnConflictStrategy.REPLACE)
4048
suspend fun upsertPlaylistSongs(entities: List<PlaylistSongEntity>)
4149

app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1497,7 +1497,10 @@ interface MusicDao {
14971497
@Query("DELETE FROM albums WHERE NOT EXISTS (SELECT 1 FROM songs WHERE songs.album_id = albums.id)")
14981498
suspend fun deleteOrphanedAlbums()
14991499

1500-
@Query("DELETE FROM artists WHERE NOT EXISTS (SELECT 1 FROM song_artist_cross_ref WHERE song_artist_cross_ref.artist_id = artists.id)")
1500+
// Guard against deleting an artist still referenced by songs.artist_id: that column is NOT NULL but its
1501+
// foreign key is declared ON DELETE SET NULL, so deleting a referenced artist would abort the transaction
1502+
// with a NOT NULL violation. Excluding still-referenced artists keeps cleanup safe (F72).
1503+
@Query("DELETE FROM artists WHERE NOT EXISTS (SELECT 1 FROM song_artist_cross_ref WHERE song_artist_cross_ref.artist_id = artists.id) AND NOT EXISTS (SELECT 1 FROM songs WHERE songs.artist_id = artists.id)")
15011504
suspend fun deleteOrphanedArtists()
15021505

15031506
// --- Favorite Operations ---

app/src/main/java/com/lostf1sh/pixelplayeross/data/database/PixelPlayerDatabase.kt

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
2525
JellyfinSongEntity::class,
2626
JellyfinPlaylistEntity::class
2727
],
28-
version = 45,
28+
version = 46,
2929
exportSchema = true
3030
)
3131
abstract class PixelPlayerDatabase : RoomDatabase() {
@@ -555,6 +555,52 @@ abstract class PixelPlayerDatabase : RoomDatabase() {
555555
}
556556
}
557557

558+
val MIGRATION_45_46 = object : Migration(45, 46) {
559+
override fun migrate(db: SupportSQLiteDatabase) {
560+
// album_art_themes' primary key changes from (albumArtUriString) to
561+
// (albumArtUriString, paletteStyle) so every palette-style variant is cached as its
562+
// own row instead of overwriting the single row for that art (F71). SQLite cannot
563+
// ALTER a primary key, and the table is a regenerable palette cache, so drop and
564+
// recreate it with the new schema; entries are recomputed on demand.
565+
recreateAlbumArtThemesTableWithCompositeKey(db)
566+
}
567+
}
568+
569+
private fun recreateAlbumArtThemesTableWithCompositeKey(db: SupportSQLiteDatabase) {
570+
db.execSQL("DROP TABLE IF EXISTS album_art_themes")
571+
572+
val colorColumns = listOf(
573+
"primary", "onPrimary", "primaryContainer", "onPrimaryContainer",
574+
"secondary", "onSecondary", "secondaryContainer", "onSecondaryContainer",
575+
"tertiary", "onTertiary", "tertiaryContainer", "onTertiaryContainer",
576+
"background", "onBackground", "surface", "onSurface",
577+
"surfaceVariant", "onSurfaceVariant", "error", "onError",
578+
"outline", "errorContainer", "onErrorContainer",
579+
"inversePrimary", "inverseSurface", "inverseOnSurface",
580+
"surfaceTint", "outlineVariant", "scrim",
581+
"surfaceBright", "surfaceDim",
582+
"surfaceContainer", "surfaceContainerHigh", "surfaceContainerHighest", "surfaceContainerLow", "surfaceContainerLowest",
583+
"primaryFixed", "primaryFixedDim", "onPrimaryFixed", "onPrimaryFixedVariant",
584+
"secondaryFixed", "secondaryFixedDim", "onSecondaryFixed", "onSecondaryFixedVariant",
585+
"tertiaryFixed", "tertiaryFixedDim", "onTertiaryFixed", "onTertiaryFixedVariant"
586+
)
587+
588+
val themePrefixes = listOf("light_", "dark_")
589+
val columnDefinitions = StringBuilder()
590+
columnDefinitions.append("albumArtUriString TEXT NOT NULL, ")
591+
columnDefinitions.append("paletteStyle TEXT NOT NULL, ")
592+
themePrefixes.forEach { prefix ->
593+
colorColumns.forEach { column ->
594+
columnDefinitions.append("${prefix}${column} TEXT NOT NULL, ")
595+
}
596+
}
597+
val columnsSql = columnDefinitions.toString().trimEnd(',', ' ')
598+
599+
db.execSQL(
600+
"CREATE TABLE IF NOT EXISTS album_art_themes ($columnsSql, PRIMARY KEY(albumArtUriString, paletteStyle))"
601+
)
602+
}
603+
558604
private fun ensureSongsTableHasDateAdded(db: SupportSQLiteDatabase) {
559605
if (!tableExists(db, "songs")) {
560606
recreateSongsTable(db)

app/src/main/java/com/lostf1sh/pixelplayeross/data/database/TransitionDao.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,22 @@ interface TransitionDao {
2121
@Upsert
2222
suspend fun setRules(rules: List<TransitionRuleEntity>)
2323

24+
/**
25+
* Upserts a single rule, deduping the per-playlist default rule (both track ids null) by hand.
26+
* The unique index on (playlistId, fromTrackId, toTrackId) treats SQL NULLs as distinct, so a plain
27+
* @Upsert keeps inserting duplicate default rules instead of replacing the existing one. Deleting any
28+
* existing default first also cleans up duplicates left by the previous behavior (F76).
29+
*/
30+
@Transaction
31+
suspend fun upsertRule(rule: TransitionRuleEntity) {
32+
if (rule.fromTrackId == null && rule.toTrackId == null) {
33+
deletePlaylistDefaultRule(rule.playlistId)
34+
setRule(rule.copy(id = 0))
35+
} else {
36+
setRule(rule)
37+
}
38+
}
39+
2440
/**
2541
* Gets the default transition rule for a given playlist.
2642
* A default rule is one where fromTrackId and toTrackId are both null.

app/src/main/java/com/lostf1sh/pixelplayeross/data/jellyfin/JellyfinRepository.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ class JellyfinRepository @Inject constructor(
5454
private const val KEY_USERNAME = "username"
5555
private const val KEY_ACCESS_TOKEN = "access_token"
5656
private const val KEY_USER_ID = "user_id"
57+
private const val KEY_LAST_FULL_SYNC = "last_full_sync"
58+
private const val SYNC_THRESHOLD_MS = 24 * 60 * 60 * 1000L // 24 hours
5759

5860
private const val JELLYFIN_SONG_ID_OFFSET = 12_000_000_000_000L
5961
private const val JELLYFIN_ALBUM_ID_OFFSET = 13_000_000_000_000L
@@ -83,6 +85,17 @@ class JellyfinRepository @Inject constructor(
8385
private val _isLoggedInFlow = MutableStateFlow(false)
8486
val isLoggedInFlow: StateFlow<Boolean> = _isLoggedInFlow.asStateFlow()
8587

88+
/** Wall-clock millis of the last successful full library+playlist sync (0 if never). */
89+
private var lastFullSyncTime: Long
90+
get() = prefs.getLong(KEY_LAST_FULL_SYNC, 0L)
91+
set(value) {
92+
prefs.edit().putLong(KEY_LAST_FULL_SYNC, value).apply()
93+
}
94+
95+
/** True when the cached library has not been fully synced within the staleness threshold (F111). */
96+
fun isLibrarySyncStale(): Boolean =
97+
System.currentTimeMillis() - lastFullSyncTime > SYNC_THRESHOLD_MS
98+
8699
init {
87100
initFromSavedCredentials()
88101
}
@@ -381,6 +394,9 @@ class JellyfinRepository @Inject constructor(
381394
Timber.e(e, "$TAG: Failed to sync unified library")
382395
}
383396

397+
// Mark a successful full sync so the dashboard/home don't re-sync on every open (F111).
398+
lastFullSyncTime = System.currentTimeMillis()
399+
384400
Result.success(
385401
BulkSyncResult(
386402
playlistCount = playlistResult.size,

app/src/main/java/com/lostf1sh/pixelplayeross/data/media/SongMetadataEditor.kt

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -529,12 +529,16 @@ class SongMetadataEditor(
529529
Timber.tag(TAG).e("METADATA_EDIT: Writer failed on temp file ${tempFile.absolutePath}")
530530
return false
531531
}
532-
// Stream edited bytes back to the original path (truncate + overwrite).
533-
tempFile.inputStream().use { input ->
534-
FileOutputStream(originalFile, false).use { out -> input.copyTo(out) }
532+
// Durably stage the edited bytes next to the original, then atomically swap
533+
// them in. The original is never truncated until the new bytes are on disk,
534+
// so an interrupted/failed copy-back leaves the original intact.
535+
val replaceOk = safelyReplaceFileContents(originalFile, tempFile)
536+
if (!replaceOk) {
537+
Timber.tag(TAG).e("METADATA_EDIT: Failed to atomically replace $originalPath")
538+
return false
535539
}
536540
Timber.tag(TAG).d(
537-
"METADATA_EDIT: Restored ${tempFile.length()} edited bytes back to $originalPath"
541+
"METADATA_EDIT: Restored ${originalFile.length()} edited bytes back to $originalPath"
538542
)
539543
true
540544
} catch (e: Exception) {
@@ -547,6 +551,76 @@ class SongMetadataEditor(
547551
}
548552
}
549553

554+
/**
555+
* Durably replaces [target]'s contents with the bytes from [source] without ever leaving the
556+
* target truncated or empty on failure.
557+
*
558+
* Strategy: copy [source]'s bytes into a sibling staging file in the SAME directory as [target]
559+
* (so an atomic rename can stay on one filesystem), fsync the staging file, then atomically
560+
* rename it over [target]. The original is never opened for truncation; if any step throws the
561+
* original is left intact and the staging file is cleaned up, so an interrupted/failed write
562+
* (disk full, transient I/O, process kill) cannot corrupt or zero out the user's media file.
563+
*
564+
* Falls back to a guarded copy-into-place only if a same-directory staging file cannot be created
565+
* (e.g. read-only parent), and even then the original is overwritten only after the staged bytes
566+
* are durably written.
567+
*/
568+
private fun safelyReplaceFileContents(target: File, source: File): Boolean {
569+
val parent = target.absoluteFile.parentFile
570+
val staging = if (parent != null) {
571+
File(parent, ".${target.name}.metaedit_${System.nanoTime()}.tmp")
572+
} else {
573+
null
574+
}
575+
576+
if (staging != null) {
577+
return try {
578+
source.inputStream().use { input ->
579+
FileOutputStream(staging).use { out ->
580+
input.copyTo(out)
581+
out.fd.sync()
582+
}
583+
}
584+
val renamed = staging.renameTo(target)
585+
if (renamed) {
586+
true
587+
} else {
588+
// renameTo can fail across odd filesystems; fall back to a verified copy-in-place
589+
// using the already-durable staging file as the source of truth.
590+
Timber.tag(TAG).w(
591+
"METADATA_EDIT: Atomic rename to ${target.absolutePath} failed; falling back to copy-in-place"
592+
)
593+
copyIntoPlaceDurably(target, staging)
594+
}
595+
} catch (e: Exception) {
596+
Timber.tag(TAG).e(e, "METADATA_EDIT: Safe replace failed for ${target.absolutePath}")
597+
false
598+
} finally {
599+
if (staging.exists() && !staging.delete()) {
600+
Timber.tag(TAG).w("METADATA_EDIT: Could not delete staging file ${staging.absolutePath}")
601+
}
602+
}
603+
}
604+
605+
// No writable parent for staging: copy directly but still fsync so bytes are durable.
606+
return copyIntoPlaceDurably(target, source)
607+
}
608+
609+
private fun copyIntoPlaceDurably(target: File, source: File): Boolean {
610+
return try {
611+
source.inputStream().use { input ->
612+
FileOutputStream(target, false).use { output ->
613+
input.copyTo(output)
614+
output.fd.sync()
615+
}
616+
}
617+
true
618+
} catch (e: Exception) {
619+
Timber.tag(TAG).e(e, "METADATA_EDIT: Copy-in-place failed for ${target.absolutePath}")
620+
false
621+
}
622+
}
623+
550624
/**
551625
* FLAC files with high sample rates (>96kHz) or bit depths (>24bit) can cause issues with TagLib.
552626
* This function detects such files and logs warnings.
@@ -1010,12 +1084,13 @@ class SongMetadataEditor(
10101084
}
10111085
Timber.tag(TAG)
10121086
.e("VORBISJAVA: Temp file size: ${tempFile.length()} bytes, original: ${audioFile.length()} bytes")
1013-
1014-
tempFile.inputStream().use { input ->
1015-
FileOutputStream(audioFile, false).use { output ->
1016-
input.copyTo(output)
1017-
output.fd.sync()
1018-
}
1087+
1088+
// Atomically swap the edited bytes in via a same-directory staging file + fsync + rename.
1089+
// The original is never truncated before the new bytes are durable, so an interrupted
1090+
// copy-back cannot leave the .opus file empty/corrupted.
1091+
if (!safelyReplaceFileContents(audioFile, tempFile)) {
1092+
Timber.tag(TAG).e("VORBISJAVA: Failed to atomically replace ${audioFile.path}")
1093+
return false
10191094
}
10201095

10211096
Timber.tag(TAG).e("VORBISJAVA: SUCCESS - Updated file metadata: ${audioFile.path}")

0 commit comments

Comments
 (0)