diff --git a/.github/workflows/android_ci.yml b/.github/workflows/android_ci.yml index 089b451aeb..76dcf8897e 100644 --- a/.github/workflows/android_ci.yml +++ b/.github/workflows/android_ci.yml @@ -216,7 +216,8 @@ jobs: -no-window -gpu ${{ matrix.emulator_gpu }} -no-snapshot -noaudio -no-boot-anim # 保留 Launcher 组件,用设置页承接测试前的前台焦点 # 首次沉浸模式提示会抢走原生输入,测试 AVD 预先标记已确认 - # 隐藏错误弹窗不会关闭启动时已有的 ANR,对 Launcher 停止进程以清理遗留窗口 + # 隐藏错误弹窗不会关闭已有 ANR,停止启动器和 SDK Setup 以清理无关窗口 + # 歌词页使用固定离线夹具,避免虚拟 Wi-Fi 失联反复移动迷你播放器 script: >- { ps -eLo pid,tid,comm > build/reports/emulator-host/before-tests-threads.txt || true; } && adb shell settings put global hide_error_dialogs 1 && @@ -224,6 +225,9 @@ jobs: adb shell settings --user 0 put secure immersive_mode_confirmations confirmed && adb shell am start -W -a android.settings.SETTINGS && adb shell am force-stop --user 0 com.google.android.apps.nexuslauncher && + adb shell am force-stop --user 0 com.google.android.googlesdksetup && + { [ "${{ matrix.test_group }}" != "External Lyrics host" ] || + { adb shell cmd connectivity airplane-mode enable && adb shell svc wifi disable; }; } && { [ -z "${{ matrix.module_tests }}" ] || ./gradlew ${{ matrix.module_tests }} -Pandroid.testInstrumentationRunnerArguments.notAnnotation=moe.ouom.neriplayer.testing.ManualInstrumentedTest --no-daemon -Pkotlin.compiler.execution.strategy=in-process --warning-mode all --stacktrace; } && diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/core/download/execution/LocalSongDownloadAdmissionTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/core/download/execution/LocalSongDownloadAdmissionTest.kt new file mode 100644 index 0000000000..32f0505367 --- /dev/null +++ b/app/src/androidTest/java/moe/ouom/neriplayer/core/download/execution/LocalSongDownloadAdmissionTest.kt @@ -0,0 +1,56 @@ +package moe.ouom.neriplayer.core.download.execution + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.runBlocking +import moe.ouom.neriplayer.core.download.GlobalDownloadManager +import moe.ouom.neriplayer.core.download.manager.batch.startBatchDownload +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LocalSongDownloadAdmissionTest { + private val context get() = ApplicationProvider.getApplicationContext() + + @Test + fun allLocalSourcesAreRejectedBeforeCreatingBatchStartup() = runBlocking { + val localSongs = listOf( + song(1).copy(mediaUri = "content://test.documents/document/local"), + song(2).copy(localFilePath = "/synthetic/local.flac") + ) + val beforePresentationId = GlobalDownloadManager.batchDownloadPresentationIdGenerator.get() + val startupJob = GlobalDownloadManager.startBatchDownload( + context = context, + songs = localSongs, + skipTrafficRiskPrompt = true, + // 让旧实现即使创建了启动协程也不能写入下载队列 + requestedAdmissionTicket = Long.MIN_VALUE, + awaitAdmissionWhenUnavailable = false + ) + try { + assertNull("local sources must not create a download startup job", startupJob) + assertEquals( + "local sources must not allocate a batch presentation", + beforePresentationId, + GlobalDownloadManager.batchDownloadPresentationIdGenerator.get() + ) + } finally { + startupJob?.cancelAndJoin() + } + } + + private fun song(id: Long) = SongItem( + id = id, + name = "local-song-$id", + artist = "synthetic artist", + album = "user edited album", + albumId = 42L, + durationMs = 180_000L, + coverUrl = null + ) +} diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/core/startup/legacy/LegacyDownloadUpgradeRecoveryTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/core/startup/legacy/LegacyDownloadUpgradeRecoveryTest.kt index 5c782b1bbe..041486b882 100644 --- a/app/src/androidTest/java/moe/ouom/neriplayer/core/startup/legacy/LegacyDownloadUpgradeRecoveryTest.kt +++ b/app/src/androidTest/java/moe/ouom/neriplayer/core/startup/legacy/LegacyDownloadUpgradeRecoveryTest.kt @@ -727,7 +727,8 @@ class LegacyDownloadUpgradeRecoveryTest { NeriUserDataDatabase.MIGRATION_16_17, NeriUserDataDatabase.MIGRATION_17_18, NeriUserDataDatabase.MIGRATION_18_19, - NeriUserDataDatabase.MIGRATION_19_20 + NeriUserDataDatabase.MIGRATION_19_20, + NeriUserDataDatabase.MIGRATION_20_21 ).close() return Room.databaseBuilder( fixture.baseContext, diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportStandaloneFlacTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportStandaloneFlacTest.kt index ca0a27261c..4a8c5e931e 100644 --- a/app/src/androidTest/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportStandaloneFlacTest.kt +++ b/app/src/androidTest/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportStandaloneFlacTest.kt @@ -2,6 +2,7 @@ package moe.ouom.neriplayer.data.local.audioimport import moe.ouom.neriplayer.data.model.local.LocalAudioImportResult +import android.media.MediaPlayer import android.net.Uri import android.os.ParcelFileDescriptor import android.provider.DocumentsContract @@ -17,7 +18,11 @@ import moe.ouom.neriplayer.core.download.ManagedDownloadMigrationTestDocumentPro import moe.ouom.neriplayer.core.download.ManagedDownloadStorage import moe.ouom.neriplayer.core.player.download.AudioDownloadManager import moe.ouom.neriplayer.core.player.download.playback.LocalPlaybackReferenceResolution +import moe.ouom.neriplayer.data.identity.sameIdentityAs import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.playlist.LocalPlaylistRepository +import moe.ouom.neriplayer.data.local.playlist.system.LocalFilesPlaylist import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals @@ -26,6 +31,7 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Assume.assumeTrue import org.junit.Test import org.junit.runner.RunWith @@ -192,6 +198,208 @@ class LocalAudioImportStandaloneFlacTest { } } + @Test + fun scannedSafSongSurvivesPlaylistPersistenceAndDecodes() = runBlocking { + AudioDownloadManager.initialize(context) + val sourceUri = createAudio(fixtures.first()) + ManagedDownloadStorage.updateCustomDirectoryUri(targetTreeUri.toString()) + val result = LocalAudioImportManager.scanFolderSongs(context, treeUri) + assertTrue(result.completed) + assertEquals(0, result.failedCount) + val scannedSong = result.songs.single() + assertSameDocument(sourceUri, Uri.parse(requireNotNull(scannedSong.mediaUri))) + + val repository = LocalPlaylistRepository.getInstance(context) + assertTrue(repository.awaitInitialized()) + var ownedPlaylistId: Long? = null + try { + val playlist = repository.createPlaylistWithScannedSongs( + name = "SAF playback regression ${UUID.randomUUID()}", + songs = listOf(scannedSong) + ) + ownedPlaylistId = playlist.id + val restoredSong = requireNotNull(repository.readFastPlaylist(playlist.id)).songs.single() + assertEquals(scannedSong.id, restoredSong.id) + assertEquals(scannedSong.mediaUri, restoredSong.mediaUri) + assertEquals(scannedSong.channelId, restoredSong.channelId) + val resolution = AudioDownloadManager.resolvePermittedLocalPlayback( + context, restoredSong, restoredSong.mediaUri + ) + assertEquals(LocalPlaybackReferenceResolution.Playable(requireNotNull(scannedSong.mediaUri)), resolution) + + val player = MediaPlayer() + try { + player.setDataSource(context, Uri.parse((resolution as LocalPlaybackReferenceResolution.Playable).reference)) + player.prepare() + assertTrue("the persisted SAF audio must decode", player.duration > 0) + } finally { + player.release() + } + } finally { + ownedPlaylistId?.let { assertTrue("remove only the test-owned playlist", repository.deletePlaylist(it)) } + } + } + + @Test + fun ordinaryPlaylistKeepsScannedRemoteIdentitySourceAndDecodes() = runBlocking { + AudioDownloadManager.initialize(context) + val fixture = fixtures.first().copy(fileName = "ordinary-scan-${UUID.randomUUID()}.flac") + val sourceUri = createAudio(fixture) + val metadataUri = createDocument("${fixture.fileName}.npmeta.json", "application/json") + val metadata = JSONObject().apply { + put("songId", 859470L) + put("channelId", "netease") + put("audioId", "859470") + put("stableKey", "859470|netease|") + put("name", fixture.title) + put("artist", fixture.artist) + put("album", fixture.album) + put("downloadFinalized", true) + } + requireNotNull(context.contentResolver.openOutputStream(metadataUri)).use { + it.write(metadata.toString().toByteArray(Charsets.UTF_8)) + } + ManagedDownloadStorage.updateCustomDirectoryUri(targetTreeUri.toString()) + val scan = LocalAudioImportManager.scanFolderSongs(context, treeUri) + assertTrue(scan.completed) + assertEquals(0, scan.failedCount) + val scanned = scan.songs.single() + val selectedReference = requireNotNull(scanned.mediaUri) + assertSameDocument(sourceUri, Uri.parse(selectedReference)) + assertEquals("netease", scanned.channelId) + assertEquals("859470", scanned.audioId) + + val repository = LocalPlaylistRepository.getInstance(context) + assertTrue(repository.awaitInitialized()) + val manualSourcesBefore = LocalFilesPlaylist.firstOrNull(repository.playlists.value)?.songs?.toList() + var ownedPlaylistId: Long? = null + try { + val playlist = repository.createPlaylistWithScannedSongs( + "Ordinary SAF regression ${UUID.randomUUID()}", listOf(scanned) + ) + ownedPlaylistId = playlist.id + val restored = requireNotNull(repository.readFastPlaylist(playlist.id)).songs.single() + assertEquals(selectedReference, restored.mediaUri) + assertEquals(selectedReference, restored.explicitLocalSourceReference) + assertEquals("859470|netease|", restored.sourceStableKey) + assertEquals(true, LocalPlaylistRepository.peekExplicitLocalFileReference(selectedReference)) + assertEquals(manualSourcesBefore, LocalFilesPlaylist.firstOrNull(repository.playlists.value)?.songs) + + val resolution = AudioDownloadManager.resolvePermittedLocalPlayback( + context, restored, restored.mediaUri + ) + assertEquals(LocalPlaybackReferenceResolution.Playable(selectedReference), resolution) + val player = MediaPlayer() + try { + player.setDataSource(context, Uri.parse((resolution as LocalPlaybackReferenceResolution.Playable).reference)) + player.prepare() + assertTrue("the ordinary playlist must decode its scanned SAF source", player.duration > 0) + } finally { + player.release() + } + } finally { + ownedPlaylistId?.let { assertTrue(repository.deletePlaylist(it)) } + } + } + + @Test + fun manuallyScannedRemoteIdentitySidecarKeepsItsSafSourceAfterPersistence() = runBlocking { + AudioDownloadManager.initialize(context) + val fixtureId = UUID.randomUUID().toString() + val fixture = fixtures.first().copy( + fileName = "explicit-sidecar-$fixtureId.flac", + title = "Explicit SAF $fixtureId" + ) + val sourceUri = createAudio(fixture) + val metadataUri = createDocument("${fixture.fileName}.npmeta.json", "application/json") + val metadata = JSONObject().apply { + put("songId", 859470L) + put("channelId", "netease") + put("audioId", "859470") + put("stableKey", "859470|netease|") + put("identityAlbum", "netease") + put("name", fixture.title) + put("artist", fixture.artist) + put("album", fixture.album) + put("mediaUri", sourceUri.toString()) + put("downloadFinalized", true) + } + requireNotNull(context.contentResolver.openOutputStream(metadataUri)).use { output -> + output.write(metadata.toString().toByteArray(Charsets.UTF_8)) + } + ManagedDownloadStorage.updateCustomDirectoryUri(targetTreeUri.toString()) + + val result = LocalAudioImportManager.scanFolderSongs(context, treeUri) + assertTrue(result.completed) + assertEquals(0, result.failedCount) + val scannedSong = result.songs.single() + val scannedReference = requireNotNull(scannedSong.mediaUri) + assertSameDocument(sourceUri, Uri.parse(scannedReference)) + assertEquals(859470L, scannedSong.id) + assertEquals("netease", scannedSong.channelId) + assertEquals("859470", scannedSong.audioId) + assertEquals("859470|netease|", scannedSong.sourceStableKey) + assertTrue(ManagedDownloadStorage.isLikelyManagedDownloadSongFast(context, scannedSong)) + + val repository = LocalPlaylistRepository.getInstance(context) + assertTrue(repository.awaitInitialized()) + val previousSongs = requireNotNull(LocalFilesPlaylist.firstOrNull(repository.playlists.value)).songs.toList() + assumeTrue( + "the fixed remote identity must not merge into an existing manual song", + previousSongs.none { existing -> + existing.sameIdentityAs(scannedSong) || LocalSongSupport.hasSameLocalSource( + existing, scannedSong, includeMetadataFallback = true + ) + } + ) + try { + val added = repository.addScannedSongsToLocalFilesPlaylistWithResult(listOf(scannedSong)) + assertEquals(1, added.addedCount) + assertEquals(scannedReference, added.addedSongs.single().mediaUri) + val restored = requireNotNull(repository.readFastPlaylist(LocalFilesPlaylist.SYSTEM_ID)) + val restoredSong = restored.songs.single { it.mediaUri == scannedReference } + assertEquals(previousSongs, restored.songs.filterNot { it.mediaUri == scannedReference }) + assertEquals(859470L, restoredSong.id) + assertEquals("netease", restoredSong.channelId) + assertEquals("859470", restoredSong.audioId) + assertEquals("859470|netease|", restoredSong.sourceStableKey) + assertEquals(true, LocalPlaylistRepository.peekExplicitLocalFileReference(scannedReference)) + assertEquals(targetTreeUri.toString(), ManagedDownloadStorage.configuredDirectoryUri()) + + val resolution = AudioDownloadManager.resolvePermittedLocalPlayback( + context, restoredSong, restoredSong.mediaUri + ) + assertEquals(LocalPlaybackReferenceResolution.Playable(scannedReference), resolution) + val player = MediaPlayer() + try { + val playable = resolution as LocalPlaybackReferenceResolution.Playable + player.setDataSource(context, Uri.parse(playable.reference)) + player.prepare() + assertTrue("the manually persisted SAF audio must decode", player.duration > 0) + } finally { + player.release() + } + } finally { + val currentSongs = requireNotNull(LocalFilesPlaylist.firstOrNull(repository.playlists.value)).songs + val ownedSongs = currentSongs.filter { it.mediaUri == scannedReference } + assertTrue( + "cleanup must not select any pre-existing manual song", + currentSongs.none { member -> + member.mediaUri != scannedReference && ownedSongs.any(member::sameIdentityAs) + } + ) + val removed = repository.removeSongsFromPlaylistByIdentityWithResult( + LocalFilesPlaylist.SYSTEM_ID, ownedSongs + ) + assertEquals(ownedSongs, removed.map { it.song }) + assertEquals( + "pre-existing manual songs must survive fixture cleanup", + previousSongs, + requireNotNull(LocalFilesPlaylist.firstOrNull(repository.playlists.value)).songs + ) + } + } + @Test fun externalImportKeepsItsLocalCopyWhenRemoteDownloadIsCached() = runBlocking { AudioDownloadManager.initialize(context) diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/data/local/database/NeriUserDataDatabaseMigrationTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/data/local/database/NeriUserDataDatabaseMigrationTest.kt index 9fc3cecc46..bd032193a6 100644 --- a/app/src/androidTest/java/moe/ouom/neriplayer/data/local/database/NeriUserDataDatabaseMigrationTest.kt +++ b/app/src/androidTest/java/moe/ouom/neriplayer/data/local/database/NeriUserDataDatabaseMigrationTest.kt @@ -900,7 +900,8 @@ class NeriUserDataDatabaseMigrationTest { NeriUserDataDatabase.MIGRATION_16_17, NeriUserDataDatabase.MIGRATION_17_18, NeriUserDataDatabase.MIGRATION_18_19, - NeriUserDataDatabase.MIGRATION_19_20 + NeriUserDataDatabase.MIGRATION_19_20, + NeriUserDataDatabase.MIGRATION_20_21 ) migrated.use { @@ -966,7 +967,8 @@ class NeriUserDataDatabaseMigrationTest { NeriUserDataDatabase.MIGRATION_16_17, NeriUserDataDatabase.MIGRATION_17_18, NeriUserDataDatabase.MIGRATION_18_19, - NeriUserDataDatabase.MIGRATION_19_20 + NeriUserDataDatabase.MIGRATION_19_20, + NeriUserDataDatabase.MIGRATION_20_21 ) migrated.use { diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalMediaStorePlaybackReferenceTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalMediaStorePlaybackReferenceTest.kt new file mode 100644 index 0000000000..698261ec2c --- /dev/null +++ b/app/src/androidTest/java/moe/ouom/neriplayer/data/local/media/LocalMediaStorePlaybackReferenceTest.kt @@ -0,0 +1,156 @@ +package moe.ouom.neriplayer.data.local.media + +import android.content.ContentValues +import android.media.MediaPlayer +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID +import kotlinx.coroutines.runBlocking +import moe.ouom.neriplayer.core.download.storage.reference.ManagedDownloadReferenceLookup +import moe.ouom.neriplayer.core.player.download.AudioDownloadManager +import moe.ouom.neriplayer.core.player.download.playback.LocalPlaybackReferenceResolution +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.Q) +class LocalMediaStorePlaybackReferenceTest { + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun mediaStoreAudioWithLocalChannelAndNamedAlbumRemainsPlayable() = runBlocking { + withMediaStoreAudio { uri, displayName -> + assertPlayable(localSong(uri, displayName)) + } + } + + @Test + fun renamingMediaStoreAudioPreservesThePlayableReference() = runBlocking { + withMediaStoreAudio { uri, displayName -> + val song = localSong(uri, displayName) + val renamed = "renamed-$displayName" + assertEquals( + 1, + context.contentResolver.update( + uri, + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, renamed) + }, + null, + null + ) + ) + requireNotNull( + context.contentResolver.query( + uri, + arrayOf(MediaStore.MediaColumns.DISPLAY_NAME), + null, + null, + null + ) + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(renamed, cursor.getString(0)) + } + + assertPlayable(song.copy(localFileName = renamed)) + } + } + + private suspend fun assertPlayable(song: SongItem) { + val uri = requireNotNull(song.mediaUri) + assertEquals( + ManagedDownloadReferenceLookup.Result.Present, + ManagedDownloadReferenceLookup.inspect(context, song.mediaUri) + ) + assertEquals( + LocalPlaybackReferenceResolution.Playable(uri), + AudioDownloadManager.resolvePermittedLocalPlayback(context, song, song.mediaUri) + ) + val player = MediaPlayer() + try { + player.setDataSource(context, Uri.parse(uri)) + player.prepare() + assertTrue("the resolved MediaStore audio must decode", player.duration > 0) + } finally { + player.release() + } + } + + private suspend fun withMediaStoreAudio(block: suspend (Uri, String) -> Unit) { + val displayName = "neriplayer-playback-${UUID.randomUUID()}.wav" + val resolver = context.contentResolver + val uri = requireNotNull( + resolver.insert( + MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, "audio/wav") + put(MediaStore.MediaColumns.RELATIVE_PATH, "Music/NeriPlayer-tests") + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + ) + ) + try { + requireNotNull(resolver.openOutputStream(uri, "w")).use { it.write(silentWav()) } + assertEquals( + 1, + resolver.update( + uri, + ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, + null, + null + ) + ) + block(uri, displayName) + } finally { + assertEquals("remove only the test-owned MediaStore row", 1, resolver.delete(uri, null, null)) + } + } + + private fun localSong(uri: Uri, displayName: String): SongItem { + val id = UUID.randomUUID().leastSignificantBits and Long.MAX_VALUE + return SongItem( + id = id, + name = "MediaStore title", + artist = "MediaStore artist", + album = "Named local album", + albumId = 0L, + durationMs = 1_000L, + coverUrl = null, + mediaUri = uri.toString(), + localFileName = displayName, + channelId = "local", + audioId = id.toString() + ) + } + + private fun silentWav(): ByteArray { + val sampleRate = 8_000 + val pcm = ByteArray(sampleRate * 2) + return ByteBuffer.allocate(44 + pcm.size).order(ByteOrder.LITTLE_ENDIAN).apply { + put("RIFF".toByteArray(Charsets.US_ASCII)) + putInt(36 + pcm.size) + put("WAVEfmt ".toByteArray(Charsets.US_ASCII)) + putInt(16) + putShort(1) + putShort(1) + putInt(sampleRate) + putInt(sampleRate * 2) + putShort(2) + putShort(16) + put("data".toByteArray(Charsets.US_ASCII)) + putInt(pcm.size) + put(pcm) + }.array() + } +} diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/testing/RestoredLocalPlaybackThreadTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/testing/RestoredLocalPlaybackThreadTest.kt new file mode 100644 index 0000000000..be55696b95 --- /dev/null +++ b/app/src/androidTest/java/moe/ouom/neriplayer/testing/RestoredLocalPlaybackThreadTest.kt @@ -0,0 +1,332 @@ +package moe.ouom.neriplayer.testing + +import android.app.Application +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.net.Uri +import android.os.Build +import android.os.IBinder +import android.os.Looper +import android.os.StrictMode +import android.os.SystemClock +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.runBlocking +import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.core.player.host.PlayerDependencies +import moe.ouom.neriplayer.core.player.persistence.PlaybackQueueRoomStore +import moe.ouom.neriplayer.core.player.persistence.RestoredPlayerStateSnapshot +import moe.ouom.neriplayer.core.player.service.AudioPlayerService +import moe.ouom.neriplayer.data.local.database.NeriUserDataDatabase +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.playback.PersistedState +import moe.ouom.neriplayer.data.settings.playback.readPlaybackPreferenceSnapshotSync +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeFalse +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RestoredLocalPlaybackThreadTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context get() = instrumentation.targetContext + + @Test + fun restoredRawAndFileSourcesKeepProgressUntilReadableWithoutMainThreadDiskProbes() { + assumeTrue("仅在 API 35 模拟器运行", Build.VERSION.SDK_INT == 35 && + Build.HARDWARE in setOf("ranchu", "goldfish")) + assertTrue("suspend 恢复必须从 instrumentation 线程调用", Looper.myLooper() != Looper.getMainLooper()) + val app = context.applicationContext as Application + val preferences = readPlaybackPreferenceSnapshotSync(app) + val savedQueue = runBlocking { SavedPlaybackQueue.read(context) } + val directory = File(context.cacheDir, "restored-thread-${UUID.randomUUID()}") + var failure: Throwable? = null + try { + check(directory.mkdirs()) + listOf(false, true).forEach { fileUri -> + val audio = File(directory, if (fileUri) "file.wav" else "raw.wav") + assertFalse(audio.exists()) + val reference = if (fileUri) Uri.fromFile(audio).toString() else audio.absolutePath + val song = SongItem( + id = -System.nanoTime(), name = "Restored thread fixture", artist = "Offline fixture", + album = "Test", albumId = 0L, durationMs = 10_000L, coverUrl = null, + mediaUri = reference, localFilePath = audio.absolutePath.takeUnless { fileUri }, + localFileName = audio.name, channelId = "local", audioId = audio.name + ) + instrumentation.runOnMainSync { + PlayerManager.release() + PlayerManager.initializePreloaded(app, preferences.copy( + keepLastPlaybackProgress = true, maxCacheSizeBytes = 0L, usbExclusivePlayback = false + ), restored(song)) + } + assertBootstrapWithoutDiskReads() + assertNull(runBlocking { PlayerManager.resumeRestoredPlaybackIfNeeded() }) + instrumentation.runOnMainSync { + assertEquals(listOf(song), PlayerManager.currentQueueFlow.value) + assertEquals(song, PlayerManager.currentSongFlow.value) + assertEquals(PositionMs, PlayerManager.playbackPositionFlow.value) + } + writeWav(audio) + val resumed = runBlocking { PlayerManager.resumeRestoredPlaybackIfNeeded() } + instrumentation.runOnMainSync { PlayerManager.release() } + assertEquals(PositionMs, resumed) + } + } catch (error: Throwable) { + failure = error + throw error + } finally { + val failures = mutableListOf() + fun cleanup(action: () -> Unit) { + runCatching(action).exceptionOrNull()?.let(failures::add) + } + cleanup { instrumentation.runOnMainSync { PlayerManager.release() } } + cleanup { runBlocking { savedQueue.restore(context) } } + cleanup { check(!directory.exists() || directory.deleteRecursively()) } + cleanup { instrumentation.runOnMainSync { PlayerManager.initializePreloaded(app, preferences) } } + if (failure != null) failures.forEach(failure::addSuppressed) + else if (failures.isNotEmpty()) { + val first = failures.first() + failures.drop(1).forEach(first::addSuppressed) + throw first + } + } + } + + @Test + fun serviceStartsStopAfterMissingRestoredSourcesWithoutDiscardingRetryState() { + assumeTrue("仅在 API 35 模拟器运行", Build.VERSION.SDK_INT == 35 && + Build.HARDWARE in setOf("ranchu", "goldfish")) + assumeFalse("已有服务时不干预其生命周期", AudioPlayerService.isInstanceActiveForDiagnostics()) + assumeFalse("已有播放时不干预播放器", PlayerManager.isPlayingFlow.value || + PlayerManager.playWhenReadyFlow.value || PlayerManager.playbackControlPlayingFlow.value) + assumeFalse("安全模式不创建播放服务", PlayerDependencies.presentation.shouldEnterSafeMode(context)) + val app = context.applicationContext as Application + val preferences = readPlaybackPreferenceSnapshotSync(app) + val savedQueue = runBlocking { SavedPlaybackQueue.read(context) } + val directory = File(context.cacheDir, "restored-service-${UUID.randomUUID()}") + var connection: ServiceConnection? = null + var ownsService = false + var failure: Throwable? = null + try { + check(directory.mkdirs()) + ActivityScenario.launch(TestComposeActivity::class.java).use { scenario -> + val starts = listOf(AudioPlayerService.ACTION_SYNC, null) + .flatMap { action -> listOf(false, true).map { fileUri -> action to fileUri } } + starts.forEach { (action, fileUri) -> + val sourceName = if (action == null) "restart" else "app_bootstrap" + val audio = File(directory, "$sourceName-${if (fileUri) "file" else "raw"}.wav") + assertFalse(audio.exists()) + val song = SongItem( + id = -System.nanoTime(), name = "Restored service fixture", artist = "Offline fixture", + album = "Test", albumId = 0L, durationMs = 10_000L, coverUrl = null, + mediaUri = if (fileUri) Uri.fromFile(audio).toString() else audio.absolutePath, + localFilePath = audio.absolutePath.takeUnless { fileUri }, localFileName = audio.name, + channelId = "local", audioId = audio.name + ) + instrumentation.runOnMainSync { + assertNull("初始化快照前必须没有服务观察者", activeService()) + PlayerManager.release() + PlayerManager.initializePreloaded(app, preferences.copy( + keepLastPlaybackProgress = true, maxCacheSizeBytes = 0L, usbExclusivePlayback = false + ), restored(song)) + assertEquals(listOf(song), PlayerManager.currentQueueFlow.value) + assertEquals(song, PlayerManager.currentSongFlow.value) + assertEquals(PositionMs, PlayerManager.playbackPositionFlow.value) + assertTrue(PlayerManager.shouldBootstrapPlaybackServiceOnAppLaunch()) + } + val connected = CompletableFuture() + val client = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + try { + val token = checkNotNull(binder).javaClass.getMethod("getSessionToken").invoke(binder) + checkNotNull(token) { "绑定必须提供可用的媒体会话" } + connected.complete(checkNotNull(activeService())) + } catch (error: Exception) { + connected.completeExceptionally(error) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + connected.completeExceptionally(AssertionError("播放服务绑定中断")) + } + } + instrumentation.runOnMainSync { + val intent = Intent(context, AudioPlayerService::class.java).setAction(ActionBindCar) + assertTrue(context.bindService(intent, client, Context.BIND_AUTO_CREATE)) + connection = client + ownsService = true + } + val service = connected.get(ServiceTimeoutSeconds, TimeUnit.SECONDS) + awaitServiceState("绑定服务必须完成播放器初始化") { service.booleanField("playerRuntimeReady") } + var previousStartId = 0 + var callbackGeneration = 0L + instrumentation.runOnMainSync { + assertEquals(listOf(song), PlayerManager.currentQueueFlow.value) + assertEquals(song, PlayerManager.currentSongFlow.value) + assertEquals(PositionMs, PlayerManager.playbackPositionFlow.value) + assertTrue(PlayerManager.shouldBootstrapPlaybackServiceOnAppLaunch()) + previousStartId = service.field("latestStartId") as Int + callbackGeneration = service.field("mediaSessionCallback").field("requestGeneration") as Long + } + scenario.onActivity { activity -> + val intent = Intent(activity, AudioPlayerService::class.java).setAction(action) + if (action == AudioPlayerService.ACTION_SYNC) { + intent.putExtra(AudioPlayerService.EXTRA_START_SOURCE, "app_bootstrap") + } + assertNotNull(activity.startService(intent)) + } + awaitServiceState("$sourceName 启动必须收到新的 AMS startId") { + val startId = service.field("latestStartId") as Int + startId > 0 && startId > previousStartId + } + awaitServiceState("缺源恢复必须停止前台并禁止 sticky 重启") { + !service.booleanField("hasReceivedStartCommand") && + !service.booleanField("isForegroundStarted") && + !AudioPlayerService.isForegroundActiveForDiagnostics() && + service.booleanField("keepPlayerRuntimeAfterServiceStop") && + !service.booleanField("allowServiceRestart") + } + instrumentation.runOnMainSync { + // 前台提升失败也会退出服务,不能把该路径误认为缺源恢复完成 + assertEquals(callbackGeneration, + service.field("mediaSessionCallback").field("requestGeneration") as Long) + context.unbindService(client) + connection = null + } + awaitServiceState("已启动的服务解绑后必须真正销毁") { activeService() == null } + ownsService = false + instrumentation.runOnMainSync { + assertFalse(AudioPlayerService.isInstanceActiveForDiagnostics()) + assertEquals(listOf(song), PlayerManager.currentQueueFlow.value) + assertEquals(song, PlayerManager.currentSongFlow.value) + assertEquals(PositionMs, PlayerManager.playbackPositionFlow.value) + assertTrue(PlayerManager.shouldBootstrapPlaybackServiceOnAppLaunch()) + } + } + } + } catch (error: Throwable) { + failure = error + throw error + } finally { + val failures = mutableListOf() + fun cleanup(action: () -> Unit) { + runCatching(action).exceptionOrNull()?.let(failures::add) + } + cleanup { + instrumentation.runOnMainSync { + connection?.let(context::unbindService) + connection = null + if (ownsService) context.stopService(Intent(context, AudioPlayerService::class.java)) + } + } + if (ownsService) cleanup { awaitServiceState("清理本次服务") { activeService() == null } } + cleanup { instrumentation.runOnMainSync { PlayerManager.release() } } + cleanup { runBlocking { savedQueue.restore(context) } } + cleanup { check(!directory.exists() || directory.deleteRecursively()) } + cleanup { instrumentation.runOnMainSync { PlayerManager.initializePreloaded(app, preferences) } } + if (failure != null) failures.forEach(failure::addSuppressed) + else if (failures.isNotEmpty()) { + val first = failures.first() + failures.drop(1).forEach(first::addSuppressed) + throw first + } + } + } + + private fun activeService(): AudioPlayerService? = AudioPlayerService::class.java + .getDeclaredField("activeServiceInstance").apply { isAccessible = true }.get(null) as? AudioPlayerService + + private fun Any.field(name: String): Any = javaClass.getDeclaredField(name) + .apply { isAccessible = true }.get(this) ?: error("字段为空: $name") + + private fun AudioPlayerService.booleanField(name: String): Boolean = field(name) as Boolean + + private fun awaitServiceState(message: String, predicate: () -> Boolean) { + val deadline = SystemClock.elapsedRealtime() + TimeUnit.SECONDS.toMillis(ServiceTimeoutSeconds) + do { + var matched = false + instrumentation.runOnMainSync { matched = predicate() } + if (matched) return + Thread.sleep(25) + } while (SystemClock.elapsedRealtime() < deadline) + throw AssertionError(message) + } + + private fun restored(song: SongItem) = RestoredPlayerStateSnapshot( + playlist = listOf(song), currentIndex = 0, currentMediaUrl = null, + repeatMode = 0, shuffleEnabled = false, shuffleRestorePlaylist = null, shuffleRestoreIndex = -1, + resumePositionMs = PositionMs, shouldResumePlayback = true, originalPlaylistSize = 1, persistedIndex = 0 + ) + + private fun assertBootstrapWithoutDiskReads() { + val violations = ConcurrentLinkedQueue() + instrumentation.runOnMainSync { + val previous = StrictMode.getThreadPolicy() + try { + StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder().detectDiskReads() + .penaltyListener({ it.run() }) { violation -> violations.add(violation.javaClass.name) } + .build()) + assertTrue(PlayerManager.shouldBootstrapPlaybackServiceOnAppLaunch()) + } finally { + StrictMode.setThreadPolicy(previous) + } + } + instrumentation.waitForIdleSync() + assertTrue("bootstrap 主线程磁盘探测: $violations", violations.isEmpty()) + } + + private fun writeWav(audio: File) { + val pcm = ByteArray(8_000 * 10 * 2) + val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN) + .put("RIFF".toByteArray()).putInt(36 + pcm.size).put("WAVEfmt ".toByteArray()) + .putInt(16).putShort(1).putShort(1).putInt(8_000).putInt(16_000) + .putShort(2).putShort(16).put("data".toByteArray()).putInt(pcm.size).array() + audio.outputStream().use { it.write(header); it.write(pcm) } + } + + private data class SavedPlaybackQueue( + val roomPrimary: Boolean, + val state: PersistedState?, + val legacyFiles: Map + ) { + suspend fun restore(context: Context) { + val store = PlaybackQueueRoomStore(NeriUserDataDatabase.getInstance(context)) + if (roomPrimary && state != null) store.replaceSnapshot(state) else store.clear() + legacyFiles.forEach { (name, bytes) -> + val file = File(context.filesDir, name) + if (bytes != null) file.writeBytes(bytes) else check(!file.exists() || file.delete()) + } + if (!roomPrimary) store.markLegacyJsonPrimary(System.currentTimeMillis()) + } + + companion object { + suspend fun read(context: Context): SavedPlaybackQueue { + val store = PlaybackQueueRoomStore(NeriUserDataDatabase.getInstance(context)) + return SavedPlaybackQueue(store.isRoomPrimary(), store.readIfRoomPrimary(), + QueueFiles.associateWith { name -> File(context.filesDir, name).takeIf(File::exists)?.readBytes() }) + } + } + } + + private companion object { + const val PositionMs = 1_500L + const val ServiceTimeoutSeconds = 25L + const val ActionBindCar = "moe.ouom.neriplayer.action.BIND_CAR" + val QueueFiles = listOf("last_playlist.json", "last_playback_state.json") + } +} diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/ui/screen/playlist/LocalPlaylistDownloadSelectionTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/ui/screen/playlist/LocalPlaylistDownloadSelectionTest.kt new file mode 100644 index 0000000000..ec7883c8a9 --- /dev/null +++ b/app/src/androidTest/java/moe/ouom/neriplayer/ui/screen/playlist/LocalPlaylistDownloadSelectionTest.kt @@ -0,0 +1,166 @@ +package moe.ouom.neriplayer.ui.screen.playlist + +import androidx.compose.foundation.LocalOverscrollFactory +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTouchInput +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import moe.ouom.neriplayer.common.R as CoreCommonR +import moe.ouom.neriplayer.core.di.AppContainer +import moe.ouom.neriplayer.data.local.playlist.LocalPlaylistRepository +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.testutil.assumeComposeHostAvailable +import moe.ouom.neriplayer.ui.effect.glass.AdvancedGlassOverscrollFactory +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class LocalPlaylistDownloadSelectionTest { + @get:Rule + val composeRule = createComposeRule() + + private val context + get() = InstrumentationRegistry.getInstrumentation().targetContext + private val repository by lazy { LocalPlaylistRepository.getInstance(context) } + private val screenVisible = mutableStateOf(false) + private var ownedPlaylistId: Long? = null + private var screenMounted = false + + @Before + fun assumeDeviceUnlocked() { + assumeComposeHostAvailable() + } + + @After + fun removeOwnedPlaylist() { + val playlistId = ownedPlaylistId ?: return + runBlocking { + withTimeout(20_000L) { repository.deletePlaylist(playlistId) } + } + if (screenMounted) { + composeRule.waitUntil(20_000L) { !screenVisible.value } + } + AppContainer.playlistUsageRepo.removeEntry(playlistId, "local") + } + + @Test + fun longPressingSafSongWithEditedAlbumHidesDownloadAction() { + val songs = createAndShowPlaylist() + + longPressSong(songs[0]) + + downloadAction().assertDoesNotExist() + } + + @Test + fun longPressingLocalPathWithEditedAlbumHidesDownloadAction() { + val songs = createAndShowPlaylist() + + longPressSong(songs[1]) + + downloadAction().assertDoesNotExist() + } + + @Test + fun selectingLocalSourcesKeepsDownloadHiddenUntilRemoteSongIsSelected() { + val songs = createAndShowPlaylist() + longPressSong(songs[0]) + clickSong(songs[1]) + + downloadAction().assertDoesNotExist() + + clickSong(songs[2]) + + downloadAction().assertIsEnabled() + + clickSong(songs[2]) + + downloadAction().assertDoesNotExist() + } + + private fun createAndShowPlaylist(): List { + val token = UUID.randomUUID().toString().take(8) + val baseId = System.currentTimeMillis() * 1_000L + val songs = List(3) { index -> + SongItem( + id = baseId + index, + name = "download-$token-song-${index + 1}", + artist = "synthetic artist", + album = "user edited album", + albumId = 42L, + durationMs = 180_000L, + coverUrl = null, + mediaUri = if (index == 0) "content://neriplayer.test.documents/document/$token" else null, + localFilePath = if (index == 1) "/synthetic/$token/song.flac" else null + ) + } + val playlist = runBlocking { + withTimeout(20_000L) { + assertTrue("local playlist repository failed to initialize", repository.awaitInitialized()) + repository.createPlaylistWithPreparedSongs("DL$token", songs) + } + } + ownedPlaylistId = playlist.id + screenVisible.value = true + composeRule.setContent { + CompositionLocalProvider(LocalOverscrollFactory provides AdvancedGlassOverscrollFactory) { + MaterialTheme { + val navController = rememberNavController() + NavHost(navController, startDestination = "download-selection") { + composable("download-selection") { + if (screenVisible.value) { + LocalPlaylistDetailScreen( + playlistId = playlist.id, + onBack = { screenVisible.value = false }, + onDeleted = { screenVisible.value = false }, + offlineMode = true + ) + } + } + } + } + } + } + screenMounted = true + composeRule.waitUntil(20_000L) { + composeRule.onAllNodesWithText(playlist.name).fetchSemanticsNodes().isNotEmpty() + } + return playlist.songs + } + + private fun longPressSong(song: SongItem) { + composeRule.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(song.name)) + composeRule.onNodeWithText(song.name).performTouchInput { longClick() } + } + + private fun clickSong(song: SongItem) { + composeRule.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(song.name)) + composeRule.onNodeWithText(song.name).performClick() + } + + private fun downloadAction() = composeRule.onNodeWithContentDescription( + context.getString(CoreCommonR.string.cd_download_selected) + ) +} diff --git a/app/src/androidTest/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/floating/SettingsExternalLyricsPageTouchTest.kt b/app/src/androidTest/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/floating/SettingsExternalLyricsPageTouchTest.kt index 7ed59ebc10..5970761435 100644 --- a/app/src/androidTest/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/floating/SettingsExternalLyricsPageTouchTest.kt +++ b/app/src/androidTest/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/floating/SettingsExternalLyricsPageTouchTest.kt @@ -381,9 +381,10 @@ class SettingsExternalLyricsPageTouchTest { target: SemanticsNodeInteraction, viewport: SemanticsNodeInteraction, avoidMiniPlayer: Boolean - ) { + ): SemanticsNode { var lastGeometry = "not sampled" repeat(28) { attempt -> + waitForStableTarget(target, viewport, requireTarget = false) val node = viewport.fetchSemanticsNode() // 手势和可见性统一使用屏幕像素,避免应用缩放后的 dp 与原生坐标混用 val viewportBounds = node.boundsInRoot.translate(node.positionOnScreen - node.positionInRoot) @@ -393,12 +394,13 @@ class SettingsExternalLyricsPageTouchTest { val clearance = with(node.layoutInfo.density) { 4.dp.toPx() } val safeBounds = Rect(viewportBounds.left, viewportBounds.top, viewportBounds.right, bottom - clearance) assertTrue("原生滑动需要真实可用的页面区域: $safeBounds", safeBounds.width > 4f && safeBounds.height > 4f) - val targetBounds = runCatching { target.fetchSemanticsNode().unclippedScreenBounds() }.getOrNull() + val targetNode = runCatching { target.fetchSemanticsNode() }.getOrNull() + val targetBounds = targetNode?.unclippedScreenBounds() if (targetBounds != null) { assertTrue("目标无法完整放入迷你播放器上方: target=$targetBounds, safe=$safeBounds", targetBounds.height <= safeBounds.height) if (targetBounds.top >= safeBounds.top && targetBounds.bottom <= safeBounds.bottom && target.isDisplayed()) { - return + return checkNotNull(targetNode) } } val correction = when { @@ -426,9 +428,8 @@ class SettingsExternalLyricsPageTouchTest { Log.i(DiagnosticTag, "density=$fixtureDensityScale, swipe=$lastGeometry") nativeSwipe(swipeStart, swipeEnd, stopBeforeUp = precise) composeRule.waitForIdle() - waitForStableTarget(target, viewport, requireTarget = false) } - fail("真实滑动后目标仍未完整露出迷你播放器上方: $lastGeometry") + throw AssertionError("真实滑动后目标仍未完整露出迷你播放器上方: $lastGeometry") } private fun SemanticsNode.unclippedScreenBounds(): Rect? { @@ -447,7 +448,9 @@ class SettingsExternalLyricsPageTouchTest { val maxValue: Float, val targetPosition: Offset?, val targetSize: IntSize?, - val targetBounds: Rect? + val targetBounds: Rect?, + val viewportBounds: Rect, + val miniPlayerBounds: Rect? ) private fun waitForStableTarget( @@ -460,16 +463,20 @@ class SettingsExternalLyricsPageTouchTest { var stableSamples = 0 while (SystemClock.uptimeMillis() < deadline) { awaitHardwareFrame(deadline) - val scroll = viewport.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange] + val viewportNode = viewport.fetchSemanticsNode() + val scroll = viewportNode.config[SemanticsProperties.VerticalScrollAxisRange] val node = runCatching { target.fetchSemanticsNode() }.getOrNull() + val miniPlayer = composeRule.onNodeWithTag(MiniPlayerTag, useUnmergedTree = true).fetchSemanticsNode() val sample = ScrollFrameSample(scroll.value(), scroll.maxValue(), - node?.positionOnScreen, node?.size, node?.boundsInRoot) + node?.positionOnScreen, node?.size, node?.boundsInRoot, + viewportNode.boundsInRoot.translate(viewportNode.positionOnScreen - viewportNode.positionInRoot), + miniPlayer.unclippedScreenBounds()) stableSamples = if (sample == previous) stableSamples + 1 else 1 Log.i(DiagnosticTag, "density=$fixtureDensityScale, settling=$sample, stableFrames=$stableSamples") if (stableSamples >= 3 && (!requireTarget || node != null)) return previous = sample } - fail("真实渲染帧中滚动值及目标坐标未连续三次稳定: last=$previous, stableFrames=$stableSamples") + fail("真实渲染帧中滚动、目标、页面及迷你播放器未连续三次稳定: last=$previous, stableFrames=$stableSamples") } private fun awaitHardwareFrame(deadline: Long) { @@ -692,9 +699,7 @@ class SettingsExternalLyricsPageTouchTest { private fun dragSlider(spec: SliderCase, viewport: SemanticsNodeInteraction) { val target = slider(spec.title) - swipeUntilVisible(target, viewport, avoidMiniPlayer = true) - waitForStableTarget(target, viewport, requireTarget = true) - val node = target.fetchSemanticsNode() + val node = swipeUntilVisible(target, viewport, avoidMiniPlayer = true) val range = node.config[SemanticsProperties.ProgressBarRangeInfo] val initialTolerance = if (range.steps > 0) { (range.range.endInclusive - range.range.start) / (range.steps + 1) / 2f + 0.0001f @@ -706,11 +711,14 @@ class SettingsExternalLyricsPageTouchTest { val next = if (toEnd) range.range.endInclusive else range.range.start val fraction = ((range.current - range.range.start) / (range.range.endInclusive - range.range.start)) .coerceIn(0.05f, 0.95f) - val start = node.positionOnScreen + Offset(node.size.width * fraction, node.size.height / 2f) - val end = node.positionOnScreen + Offset(if (toEnd) node.size.width - 1f else 1f, node.size.height / 2f) + logTouchGeometry(spec.title, viewport, "slider-before", target, fraction) + // 离线提示可能在诊断期间抬高迷你播放器,下发手势前重新确认目标没有被遮挡 + val touchNode = swipeUntilVisible(target, viewport, avoidMiniPlayer = true) + val start = touchNode.positionOnScreen + Offset(touchNode.size.width * fraction, touchNode.size.height / 2f) + val end = touchNode.positionOnScreen + Offset( + if (toEnd) touchNode.size.width - 1f else 1f, touchNode.size.height / 2f) val downTime = SystemClock.uptimeMillis() var released = false - logTouchGeometry(spec.title, viewport, "slider-before", target, fraction) try { injectTouch(downTime, MotionEvent.ACTION_DOWN, start) for (step in 1..12) { diff --git a/app/src/main/java/moe/ouom/neriplayer/core/di/player/AppPlayerDownloads.kt b/app/src/main/java/moe/ouom/neriplayer/core/di/player/AppPlayerDownloads.kt index d046166a60..751281549e 100644 --- a/app/src/main/java/moe/ouom/neriplayer/core/di/player/AppPlayerDownloads.kt +++ b/app/src/main/java/moe/ouom/neriplayer/core/di/player/AppPlayerDownloads.kt @@ -15,6 +15,7 @@ import moe.ouom.neriplayer.data.model.playback.storage.PlayerDownloadedMetadataS import moe.ouom.neriplayer.data.model.playback.storage.PlayerLocalPlaybackResolution import moe.ouom.neriplayer.data.model.playback.storage.PlayerMetadataClearRequest import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource internal object AppPlayerDownloads : PlayerDownloadAccess { override val downloadPresenceVersion get() = GlobalDownloadManager.downloadPresenceVersion @@ -35,11 +36,11 @@ internal object AppPlayerDownloads : PlayerDownloadAccess { override fun getLocalPlaybackUri(context: Context, song: SongItem): String? = AudioDownloadManager.getLocalPlaybackUri(context, song) - override suspend fun resolvePermittedLocalPlaybackUri(context: Context, song: SongItem, rawLocalReference: String?): String? = - AudioDownloadManager.resolvePermittedLocalPlaybackUri(context, song, rawLocalReference) + override suspend fun resolvePermittedLocalPlaybackUri(context: Context, song: SongItem, rawLocalReference: String?, sourceContext: ResolvedLocalMediaSource?): String? = + AudioDownloadManager.resolvePermittedLocalPlaybackUri(context, song, rawLocalReference, sourceContext) - override suspend fun resolvePermittedLocalPlayback(context: Context, song: SongItem, rawLocalReference: String?): PlayerLocalPlaybackResolution = - AudioDownloadManager.resolvePermittedLocalPlayback(context, song, rawLocalReference).toPlayerResolution() + override suspend fun resolvePermittedLocalPlayback(context: Context, song: SongItem, rawLocalReference: String?, sourceContext: ResolvedLocalMediaSource?): PlayerLocalPlaybackResolution = + AudioDownloadManager.resolvePermittedLocalPlayback(context, song, rawLocalReference, sourceContext).toPlayerResolution() override fun resolveIndexedLocalPlaybackReference(context: Context, song: SongItem): PlayerLocalPlaybackResolution = AudioDownloadManager.resolveIndexedLocalPlaybackReference(context, song).toPlayerResolution() diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/LocalArtistDetailScreen.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/LocalArtistDetailScreen.kt index c643d782b9..1dd94ad2f8 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/LocalArtistDetailScreen.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/LocalArtistDetailScreen.kt @@ -250,6 +250,9 @@ fun LocalArtistDetailScreen( selectedSongsInSourceOrder(songs, selectedKeys) } } + val selectedDownloadableSongs = remember(selectedSongsForAction) { + playlistDownloadCandidates(selectedSongsForAction) + } fun toggleSelect(song: SongItem) { val songKey = song.stableKey() @@ -446,24 +449,25 @@ fun LocalArtistDetailScreen( contentDescription = stringResource(CoreCommonR.string.cd_export_playlist) ) } - HapticIconButton( - onClick = { - val selectedSongs = selectedSongsForAction - if (selectedSongs.isNotEmpty()) { - showDownloadManager = true - exitSelectionMode() - GlobalDownloadManager.startBatchDownload( - context, - selectedSongs - ) + if (selectedDownloadableSongs.isNotEmpty()) { + HapticIconButton( + onClick = { + val selectedSongs = selectedDownloadableSongs + if (selectedSongs.isNotEmpty()) { + showDownloadManager = true + exitSelectionMode() + GlobalDownloadManager.startBatchDownload( + context, + selectedSongs + ) + } } - }, - enabled = selectedSongsForAction.isNotEmpty() - ) { - Icon( - Icons.Outlined.Download, - contentDescription = stringResource(CoreCommonR.string.cd_download_selected) - ) + ) { + Icon( + Icons.Outlined.Download, + contentDescription = stringResource(CoreCommonR.string.cd_download_selected) + ) + } } }, windowInsets = WindowInsets.statusBars, diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/LocalPlaylistDetailModernContent.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/LocalPlaylistDetailModernContent.kt index 61915fbbb7..f14450d0cc 100644 --- a/app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/LocalPlaylistDetailModernContent.kt +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/LocalPlaylistDetailModernContent.kt @@ -190,6 +190,9 @@ internal fun LocalPlaylistDetailModernContent( var insertInProgress by remember(playlistId) { mutableStateOf(false) } var curationSongs by remember(playlistId) { mutableStateOf?>(null) } val curatableTabSongs = remember(tabSongs) { tabSongs.filter { it.isLocalSong() } } + val selectedDownloadableSongs = remember(selectedSongsForAction) { + playlistDownloadCandidates(selectedSongsForAction) + } LaunchedEffect(selectionMode, canReorderCurrentSongs) { if (!selectionMode || !canReorderCurrentSongs) showInsertDialog = false } @@ -562,24 +565,26 @@ internal fun LocalPlaylistDetailModernContent( } }, actions = { - HapticIconButton( - onClick = { - val selectedSongs = selectedSongsForAction - if (selectedSongs.isNotEmpty()) { - showDownloadManager = true - exitSelectionMode() - GlobalDownloadManager.startBatchDownload( - context, - selectedSongs - ) - } - }, - enabled = selectedSongsForAction.isNotEmpty() && !insertInProgress - ) { - Icon( - Icons.Outlined.Download, - contentDescription = stringResource(CoreCommonR.string.cd_download_selected) - ) + if (selectedDownloadableSongs.isNotEmpty()) { + HapticIconButton( + onClick = { + val selectedSongs = selectedDownloadableSongs + if (selectedSongs.isNotEmpty()) { + showDownloadManager = true + exitSelectionMode() + GlobalDownloadManager.startBatchDownload( + context, + selectedSongs + ) + } + }, + enabled = !insertInProgress + ) { + Icon( + Icons.Outlined.Download, + contentDescription = stringResource(CoreCommonR.string.cd_download_selected) + ) + } } HapticIconButton( onClick = { diff --git a/app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/PlaylistDownloadCandidates.kt b/app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/PlaylistDownloadCandidates.kt new file mode 100644 index 0000000000..648b9ba83d --- /dev/null +++ b/app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/PlaylistDownloadCandidates.kt @@ -0,0 +1,7 @@ +package moe.ouom.neriplayer.ui.screen.playlist + +import moe.ouom.neriplayer.data.local.media.isLocalSong +import moe.ouom.neriplayer.data.model.SongItem + +internal fun playlistDownloadCandidates(songs: List): List = + songs.filterNot(SongItem::isLocalSong) diff --git a/app/src/test/java/moe/ouom/neriplayer/ui/screen/playlist/PlaylistDownloadCandidatesTest.kt b/app/src/test/java/moe/ouom/neriplayer/ui/screen/playlist/PlaylistDownloadCandidatesTest.kt new file mode 100644 index 0000000000..cf8fb1ae68 --- /dev/null +++ b/app/src/test/java/moe/ouom/neriplayer/ui/screen/playlist/PlaylistDownloadCandidatesTest.kt @@ -0,0 +1,62 @@ +package moe.ouom.neriplayer.ui.screen.playlist + +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlaylistDownloadCandidatesTest { + @Test + fun `local media references stay excluded after album edits`() { + val localSongs = listOf( + song(1).copy(mediaUri = "content://media/external/audio/media/1"), + song(2).copy(mediaUri = "content://test.documents/document/track%3A2"), + song(3).copy(mediaUri = "file:///storage/emulated/0/Music/track.flac"), + song(4).copy(localFilePath = "/storage/emulated/0/Music/track.flac") + ) + + assertTrue(playlistDownloadCandidates(localSongs).isEmpty()) + } + + @Test + fun `mixed selection keeps only remote songs in source order`() { + val firstRemote = song(1) + val localUri = song(2).copy(mediaUri = "content://test.documents/document/local") + val secondRemote = song(3).copy(mediaUri = "https://example.com/audio.mp3") + val localPath = song(4).copy(localFilePath = "/synthetic/local.flac") + + assertEquals( + listOf(firstRemote, secondRemote), + playlistDownloadCandidates(listOf(firstRemote, localUri, secondRemote, localPath)) + ) + } + + @Test + fun `legacy local placeholder without media reference cannot download`() { + val legacy = song(1).copy(album = "__local_files__", albumId = 0L) + + assertTrue(playlistDownloadCandidates(listOf(legacy)).isEmpty()) + } + + @Test + fun `remote album named local files does not determine source`() { + val remote = song(1).copy(album = "Local Files") + + assertEquals(listOf(remote), playlistDownloadCandidates(listOf(remote))) + } + + @Test + fun `empty selection has no download candidates`() { + assertTrue(playlistDownloadCandidates(emptyList()).isEmpty()) + } + + private fun song(id: Long) = SongItem( + id = id, + name = "song-$id", + artist = "artist", + album = "user edited album", + albumId = 42L, + durationMs = 180_000L, + coverUrl = null + ) +} diff --git a/modules/common/src/main/java/moe/ouom/neriplayer/common/logging/PlaybackLogReference.kt b/modules/common/src/main/java/moe/ouom/neriplayer/common/logging/PlaybackLogReference.kt new file mode 100644 index 0000000000..9b2eab0f7f --- /dev/null +++ b/modules/common/src/main/java/moe/ouom/neriplayer/common/logging/PlaybackLogReference.kt @@ -0,0 +1,23 @@ +package moe.ouom.neriplayer.common.logging + +import java.net.URI +import java.util.Locale + +fun playbackLogReference(reference: String?): String { + val value = reference?.trim().orEmpty() + if (value.isEmpty()) return "none" + val description = if (value.startsWith('/') && !value.startsWith("//")) value else describePlaybackUri(value) + return buildString { + description.take(768).forEach { append(if (it.isISOControl()) ' ' else it) } + } +} + +private fun describePlaybackUri(reference: String): String { + val uri = runCatching { URI(reference) }.getOrNull() ?: return "invalid" + val scheme = uri.scheme?.lowercase(Locale.ROOT) ?: return "invalid" + val authority = uri.rawAuthority?.substringAfterLast('@').orEmpty() + return when (scheme) { + "content", "file", "android.resource" -> "$scheme://$authority${uri.path.orEmpty()}" + else -> "$scheme://$authority/" + } +} diff --git a/modules/common/src/test/java/moe/ouom/neriplayer/common/logging/PlaybackLogReferenceTest.kt b/modules/common/src/test/java/moe/ouom/neriplayer/common/logging/PlaybackLogReferenceTest.kt new file mode 100644 index 0000000000..9e3d7efc47 --- /dev/null +++ b/modules/common/src/test/java/moe/ouom/neriplayer/common/logging/PlaybackLogReferenceTest.kt @@ -0,0 +1,49 @@ +package moe.ouom.neriplayer.common.logging + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlaybackLogReferenceTest { + @Test + fun `local document keeps its folder identity without credentials or query`() { + assertEquals( + "content://documents.example/tree/primary:Music/document/primary:Music/song.flac", + playbackLogReference("content://user:password@documents.example/tree/primary%3AMusic/document/primary%3AMusic/song.flac?token=secret#private") + ) + } + + @Test + fun `remote playback references hide signed paths and authentication`() { + assertEquals( + "https://cdn.example/", + playbackLogReference("https://user:password@cdn.example/secret-token/song?signature=private#secret") + ) + } + + @Test + fun `protocol relative network references never masquerade as local paths`() { + val result = playbackLogReference("//user:password@cdn.example/song?token=secret#private") + assertFalse(result.contains("password")) + assertFalse(result.contains("secret")) + assertFalse(result.contains("private")) + } + + @Test + fun `file references remain identifiable and missing references are explicit`() { + assertEquals("none", playbackLogReference(null)) + assertEquals("none", playbackLogReference(" ")) + assertEquals("/storage/Music/song.flac", playbackLogReference("/storage/Music/song.flac")) + assertEquals("file:///storage/Music/song.flac", playbackLogReference("file:///storage/Music/song.flac?secret=token")) + assertEquals("invalid", playbackLogReference("content://host/invalid space?token=private")) + } + + @Test + fun `log references cannot inject lines or exceed the diagnostic budget`() { + val result = playbackLogReference("/storage/Music/song\nforged\rline/" + "x".repeat(2_000)) + assertTrue(result.length <= 768) + assertFalse(result.contains('\n')) + assertFalse(result.contains('\r')) + } +} diff --git a/modules/database/schemas/moe.ouom.neriplayer.data.local.database.NeriUserDataDatabase/21.json b/modules/database/schemas/moe.ouom.neriplayer.data.local.database.NeriUserDataDatabase/21.json new file mode 100644 index 0000000000..debcb1a01e --- /dev/null +++ b/modules/database/schemas/moe.ouom.neriplayer.data.local.database.NeriUserDataDatabase/21.json @@ -0,0 +1,3920 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "e039ac26723d945d052198a3f601f4aa", + "entities": [ + { + "tableName": "local_playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `display_position` INTEGER NOT NULL, `custom_cover_url` TEXT, `modified_at` INTEGER NOT NULL, `song_order_version` INTEGER NOT NULL, `is_system` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayPosition", + "columnName": "display_position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customCoverUrl", + "columnName": "custom_cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songOrderVersion", + "columnName": "song_order_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSystem", + "columnName": "is_system", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id" + ] + }, + "indices": [ + { + "name": "index_local_playlist_display_position", + "unique": false, + "columnNames": [ + "display_position" + ], + "orders": [ + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_local_playlist_display_position` ON `${TABLE_NAME}` (`display_position` ASC)" + }, + { + "name": "index_local_playlist_modified_at", + "unique": false, + "columnNames": [ + "modified_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_local_playlist_modified_at` ON `${TABLE_NAME}` (`modified_at` DESC)" + } + ] + }, + { + "tableName": "track", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identity_key` TEXT NOT NULL, `identity_id` INTEGER NOT NULL, `identity_album` TEXT NOT NULL, `identity_media_uri` TEXT, `song_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `duration_ms` INTEGER NOT NULL, `cover_url` TEXT, `media_uri` TEXT, `channel_id` TEXT, `audio_id` TEXT, `sub_audio_id` TEXT, `source_stable_key` TEXT, `local_file_name` TEXT, `local_file_path` TEXT, `payload_schema_version` INTEGER NOT NULL, `durable_payload_json` TEXT NOT NULL, PRIMARY KEY(`identity_key`))", + "fields": [ + { + "fieldPath": "identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identity_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityAlbum", + "columnName": "identity_album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityMediaUri", + "columnName": "identity_media_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverUrl", + "columnName": "cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaUri", + "columnName": "media_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "channelId", + "columnName": "channel_id", + "affinity": "TEXT" + }, + { + "fieldPath": "audioId", + "columnName": "audio_id", + "affinity": "TEXT" + }, + { + "fieldPath": "subAudioId", + "columnName": "sub_audio_id", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceStableKey", + "columnName": "source_stable_key", + "affinity": "TEXT" + }, + { + "fieldPath": "localFileName", + "columnName": "local_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "localFilePath", + "columnName": "local_file_path", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadSchemaVersion", + "columnName": "payload_schema_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durablePayloadJson", + "columnName": "durable_payload_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identity_key" + ] + }, + "indices": [ + { + "name": "index_track_identity_parts", + "unique": false, + "columnNames": [ + "identity_id", + "identity_album", + "identity_media_uri" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_identity_parts` ON `${TABLE_NAME}` (`identity_id`, `identity_album`, `identity_media_uri`)" + }, + { + "name": "index_track_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_track_artist", + "unique": false, + "columnNames": [ + "artist" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_track_artist` ON `${TABLE_NAME}` (`artist`)" + } + ] + }, + { + "tableName": "playlist_member", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `identity_key` TEXT NOT NULL, `display_position` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, `order_tie_break` INTEGER NOT NULL, `playlist_context_id` TEXT, `member_payload_schema_version` INTEGER NOT NULL, `member_payload_json` TEXT NOT NULL, PRIMARY KEY(`playlist_id`, `identity_key`), FOREIGN KEY(`playlist_id`) REFERENCES `local_playlist`(`playlist_id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`identity_key`) REFERENCES `track`(`identity_key`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayPosition", + "columnName": "display_position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderTieBreak", + "columnName": "order_tie_break", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistContextId", + "columnName": "playlist_context_id", + "affinity": "TEXT" + }, + { + "fieldPath": "memberPayloadSchemaVersion", + "columnName": "member_payload_schema_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memberPayloadJson", + "columnName": "member_payload_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "identity_key" + ] + }, + "indices": [ + { + "name": "index_playlist_member_display_order", + "unique": false, + "columnNames": [ + "playlist_id", + "display_position" + ], + "orders": [ + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_member_display_order` ON `${TABLE_NAME}` (`playlist_id` ASC, `display_position` ASC)" + }, + { + "name": "index_playlist_member_added_order", + "unique": false, + "columnNames": [ + "playlist_id", + "added_at", + "order_tie_break" + ], + "orders": [ + "ASC", + "DESC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_member_added_order` ON `${TABLE_NAME}` (`playlist_id` ASC, `added_at` DESC, `order_tie_break` ASC)" + }, + { + "name": "index_playlist_member_identity_key", + "unique": false, + "columnNames": [ + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_member_identity_key` ON `${TABLE_NAME}` (`identity_key`)" + } + ], + "foreignKeys": [ + { + "table": "local_playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "playlist_id" + ] + }, + { + "table": "track", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identity_key" + ], + "referencedColumns": [ + "identity_key" + ] + } + ] + }, + { + "tableName": "playlist_member_token", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `identity_key` TEXT NOT NULL, `device_id` TEXT NOT NULL, `counter` INTEGER NOT NULL, `token_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `identity_key`, `device_id`, `counter`), FOREIGN KEY(`playlist_id`, `identity_key`) REFERENCES `playlist_member`(`playlist_id`, `identity_key`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "counter", + "columnName": "counter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenIndex", + "columnName": "token_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "identity_key", + "device_id", + "counter" + ] + }, + "indices": [ + { + "name": "index_playlist_member_token_member", + "unique": false, + "columnNames": [ + "playlist_id", + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_member_token_member` ON `${TABLE_NAME}` (`playlist_id`, `identity_key`)" + } + ], + "foreignKeys": [ + { + "table": "playlist_member", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id", + "identity_key" + ], + "referencedColumns": [ + "playlist_id", + "identity_key" + ] + } + ] + }, + { + "tableName": "sync_outbox", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `operation_id` TEXT NOT NULL, `expected_domain_revision` INTEGER NOT NULL, `payload_version` INTEGER NOT NULL, `mutation_payload_json` TEXT NOT NULL, `status` TEXT NOT NULL, `attempt_count` INTEGER NOT NULL, `last_error_type` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "operationId", + "columnName": "operation_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expectedDomainRevision", + "columnName": "expected_domain_revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadVersion", + "columnName": "payload_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mutationPayloadJson", + "columnName": "mutation_payload_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attemptCount", + "columnName": "attempt_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastErrorType", + "columnName": "last_error_type", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "sequence" + ] + }, + "indices": [ + { + "name": "index_sync_outbox_operation_id", + "unique": true, + "columnNames": [ + "operation_id" + ], + "orders": [ + "ASC" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_sync_outbox_operation_id` ON `${TABLE_NAME}` (`operation_id` ASC)" + }, + { + "name": "index_sync_outbox_status_sequence", + "unique": false, + "columnNames": [ + "status", + "sequence" + ], + "orders": [ + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_outbox_status_sequence` ON `${TABLE_NAME}` (`status` ASC, `sequence` ASC)" + } + ] + }, + { + "tableName": "sync_replica_checkpoint", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transport_id` TEXT NOT NULL, `domain_revision` INTEGER NOT NULL, `remote_version` TEXT, `remote_fingerprint` TEXT, `status` TEXT NOT NULL, `last_synced_at` INTEGER NOT NULL, `last_attempt_at` INTEGER NOT NULL, PRIMARY KEY(`transport_id`))", + "fields": [ + { + "fieldPath": "transportId", + "columnName": "transport_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "domainRevision", + "columnName": "domain_revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteVersion", + "columnName": "remote_version", + "affinity": "TEXT" + }, + { + "fieldPath": "remoteFingerprint", + "columnName": "remote_fingerprint", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "last_synced_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAttemptAt", + "columnName": "last_attempt_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transport_id" + ] + } + }, + { + "tableName": "migration_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT" + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "play_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identity_key` TEXT NOT NULL, `identity_id` INTEGER NOT NULL, `identity_album` TEXT NOT NULL, `identity_media_uri` TEXT, `id` INTEGER NOT NULL, `name` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `duration_ms` INTEGER NOT NULL, `resume_position_ms` INTEGER NOT NULL, `cover_url` TEXT, `media_uri` TEXT, `matched_lyric` TEXT, `matched_translated_lyric` TEXT, `custom_cover_url` TEXT, `custom_name` TEXT, `custom_artist` TEXT, `original_name` TEXT, `original_artist` TEXT, `original_cover_url` TEXT, `original_lyric` TEXT, `original_translated_lyric` TEXT, `local_file_name` TEXT, `local_file_path` TEXT, `channel_id` TEXT, `audio_id` TEXT, `sub_audio_id` TEXT, `source_stable_key` TEXT, `played_at` INTEGER NOT NULL, `lyric_sync_payload_json` TEXT, `explicit_local_source_reference` TEXT, PRIMARY KEY(`identity_key`))", + "fields": [ + { + "fieldPath": "identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identity_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityAlbum", + "columnName": "identity_album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityMediaUri", + "columnName": "identity_media_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "resumePositionMs", + "columnName": "resume_position_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverUrl", + "columnName": "cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaUri", + "columnName": "media_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "matchedLyric", + "columnName": "matched_lyric", + "affinity": "TEXT" + }, + { + "fieldPath": "matchedTranslatedLyric", + "columnName": "matched_translated_lyric", + "affinity": "TEXT" + }, + { + "fieldPath": "customCoverUrl", + "columnName": "custom_cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "customName", + "columnName": "custom_name", + "affinity": "TEXT" + }, + { + "fieldPath": "customArtist", + "columnName": "custom_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "originalName", + "columnName": "original_name", + "affinity": "TEXT" + }, + { + "fieldPath": "originalArtist", + "columnName": "original_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "originalCoverUrl", + "columnName": "original_cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "originalLyric", + "columnName": "original_lyric", + "affinity": "TEXT" + }, + { + "fieldPath": "originalTranslatedLyric", + "columnName": "original_translated_lyric", + "affinity": "TEXT" + }, + { + "fieldPath": "localFileName", + "columnName": "local_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "localFilePath", + "columnName": "local_file_path", + "affinity": "TEXT" + }, + { + "fieldPath": "channelId", + "columnName": "channel_id", + "affinity": "TEXT" + }, + { + "fieldPath": "audioId", + "columnName": "audio_id", + "affinity": "TEXT" + }, + { + "fieldPath": "subAudioId", + "columnName": "sub_audio_id", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceStableKey", + "columnName": "source_stable_key", + "affinity": "TEXT" + }, + { + "fieldPath": "playedAt", + "columnName": "played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lyricSyncPayloadJson", + "columnName": "lyric_sync_payload_json", + "affinity": "TEXT" + }, + { + "fieldPath": "explicitLocalSourceReference", + "columnName": "explicit_local_source_reference", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identity_key" + ] + }, + "indices": [ + { + "name": "index_play_history_played_at", + "unique": false, + "columnNames": [ + "played_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_play_history_played_at` ON `${TABLE_NAME}` (`played_at` DESC)" + }, + { + "name": "index_play_history_identity_parts", + "unique": false, + "columnNames": [ + "identity_id", + "identity_album", + "identity_media_uri" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_play_history_identity_parts` ON `${TABLE_NAME}` (`identity_id`, `identity_album`, `identity_media_uri`)" + } + ] + }, + { + "tableName": "playlist_usage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`usage_key` TEXT NOT NULL, `id` INTEGER NOT NULL, `name` TEXT NOT NULL, `pic_url` TEXT, `track_count` INTEGER NOT NULL, `source` TEXT NOT NULL, `last_opened` INTEGER NOT NULL, `open_count` INTEGER NOT NULL, `first_opened` INTEGER NOT NULL, `counter_base_open_count` INTEGER NOT NULL, `fid` INTEGER, `mid` INTEGER, `browse_id` TEXT, `playlist_id` TEXT, `subtype` TEXT, `subtitle` TEXT, `usage_deletion_tokens_json` TEXT, PRIMARY KEY(`usage_key`))", + "fields": [ + { + "fieldPath": "usageKey", + "columnName": "usage_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "picUrl", + "columnName": "pic_url", + "affinity": "TEXT" + }, + { + "fieldPath": "trackCount", + "columnName": "track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastOpened", + "columnName": "last_opened", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "openCount", + "columnName": "open_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstOpened", + "columnName": "first_opened", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterBaseOpenCount", + "columnName": "counter_base_open_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fid", + "columnName": "fid", + "affinity": "INTEGER" + }, + { + "fieldPath": "mid", + "columnName": "mid", + "affinity": "INTEGER" + }, + { + "fieldPath": "browseId", + "columnName": "browse_id", + "affinity": "TEXT" + }, + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "subtype", + "columnName": "subtype", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "usageDeletionTokensJson", + "columnName": "usage_deletion_tokens_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "usage_key" + ] + }, + "indices": [ + { + "name": "index_playlist_usage_last_opened", + "unique": false, + "columnNames": [ + "last_opened" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_usage_last_opened` ON `${TABLE_NAME}` (`last_opened` DESC)" + }, + { + "name": "index_playlist_usage_source_id", + "unique": false, + "columnNames": [ + "source", + "playlist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_usage_source_id` ON `${TABLE_NAME}` (`source`, `playlist_id`)" + } + ] + }, + { + "tableName": "playlist_usage_counter_shard", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`usage_key` TEXT NOT NULL, `device_id` TEXT NOT NULL, `epoch_started_at` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `first_played_at` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, PRIMARY KEY(`usage_key`, `device_id`, `epoch_started_at`), FOREIGN KEY(`usage_key`) REFERENCES `playlist_usage`(`usage_key`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "usageKey", + "columnName": "usage_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochStartedAt", + "columnName": "epoch_started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstPlayedAt", + "columnName": "first_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "usage_key", + "device_id", + "epoch_started_at" + ] + }, + "indices": [ + { + "name": "index_playlist_usage_counter_usage_key", + "unique": false, + "columnNames": [ + "usage_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_usage_counter_usage_key` ON `${TABLE_NAME}` (`usage_key`)" + } + ], + "foreignKeys": [ + { + "table": "playlist_usage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "usage_key" + ], + "referencedColumns": [ + "usage_key" + ] + } + ] + }, + { + "tableName": "local_playlist_playback_stat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `total_play_count` INTEGER NOT NULL, `first_played_at` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, `counter_base_play_count` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayCount", + "columnName": "total_play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstPlayedAt", + "columnName": "first_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterBasePlayCount", + "columnName": "counter_base_play_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id" + ] + }, + "indices": [ + { + "name": "index_local_playlist_playback_stat_last_played", + "unique": false, + "columnNames": [ + "last_played_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_local_playlist_playback_stat_last_played` ON `${TABLE_NAME}` (`last_played_at` DESC)" + } + ] + }, + { + "tableName": "local_playlist_playback_bucket", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `day_start_at` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `first_played_at` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, `counter_base_play_count` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `day_start_at`), FOREIGN KEY(`playlist_id`) REFERENCES `local_playlist_playback_stat`(`playlist_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dayStartAt", + "columnName": "day_start_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstPlayedAt", + "columnName": "first_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterBasePlayCount", + "columnName": "counter_base_play_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "day_start_at" + ] + }, + "indices": [ + { + "name": "index_local_playlist_playback_bucket_day", + "unique": false, + "columnNames": [ + "playlist_id", + "day_start_at" + ], + "orders": [ + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_local_playlist_playback_bucket_day` ON `${TABLE_NAME}` (`playlist_id` ASC, `day_start_at` ASC)" + } + ], + "foreignKeys": [ + { + "table": "local_playlist_playback_stat", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "playlist_id" + ] + } + ] + }, + { + "tableName": "local_playlist_playback_counter_shard", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `day_start_at` INTEGER NOT NULL, `device_id` TEXT NOT NULL, `epoch_started_at` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `first_played_at` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `day_start_at`, `device_id`, `epoch_started_at`), FOREIGN KEY(`playlist_id`) REFERENCES `local_playlist_playback_stat`(`playlist_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dayStartAt", + "columnName": "day_start_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochStartedAt", + "columnName": "epoch_started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstPlayedAt", + "columnName": "first_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "day_start_at", + "device_id", + "epoch_started_at" + ] + }, + "indices": [ + { + "name": "index_local_playlist_playback_counter_scope", + "unique": false, + "columnNames": [ + "playlist_id", + "day_start_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_local_playlist_playback_counter_scope` ON `${TABLE_NAME}` (`playlist_id`, `day_start_at`)" + } + ], + "foreignKeys": [ + { + "table": "local_playlist_playback_stat", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "playlist_id" + ] + } + ] + }, + { + "tableName": "playback_stat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identity_key` TEXT NOT NULL, `id` INTEGER NOT NULL, `name` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `cover_url` TEXT, `duration_ms` INTEGER NOT NULL, `total_listen_ms` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, `first_played_at` INTEGER NOT NULL, `media_uri` TEXT, `local_file_path` TEXT, `local_file_name` TEXT, `custom_name` TEXT, `custom_artist` TEXT, `custom_cover_url` TEXT, PRIMARY KEY(`identity_key`))", + "fields": [ + { + "fieldPath": "identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverUrl", + "columnName": "cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalListenMs", + "columnName": "total_listen_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstPlayedAt", + "columnName": "first_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaUri", + "columnName": "media_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "localFilePath", + "columnName": "local_file_path", + "affinity": "TEXT" + }, + { + "fieldPath": "localFileName", + "columnName": "local_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "customName", + "columnName": "custom_name", + "affinity": "TEXT" + }, + { + "fieldPath": "customArtist", + "columnName": "custom_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "customCoverUrl", + "columnName": "custom_cover_url", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identity_key" + ] + }, + "indices": [ + { + "name": "index_playback_stat_last_played", + "unique": false, + "columnNames": [ + "last_played_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_stat_last_played` ON `${TABLE_NAME}` (`last_played_at` DESC)" + }, + { + "name": "index_playback_stat_media_uri", + "unique": false, + "columnNames": [ + "media_uri" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_stat_media_uri` ON `${TABLE_NAME}` (`media_uri`)" + }, + { + "name": "index_playback_stat_play_count", + "unique": false, + "columnNames": [ + "play_count", + "identity_key" + ], + "orders": [ + "DESC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_stat_play_count` ON `${TABLE_NAME}` (`play_count` DESC, `identity_key` ASC)" + }, + { + "name": "index_playback_stat_listen_time", + "unique": false, + "columnNames": [ + "total_listen_ms", + "identity_key" + ], + "orders": [ + "DESC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_stat_listen_time` ON `${TABLE_NAME}` (`total_listen_ms` DESC, `identity_key` ASC)" + }, + { + "name": "index_playback_stat_recent_identity", + "unique": false, + "columnNames": [ + "last_played_at", + "identity_key" + ], + "orders": [ + "DESC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_stat_recent_identity` ON `${TABLE_NAME}` (`last_played_at` DESC, `identity_key` ASC)" + }, + { + "name": "index_playback_stat_first_identity", + "unique": false, + "columnNames": [ + "first_played_at", + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_stat_first_identity` ON `${TABLE_NAME}` (`first_played_at`, `identity_key`)" + } + ] + }, + { + "tableName": "playback_stats_snapshot", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `revision` INTEGER NOT NULL, `cleared_at` INTEGER NOT NULL, `counter_epoch_started_at` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `sealed` INTEGER NOT NULL, `owner_process_id` TEXT NOT NULL, `is_diff` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clearedAt", + "columnName": "cleared_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterEpochStartedAt", + "columnName": "counter_epoch_started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sealed", + "columnName": "sealed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerProcessId", + "columnName": "owner_process_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDiff", + "columnName": "is_diff", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playback_stat_snapshot_track", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`snapshot_id` TEXT NOT NULL, `identity_key` TEXT NOT NULL, `id` INTEGER NOT NULL, `name` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `cover_url` TEXT, `duration_ms` INTEGER NOT NULL, `total_listen_ms` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, `first_played_at` INTEGER NOT NULL, `media_uri` TEXT, `local_file_path` TEXT, `local_file_name` TEXT, `custom_name` TEXT, `custom_artist` TEXT, `custom_cover_url` TEXT, PRIMARY KEY(`snapshot_id`, `identity_key`))", + "fields": [ + { + "fieldPath": "snapshotId", + "columnName": "snapshot_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stat.identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stat.id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stat.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stat.artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stat.album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stat.albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stat.coverUrl", + "columnName": "cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "stat.durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stat.totalListenMs", + "columnName": "total_listen_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stat.playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stat.lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stat.firstPlayedAt", + "columnName": "first_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stat.mediaUri", + "columnName": "media_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "stat.localFilePath", + "columnName": "local_file_path", + "affinity": "TEXT" + }, + { + "fieldPath": "stat.localFileName", + "columnName": "local_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "stat.customName", + "columnName": "custom_name", + "affinity": "TEXT" + }, + { + "fieldPath": "stat.customArtist", + "columnName": "custom_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "stat.customCoverUrl", + "columnName": "custom_cover_url", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "snapshot_id", + "identity_key" + ] + } + }, + { + "tableName": "playback_stat_snapshot_bucket", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`snapshot_id` TEXT NOT NULL, `day_start_at` INTEGER NOT NULL, `identity_key` TEXT NOT NULL, `id` INTEGER NOT NULL, `name` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `cover_url` TEXT, `duration_ms` INTEGER NOT NULL, `total_listen_ms` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, `first_played_at` INTEGER NOT NULL, `media_uri` TEXT, `local_file_path` TEXT, `local_file_name` TEXT, `custom_name` TEXT, `custom_artist` TEXT, `custom_cover_url` TEXT, PRIMARY KEY(`snapshot_id`, `day_start_at`, `identity_key`))", + "fields": [ + { + "fieldPath": "snapshotId", + "columnName": "snapshot_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucket.dayStartAt", + "columnName": "day_start_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bucket.identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucket.id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bucket.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucket.artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucket.album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bucket.albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bucket.coverUrl", + "columnName": "cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "bucket.durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bucket.totalListenMs", + "columnName": "total_listen_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bucket.playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bucket.lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bucket.firstPlayedAt", + "columnName": "first_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bucket.mediaUri", + "columnName": "media_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "bucket.localFilePath", + "columnName": "local_file_path", + "affinity": "TEXT" + }, + { + "fieldPath": "bucket.localFileName", + "columnName": "local_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "bucket.customName", + "columnName": "custom_name", + "affinity": "TEXT" + }, + { + "fieldPath": "bucket.customArtist", + "columnName": "custom_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "bucket.customCoverUrl", + "columnName": "custom_cover_url", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "snapshot_id", + "day_start_at", + "identity_key" + ] + }, + "indices": [ + { + "name": "index_playback_snapshot_bucket_identity", + "unique": false, + "columnNames": [ + "snapshot_id", + "identity_key", + "day_start_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_snapshot_bucket_identity` ON `${TABLE_NAME}` (`snapshot_id`, `identity_key`, `day_start_at`)" + } + ] + }, + { + "tableName": "playback_stat_snapshot_counter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`snapshot_id` TEXT NOT NULL, `identity_key` TEXT NOT NULL, `device_id` TEXT NOT NULL, `epoch_started_at` INTEGER NOT NULL, `total_listen_ms` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `first_played_at` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, PRIMARY KEY(`snapshot_id`, `identity_key`, `device_id`, `epoch_started_at`))", + "fields": [ + { + "fieldPath": "snapshotId", + "columnName": "snapshot_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shard.identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shard.deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shard.epochStartedAt", + "columnName": "epoch_started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shard.totalListenMs", + "columnName": "total_listen_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shard.playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shard.firstPlayedAt", + "columnName": "first_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shard.lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "snapshot_id", + "identity_key", + "device_id", + "epoch_started_at" + ] + } + }, + { + "tableName": "playback_stat_snapshot_daily_counter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`snapshot_id` TEXT NOT NULL, `day_start_at` INTEGER NOT NULL, `identity_key` TEXT NOT NULL, `device_id` TEXT NOT NULL, `epoch_started_at` INTEGER NOT NULL, `total_listen_ms` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `first_played_at` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, PRIMARY KEY(`snapshot_id`, `day_start_at`, `identity_key`, `device_id`, `epoch_started_at`))", + "fields": [ + { + "fieldPath": "snapshotId", + "columnName": "snapshot_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shard.dayStartAt", + "columnName": "day_start_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shard.identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shard.deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shard.epochStartedAt", + "columnName": "epoch_started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shard.totalListenMs", + "columnName": "total_listen_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shard.playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shard.firstPlayedAt", + "columnName": "first_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shard.lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "snapshot_id", + "day_start_at", + "identity_key", + "device_id", + "epoch_started_at" + ] + } + }, + { + "tableName": "playback_stat_snapshot_deleted_track", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`snapshot_id` TEXT NOT NULL, `identity_key` TEXT NOT NULL, PRIMARY KEY(`snapshot_id`, `identity_key`))", + "fields": [ + { + "fieldPath": "snapshotId", + "columnName": "snapshot_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "snapshot_id", + "identity_key" + ] + } + }, + { + "tableName": "playback_stat_snapshot_deleted_bucket", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`snapshot_id` TEXT NOT NULL, `day_start_at` INTEGER NOT NULL, `identity_key` TEXT NOT NULL, PRIMARY KEY(`snapshot_id`, `day_start_at`, `identity_key`))", + "fields": [ + { + "fieldPath": "snapshotId", + "columnName": "snapshot_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dayStartAt", + "columnName": "day_start_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "snapshot_id", + "day_start_at", + "identity_key" + ] + } + }, + { + "tableName": "playback_stats_pending_delta", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sequence` INTEGER NOT NULL, `track_json` TEXT NOT NULL, `listened_ms` INTEGER NOT NULL, `play_count_increment` INTEGER, `played_at` INTEGER NOT NULL, `epoch_started_at` INTEGER NOT NULL, `device_id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trackJson", + "columnName": "track_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "listenedMs", + "columnName": "listened_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCountIncrement", + "columnName": "play_count_increment", + "affinity": "INTEGER" + }, + { + "fieldPath": "playedAt", + "columnName": "played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "epochStartedAt", + "columnName": "epoch_started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playback_pending_sequence", + "unique": true, + "columnNames": [ + "sequence" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playback_pending_sequence` ON `${TABLE_NAME}` (`sequence`)" + } + ] + }, + { + "tableName": "playback_stats_event_receipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accepted_at` INTEGER NOT NULL, `payload_hash` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acceptedAt", + "columnName": "accepted_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payloadHash", + "columnName": "payload_hash", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playback_stat_bucket", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_at` INTEGER NOT NULL, `identity_key` TEXT NOT NULL, `id` INTEGER NOT NULL, `name` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `cover_url` TEXT, `duration_ms` INTEGER NOT NULL, `total_listen_ms` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, `first_played_at` INTEGER NOT NULL, `media_uri` TEXT, `local_file_path` TEXT, `local_file_name` TEXT, `custom_name` TEXT, `custom_artist` TEXT, `custom_cover_url` TEXT, PRIMARY KEY(`day_start_at`, `identity_key`), FOREIGN KEY(`identity_key`) REFERENCES `playback_stat`(`identity_key`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "dayStartAt", + "columnName": "day_start_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverUrl", + "columnName": "cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalListenMs", + "columnName": "total_listen_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstPlayedAt", + "columnName": "first_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaUri", + "columnName": "media_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "localFilePath", + "columnName": "local_file_path", + "affinity": "TEXT" + }, + { + "fieldPath": "localFileName", + "columnName": "local_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "customName", + "columnName": "custom_name", + "affinity": "TEXT" + }, + { + "fieldPath": "customArtist", + "columnName": "custom_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "customCoverUrl", + "columnName": "custom_cover_url", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day_start_at", + "identity_key" + ] + }, + "indices": [ + { + "name": "index_playback_stat_bucket_day", + "unique": false, + "columnNames": [ + "day_start_at", + "identity_key" + ], + "orders": [ + "DESC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_stat_bucket_day` ON `${TABLE_NAME}` (`day_start_at` DESC, `identity_key` ASC)" + }, + { + "name": "index_playback_stat_bucket_identity", + "unique": false, + "columnNames": [ + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_stat_bucket_identity` ON `${TABLE_NAME}` (`identity_key`)" + }, + { + "name": "index_playback_stat_bucket_identity_day", + "unique": false, + "columnNames": [ + "identity_key", + "day_start_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_stat_bucket_identity_day` ON `${TABLE_NAME}` (`identity_key`, `day_start_at`)" + }, + { + "name": "index_playback_stat_bucket_latest", + "unique": false, + "columnNames": [ + "identity_key", + "last_played_at", + "day_start_at" + ], + "orders": [ + "ASC", + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_stat_bucket_latest` ON `${TABLE_NAME}` (`identity_key` ASC, `last_played_at` DESC, `day_start_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "playback_stat", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identity_key" + ], + "referencedColumns": [ + "identity_key" + ] + } + ] + }, + { + "tableName": "playback_stat_counter_shard", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identity_key` TEXT NOT NULL, `device_id` TEXT NOT NULL, `epoch_started_at` INTEGER NOT NULL, `total_listen_ms` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `first_played_at` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, PRIMARY KEY(`identity_key`, `device_id`, `epoch_started_at`), FOREIGN KEY(`identity_key`) REFERENCES `playback_stat`(`identity_key`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochStartedAt", + "columnName": "epoch_started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalListenMs", + "columnName": "total_listen_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstPlayedAt", + "columnName": "first_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identity_key", + "device_id", + "epoch_started_at" + ] + }, + "indices": [ + { + "name": "index_playback_stat_counter_identity", + "unique": false, + "columnNames": [ + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_stat_counter_identity` ON `${TABLE_NAME}` (`identity_key`)" + } + ], + "foreignKeys": [ + { + "table": "playback_stat", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identity_key" + ], + "referencedColumns": [ + "identity_key" + ] + } + ] + }, + { + "tableName": "playback_stat_daily_counter_shard", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_at` INTEGER NOT NULL, `identity_key` TEXT NOT NULL, `device_id` TEXT NOT NULL, `epoch_started_at` INTEGER NOT NULL, `total_listen_ms` INTEGER NOT NULL, `play_count` INTEGER NOT NULL, `first_played_at` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, PRIMARY KEY(`day_start_at`, `identity_key`, `device_id`, `epoch_started_at`), FOREIGN KEY(`day_start_at`, `identity_key`) REFERENCES `playback_stat_bucket`(`day_start_at`, `identity_key`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "dayStartAt", + "columnName": "day_start_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityKey", + "columnName": "identity_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epochStartedAt", + "columnName": "epoch_started_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalListenMs", + "columnName": "total_listen_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstPlayedAt", + "columnName": "first_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day_start_at", + "identity_key", + "device_id", + "epoch_started_at" + ] + }, + "indices": [ + { + "name": "index_playback_stat_daily_counter_scope", + "unique": false, + "columnNames": [ + "day_start_at", + "identity_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playback_stat_daily_counter_scope` ON `${TABLE_NAME}` (`day_start_at`, `identity_key`)" + } + ], + "foreignKeys": [ + { + "table": "playback_stat_bucket", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "day_start_at", + "identity_key" + ], + "referencedColumns": [ + "day_start_at", + "identity_key" + ] + } + ] + }, + { + "tableName": "favorite_playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `source` TEXT NOT NULL, `name` TEXT NOT NULL, `cover_url` TEXT, `track_count` INTEGER NOT NULL, `browse_id` TEXT, `remote_playlist_id` TEXT, `subtitle` TEXT, `added_time` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `is_deleted` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `source`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coverUrl", + "columnName": "cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "trackCount", + "columnName": "track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browse_id", + "affinity": "TEXT" + }, + { + "fieldPath": "remotePlaylistId", + "columnName": "remote_playlist_id", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "addedTime", + "columnName": "added_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "source" + ] + }, + "indices": [ + { + "name": "index_favorite_playlist_sort", + "unique": false, + "columnNames": [ + "sort_order", + "modified_at" + ], + "orders": [ + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_favorite_playlist_sort` ON `${TABLE_NAME}` (`sort_order` DESC, `modified_at` DESC)" + }, + { + "name": "index_favorite_playlist_visibility", + "unique": false, + "columnNames": [ + "is_deleted", + "sort_order" + ], + "orders": [ + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_favorite_playlist_visibility` ON `${TABLE_NAME}` (`is_deleted` ASC, `sort_order` DESC)" + } + ] + }, + { + "tableName": "favorite_playlist_song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `source` TEXT NOT NULL, `display_position` INTEGER NOT NULL, `song_payload_json` TEXT NOT NULL, PRIMARY KEY(`playlist_id`, `source`, `display_position`), FOREIGN KEY(`playlist_id`, `source`) REFERENCES `favorite_playlist`(`playlist_id`, `source`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayPosition", + "columnName": "display_position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songPayloadJson", + "columnName": "song_payload_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "source", + "display_position" + ] + }, + "indices": [ + { + "name": "index_favorite_playlist_song_order", + "unique": false, + "columnNames": [ + "playlist_id", + "source", + "display_position" + ], + "orders": [ + "ASC", + "ASC", + "ASC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_favorite_playlist_song_order` ON `${TABLE_NAME}` (`playlist_id` ASC, `source` ASC, `display_position` ASC)" + } + ], + "foreignKeys": [ + { + "table": "favorite_playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id", + "source" + ], + "referencedColumns": [ + "playlist_id", + "source" + ] + } + ] + }, + { + "tableName": "traffic_stats_bucket", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_start_at` INTEGER NOT NULL, `wifi_bytes` INTEGER NOT NULL, `mobile_bytes` INTEGER NOT NULL, `roaming_bytes` INTEGER NOT NULL, `playback_network_bytes` INTEGER NOT NULL, `download_network_bytes` INTEGER NOT NULL, `cache_hit_bytes` INTEGER NOT NULL, `request_count` INTEGER NOT NULL, `cache_hit_count` INTEGER NOT NULL, PRIMARY KEY(`day_start_at`))", + "fields": [ + { + "fieldPath": "dayStartAt", + "columnName": "day_start_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wifiBytes", + "columnName": "wifi_bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileBytes", + "columnName": "mobile_bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "roamingBytes", + "columnName": "roaming_bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playbackNetworkBytes", + "columnName": "playback_network_bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadNetworkBytes", + "columnName": "download_network_bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cacheHitBytes", + "columnName": "cache_hit_bytes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requestCount", + "columnName": "request_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cacheHitCount", + "columnName": "cache_hit_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day_start_at" + ] + }, + "indices": [ + { + "name": "index_traffic_stats_bucket_day", + "unique": false, + "columnNames": [ + "day_start_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traffic_stats_bucket_day` ON `${TABLE_NAME}` (`day_start_at` DESC)" + } + ] + }, + { + "tableName": "playback_queue_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `current_index` INTEGER NOT NULL, `media_url` TEXT, `position_ms` INTEGER NOT NULL, `should_resume_playback` INTEGER NOT NULL, `repeat_mode` INTEGER, `shuffle_enabled` INTEGER, `shuffle_restore_index` INTEGER, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentIndex", + "columnName": "current_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaUrl", + "columnName": "media_url", + "affinity": "TEXT" + }, + { + "fieldPath": "positionMs", + "columnName": "position_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldResumePlayback", + "columnName": "should_resume_playback", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatMode", + "columnName": "repeat_mode", + "affinity": "INTEGER" + }, + { + "fieldPath": "shuffleEnabled", + "columnName": "shuffle_enabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "shuffleRestoreIndex", + "columnName": "shuffle_restore_index", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playback_queue_song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`queue_id` TEXT NOT NULL, `position` INTEGER NOT NULL, `id` INTEGER NOT NULL, `name` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER NOT NULL, `duration_ms` INTEGER NOT NULL, `cover_url` TEXT, `media_uri` TEXT, `matched_lyric` TEXT, `matched_translated_lyric` TEXT, `matched_lyric_source` TEXT, `matched_song_id` TEXT, `user_lyric_offset_ms` INTEGER NOT NULL, `custom_cover_url` TEXT, `custom_name` TEXT, `custom_artist` TEXT, `original_name` TEXT, `original_artist` TEXT, `original_cover_url` TEXT, `original_lyric` TEXT, `original_translated_lyric` TEXT, `local_file_name` TEXT, `local_file_path` TEXT, `channel_id` TEXT, `audio_id` TEXT, `sub_audio_id` TEXT, `playlist_context_id` TEXT, `stream_url` TEXT, `lyric_sync_payload_json` TEXT, `explicit_local_source_reference` TEXT, PRIMARY KEY(`queue_id`, `position`))", + "fields": [ + { + "fieldPath": "queueId", + "columnName": "queue_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverUrl", + "columnName": "cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "mediaUri", + "columnName": "media_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "matchedLyric", + "columnName": "matched_lyric", + "affinity": "TEXT" + }, + { + "fieldPath": "matchedTranslatedLyric", + "columnName": "matched_translated_lyric", + "affinity": "TEXT" + }, + { + "fieldPath": "matchedLyricSource", + "columnName": "matched_lyric_source", + "affinity": "TEXT" + }, + { + "fieldPath": "matchedSongId", + "columnName": "matched_song_id", + "affinity": "TEXT" + }, + { + "fieldPath": "userLyricOffsetMs", + "columnName": "user_lyric_offset_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customCoverUrl", + "columnName": "custom_cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "customName", + "columnName": "custom_name", + "affinity": "TEXT" + }, + { + "fieldPath": "customArtist", + "columnName": "custom_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "originalName", + "columnName": "original_name", + "affinity": "TEXT" + }, + { + "fieldPath": "originalArtist", + "columnName": "original_artist", + "affinity": "TEXT" + }, + { + "fieldPath": "originalCoverUrl", + "columnName": "original_cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "originalLyric", + "columnName": "original_lyric", + "affinity": "TEXT" + }, + { + "fieldPath": "originalTranslatedLyric", + "columnName": "original_translated_lyric", + "affinity": "TEXT" + }, + { + "fieldPath": "localFileName", + "columnName": "local_file_name", + "affinity": "TEXT" + }, + { + "fieldPath": "localFilePath", + "columnName": "local_file_path", + "affinity": "TEXT" + }, + { + "fieldPath": "channelId", + "columnName": "channel_id", + "affinity": "TEXT" + }, + { + "fieldPath": "audioId", + "columnName": "audio_id", + "affinity": "TEXT" + }, + { + "fieldPath": "subAudioId", + "columnName": "sub_audio_id", + "affinity": "TEXT" + }, + { + "fieldPath": "playlistContextId", + "columnName": "playlist_context_id", + "affinity": "TEXT" + }, + { + "fieldPath": "streamUrl", + "columnName": "stream_url", + "affinity": "TEXT" + }, + { + "fieldPath": "lyricSyncPayloadJson", + "columnName": "lyric_sync_payload_json", + "affinity": "TEXT" + }, + { + "fieldPath": "explicitLocalSourceReference", + "columnName": "explicit_local_source_reference", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "queue_id", + "position" + ] + } + }, + { + "tableName": "bili_video_skip_rule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bvid` TEXT NOT NULL, `cid` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, `is_deleted` INTEGER NOT NULL, PRIMARY KEY(`bvid`, `cid`))", + "fields": [ + { + "fieldPath": "bvid", + "columnName": "bvid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cid", + "columnName": "cid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bvid", + "cid" + ] + }, + "indices": [ + { + "name": "index_bili_video_skip_rule_modified_at", + "unique": false, + "columnNames": [ + "modified_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_bili_video_skip_rule_modified_at` ON `${TABLE_NAME}` (`modified_at` DESC)" + } + ] + }, + { + "tableName": "bili_video_skip_interval", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bvid` TEXT NOT NULL, `cid` INTEGER NOT NULL, `position` INTEGER NOT NULL, `start_ms` INTEGER NOT NULL, `end_ms` INTEGER NOT NULL, PRIMARY KEY(`bvid`, `cid`, `position`), FOREIGN KEY(`bvid`, `cid`) REFERENCES `bili_video_skip_rule`(`bvid`, `cid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "bvid", + "columnName": "bvid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cid", + "columnName": "cid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startMs", + "columnName": "start_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMs", + "columnName": "end_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "bvid", + "cid", + "position" + ] + }, + "foreignKeys": [ + { + "table": "bili_video_skip_rule", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bvid", + "cid" + ], + "referencedColumns": [ + "bvid", + "cid" + ] + } + ] + }, + { + "tableName": "bili_video_skip_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`target_key` TEXT NOT NULL, `bvid` TEXT NOT NULL, `cid` INTEGER NOT NULL, `start_text` TEXT NOT NULL, `end_text` TEXT NOT NULL, `modified_at` INTEGER NOT NULL, PRIMARY KEY(`target_key`))", + "fields": [ + { + "fieldPath": "targetKey", + "columnName": "target_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bvid", + "columnName": "bvid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cid", + "columnName": "cid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startText", + "columnName": "start_text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endText", + "columnName": "end_text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "target_key" + ] + } + }, + { + "tableName": "cover_url_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`local_url` TEXT NOT NULL, `network_url` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`local_url`))", + "fields": [ + { + "fieldPath": "localUrl", + "columnName": "local_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkUrl", + "columnName": "network_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "local_url" + ] + }, + "indices": [ + { + "name": "index_cover_url_mapping_updated_at", + "unique": false, + "columnNames": [ + "updated_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_cover_url_mapping_updated_at` ON `${TABLE_NAME}` (`updated_at` DESC)" + } + ] + }, + { + "tableName": "download_operation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`operation_id` TEXT NOT NULL, `stable_key` TEXT NOT NULL, `library_id` TEXT NOT NULL, `state` TEXT NOT NULL, `queue_order` INTEGER NOT NULL, `source_hint_json` TEXT NOT NULL, `staging_dir_name` TEXT NOT NULL, `bytes_written` INTEGER NOT NULL, `total_bytes` INTEGER, `resume_json` TEXT, `retry_count` INTEGER NOT NULL, `next_retry_at_ms` INTEGER, `last_error_code` TEXT, `stop_requested_by_user` INTEGER NOT NULL DEFAULT 0, `created_at_ms` INTEGER NOT NULL, `updated_at_ms` INTEGER NOT NULL, `host_process_token` TEXT, `host_admitted_at_ms` INTEGER, `batch_id` TEXT, `batch_generation` INTEGER, PRIMARY KEY(`operation_id`))", + "fields": [ + { + "fieldPath": "operationId", + "columnName": "operation_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stableKey", + "columnName": "stable_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "libraryId", + "columnName": "library_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "queueOrder", + "columnName": "queue_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sourceHintJson", + "columnName": "source_hint_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stagingDirName", + "columnName": "staging_dir_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bytesWritten", + "columnName": "bytes_written", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalBytes", + "columnName": "total_bytes", + "affinity": "INTEGER" + }, + { + "fieldPath": "resumeJson", + "columnName": "resume_json", + "affinity": "TEXT" + }, + { + "fieldPath": "retryCount", + "columnName": "retry_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nextRetryAtMs", + "columnName": "next_retry_at_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastErrorCode", + "columnName": "last_error_code", + "affinity": "TEXT" + }, + { + "fieldPath": "stopRequestedByUser", + "columnName": "stop_requested_by_user", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "createdAtMs", + "columnName": "created_at_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updated_at_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hostProcessToken", + "columnName": "host_process_token", + "affinity": "TEXT" + }, + { + "fieldPath": "hostAdmittedAtMs", + "columnName": "host_admitted_at_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "batchId", + "columnName": "batch_id", + "affinity": "TEXT" + }, + { + "fieldPath": "batchGeneration", + "columnName": "batch_generation", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "operation_id" + ] + }, + "indices": [ + { + "name": "index_download_operation_recovery_cursor", + "unique": false, + "columnNames": [ + "state", + "stop_requested_by_user", + "queue_order", + "created_at_ms", + "operation_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_operation_recovery_cursor` ON `${TABLE_NAME}` (`state`, `stop_requested_by_user`, `queue_order`, `created_at_ms`, `operation_id`)" + }, + { + "name": "index_download_operation_state_queue", + "unique": false, + "columnNames": [ + "state", + "queue_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_operation_state_queue` ON `${TABLE_NAME}` (`state`, `queue_order`)" + }, + { + "name": "index_download_operation_host_process_library", + "unique": false, + "columnNames": [ + "host_process_token", + "library_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_operation_host_process_library` ON `${TABLE_NAME}` (`host_process_token`, `library_id`)" + }, + { + "name": "index_download_operation_batch", + "unique": false, + "columnNames": [ + "batch_id", + "batch_generation" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_operation_batch` ON `${TABLE_NAME}` (`batch_id`, `batch_generation`)" + } + ] + }, + { + "tableName": "download_batch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`batch_id` TEXT NOT NULL, `generation` INTEGER NOT NULL, `total_count` INTEGER NOT NULL, `state_bits` INTEGER NOT NULL, `clear_epoch` INTEGER NOT NULL, `network_generation` INTEGER, `updated_at_ms` INTEGER NOT NULL, `created_at_ms` INTEGER NOT NULL, PRIMARY KEY(`batch_id`))", + "fields": [ + { + "fieldPath": "batchId", + "columnName": "batch_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "generation", + "columnName": "generation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "total_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stateBits", + "columnName": "state_bits", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "clearEpoch", + "columnName": "clear_epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkGeneration", + "columnName": "network_generation", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updated_at_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "created_at_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "batch_id" + ] + }, + "indices": [ + { + "name": "index_download_batch_state_updated", + "unique": false, + "columnNames": [ + "state_bits", + "updated_at_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_batch_state_updated` ON `${TABLE_NAME}` (`state_bits`, `updated_at_ms`)" + }, + { + "name": "index_download_batch_generation", + "unique": true, + "columnNames": [ + "generation" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_batch_generation` ON `${TABLE_NAME}` (`generation`)" + } + ] + }, + { + "tableName": "download_batch_member", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`batch_id` TEXT NOT NULL, `ordinal` INTEGER NOT NULL, `stable_key` TEXT NOT NULL, `terminal_bits` INTEGER NOT NULL, `max_fraction_milli` INTEGER NOT NULL, `initially_completed` INTEGER NOT NULL, `operation_id` TEXT, `attempt_id` INTEGER, `updated_at_ms` INTEGER NOT NULL, PRIMARY KEY(`batch_id`, `stable_key`))", + "fields": [ + { + "fieldPath": "batchId", + "columnName": "batch_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ordinal", + "columnName": "ordinal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stableKey", + "columnName": "stable_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "terminalBits", + "columnName": "terminal_bits", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxFractionMilli", + "columnName": "max_fraction_milli", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "initiallyCompleted", + "columnName": "initially_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "operationId", + "columnName": "operation_id", + "affinity": "TEXT" + }, + { + "fieldPath": "attemptId", + "columnName": "attempt_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updated_at_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "batch_id", + "stable_key" + ] + }, + "indices": [ + { + "name": "index_download_batch_member_order", + "unique": true, + "columnNames": [ + "batch_id", + "ordinal" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_download_batch_member_order` ON `${TABLE_NAME}` (`batch_id`, `ordinal`)" + }, + { + "name": "index_download_batch_member_terminal", + "unique": false, + "columnNames": [ + "batch_id", + "terminal_bits", + "ordinal" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_batch_member_terminal` ON `${TABLE_NAME}` (`batch_id`, `terminal_bits`, `ordinal`)" + }, + { + "name": "index_download_batch_member_operation", + "unique": false, + "columnNames": [ + "operation_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_batch_member_operation` ON `${TABLE_NAME}` (`operation_id`)" + } + ] + }, + { + "tableName": "managed_library_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`library_id` TEXT NOT NULL, `stable_key` TEXT NOT NULL, `artifact_id` TEXT NOT NULL, `state` TEXT NOT NULL, `lease_id` TEXT, `audio_reference` TEXT, `audio_name` TEXT, `file_size` INTEGER, `content_hash` TEXT, `library_added_at_ms` INTEGER, `source_created_at_ms` INTEGER, `source_modified_at_ms` INTEGER, `downloaded_at_ms` INTEGER, `migrated_at_ms` INTEGER, `finalized_at_ms` INTEGER, `updated_at_ms` INTEGER NOT NULL, `needs_reconcile` INTEGER NOT NULL, `last_error_code` TEXT, `metadata_name` TEXT, `locator_hint` TEXT, `title_preview` TEXT, `artist_preview` TEXT, `cover_key_preview` TEXT, `metadata_revision` INTEGER NOT NULL, PRIMARY KEY(`library_id`, `stable_key`))", + "fields": [ + { + "fieldPath": "rootKey", + "columnName": "library_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stableKey", + "columnName": "stable_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifact_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "leaseId", + "columnName": "lease_id", + "affinity": "TEXT" + }, + { + "fieldPath": "audioReference", + "columnName": "audio_reference", + "affinity": "TEXT" + }, + { + "fieldPath": "audioName", + "columnName": "audio_name", + "affinity": "TEXT" + }, + { + "fieldPath": "fileSize", + "columnName": "file_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "contentHash", + "columnName": "content_hash", + "affinity": "TEXT" + }, + { + "fieldPath": "libraryAddedAtMs", + "columnName": "library_added_at_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceCreatedAtMs", + "columnName": "source_created_at_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "sourceModifiedAtMs", + "columnName": "source_modified_at_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "downloadedAtMs", + "columnName": "downloaded_at_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "migratedAtMs", + "columnName": "migrated_at_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "finalizedAtMs", + "columnName": "finalized_at_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtMs", + "columnName": "updated_at_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsReconcile", + "columnName": "needs_reconcile", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastErrorCode", + "columnName": "last_error_code", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataName", + "columnName": "metadata_name", + "affinity": "TEXT" + }, + { + "fieldPath": "locatorHint", + "columnName": "locator_hint", + "affinity": "TEXT" + }, + { + "fieldPath": "titlePreview", + "columnName": "title_preview", + "affinity": "TEXT" + }, + { + "fieldPath": "artistPreview", + "columnName": "artist_preview", + "affinity": "TEXT" + }, + { + "fieldPath": "coverKeyPreview", + "columnName": "cover_key_preview", + "affinity": "TEXT" + }, + { + "fieldPath": "metadataRevision", + "columnName": "metadata_revision", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "library_id", + "stable_key" + ] + }, + "indices": [ + { + "name": "index_managed_library_item_artifact", + "unique": true, + "columnNames": [ + "artifact_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_managed_library_item_artifact` ON `${TABLE_NAME}` (`artifact_id`)" + } + ] + }, + { + "tableName": "platform_playlist_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`platform` TEXT NOT NULL, `cache_key` TEXT NOT NULL, `source_id` INTEGER, `alternate_key` TEXT, `kind` TEXT, `title` TEXT, `subtitle` TEXT, `creator_name` TEXT, `cover_url` TEXT, `play_count` INTEGER, `track_count` INTEGER NOT NULL, `total_count` INTEGER NOT NULL, `signature_primary` TEXT, `signature_secondary` TEXT, `has_more` INTEGER, `saved_at_ms` INTEGER NOT NULL, PRIMARY KEY(`platform`, `cache_key`))", + "fields": [ + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cache_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "source_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "alternateKey", + "columnName": "alternate_key", + "affinity": "TEXT" + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT" + }, + { + "fieldPath": "creatorName", + "columnName": "creator_name", + "affinity": "TEXT" + }, + { + "fieldPath": "coverUrl", + "columnName": "cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "playCount", + "columnName": "play_count", + "affinity": "INTEGER" + }, + { + "fieldPath": "trackCount", + "columnName": "track_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalCount", + "columnName": "total_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signaturePrimary", + "columnName": "signature_primary", + "affinity": "TEXT" + }, + { + "fieldPath": "signatureSecondary", + "columnName": "signature_secondary", + "affinity": "TEXT" + }, + { + "fieldPath": "hasMore", + "columnName": "has_more", + "affinity": "INTEGER" + }, + { + "fieldPath": "savedAtMs", + "columnName": "saved_at_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platform", + "cache_key" + ] + }, + "indices": [ + { + "name": "index_platform_playlist_cache_source_id", + "unique": false, + "columnNames": [ + "platform", + "source_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_playlist_cache_source_id` ON `${TABLE_NAME}` (`platform`, `source_id`)" + }, + { + "name": "index_platform_playlist_cache_saved_at", + "unique": false, + "columnNames": [ + "platform", + "saved_at_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_playlist_cache_saved_at` ON `${TABLE_NAME}` (`platform`, `saved_at_ms`)" + } + ] + }, + { + "tableName": "platform_playlist_cache_track", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`platform` TEXT NOT NULL, `cache_key` TEXT NOT NULL, `position` INTEGER NOT NULL, `item_id` INTEGER, `item_key` TEXT, `name` TEXT NOT NULL, `artist` TEXT NOT NULL, `album` TEXT NOT NULL, `album_id` INTEGER, `duration_ms` INTEGER NOT NULL, `cover_url` TEXT, `audio_id` TEXT, `uploader_mid` INTEGER, `added_at` INTEGER NOT NULL, PRIMARY KEY(`platform`, `cache_key`, `position`), FOREIGN KEY(`platform`, `cache_key`) REFERENCES `platform_playlist_cache`(`platform`, `cache_key`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cache_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "item_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "itemKey", + "columnName": "item_key", + "affinity": "TEXT" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "album", + "columnName": "album", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "album_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "durationMs", + "columnName": "duration_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coverUrl", + "columnName": "cover_url", + "affinity": "TEXT" + }, + { + "fieldPath": "audioId", + "columnName": "audio_id", + "affinity": "TEXT" + }, + { + "fieldPath": "uploaderMid", + "columnName": "uploader_mid", + "affinity": "INTEGER" + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platform", + "cache_key", + "position" + ] + }, + "indices": [ + { + "name": "index_platform_playlist_cache_track_item_id", + "unique": false, + "columnNames": [ + "platform", + "cache_key", + "item_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_playlist_cache_track_item_id` ON `${TABLE_NAME}` (`platform`, `cache_key`, `item_id`)" + }, + { + "name": "index_platform_playlist_cache_track_item_key", + "unique": false, + "columnNames": [ + "platform", + "cache_key", + "item_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_playlist_cache_track_item_key` ON `${TABLE_NAME}` (`platform`, `cache_key`, `item_key`)" + } + ], + "foreignKeys": [ + { + "table": "platform_playlist_cache", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "platform", + "cache_key" + ], + "referencedColumns": [ + "platform", + "cache_key" + ] + } + ] + }, + { + "tableName": "platform_playlist_cache_track_artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`platform` TEXT NOT NULL, `cache_key` TEXT NOT NULL, `track_position` INTEGER NOT NULL, `artist_position` INTEGER NOT NULL, `artist_id` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`platform`, `cache_key`, `track_position`, `artist_position`), FOREIGN KEY(`platform`, `cache_key`, `track_position`) REFERENCES `platform_playlist_cache_track`(`platform`, `cache_key`, `position`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "platform", + "columnName": "platform", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cacheKey", + "columnName": "cache_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackPosition", + "columnName": "track_position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistPosition", + "columnName": "artist_position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platform", + "cache_key", + "track_position", + "artist_position" + ] + }, + "indices": [ + { + "name": "index_platform_playlist_cache_artist_track", + "unique": false, + "columnNames": [ + "platform", + "cache_key", + "track_position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_playlist_cache_artist_track` ON `${TABLE_NAME}` (`platform`, `cache_key`, `track_position`)" + } + ], + "foreignKeys": [ + { + "table": "platform_playlist_cache_track", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "platform", + "cache_key", + "track_position" + ], + "referencedColumns": [ + "platform", + "cache_key", + "position" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e039ac26723d945d052198a3f601f4aa')" + ] + } +} \ No newline at end of file diff --git a/modules/database/src/androidTest/java/moe/ouom/neriplayer/data/local/database/DatabaseUpgradeInstrumentedTest.kt b/modules/database/src/androidTest/java/moe/ouom/neriplayer/data/local/database/DatabaseUpgradeInstrumentedTest.kt index 01cd6825e3..5fdd6f945b 100644 --- a/modules/database/src/androidTest/java/moe/ouom/neriplayer/data/local/database/DatabaseUpgradeInstrumentedTest.kt +++ b/modules/database/src/androidTest/java/moe/ouom/neriplayer/data/local/database/DatabaseUpgradeInstrumentedTest.kt @@ -8,6 +8,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.json.JSONObject import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -115,7 +116,8 @@ class DatabaseUpgradeInstrumentedTest { "database-upgrade-from-v17", NeriUserDataDatabase.FINAL_DB_VERSION, true, NeriUserDataDatabase.MIGRATION_17_18, NeriUserDataDatabase.MIGRATION_18_19, - NeriUserDataDatabase.MIGRATION_19_20 + NeriUserDataDatabase.MIGRATION_19_20, + NeriUserDataDatabase.MIGRATION_20_21 ).use { database -> database.query("SELECT state, bytes_written FROM download_operation WHERE operation_id = 'operation'").use { cursor -> check(cursor.moveToFirst()) @@ -130,6 +132,59 @@ class DatabaseUpgradeInstrumentedTest { } } + @Test + fun version20PreservesHistoryAndQueuesWithoutInventingLocalSourceProvenance() { + val databaseName = "database-upgrade-source-provenance-v20" + val reference = "content://media/external/audio/media/42" + val lyricPayload = """{"revision":7,"edited":true}""" + helper.createDatabase(databaseName, 20).use { database -> + database.execSQL( + "INSERT INTO play_history " + + "(identity_key, identity_id, identity_album, identity_media_uri, id, name, artist, album, " + + "album_id, duration_ms, resume_position_ms, played_at, media_uri, lyric_sync_payload_json) " + + "VALUES (?, 42, 'Album', ?, 42, 'History', 'Artist', 'Album', 0, 1000, 1500, 10, ?, ?)", + arrayOf("42|Album|$reference", reference, reference, lyricPayload) + ) + database.execSQL( + "INSERT INTO playback_queue_song " + + "(queue_id, position, id, name, artist, album, album_id, duration_ms, user_lyric_offset_ms, " + + "media_uri, lyric_sync_payload_json) VALUES ('main', 0, 42, 'Queue', 'Artist', 'Album', 0, 1000, 25, ?, ?)", + arrayOf(reference, lyricPayload) + ) + database.execSQL( + "INSERT INTO playback_queue_state " + + "(id, current_index, position_ms, should_resume_playback, updated_at) VALUES (1, 0, 1500, 1, 10)" + ) + } + + helper.runMigrationsAndValidate(databaseName, NeriUserDataDatabase.FINAL_DB_VERSION, true, + NeriUserDataDatabase.MIGRATION_20_21).use { database -> + database.query("SELECT name, media_uri, resume_position_ms, lyric_sync_payload_json, " + + "explicit_local_source_reference FROM play_history WHERE id = 42").use { cursor -> + check(cursor.moveToFirst()) + assertEquals("History", cursor.getString(0)) + assertEquals(reference, cursor.getString(1)) + assertEquals(1500L, cursor.getLong(2)) + assertEquals(lyricPayload, cursor.getString(3)) + assertTrue(cursor.isNull(4)) + } + database.query("SELECT name, media_uri, user_lyric_offset_ms, lyric_sync_payload_json, " + + "explicit_local_source_reference FROM playback_queue_song WHERE queue_id = 'main' AND position = 0").use { cursor -> + check(cursor.moveToFirst()) + assertEquals("Queue", cursor.getString(0)) + assertEquals(reference, cursor.getString(1)) + assertEquals(25L, cursor.getLong(2)) + assertEquals(lyricPayload, cursor.getString(3)) + assertTrue(cursor.isNull(4)) + } + database.query("SELECT position_ms, should_resume_playback FROM playback_queue_state WHERE id = 1").use { cursor -> + check(cursor.moveToFirst()) + assertEquals(1500L, cursor.getLong(0)) + assertEquals(1, cursor.getInt(1)) + } + } + } + private fun allMigrations(): Array = arrayOf( NeriUserDataDatabase.MIGRATION_1_2, NeriUserDataDatabase.MIGRATION_2_3, @@ -149,6 +204,7 @@ class DatabaseUpgradeInstrumentedTest { NeriUserDataDatabase.MIGRATION_16_17, NeriUserDataDatabase.MIGRATION_17_18, NeriUserDataDatabase.MIGRATION_18_19, - NeriUserDataDatabase.MIGRATION_19_20 + NeriUserDataDatabase.MIGRATION_19_20, + NeriUserDataDatabase.MIGRATION_20_21 ) } diff --git a/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/NeriUserDataDatabase.kt b/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/NeriUserDataDatabase.kt index 71017a3891..0f097bf006 100644 --- a/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/NeriUserDataDatabase.kt +++ b/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/NeriUserDataDatabase.kt @@ -69,6 +69,7 @@ import moe.ouom.neriplayer.data.local.database.entity.PlatformPlaylistCacheTrack import moe.ouom.neriplayer.data.local.database.migration.library.PlayHistoryMigration import moe.ouom.neriplayer.data.local.database.migration.library.LyricSyncPersistenceMigration +import moe.ouom.neriplayer.data.local.database.migration.library.LocalSourceProvenanceMigration import moe.ouom.neriplayer.data.local.database.migration.library.PlaylistUsageMigration import moe.ouom.neriplayer.data.local.database.migration.library.LocalPlaylistPlaybackMigration import moe.ouom.neriplayer.data.local.database.migration.library.PlaybackStatsMigration @@ -86,7 +87,7 @@ import moe.ouom.neriplayer.data.local.database.migration.download.DownloadFinali import moe.ouom.neriplayer.data.local.database.migration.download.DownloadBatchMigration import moe.ouom.neriplayer.data.local.database.migration.download.DownloadRecoveryCursorMigration -private const val NERI_USER_DATA_FINAL_VERSION = 20 +private const val NERI_USER_DATA_FINAL_VERSION = 21 @Database( entities = [ @@ -226,7 +227,8 @@ abstract class NeriUserDataDatabase : RoomDatabase() { MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, - MIGRATION_19_20 + MIGRATION_19_20, + MIGRATION_20_21 ) private fun checkMainProcess(context: Context) { @@ -257,6 +259,7 @@ abstract class NeriUserDataDatabase : RoomDatabase() { val MIGRATION_17_18: Migration = DownloadRecoveryCursorMigration val MIGRATION_18_19: Migration = LyricSyncPersistenceMigration val MIGRATION_19_20: Migration = PlaybackStatsPagedMigration + val MIGRATION_20_21: Migration = LocalSourceProvenanceMigration const val FINAL_DB_VERSION = NERI_USER_DATA_FINAL_VERSION } diff --git a/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/entity/PlayHistoryEntity.kt b/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/entity/PlayHistoryEntity.kt index a28bddf15a..b54812d60c 100644 --- a/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/entity/PlayHistoryEntity.kt +++ b/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/entity/PlayHistoryEntity.kt @@ -79,5 +79,7 @@ data class PlayHistoryEntity( @ColumnInfo(name = "played_at") val playedAt: Long, @ColumnInfo(name = "lyric_sync_payload_json") - val lyricSyncPayloadJson: String? = null + val lyricSyncPayloadJson: String? = null, + @ColumnInfo(name = "explicit_local_source_reference") + val explicitLocalSourceReference: String? = null ) diff --git a/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/entity/PlaybackQueueEntities.kt b/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/entity/PlaybackQueueEntities.kt index e3bd566e4f..6e5c589bbf 100644 --- a/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/entity/PlaybackQueueEntities.kt +++ b/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/entity/PlaybackQueueEntities.kt @@ -93,5 +93,7 @@ data class PlaybackQueueSongEntity( @ColumnInfo(name = "stream_url") val streamUrl: String?, @ColumnInfo(name = "lyric_sync_payload_json") - val lyricSyncPayloadJson: String? = null + val lyricSyncPayloadJson: String? = null, + @ColumnInfo(name = "explicit_local_source_reference") + val explicitLocalSourceReference: String? = null ) diff --git a/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/migration/library/LocalSourceProvenanceMigration.kt b/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/migration/library/LocalSourceProvenanceMigration.kt new file mode 100644 index 0000000000..849fc8596b --- /dev/null +++ b/modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/migration/library/LocalSourceProvenanceMigration.kt @@ -0,0 +1,11 @@ +package moe.ouom.neriplayer.data.local.database.migration.library + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +internal object LocalSourceProvenanceMigration : Migration(20, 21) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `play_history` ADD COLUMN `explicit_local_source_reference` TEXT") + db.execSQL("ALTER TABLE `playback_queue_song` ADD COLUMN `explicit_local_source_reference` TEXT") + } +} diff --git a/modules/database/src/test/java/moe/ouom/neriplayer/data/local/database/migration/LocalSourceProvenanceMigrationTest.kt b/modules/database/src/test/java/moe/ouom/neriplayer/data/local/database/migration/LocalSourceProvenanceMigrationTest.kt new file mode 100644 index 0000000000..8039c714f9 --- /dev/null +++ b/modules/database/src/test/java/moe/ouom/neriplayer/data/local/database/migration/LocalSourceProvenanceMigrationTest.kt @@ -0,0 +1,20 @@ +package moe.ouom.neriplayer.data.local.database.migration + +import moe.ouom.neriplayer.data.local.database.migration.legacy.LegacyMigrationDatabase +import moe.ouom.neriplayer.data.local.database.migration.library.LocalSourceProvenanceMigration +import org.junit.Assert.assertEquals +import org.junit.Test + +class LocalSourceProvenanceMigrationTest { + @Test + fun `upgrade adds nullable source provenance to history and queue without rewriting existing rows`() { + val fixture = LegacyMigrationDatabase() + + LocalSourceProvenanceMigration.migrate(fixture.database) + + assertEquals(listOf( + "ALTER TABLE `play_history` ADD COLUMN `explicit_local_source_reference` TEXT", + "ALTER TABLE `playback_queue_song` ADD COLUMN `explicit_local_source_reference` TEXT" + ), fixture.statements) + } +} diff --git a/modules/download/logic/src/main/java/moe/ouom/neriplayer/core/download/storage/reference/ManagedDownloadReferenceIo.kt b/modules/download/logic/src/main/java/moe/ouom/neriplayer/core/download/storage/reference/ManagedDownloadReferenceIo.kt index fb64afd727..1f0ecf8b2a 100644 --- a/modules/download/logic/src/main/java/moe/ouom/neriplayer/core/download/storage/reference/ManagedDownloadReferenceIo.kt +++ b/modules/download/logic/src/main/java/moe/ouom/neriplayer/core/download/storage/reference/ManagedDownloadReferenceIo.kt @@ -6,6 +6,7 @@ import android.database.Cursor import android.net.Uri import android.os.Build import android.provider.DocumentsContract +import android.provider.OpenableColumns import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import java.io.File @@ -317,7 +318,8 @@ object ManagedDownloadReferenceIo { val cursor = queryDocumentCursor( context = context, uri = uri, - projection = arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + // 音频也可能来自 MediaStore,不能要求文档提供方专用的列 + projection = arrayOf(OpenableColumns.SIZE) ) ?: return inspectDocumentWithoutCursorWithSize(context, uri) cursor.use { if (!it.moveToFirst()) return AccessObservation(AccessResult.Missing, null) diff --git a/modules/download/logic/src/test/java/moe/ouom/neriplayer/core/download/storage/reference/ManagedDownloadReferenceIoTest.kt b/modules/download/logic/src/test/java/moe/ouom/neriplayer/core/download/storage/reference/ManagedDownloadReferenceIoTest.kt index 7a12bea032..a8af27dcd1 100644 --- a/modules/download/logic/src/test/java/moe/ouom/neriplayer/core/download/storage/reference/ManagedDownloadReferenceIoTest.kt +++ b/modules/download/logic/src/test/java/moe/ouom/neriplayer/core/download/storage/reference/ManagedDownloadReferenceIoTest.kt @@ -106,6 +106,24 @@ class ManagedDownloadReferenceIoTest { verify(descriptor).close() } + @Test + fun `media store audio is readable without document provider columns`() { + val rowCursor = mock(Cursor::class.java).also { `when`(it.moveToFirst()).thenReturn(true) } + val descriptor = mock(ParcelFileDescriptor::class.java).also { `when`(it.statSize).thenReturn(128L) } + `when`(resolver.query(any(Uri::class.java), any(), any(), any(), any())).thenAnswer { invocation -> + val projection = invocation.getArgument>(1) + if (DocumentsContract.Document.COLUMN_DOCUMENT_ID in projection) { + throw IllegalArgumentException("Invalid column document_id") + } + rowCursor + } + `when`(resolver.openFileDescriptor(documentUri, "r")).thenReturn(descriptor) + + assertEquals(AccessResult.Accessible, ManagedDownloadReferenceIo.inspect(context, documentUri)) + verify(descriptor).close() + verify(rowCursor).close() + } + @Test fun `document cursors decide between missing and readable documents`() { val emptyCursor = mock(Cursor::class.java) diff --git a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/manager/batch/GlobalDownloadManagerBatchPrepare.kt b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/manager/batch/GlobalDownloadManagerBatchPrepare.kt index b89f3f1985..74eb5ae4fc 100644 --- a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/manager/batch/GlobalDownloadManagerBatchPrepare.kt +++ b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/manager/batch/GlobalDownloadManagerBatchPrepare.kt @@ -56,6 +56,7 @@ import moe.ouom.neriplayer.core.player.download.network.currentDownloadParalleli import moe.ouom.neriplayer.core.player.download.network.resolveDownloadDispatchWindow import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.data.model.stableKey +import moe.ouom.neriplayer.data.local.media.LocalSongSupport import moe.ouom.neriplayer.data.traffic.currentDownloadNetworkTypeOrNull import moe.ouom.neriplayer.common.time.elapsedMillisSince @@ -74,7 +75,8 @@ internal fun GlobalDownloadManager.startBatchDownload( val startupStartedAtNs = System.nanoTime() val appContext = context.applicationContext - var requestedSongs = songs.distinctBy(SongItem::stableKey) + var requestedSongs = songs.filterNot { LocalSongSupport.isLocalSong(it, appContext) } + .distinctBy(SongItem::stableKey) if (requestedSongs.isEmpty()) { return null } diff --git a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/manager/runtime/GlobalDownloadManagerRequest.kt b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/manager/runtime/GlobalDownloadManagerRequest.kt index 2b325d770b..f03226ccac 100644 --- a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/manager/runtime/GlobalDownloadManagerRequest.kt +++ b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/manager/runtime/GlobalDownloadManagerRequest.kt @@ -56,6 +56,7 @@ import moe.ouom.neriplayer.core.download.execution.persistence.WAITING_STORAGE_M import moe.ouom.neriplayer.core.download.execution.retry.ARTIFACT_LEASE_CONTENDED_ERROR_CODE import moe.ouom.neriplayer.common.logging.NPLogger import moe.ouom.neriplayer.core.player.download.AudioDownloadManager +import moe.ouom.neriplayer.data.local.media.LocalSongSupport import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.data.model.stableKey import moe.ouom.neriplayer.data.traffic.currentDownloadNetworkTypeOrNull @@ -71,6 +72,7 @@ internal fun GlobalDownloadManager.scheduleUserDownload( manualRetry: Boolean = false ) { val appContext = context.applicationContext + if (LocalSongSupport.isLocalSong(song, appContext)) return // 请求创建时记录代次,清空开始后旧协程只能退出不能重新登记任务 val songKey = song.stableKey() val capturedAdmissionTicket = requestedAdmissionTicket diff --git a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/storage/facade/ManagedDownloadStorageFacadeCatalog.kt b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/storage/facade/ManagedDownloadStorageFacadeCatalog.kt index d4e5e4e2fa..c3f2673615 100644 --- a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/storage/facade/ManagedDownloadStorageFacadeCatalog.kt +++ b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/download/storage/facade/ManagedDownloadStorageFacadeCatalog.kt @@ -67,6 +67,7 @@ import moe.ouom.neriplayer.core.download.index.ManagedLibraryFastIndexRebuildTok import moe.ouom.neriplayer.core.download.index.ManagedLibraryFastIndexShardWriteResult import moe.ouom.neriplayer.core.download.index.ManagedLibraryIndexEntry import moe.ouom.neriplayer.common.logging.NPLogger +import moe.ouom.neriplayer.common.logging.playbackLogReference import moe.ouom.neriplayer.data.local.media.LocalSongSupport import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.data.identity.remoteDownloadIdentityOrNull @@ -78,14 +79,16 @@ import java.util.Locale internal fun ManagedDownloadStorage.isLikelyManagedDownloadSongImpl(context: Context, song: SongItem): Boolean { if (hasManagedDownloadIdentityHint(song)) { - return true + return traceManagedSongClassification(song, "slow", true, "identity_hint") } val defaultRoot = ManagedDownloadRootResolver.defaultRootDirectory(context) - if (song.independentExternalImportReference(defaultRoot) != null) { - return false + val independentImportReference = song.independentExternalImportReference(defaultRoot) + if (independentImportReference != null) { + return traceManagedSongClassification(song, "slow", false, "import_copy", independentImportReference) } - if (peekDownloadedAudio(song) != null) { - return true + val cachedAudio = peekDownloadedAudio(song) + if (cachedAudio != null) { + return traceManagedSongClassification(song, "slow", true, "catalog_snapshot", cachedAudio.reference) } val configuredRoot = defaultRoot.absolutePath val directPaths = listOfNotNull(song.localFilePath, song.mediaUri) @@ -98,12 +101,13 @@ internal fun ManagedDownloadStorage.isLikelyManagedDownloadSongImpl(context: Con else -> null } } - if (directPaths.any { directPath -> - isPathInside(directPath, configuredRoot) || - isPathInside(directPath, LEGACY_DOWNLOAD_ROOT_PATH) + for (directPath in directPaths) { + if (isPathInside(directPath, configuredRoot)) { + return traceManagedSongClassification(song, "slow", true, "private_path", directPath) + } + if (isPathInside(directPath, LEGACY_DOWNLOAD_ROOT_PATH)) { + return traceManagedSongClassification(song, "slow", true, "historical_root", directPath) } - ) { - return true } val configuredTree = settings.configuredDirectoryUri ?.takeIf(String::isNotBlank) @@ -120,37 +124,49 @@ internal fun ManagedDownloadStorage.isLikelyManagedDownloadSongImpl(context: Con treeDocumentId = treeDocumentId ) ) { - return true + return traceManagedSongClassification( + song, + "slow", + true, + "media_store", + songReferences.firstOrNull { it.authority.equals("media", ignoreCase = true) }?.toString() + ) } - if (songReferences.any { songReference -> - val documentId = runCatching { - DocumentsContract.getDocumentId(songReference) - }.getOrNull() - documentId != null && isKnownManagedDownloadDocumentId( - documentId = documentId, - treeDocumentId = treeDocumentId - ) - } - ) { - return true + val currentTreeReference = songReferences.firstOrNull { songReference -> + val documentId = runCatching { + DocumentsContract.getDocumentId(songReference) + }.getOrNull() + documentId != null && isKnownManagedDownloadDocumentId( + documentId = documentId, + treeDocumentId = treeDocumentId + ) + } + if (currentTreeReference != null) { + return traceManagedSongClassification(song, "slow", true, "current_tree", currentTreeReference.toString()) } if (configuredTree != null && songReferences.isNotEmpty()) { if (!treeDocumentId.isNullOrBlank()) { val treeAuthority = configuredTree.authority - if (songReferences.any { songReference -> - songReference.authority == treeAuthority && - isDocumentWithinManagedTree( - context = context, - songReference = songReference, - treeDocumentId = treeDocumentId - ) - } - ) { - return true + val providerDocumentReference = songReferences.firstOrNull { songReference -> + songReference.authority == treeAuthority && + isDocumentWithinManagedTree( + context = context, + songReference = songReference, + treeDocumentId = treeDocumentId + ) + } + if (providerDocumentReference != null) { + return traceManagedSongClassification( + song, + "slow", + true, + "provider_document_path", + providerDocumentReference.toString() + ) } } } - return false + return traceManagedSongClassification(song, "slow", false, "unmatched") } internal fun ManagedDownloadStorage.isLikelyManagedDownloadSongFastImpl( @@ -158,28 +174,38 @@ internal fun ManagedDownloadStorage.isLikelyManagedDownloadSongFastImpl( song: SongItem ): Boolean { if (!LocalSongSupport.isLocalSong(song, null)) { - return false + return traceManagedSongClassification(song, "fast", false, "not_local") } if (hasManagedDownloadIdentityHint(song)) { - return true + return traceManagedSongClassification(song, "fast", true, "identity_hint") } val defaultRoot = runCatching { ManagedDownloadRootResolver.defaultRootDirectory(context) }.getOrNull() - if (defaultRoot != null && song.independentExternalImportReference(defaultRoot) != null) { - return false + val independentImportReference = defaultRoot?.let(song::independentExternalImportReference) + if (independentImportReference != null) { + return traceManagedSongClassification(song, "fast", false, "import_copy", independentImportReference) } - if (peekDownloadedAudio(song) != null) { - return true + val cachedAudio = peekDownloadedAudio(song) + if (cachedAudio != null) { + return traceManagedSongClassification(song, "fast", true, "catalog_snapshot", cachedAudio.reference) } val configuredRoot = settings.configuredDirectoryUri ?.takeIf(String::isNotBlank) ?.let { runCatching { it.toUri() }.getOrNull() } val songTree = listOfNotNull(song.mediaUri, song.localFilePath) - .firstNotNullOfOrNull(::managedDownloadTreeUri) + .firstNotNullOfOrNull { reference -> + managedDownloadTreeUri(reference)?.let { tree -> reference to tree } + } if (configuredRoot != null && songTree != null) { - return areEquivalentDirectoryUris(songTree.toString(), configuredRoot.toString()) + return traceManagedSongClassification( + song, + "fast", + areEquivalentDirectoryUris(songTree.second.toString(), configuredRoot.toString()), + "current_tree", + songTree.first + ) } val directPaths = listOfNotNull(song.localFilePath, song.mediaUri) @@ -192,10 +218,36 @@ internal fun ManagedDownloadStorage.isLikelyManagedDownloadSongFastImpl( else -> null } } - return directPaths.any { path -> - (defaultRoot != null && isPathInside(path, defaultRoot.absolutePath)) || - isPathInside(path, LEGACY_DOWNLOAD_ROOT_PATH) + for (path in directPaths) { + if (defaultRoot != null && isPathInside(path, defaultRoot.absolutePath)) { + return traceManagedSongClassification(song, "fast", true, "private_path", path) + } + if (isPathInside(path, LEGACY_DOWNLOAD_ROOT_PATH)) { + return traceManagedSongClassification(song, "fast", true, "historical_root", path) + } } + return traceManagedSongClassification(song, "fast", false, "unmatched") +} + +private fun ManagedDownloadStorage.traceManagedSongClassification( + song: SongItem, + mode: String, + result: Boolean, + reason: String, + reference: String? = song.localFilePath ?: song.mediaUri +): Boolean { + val channel = song.channelId?.filterNot { it.isISOControl() }?.take(80) ?: "none" + NPLogger.d( + TAG, + "LocalPlaybackTrace stage=managed_classification mode=$mode " + + "songId=${song.id} channel=$channel " + + "audioIdPresent=${!song.audioId.isNullOrBlank()} " + + "sourceStableKeyPresent=${!song.sourceStableKey.isNullOrBlank()} " + + "result=$result reason=$reason " + + "configuredRoot=${playbackLogReference(settings.configuredDirectoryUri)} " + + "reference=${playbackLogReference(reference)}" + ) + return result } internal fun ManagedDownloadStorage.resolveIndependentExternalImportReferenceImpl( diff --git a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/player/download/AudioDownloadManager.kt b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/player/download/AudioDownloadManager.kt index 8324abd206..cf2bbc6ca8 100644 --- a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/player/download/AudioDownloadManager.kt +++ b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/player/download/AudioDownloadManager.kt @@ -106,6 +106,7 @@ import moe.ouom.neriplayer.core.download.execution.clear.ManagedDownloadDirector import moe.ouom.neriplayer.core.download.storage.metadata.MAX_SOURCE_COVER_BYTES import moe.ouom.neriplayer.common.logging.NPLogger import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource import moe.ouom.neriplayer.data.identity.stableKey import moe.ouom.neriplayer.platform.youtube.api.transport.isTrustedYouTubeHost import moe.ouom.neriplayer.data.model.settings.download.DownloadAudioQualitySelection @@ -1609,21 +1610,25 @@ object AudioDownloadManager { suspend fun resolvePermittedLocalPlaybackUri( context: Context, song: SongItem, - rawLocalReference: String? + rawLocalReference: String?, + sourceContext: ResolvedLocalMediaSource? = null ): String? = playbackCoordinator.resolvePermittedLocalPlaybackUri( context = context, song = song, - rawLocalReference = rawLocalReference + rawLocalReference = rawLocalReference, + sourceContext = sourceContext ) suspend fun resolvePermittedLocalPlayback( context: Context, song: SongItem, - rawLocalReference: String? + rawLocalReference: String?, + sourceContext: ResolvedLocalMediaSource? = null ): LocalPlaybackReferenceResolution = playbackCoordinator.resolvePermittedLocalPlayback( context = context, song = song, - rawLocalReference = rawLocalReference + rawLocalReference = rawLocalReference, + sourceContext = sourceContext ) fun resolveIndexedLocalPlaybackReference( diff --git a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/player/download/playback/AudioDownloadPlaybackCoordinator.kt b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/player/download/playback/AudioDownloadPlaybackCoordinator.kt index 90db325b9f..6e65314a23 100644 --- a/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/player/download/playback/AudioDownloadPlaybackCoordinator.kt +++ b/modules/download/runtime/src/main/java/moe/ouom/neriplayer/core/player/download/playback/AudioDownloadPlaybackCoordinator.kt @@ -18,6 +18,7 @@ import moe.ouom.neriplayer.core.download.execution.clear.ManagedDownloadDirector import moe.ouom.neriplayer.core.download.storage.PENDING_AUDIO_WRITE_MARKER import moe.ouom.neriplayer.core.download.storage.reference.ManagedDownloadReferenceLookup import moe.ouom.neriplayer.common.logging.NPLogger +import moe.ouom.neriplayer.common.logging.playbackLogReference import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.data.model.stableKey import kotlinx.coroutines.runBlocking @@ -32,6 +33,8 @@ import moe.ouom.neriplayer.core.download.storage.DOWNLOAD_STAGING_FILE_PREFIX import moe.ouom.neriplayer.core.download.storage.DOWNLOAD_STAGING_FILE_SUFFIX import moe.ouom.neriplayer.data.local.media.LocalMediaSupport import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource +import moe.ouom.neriplayer.data.local.playlist.LocalPlaylistRepository import kotlin.time.Duration.Companion.milliseconds import moe.ouom.neriplayer.data.model.download.DownloadedAudioMetadata @@ -45,7 +48,9 @@ internal class AudioDownloadPlaybackCoordinator( private val completedAudioReferenceRegistry: AudioDownloadReferenceRegistry, private val isSongDownloadActive: (String) -> Boolean, private val directoryMutationWaitMs: Long, - private val tag: String = "NERI-Downloader" + private val tag: String = "NERI-Downloader", + private val explicitLocalFileReference: (String) -> Boolean? = + LocalPlaylistRepository::peekExplicitLocalFileReference ) { private val LEGACY_INDEX_REFRESH_COOLDOWN_MS = 5_000L private val MANAGED_PLAYBACK_REBIND_COOLDOWN_MS = 1_500L @@ -61,6 +66,89 @@ internal class AudioDownloadPlaybackCoordinator( val audio: ManagedDownloadStorage.StoredEntry ) + private class PlaybackDiagnostics( + private val tag: String, + private val stage: String, + private val song: SongItem, + var rawReference: String? = null + ) { + var managedFast: Boolean? = null + var managedFull: Boolean? = null + var configuredDirectory = "not_checked" + var rawReferenceCompatible: Boolean? = null + var explicitLocalFile = "not_checked" + var rawProbeExecuted = false + var rawEvidence: ManagedDownloadReferenceLookup.Result? = null + var explicitlyIncomplete: Boolean? = null + var verifiedReference: String? = null + var refreshedReference: String? = null + var refreshedEvidence: ManagedDownloadReferenceLookup.Result? = null + var fence = "not_checked" + var bridge = "not_checked" + var rebind = "not_attempted" + var errorType = "none" + + fun finish( + resolution: LocalPlaybackReferenceResolution, + reason: String + ): LocalPlaybackReferenceResolution { + val message = message(resolution, reason) + if (resolution is LocalPlaybackReferenceResolution.Playable || + resolution == LocalPlaybackReferenceResolution.NotIndexed + ) { + NPLogger.d(tag, message) + } else { + NPLogger.w(tag, message) + } + return resolution + } + + private fun message(resolution: LocalPlaybackReferenceResolution, reason: String): String { + return "LocalPlaybackTrace stage=$stage id=${song.id} " + + "channel=${song.channelId} albumId=${song.albumId} " + + "managedFast=${managedFast ?: "not_checked"} " + + "managedFull=${managedFull ?: "not_checked"} " + + "configuredDirectory=$configuredDirectory " + + "rawReference=${playbackLogReference(rawReference)} " + + "rawReferenceCompatible=${rawReferenceCompatible ?: "not_checked"} " + + "explicitLocalFile=$explicitLocalFile " + + "rawProbeExecuted=$rawProbeExecuted " + + "rawEvidence=${evidenceName(rawEvidence)} rawErrorType=${evidenceErrorType(rawEvidence)} " + + "managedReferenceIsExplicitlyIncomplete=${explicitlyIncomplete ?: "not_checked"} " + + "verifiedReference=${playbackLogReference(verifiedReference)} " + + "refreshedReference=${playbackLogReference(refreshedReference)} " + + "refreshedEvidence=${evidenceName(refreshedEvidence)} " + + "refreshedErrorType=${evidenceErrorType(refreshedEvidence)} " + + "fence=$fence bridge=$bridge rebind=$rebind errorType=$errorType " + + "result=${resultName(resolution)} reason=$reason reference=" + + playbackLogReference((resolution as? LocalPlaybackReferenceResolution.Playable)?.reference) + } + + private fun resultName(resolution: LocalPlaybackReferenceResolution): String = when (resolution) { + is LocalPlaybackReferenceResolution.Playable -> "playable" + LocalPlaybackReferenceResolution.Missing -> "missing" + LocalPlaybackReferenceResolution.NotIndexed -> "not_indexed" + is LocalPlaybackReferenceResolution.TemporarilyUnavailable -> "temporary" + } + + companion object { + fun evidenceName(evidence: ManagedDownloadReferenceLookup.Result?): String = when (evidence) { + ManagedDownloadReferenceLookup.Result.Present -> "present" + ManagedDownloadReferenceLookup.Result.Missing -> "missing" + ManagedDownloadReferenceLookup.Result.OutOfScope -> "out_of_scope" + is ManagedDownloadReferenceLookup.Result.PermissionLost -> "permission_lost" + is ManagedDownloadReferenceLookup.Result.ProviderFailure -> "provider_failure" + null -> "not_checked" + } + + fun evidenceErrorType(evidence: ManagedDownloadReferenceLookup.Result?): String = when (evidence) { + is ManagedDownloadReferenceLookup.Result.PermissionLost -> evidence.cause.javaClass.name + is ManagedDownloadReferenceLookup.Result.ProviderFailure -> evidence.cause.javaClass.name + else -> "none" + } + } + } + private fun canBlockStorageLookup(): Boolean { return android.os.Looper.myLooper() != android.os.Looper.getMainLooper() } @@ -157,7 +245,7 @@ internal class AudioDownloadPlaybackCoordinator( NPLogger.d( tag, "目录切换后忽略缓存中的旧播放引用: " + - "song=${song.name}, reference=$catalogPlaybackReference" + "song=${song.name}, reference=${playbackLogReference(catalogPlaybackReference)}" ) } resolveReadableManagedDownload(context, song) @@ -181,43 +269,58 @@ internal class AudioDownloadPlaybackCoordinator( suspend fun resolvePermittedLocalPlaybackUri( context: Context, song: SongItem, - rawLocalReference: String? + rawLocalReference: String?, + sourceContext: ResolvedLocalMediaSource? = null ): String? { return (resolvePermittedLocalPlayback( context = context, song = song, - rawLocalReference = rawLocalReference + rawLocalReference = rawLocalReference, + sourceContext = sourceContext ) as? LocalPlaybackReferenceResolution.Playable)?.reference } suspend fun resolvePermittedLocalPlayback( context: Context, song: SongItem, - rawLocalReference: String? + rawLocalReference: String?, + sourceContext: ResolvedLocalMediaSource? = null ): LocalPlaybackReferenceResolution = withContext(Dispatchers.IO) { val appContext = context.applicationContext - resolveIndependentExternalImportPlayback(appContext, song)?.let { return@withContext it } + val diagnostics = PlaybackDiagnostics(tag, "download_permitted", song, rawLocalReference) + resolveIndependentExternalImportPlayback(appContext, song, diagnostics)?.let { + return@withContext diagnostics.finish(it, "independent_import") + } val reference = rawLocalReference?.trim()?.takeIf(String::isNotBlank) if (reference == null) { // 队列恢复可能先拿到歌曲身份,再异步补齐引用 getLocalPlaybackUri(appContext, song)?.let { verifiedReference -> - return@withContext LocalPlaybackReferenceResolution.Playable( - verifiedReference + diagnostics.verifiedReference = verifiedReference + return@withContext diagnostics.finish( + LocalPlaybackReferenceResolution.Playable(verifiedReference), + "missing_raw_reference_recovered" ) } - return@withContext LocalPlaybackReferenceResolution.TemporarilyUnavailable( - ManagedDownloadReferenceLookup.Result.OutOfScope + return@withContext diagnostics.finish( + LocalPlaybackReferenceResolution.TemporarilyUnavailable( + ManagedDownloadReferenceLookup.Result.OutOfScope + ), + "missing_raw_reference_unavailable" ) } + diagnostics.rawReference = reference val managedDownloadHint = runCatching { ManagedDownloadStorage.isLikelyManagedDownloadSongFast(appContext, song) }.getOrDefault(false) + diagnostics.managedFast = managedDownloadHint + val mutationActive = runCatching { + ManagedDownloadDirectoryMutationFence.isActive(appContext) + }.getOrDefault(true) + diagnostics.fence = if (mutationActive) "active" else "open" if ( shouldWaitForManagedPlaybackDirectoryMutation( isManagedDownload = managedDownloadHint, - mutationActive = runCatching { - ManagedDownloadDirectoryMutationFence.isActive(appContext) - }.getOrDefault(true) + mutationActive = mutationActive ) ) { val opened = withTimeoutOrNull(directoryMutationWaitMs.milliseconds) { @@ -225,53 +328,71 @@ internal class AudioDownloadPlaybackCoordinator( true } == true if (!opened) { - NPLogger.d( - tag, - "目录迁移仍在进行,暂不使用旧播放引用: song=${song.name}" - ) - return@withContext LocalPlaybackReferenceResolution.TemporarilyUnavailable( - ManagedDownloadReferenceLookup.Result.OutOfScope + diagnostics.fence = "timeout" + return@withContext diagnostics.finish( + LocalPlaybackReferenceResolution.TemporarilyUnavailable( + ManagedDownloadReferenceLookup.Result.OutOfScope + ), + "directory_mutation_timeout" ) } + diagnostics.fence = "opened_after_wait" + } + val configuredDirectoryUri = ManagedDownloadStorage.configuredDirectoryUri() + diagnostics.configuredDirectory = playbackLogReference(configuredDirectoryUri) + val unverifiedDeclaredReference = sourceContext?.unverifiedDeclaredReferenceFor(song, reference) + if (unverifiedDeclaredReference != null && explicitLocalSourceEvidence(song, unverifiedDeclaredReference) != false) { + return@withContext diagnostics.finish( + LocalPlaybackReferenceResolution.TemporarilyUnavailable(ManagedDownloadReferenceLookup.Result.OutOfScope), + "unverified_declared_source" + ) + } + if (managedDownloadHint) { + resolveExplicitExternalFilePlayback( + appContext, song, reference, configuredDirectoryUri, diagnostics, sourceContext + )?.let { return@withContext it } } // core 提交桥已验证完整音频,必须在 SAF 查询前消费 resolveRecentlyCommittedAudioReference( context = appContext, song = song, - rawReference = reference + rawReference = reference, + diagnostics = diagnostics )?.let { bridgedReference -> - return@withContext LocalPlaybackReferenceResolution.Playable(bridgedReference) + diagnostics.verifiedReference = bridgedReference + return@withContext diagnostics.finish( + LocalPlaybackReferenceResolution.Playable(bridgedReference), + "core_bridge" + ) } val isManagedDownload = try { ManagedDownloadStorage.isLikelyManagedDownloadSong(appContext, song) } catch (cancellation: CancellationException) { throw cancellation } catch (error: Exception) { + diagnostics.errorType = error.javaClass.name NPLogger.w( tag, - "识别受管本地音频失败,按内存线索保守处理: ${error.message}" + "LocalPlaybackTrace stage=download_classification_fallback id=${song.id} " + + "channel=${song.channelId} errorType=${error.javaClass.name}" ) ManagedDownloadStorage.isLikelyManagedDownloadSongFast(appContext, song) } - val configuredDirectoryUri = ManagedDownloadStorage.configuredDirectoryUri() + diagnostics.managedFull = isManagedDownload val rawReferenceCompatible = !isManagedDownload || isManagedPlaybackReferenceCompatibleWithConfiguredSaf( reference = reference, configuredDirectoryUri = configuredDirectoryUri ) - if (isManagedDownload && !rawReferenceCompatible) { - NPLogger.d( - tag, - "目录切换后拒绝旧受管播放引用,准备重绑当前根: " + - "song=${song.name}, reference=$reference" - ) - } + diagnostics.rawReferenceCompatible = rawReferenceCompatible val rawEvidence = if (rawReferenceCompatible) { + diagnostics.rawProbeExecuted = true ManagedDownloadReferenceLookup.inspect(appContext, reference) } else { // 旧根引用不再探测 Provider,统一交给有界重绑定流程 ManagedDownloadReferenceLookup.Result.Missing } + diagnostics.rawEvidence = rawEvidence val managedReferenceIsExplicitlyIncomplete = if (isManagedDownload && rawReferenceCompatible) { isManagedReferenceExplicitlyIncomplete( context = appContext, @@ -281,6 +402,7 @@ internal class AudioDownloadPlaybackCoordinator( } else { false } + diagnostics.explicitlyIncomplete = managedReferenceIsExplicitlyIncomplete if ( rawReferenceCompatible && shouldUseDirectPresentLocalPlayback( reference = reference, @@ -293,15 +415,18 @@ internal class AudioDownloadPlaybackCoordinator( NPLogger.d( tag, "正式下载引用已确认可读,跳过旧完成状态门禁: " + - "song=${song.name}, reference=$reference" + "song=${song.name}, reference=${playbackLogReference(reference)}" ) } - return@withContext selectPermittedLocalPlaybackResolution( - rawLocalReference = reference, - isManagedDownload = isManagedDownload, - verifiedManagedReference = null, - rawEvidence = rawEvidence, - managedReferenceIsExplicitlyIncomplete = false + return@withContext diagnostics.finish( + selectPermittedLocalPlaybackResolution( + rawLocalReference = reference, + isManagedDownload = isManagedDownload, + verifiedManagedReference = null, + rawEvidence = rawEvidence, + managedReferenceIsExplicitlyIncomplete = false + ), + "direct_present_reference" ) } val verifiedManagedReference = if (isManagedDownload) { @@ -309,7 +434,8 @@ internal class AudioDownloadPlaybackCoordinator( ?: if (rawEvidence == ManagedDownloadReferenceLookup.Result.Missing) { forceResolveManagedPlaybackAfterMissing( context = appContext, - song = song + song = song, + diagnostics = diagnostics ) } else { null @@ -317,36 +443,100 @@ internal class AudioDownloadPlaybackCoordinator( } else { null } + diagnostics.verifiedReference = verifiedManagedReference // 已验证的新引用不能再被旧 pending 标记覆盖 val effectiveIncomplete = if (verifiedManagedReference != null) { false } else { managedReferenceIsExplicitlyIncomplete } - selectPermittedLocalPlaybackResolution( - rawLocalReference = reference, - isManagedDownload = isManagedDownload, - verifiedManagedReference = verifiedManagedReference, - rawEvidence = rawEvidence, - managedReferenceIsExplicitlyIncomplete = effectiveIncomplete, - missingIsTransient = isRecentManagedPlaybackReference( - song = song, - reference = reference + diagnostics.finish( + selectPermittedLocalPlaybackResolution( + rawLocalReference = reference, + isManagedDownload = isManagedDownload, + verifiedManagedReference = verifiedManagedReference, + rawEvidence = rawEvidence, + managedReferenceIsExplicitlyIncomplete = effectiveIncomplete, + missingIsTransient = isRecentManagedPlaybackReference( + song = song, + reference = reference + ) + ), + when { + verifiedManagedReference != null -> "verified_managed_reference" + !rawReferenceCompatible -> "incompatible_reference_unresolved" + effectiveIncomplete -> "managed_reference_incomplete" + else -> "raw_${PlaybackDiagnostics.evidenceName(rawEvidence)}" + } + ) + } + + private fun resolveExplicitExternalFilePlayback( + context: Context, + song: SongItem, + reference: String, + configuredDirectoryUri: String?, + diagnostics: PlaybackDiagnostics, + sourceContext: ResolvedLocalMediaSource? + ): LocalPlaybackReferenceResolution? { + val verifiedAnchor = sourceContext?.anchorFor(song, reference) + val compatible = isManagedPlaybackReferenceCompatibleWithConfiguredSaf(reference, configuredDirectoryUri) + val provenanceReference = verifiedAnchor ?: reference + val explicit = explicitLocalSourceEvidence(song, provenanceReference) + diagnostics.explicitLocalFile = explicit?.toString() ?: "unknown" + if (explicit == false) return null + diagnostics.rawReferenceCompatible = compatible + if (explicit == null) { + return diagnostics.finish( + LocalPlaybackReferenceResolution.TemporarilyUnavailable(ManagedDownloadReferenceLookup.Result.OutOfScope), + "explicit_local_evidence_unknown" + ) + } + if (!isFormalManagedAudioReference(reference) || isPlaybackReferenceCancelled(context, song, reference)) { + diagnostics.explicitlyIncomplete = true + return diagnostics.finish( + LocalPlaybackReferenceResolution.TemporarilyUnavailable(ManagedDownloadReferenceLookup.Result.OutOfScope), + "explicit_local_reference_incomplete" ) + } + // 用户明确选中的独立文件不能被同来源下载的完成桥或新目录引用替换 + val evidence = ManagedDownloadReferenceLookup.inspect(context, reference) + diagnostics.rawProbeExecuted = true + diagnostics.rawEvidence = evidence + return diagnostics.finish( + selectPermittedLocalPlaybackResolution( + rawLocalReference = reference, + isManagedDownload = false, + verifiedManagedReference = null, + rawEvidence = evidence + ), + "explicit_local_${PlaybackDiagnostics.evidenceName(evidence)}" ) } + + private fun explicitLocalSourceEvidence(song: SongItem, reference: String): Boolean? = + if (LocalSongSupport.validExplicitLocalSourceReference(song) == reference) true + else explicitLocalFileReference(reference) + private fun resolveIndependentExternalImportPlayback( context: Context, - song: SongItem + song: SongItem, + diagnostics: PlaybackDiagnostics? = null ): LocalPlaybackReferenceResolution? { val reference = ManagedDownloadStorage.resolveIndependentExternalImportReference(context, song) ?: return null + val evidence = ManagedDownloadReferenceLookup.inspect(context, reference) + diagnostics?.apply { + rawReference = reference + rawProbeExecuted = true + rawEvidence = evidence + } // 副本缺失时也不借用 content 别名或同来源下载的完成桥 return selectPermittedLocalPlaybackResolution( rawLocalReference = reference, isManagedDownload = false, verifiedManagedReference = null, - rawEvidence = ManagedDownloadReferenceLookup.inspect(context, reference) + rawEvidence = evidence ) } private fun resolveReadableManagedDownload( @@ -402,7 +592,7 @@ internal class AudioDownloadPlaybackCoordinator( tag, "本地下载索引确认缺失,准备强制刷新: " + "song=${song.name}, reference=" + - "${playableReferenceForManagedAudio(audio)}" + playbackLogReference(reference) ) GlobalDownloadManager.scanLocalFiles(context, forceRefresh = true) } @@ -413,8 +603,9 @@ internal class AudioDownloadPlaybackCoordinator( tag, "本地下载索引引用暂不可确认,保留并等待对账: " + "song=${song.name}, reference=" + - "${playableReferenceForManagedAudio(audio)}, " + - "evidence=$evidence" + "${playbackLogReference(reference)}, " + + "evidence=${PlaybackDiagnostics.evidenceName(evidence)}, " + + "errorType=${PlaybackDiagnostics.evidenceErrorType(evidence)}" ) return null } @@ -476,7 +667,7 @@ internal class AudioDownloadPlaybackCoordinator( tag, "下载索引确认缺失,准备强制刷新: " + "song=${song.name}, reference=" + - "${playableReferenceForManagedAudio(indexedAudio)}" + playbackLogReference(reference) ) GlobalDownloadManager.scanLocalFiles(context, forceRefresh = true) } @@ -487,8 +678,9 @@ internal class AudioDownloadPlaybackCoordinator( tag, "下载索引引用暂不可确认,保留并等待对账: " + "song=${song.name}, reference=" + - "${playableReferenceForManagedAudio(indexedAudio)}, " + - "evidence=$evidence" + "${playbackLogReference(reference)}, " + + "evidence=${PlaybackDiagnostics.evidenceName(evidence)}, " + + "errorType=${PlaybackDiagnostics.evidenceErrorType(evidence)}" ) return null } @@ -682,34 +874,43 @@ internal class AudioDownloadPlaybackCoordinator( private fun resolveRecentlyCommittedAudioReference( context: Context, song: SongItem, - rawReference: String? = null + rawReference: String? = null, + diagnostics: PlaybackDiagnostics? = null ): String? { val songKey = song.stableKey() + diagnostics?.bridge = "miss" val audio = rawReference?.let { reference -> completedAudioReferenceRegistry.peekCompletedAudioReferenceByRawReference(reference) } ?: completedAudioReferenceRegistry.peekCompletedAudioReference(song) ?: return null + diagnostics?.bridge = "no_playable_reference" val reference = playableReferenceForManagedAudio(audio) ?: return null if (shouldDiscardCompletedReferenceForConfiguredSaf( reference = reference, - configuredDirectoryUri = ManagedDownloadStorage.configuredDirectoryUri() + configuredDirectoryUri = ManagedDownloadStorage.configuredDirectoryUri().also { + diagnostics?.configuredDirectory = playbackLogReference(it) + } ) ) { + diagnostics?.bridge = "discarded_configured_directory" completedAudioReferenceRegistry.invalidateCompletedAudioReference(song) NPLogger.d( tag, "目录已切换到 SAF,丢弃旧完成桥接引用并等待当前根重绑定: " + - "songKey=$songKey, reference=$reference" + "id=${song.id}, channel=${song.channelId}, " + + "reference=${playbackLogReference(reference)}" ) return null } if (isMissingFilePlaybackReference(reference)) { + diagnostics?.bridge = "discarded_missing_file" completedAudioReferenceRegistry.invalidateCompletedAudioReference(song) NPLogger.d( tag, "完成桥接引用已不存在,等待当前存储根重新绑定: " + - "songKey=$songKey, reference=$reference" + "id=${song.id}, channel=${song.channelId}, " + + "reference=${playbackLogReference(reference)}" ) return null } @@ -718,12 +919,15 @@ internal class AudioDownloadPlaybackCoordinator( downloadCancelled = GlobalDownloadManager.isSongCancelled(songKey) ) ) { + diagnostics?.bridge = "rejected" return null } + diagnostics?.bridge = "used" NPLogger.d( tag, "core 音频已提交,目录回调尚未完成,直接使用已校验引用并后台校验: " + - "songKey=$songKey, reference=$reference" + "id=${song.id}, channel=${song.channelId}, " + + "reference=${playbackLogReference(reference)}" ) // 这里不能同步查询 SAF。后台扫描只负责更新索引,Provider 短暂异常时保留桥接。 GlobalDownloadManager.scanLocalFiles(context, forceRefresh = false) @@ -736,7 +940,8 @@ internal class AudioDownloadPlaybackCoordinator( /** 旧 catalog 只剩失效路径时,单飞一次强制快照重绑到迁移后的引用 */ private suspend fun forceResolveManagedPlaybackAfterMissing( context: Context, - song: SongItem + song: SongItem, + diagnostics: PlaybackDiagnostics? = null ): String? { val songKey = song.stableKey() val nowMs = System.currentTimeMillis() @@ -750,8 +955,10 @@ internal class AudioDownloadPlaybackCoordinator( } } if (!shouldRefresh) { + diagnostics?.rebind = "cooldown" return null } + diagnostics?.rebind = "refreshing" val audio = try { ManagedDownloadStorage.findDownloadedAudio( context = context, @@ -761,20 +968,28 @@ internal class AudioDownloadPlaybackCoordinator( } catch (cancellation: CancellationException) { throw cancellation } catch (error: Throwable) { + diagnostics?.apply { + rebind = "failed" + errorType = error.javaClass.name + } NPLogger.w( tag, "旧本地引用缺失时强制重绑失败,保留目录等待重试: " + - "song=${song.name}, error=${error.message}", - error + "id=${song.id}, channel=${song.channelId}, errorType=${error.javaClass.name}" ) return null } val reference = audio ?.let(::playableReferenceForManagedAudio) ?.takeIf { candidate -> - ManagedDownloadReferenceLookup.inspect(context, candidate) == - ManagedDownloadReferenceLookup.Result.Present + val evidence = ManagedDownloadReferenceLookup.inspect(context, candidate) + diagnostics?.apply { + refreshedReference = candidate + refreshedEvidence = evidence + } + evidence == ManagedDownloadReferenceLookup.Result.Present } + diagnostics?.rebind = if (reference != null) "resolved" else "unresolved" if (reference != null) { requestBackgroundDownloadIndexRefresh(context) return ManagedDownloadStorage.toPlayableUri(reference) ?: reference @@ -799,9 +1014,12 @@ internal class AudioDownloadPlaybackCoordinator( context: Context, song: SongItem ): LocalPlaybackReferenceResolution { - resolveIndependentExternalImportPlayback(context, song)?.let { return it } + val diagnostics = PlaybackDiagnostics(tag, "download_indexed", song) + resolveIndependentExternalImportPlayback(context, song, diagnostics)?.let { + return diagnostics.finish(it, "independent_import") + } if (!mayHaveIndexedLocalDownload(context, song)) { - return LocalPlaybackReferenceResolution.NotIndexed + return diagnostics.finish(LocalPlaybackReferenceResolution.NotIndexed, "no_local_index") } // mayHaveIndexedLocalDownload 可能刚从 Room/磁盘快照恢复了条目。 // catalog 仍未发布时也要把该持久化引用带入后续完整性门禁。 @@ -811,6 +1029,7 @@ internal class AudioDownloadPlaybackCoordinator( restorePersisted = false ) val verifiedReference = getLocalPlaybackUri(context, song) + diagnostics.verifiedReference = verifiedReference val indexedReference = listOfNotNull( GlobalDownloadManager.findDownloadedSongCached(song) ?.let(::resolveDownloadedSongPlaybackReference), @@ -820,14 +1039,19 @@ internal class AudioDownloadPlaybackCoordinator( ).firstOrNull { reference -> isManagedPlaybackReferenceCompatibleWithConfiguredSaf( reference = reference, - configuredDirectoryUri = ManagedDownloadStorage.configuredDirectoryUri() + configuredDirectoryUri = ManagedDownloadStorage.configuredDirectoryUri().also { + diagnostics.configuredDirectory = playbackLogReference(it) + } ) } + diagnostics.rawReference = indexedReference val indexedEvidence = if (verifiedReference == null && indexedReference != null) { + diagnostics.rawProbeExecuted = true ManagedDownloadReferenceLookup.inspect(context, indexedReference) } else { null } + diagnostics.rawEvidence = indexedEvidence if ( verifiedReference == null && indexedReference != null && @@ -842,9 +1066,12 @@ internal class AudioDownloadPlaybackCoordinator( NPLogger.d( tag, "索引中的正式下载音频已确认可读,跳过旧完成状态门禁: " + - "song=${song.name}, reference=$indexedReference" + "song=${song.name}, reference=${playbackLogReference(indexedReference)}" + ) + return diagnostics.finish( + LocalPlaybackReferenceResolution.Playable(indexedReference), + "direct_present_indexed_reference" ) - return LocalPlaybackReferenceResolution.Playable(indexedReference) } val indexedReferenceIsExplicitlyIncomplete = if ( verifiedReference == null && @@ -859,14 +1086,23 @@ internal class AudioDownloadPlaybackCoordinator( } else { false } - return selectIndexedLocalPlaybackResolution( - verifiedReference = verifiedReference, - indexedReference = indexedReference, - indexedEvidence = indexedEvidence, - indexedReferenceIsExplicitlyIncomplete = indexedReferenceIsExplicitlyIncomplete, - missingIsTransient = indexedReference?.let { - isRecentManagedPlaybackReference(song = song, reference = it) - } == true + diagnostics.explicitlyIncomplete = indexedReferenceIsExplicitlyIncomplete + return diagnostics.finish( + selectIndexedLocalPlaybackResolution( + verifiedReference = verifiedReference, + indexedReference = indexedReference, + indexedEvidence = indexedEvidence, + indexedReferenceIsExplicitlyIncomplete = indexedReferenceIsExplicitlyIncomplete, + missingIsTransient = indexedReference?.let { + isRecentManagedPlaybackReference(song = song, reference = it) + } == true + ), + when { + verifiedReference != null -> "verified_managed_reference" + indexedReference == null -> "no_compatible_indexed_reference" + indexedReferenceIsExplicitlyIncomplete -> "managed_reference_incomplete" + else -> "indexed_${PlaybackDiagnostics.evidenceName(indexedEvidence)}" + } ) } @@ -1040,7 +1276,8 @@ internal class AudioDownloadPlaybackCoordinator( } ?: return null NPLogger.d( tag, - "下载索引未命中,回退下载目录缓存播放: song=${song.name}, reference=$catalogPlaybackUri" + "下载索引未命中,回退下载目录缓存播放: " + + "song=${song.name}, reference=${playbackLogReference(catalogPlaybackUri)}" ) return catalogPlaybackUri } diff --git a/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/ManagedDownloadClassificationDiagnosticsTest.kt b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/ManagedDownloadClassificationDiagnosticsTest.kt new file mode 100644 index 0000000000..a205a60244 --- /dev/null +++ b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/download/ManagedDownloadClassificationDiagnosticsTest.kt @@ -0,0 +1,162 @@ +package moe.ouom.neriplayer.core.download + +import android.content.Context +import android.os.Environment +import java.io.File +import moe.ouom.neriplayer.core.download.storage.root.ManagedDownloadRootResolver +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.verifyNoInteractions +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ManagedDownloadClassificationDiagnosticsTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private lateinit var context: Context + private lateinit var defaultRoot: File + private var previousDirectoryUri: String? = null + + @Before + fun setUp() { + context = mock(Context::class.java) + `when`(context.applicationContext).thenReturn(context) + `when`(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC)) + .thenReturn(temporaryFolder.newFolder("Music")) + `when`(context.filesDir).thenReturn(temporaryFolder.newFolder("files")) + defaultRoot = ManagedDownloadRootResolver.defaultRootDirectory(context) + previousDirectoryUri = ManagedDownloadStorage.configuredDirectoryUri() + ManagedDownloadStorage.settings.updateDirectoryUri(null) + ManagedDownloadStorage.snapshotCacheStore.invalidate() + ShadowLog.clear() + } + + @After + fun tearDown() { + ManagedDownloadStorage.settings.updateDirectoryUri(previousDirectoryUri) + ManagedDownloadStorage.snapshotCacheStore.invalidate() + } + + @Test + fun `remote identity explains managed classification without probing storage`() { + val unusedContext = mock(Context::class.java) + ManagedDownloadStorage.settings.updateDirectoryUri( + "content://root-user:root-password@provider/tree/current?grant=root-secret#private" + ) + val reference = "content://user:password@provider/tree/old/document/Song.flac?token=secret#private" + val song = localSong(reference).copy( + channelId = "netease", + audioId = "42", + sourceStableKey = "42|netease|" + ) + + assertBothModes(song, expected = true, reason = "identity_hint", context = unusedContext) + + classificationLogs().forEach { trace -> + assertTrue(trace.contains("songId=42")) + assertTrue(trace.contains("channel=netease")) + assertTrue(trace.contains("audioIdPresent=true")) + assertTrue(trace.contains("sourceStableKeyPresent=true")) + assertTrue(trace.contains("configuredRoot=content://provider/tree/current")) + assertTrue(trace.contains("reference=content://provider/tree/old/document/Song.flac")) + assertFalse(trace.contains("password")) + assertFalse(trace.contains("secret")) + assertFalse(trace.contains("#private")) + } + verifyNoInteractions(unusedContext) + } + + @Test + fun `owned import copy reports the independent path instead of a content alias`() { + val imported = File(defaultRoot, "Imports/Imported.flac") + val song = localSong("content://provider/document/alias").copy( + localFilePath = imported.absolutePath + ) + + assertBothModes(song, expected = false, reason = "import_copy") + + classificationLogs().forEach { trace -> + assertTrue(trace.contains("reference=${imported.canonicalPath}")) + assertTrue(trace.contains("channel=local")) + } + } + + @Test + fun `path classification distinguishes private historical and unrelated files`() { + val cases = listOf( + Triple(File(defaultRoot, "Downloaded.flac").absolutePath, true, "private_path"), + Triple("/storage/emulated/0/neriplayer-download/Legacy.flac", true, "historical_root"), + Triple("/music/Unmanaged.flac", false, "unmatched") + ) + cases.forEach { (reference, expected, reason) -> + ShadowLog.clear() + assertBothModes(localSong(reference), expected, reason) + classificationLogs().forEach { trace -> + assertTrue(trace.contains("reference=$reference")) + } + } + } + + @Test + fun `fast tree classification reports a mismatch without provider access`() { + ManagedDownloadStorage.settings.updateDirectoryUri("content://provider/tree/current") + val song = localSong("content://provider/tree/other/document/Song.flac") + + assertFalse(ManagedDownloadStorage.isLikelyManagedDownloadSongFast(context, song)) + + val trace = classificationLogs().single() + assertTrue(trace.contains("mode=fast")) + assertTrue(trace.contains("result=false")) + assertTrue(trace.contains("reason=current_tree")) + assertTrue(trace.contains("configuredRoot=content://provider/tree/current")) + assertTrue(trace.contains("reference=content://provider/tree/other/document/Song.flac")) + } + + private fun assertBothModes( + song: SongItem, + expected: Boolean, + reason: String, + context: Context = this.context + ) { + assertEquals(expected, ManagedDownloadStorage.isLikelyManagedDownloadSong(context, song)) + assertEquals(expected, ManagedDownloadStorage.isLikelyManagedDownloadSongFast(context, song)) + val traces = classificationLogs() + assertEquals("both classifier decisions must be observable", 2, traces.size) + for (mode in listOf("slow", "fast")) { + val trace = traces.single { it.contains("mode=$mode") } + assertTrue(trace.contains("result=$expected")) + assertTrue(trace.contains("reason=$reason")) + } + } + + private fun localSong(reference: String) = SongItem( + id = 42L, + name = "Imported", + artist = "Artist", + album = "__local_files__", + albumId = 0L, + durationMs = 1_000L, + coverUrl = null, + mediaUri = reference, + localFilePath = reference.takeIf { it.startsWith("/") }, + channelId = "local" + ) + + private fun classificationLogs(): List = ShadowLog.getLogs() + .map { it.msg } + .filter { it.startsWith("LocalPlaybackTrace stage=managed_classification ") } +} diff --git a/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/player/download/playback/AudioDownloadExternalScannedPlaybackTest.kt b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/player/download/playback/AudioDownloadExternalScannedPlaybackTest.kt new file mode 100644 index 0000000000..45ffe19431 --- /dev/null +++ b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/player/download/playback/AudioDownloadExternalScannedPlaybackTest.kt @@ -0,0 +1,922 @@ +package moe.ouom.neriplayer.core.player.download.playback + +import android.content.ContentResolver +import android.content.Context +import android.content.ContextWrapper +import android.content.UriPermission +import android.content.pm.PackageManager +import android.content.res.AssetFileDescriptor +import android.database.MatrixCursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import androidx.test.core.app.ApplicationProvider +import java.io.File +import java.io.FileNotFoundException +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.runBlocking +import moe.ouom.neriplayer.core.download.GlobalDownloadManager +import moe.ouom.neriplayer.core.download.ManagedDownloadStorage +import moe.ouom.neriplayer.core.download.execution.clear.ManagedDownloadDirectoryMutationFence +import moe.ouom.neriplayer.core.download.storage.reference.ManagedDownloadReferenceLookup +import moe.ouom.neriplayer.core.player.download.ownership.AudioDownloadReferenceRegistry +import moe.ouom.neriplayer.data.local.audioimport.LocalAudioImportManager +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource +import moe.ouom.neriplayer.data.local.media.source.CrashLogCleanup +import moe.ouom.neriplayer.data.local.media.source.LocalMediaCoverAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaDownloadAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaHostAccess +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.identity.stableKey +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class AudioDownloadExternalScannedPlaybackTest { + @get:Rule + val directory = TemporaryFolder() + + private val currentRoot = DocumentsContract.buildTreeDocumentUri(AUTHORITY, CURRENT_ROOT_ID) + private val externalRoot = DocumentsContract.buildTreeDocumentUri(AUTHORITY, EXTERNAL_ROOT_ID) + private val audioUri = DocumentsContract.buildDocumentUriUsingTree(externalRoot, AUDIO_ID) + private val metadataUri = DocumentsContract.buildDocumentUriUsingTree(externalRoot, METADATA_ID) + private val currentAudioUri = DocumentsContract.buildDocumentUriUsingTree(currentRoot, CURRENT_AUDIO_ID) + private val alternateAudioUri = DocumentsContract.buildDocumentUriUsingTree(externalRoot, ALTERNATE_AUDIO_ID) + private val pendingAudioUri = DocumentsContract.buildDocumentUriUsingTree(externalRoot, PENDING_AUDIO_ID) + private val stagingAudioUri = DocumentsContract.buildDocumentUriUsingTree(externalRoot, STAGING_AUDIO_ID) + private val audioQueries = AtomicInteger() + private val currentRootQueries = AtomicInteger() + private val directoryQueries = AtomicInteger() + private val explicitReferences = mutableSetOf() + private val unknownExplicitReferences = mutableSetOf() + private var explicitReferencesKnown = true + private val mediaAccessField = LocalMediaHostAccess::class.java.getDeclaredField("access") + .apply { isAccessible = true } + private var previousMediaAccess: Any? = null + private var previousDirectoryUri: String? = null + private var externalReadGranted = true + private var externalAudioMissing = false + private var externalAudioFailure: RuntimeException? = null + private lateinit var context: Context + private lateinit var audio: File + private lateinit var metadata: File + private val previousRefreshJob = GlobalDownloadManager.refreshJob + private val completedReferences = AudioDownloadReferenceRegistry(30_000L, 16) + private val coordinator = AudioDownloadPlaybackCoordinator( + completedReferences, + isSongDownloadActive = { false }, + directoryMutationWaitMs = 1L, + explicitLocalFileReference = { + if (!explicitReferencesKnown || it in unknownExplicitReferences) null else it in explicitReferences + } + ) + + @Before + fun setUp() = runBlocking { + previousMediaAccess = mediaAccessField.get(null) + previousDirectoryUri = ManagedDownloadStorage.configuredDirectoryUri() + val resolver = mock(ContentResolver::class.java) + val files = directory.newFolder("files") + val cache = directory.newFolder("cache") + val externalMusic = directory.newFolder("Music") + val noBackup = directory.newFolder("no-backup") + context = object : ContextWrapper(ApplicationProvider.getApplicationContext()) { + override fun getApplicationContext(): Context = this + override fun getContentResolver(): ContentResolver = resolver + override fun getFilesDir(): File = files + override fun getCacheDir(): File = cache + override fun getNoBackupFilesDir(): File = noBackup + override fun getExternalFilesDir(type: String?): File = externalMusic + override fun checkUriPermission(uri: Uri, pid: Int, uid: Int, modeFlags: Int): Int = + if (isExternal(uri) && !externalReadGranted) PackageManager.PERMISSION_DENIED + else PackageManager.PERMISSION_GRANTED + } + audio = directory.newFile(AUDIO_NAME).apply { writeBytes(byteArrayOf(1, 2, 3)) } + metadata = directory.newFile(METADATA_NAME).apply { + writeText("""{"songId":859470,"name":"Scanned Song","artist":"Artist","album":"Album","durationMs":180000,"channelId":"netease","audioId":"859470","stableKey":"859470|netease|"}""") + } + doAnswer { invocation -> + val uri = invocation.getArgument(0) + val projection = invocation.getArgument>(1) + if (uri.pathSegments.lastOrNull() == "children") directoryQueries.incrementAndGet() + if (uri == Uri.parse("content://media/external/audio/media/859470")) { + return@doAnswer MatrixCursor(projection).apply { + addRow(projection.map { column -> + if (column == "_data") "/storage/emulated/0/NeriPlayer-Download2/$AUDIO_NAME" else null + }) + } + } + when (uri) { + audioUri, alternateAudioUri, currentAudioUri, pendingAudioUri, stagingAudioUri -> audioQueries.incrementAndGet() + } + if (runCatching { DocumentsContract.getTreeDocumentId(uri) }.getOrNull() == CURRENT_ROOT_ID) { + currentRootQueries.incrementAndGet() + } + checkReadable(uri) + val ids = if (uri == audioUri && externalAudioMissing) { + emptyList() + } else if (uri.pathSegments.lastOrNull() == "children") { + if (DocumentsContract.getDocumentId(uri) == EXTERNAL_ROOT_ID) listOf(AUDIO_ID, METADATA_ID) + else emptyList() + } else { + listOf(DocumentsContract.getDocumentId(uri)) + } + MatrixCursor(projection).apply { + ids.forEach { id -> addRow(projection.map { valueFor(id, it) }) } + } + }.`when`(resolver).query(any(), any(), any(), any(), any()) + doAnswer { invocation -> + readableFile(invocation.getArgument(0)).inputStream() + }.`when`(resolver).openInputStream(any(Uri::class.java)) + doAnswer { invocation -> + ParcelFileDescriptor.open(readableFile(invocation.getArgument(0)), ParcelFileDescriptor.MODE_READ_ONLY) + }.`when`(resolver).openFileDescriptor(any(), eq("r")) + doAnswer { invocation -> + val file = readableFile(invocation.getArgument(0)) + AssetFileDescriptor(ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY), 0L, file.length()) + }.`when`(resolver).openAssetFileDescriptor(any(), eq("r")) + doAnswer { + listOfNotNull(grant(currentRoot, write = true), grant(externalRoot).takeIf { externalReadGranted }) + }.`when`(resolver).persistedUriPermissions + + ManagedDownloadStorage.settings.updateDirectoryUri(currentRoot.toString()) + ManagedDownloadStorage.snapshotCacheStore.invalidate() + val snapshot = ManagedDownloadStorage.emptyDownloadLibrarySnapshot() + ManagedDownloadStorage.snapshotCacheStore.putSnapshot( + context, + ManagedDownloadStorage.currentSnapshotRootKey(context), + snapshot + ) + val downloads = mock(LocalMediaDownloadAccess::class.java) + doReturn(currentRoot.toString()).`when`(downloads).configuredDirectoryUri() + doReturn(snapshot).`when`(downloads).cachedDownloadLibrarySnapshot(context, true) + doReturn(emptyList()).`when`(downloads).candidateFileNameTemplates(null) + LocalMediaHostAccess.bind(downloads, mock(LocalMediaCoverAccess::class.java), CrashLogCleanup { false }) + } + + @After + fun tearDown() = runBlocking { + GlobalDownloadManager.refreshJob?.takeUnless { it === previousRefreshJob }?.cancelAndJoin() + ManagedDownloadStorage.settings.updateDirectoryUri(previousDirectoryUri) + ManagedDownloadStorage.snapshotCacheStore.invalidate() + mediaAccessField.set(null, previousMediaAccess) + LocalMediaSupport.clearLyricsLookupCache() + } + + @Test + fun `authorized external folder scan stays playable with remote metadata identity`() = runBlocking { + val scanned = scannedSong() + val previousAudioQueries = audioQueries.get() + + val result = coordinator.resolvePermittedLocalPlayback(context, scanned, scanned.mediaUri) + + assertEquals(LocalPlaybackReferenceResolution.Playable(audioUri.toString()), result) + assertTrue("playback checks the selected document", audioQueries.get() > previousAudioQueries) + assertRemoteIdentity(scanned) + } + + @Test + fun `exact scanned sources in the current root keep the selected file before a song bridge`() = runBlocking { + assertExactScannedSourceKeepsFile(externalRoot.toString()) + } + + @Test + fun `exact scanned sources without a configured root keep the selected file before a song bridge`() = runBlocking { + assertExactScannedSourceKeepsFile(null) + } + + @Test + fun `direct preferred source contexts keep the selected scanned file in compatible roots`() = runBlocking { + val song = scannedSong() + val reference = requireNotNull(song.mediaUri) + val source = ResolvedLocalMediaSource.direct(song, reference) + listOf(externalRoot.toString(), null).forEach { root -> + configureSelectedRootWithBridge(song, root) + + assertEquals(LocalPlaybackReferenceResolution.Playable(reference), + coordinator.resolvePermittedLocalPlayback(context, song, reference, source)) + } + } + + @Test + fun `missing exact scanned sources in the current root cannot consume a bridge or rebind`() = runBlocking { + assertExactScannedSourceStaysMissing(externalRoot.toString()) + } + + @Test + fun `missing exact scanned sources without a configured root cannot consume a bridge or rebind`() = runBlocking { + assertExactScannedSourceStaysMissing(null) + } + + @Test + fun `unknown exact scanned provenance in the current root blocks a bridge before probing`() = runBlocking { + assertUnknownExactScannedSourceBlocksBridge(externalRoot.toString()) + } + + @Test + fun `unknown exact scanned provenance without a configured root blocks a bridge before probing`() = runBlocking { + assertUnknownExactScannedSourceBlocksBridge(null) + } + + @Test + fun `exact references with false scanned evidence retain managed bridges in compatible roots`() = runBlocking { + val song = scannedSong(explicit = false) + listOf(externalRoot.toString(), null).forEach { root -> + configureSelectedRootWithBridge(song, root) + + assertEquals(LocalPlaybackReferenceResolution.Playable(alternateAudioUri.toString()), + coordinator.resolvePermittedLocalPlayback(context, song, song.mediaUri)) + } + } + + @Test + fun `explicit current root pending and staging sources cannot consume a bridge before probing`() = runBlocking { + val song = scannedSong() + configureSelectedRootWithBridge(song) + listOf(pendingAudioUri, stagingAudioUri).forEach { uri -> + val reference = uri.toString() + explicitReferences.add(reference) + val previousQueries = audioQueries.get() + + assertUnknown(coordinator.resolvePermittedLocalPlayback(context, + song.copy(mediaUri = reference, localFilePath = null), reference)) + assertEquals(previousQueries, audioQueries.get()) + } + } + + @Test + fun `cancelled exact current root scans cannot consume a bridge before probing`() = runBlocking { + val song = scannedSong() + configureSelectedRootWithBridge(song) + val key = song.stableKey() + val added = GlobalDownloadManager.cancelledSongKeys.add(key) + val previousQueries = audioQueries.get() + try { + assertUnknown(coordinator.resolvePermittedLocalPlayback(context, song, song.mediaUri)) + assertEquals(previousQueries, audioQueries.get()) + } finally { + if (added) GlobalDownloadManager.cancelledSongKeys.remove(key) + } + } + + @Test + fun `exact current root scans wait behind the mutation fence before probing or using a bridge`() = runBlocking { + val song = scannedSong() + configureSelectedRootWithBridge(song) + val previousQueries = audioQueries.get() + val lease = ManagedDownloadDirectoryMutationFence.closeAndDrain() + try { + assertUnknown(coordinator.resolvePermittedLocalPlayback(context, song, song.mediaUri)) + assertEquals(previousQueries, audioQueries.get()) + } finally { + lease.close() + } + } + + @Test + fun `verified folder aliases retain original manual provenance instead of consuming a download bridge`() = runBlocking { + val (song, source) = recoveredLegacySource() + rememberCurrentBridge(song) + val previousRootQueries = currentRootQueries.get() + + val result = coordinator.resolvePermittedLocalPlayback(context, song, source.reference, source) + + assertEquals(LocalPlaybackReferenceResolution.Playable(audioUri.toString()), result) + assertEquals(previousRootQueries, currentRootQueries.get()) + } + + @Test + fun `unknown verified alias provenance remains temporarily unavailable`() = runBlocking { + val (song, source) = recoveredLegacySource() + explicitReferencesKnown = false + + assertUnknown(coordinator.resolvePermittedLocalPlayback(context, song, source.reference, source)) + } + + @Test + fun `verified readable declared files retain the preferred manual anchor instead of a completed bridge`() = runBlocking { + listOf(audio.absolutePath, Uri.fromFile(audio).toString()).forEach { reference -> + val (song, source) = verifiedDeclaredFileSource(reference) + val preferred = requireNotNull(song.mediaUri) + explicitReferences.add(preferred) + rememberCurrentBridge(song) + val bridge = requireNotNull(completedReferences.peekCompletedAudioReference(song)) + val previousAudioQueries = audioQueries.get() + val previousRootQueries = currentRootQueries.get() + + assertUnknown(coordinator.resolvePermittedLocalPlayback( + context, song, reference, ResolvedLocalMediaSource.direct(song, reference) + )) + + val result = coordinator.resolvePermittedLocalPlayback(context, song, reference, source) + + assertEquals(LocalPlaybackReferenceResolution.Playable(reference), result) + assertEquals(preferred, source.anchorFor(song, reference)) + assertSame(bridge, completedReferences.peekCompletedAudioReference(song)) + assertEquals(previousAudioQueries, audioQueries.get()) + assertEquals(previousRootQueries, currentRootQueries.get()) + } + } + + @Test + fun `unknown preferred provenance blocks a verified readable declared file before provider probing`() = runBlocking { + val (song, source) = verifiedDeclaredFileSource() + unknownExplicitReferences.add(requireNotNull(song.mediaUri)) + explicitReferences.add(source.reference) + rememberCurrentBridge(song) + val bridge = requireNotNull(completedReferences.peekCompletedAudioReference(song)) + val previousAudioQueries = audioQueries.get() + val previousRootQueries = currentRootQueries.get() + val previousDirectoryQueries = directoryQueries.get() + + val result = coordinator.resolvePermittedLocalPlayback(context, song, source.reference, source) + + assertUnknown(result) + assertSame(bridge, completedReferences.peekCompletedAudioReference(song)) + assertEquals(previousAudioQueries, audioQueries.get()) + assertEquals(previousRootQueries, currentRootQueries.get()) + assertEquals(previousDirectoryQueries, directoryQueries.get()) + } + + @Test + fun `verified readable declared files preserve managed bridges when both references are nonmanual`() = runBlocking { + val (song, source) = verifiedDeclaredFileSource() + rememberCurrentBridge(song) + val bridge = requireNotNull(completedReferences.peekCompletedAudioReference(song)) + + val result = coordinator.resolvePermittedLocalPlayback(context, song, source.reference, source) + + assertEquals(LocalPlaybackReferenceResolution.Playable(currentAudioUri.toString()), result) + assertSame(bridge, completedReferences.peekCompletedAudioReference(song)) + } + + @Test + fun `a history snapshot keeps its selected source after the last playlist membership is removed`() = runBlocking { + val song = scannedSong(explicit = false).let { it.copy(explicitLocalSourceReference = it.mediaUri) } + rememberCurrentBridge(song) + + val result = coordinator.resolvePermittedLocalPlayback(context, song, song.mediaUri) + + assertEquals(LocalPlaybackReferenceResolution.Playable(audioUri.toString()), result) + } + + @Test + fun `a history snapshot can play its exact source while global playlist membership is unknown`() = runBlocking { + val song = scannedSong(explicit = false).let { it.copy(explicitLocalSourceReference = it.mediaUri) } + explicitReferencesKnown = false + rememberCurrentBridge(song) + + val result = coordinator.resolvePermittedLocalPlayback(context, song, song.mediaUri) + + assertEquals(LocalPlaybackReferenceResolution.Playable(audioUri.toString()), result) + } + + @Test + fun `a missing manual history source cannot be replaced by its remote identity bridge`() = runBlocking { + val song = scannedSong(explicit = false).let { it.copy(explicitLocalSourceReference = it.mediaUri) } + rememberCurrentBridge(song) + externalAudioMissing = true + val previousRootQueries = currentRootQueries.get() + + val result = coordinator.resolvePermittedLocalPlayback(context, song, song.mediaUri) + + assertEquals(LocalPlaybackReferenceResolution.Missing, result) + assertEquals(previousRootQueries, currentRootQueries.get()) + } + + @Test + fun `a verified history alias uses its snapshot without current playlist membership`() = runBlocking { + val (song, source) = recoveredLegacySource(snapshotMarker = true) + explicitReferences.remove(requireNotNull(song.mediaUri)) + explicitReferencesKnown = false + rememberCurrentBridge(song) + + val result = coordinator.resolvePermittedLocalPlayback(context, song, source.reference, source) + + assertEquals(LocalPlaybackReferenceResolution.Playable(audioUri.toString()), result) + } + + @Test + fun `an unverified alias cannot replace a manual history snapshot with a bridge`() = runBlocking { + val (song, verified) = recoveredLegacySource(snapshotMarker = true) + explicitReferences.remove(requireNotNull(song.mediaUri)) + val source = ResolvedLocalMediaSource.direct(song, verified.reference) + rememberCurrentBridge(song) + val previousQueries = audioQueries.get() + + val result = coordinator.resolvePermittedLocalPlayback(context, song, source.reference, source) + + assertUnknown(result) + assertEquals(previousQueries, audioQueries.get()) + } + + @Test + fun `invalid history markers retain ordinary managed bridge recovery`() = runBlocking { + val scanned = scannedSong(explicit = false) + listOf(alternateAudioUri.toString(), "https://example.com/song.flac", "", " ").forEach { marker -> + val song = scanned.copy(explicitLocalSourceReference = marker) + rememberCurrentBridge(song) + + val result = coordinator.resolvePermittedLocalPlayback(context, song, song.mediaUri) + + assertEquals(LocalPlaybackReferenceResolution.Playable(currentAudioUri.toString()), result) + } + } + + @Test + fun `a stale history marker cannot override unknown global membership for a different source`() = runBlocking { + val song = scannedSong(explicit = false).copy(explicitLocalSourceReference = alternateAudioUri.toString()) + explicitReferencesKnown = false + rememberCurrentBridge(song) + val previousQueries = audioQueries.get() + + assertUnknown(coordinator.resolvePermittedLocalPlayback(context, song, song.mediaUri)) + assertEquals(previousQueries, audioQueries.get()) + } + + @Test + fun `a verified manual alias in the current root keeps its selected file instead of a song bridge`() = runBlocking { + val (song, source) = recoveredLegacySource() + configureSelectedRootWithBridge(song) + + assertEquals(LocalPlaybackReferenceResolution.Playable(audioUri.toString()), + coordinator.resolvePermittedLocalPlayback(context, song, source.reference, source)) + } + + @Test + fun `a verified alias in the current root cannot consume a bridge while provenance is unknown`() = runBlocking { + val (song, source) = recoveredLegacySource() + unknownExplicitReferences.add(requireNotNull(song.mediaUri)) + configureSelectedRootWithBridge(song) + + assertUnknown(coordinator.resolvePermittedLocalPlayback(context, song, source.reference, source)) + } + + @Test + fun `a verified alias in the current root retains its bridge when neither reference is manual`() = runBlocking { + val (song, source) = recoveredLegacySource() + explicitReferences.remove(requireNotNull(song.mediaUri)) + configureSelectedRootWithBridge(song) + + assertEquals(LocalPlaybackReferenceResolution.Playable(alternateAudioUri.toString()), + coordinator.resolvePermittedLocalPlayback(context, song, source.reference, source)) + } + + @Test + fun `current root references without exact manual evidence retain their bridge without a source context`() = runBlocking { + val (song, source) = recoveredLegacySource() + configureSelectedRootWithBridge(song) + + assertEquals(LocalPlaybackReferenceResolution.Playable(alternateAudioUri.toString()), + coordinator.resolvePermittedLocalPlayback(context, song, source.reference)) + } + + @Test + fun `the same recovered URI cannot borrow provenance from another source snapshot`() = runBlocking { + val (song, source) = recoveredLegacySource() + val otherSong = song.copy(localFilePath = "/storage/emulated/0/Other/Scanned Song.flac") + + val result = coordinator.resolvePermittedLocalPlayback(context, otherSong, source.reference, source) + + assertTrue(result !is LocalPlaybackReferenceResolution.Playable) + } + + @Test + fun `unverified direct aliases cannot borrow the preferred reference provenance`() = runBlocking { + val (song, source) = recoveredLegacySource() + val unverified = ResolvedLocalMediaSource.direct(song, source.reference) + + val result = coordinator.resolvePermittedLocalPlayback(context, song, source.reference, unverified) + + assertTrue(result !is LocalPlaybackReferenceResolution.Playable) + } + + @Test + fun `an unverified alias cannot consume a download bridge for a declared manual source`() = runBlocking { + assertUnverifiedAliasBlocksBridge(manualSourceKnown = true) + } + + @Test + fun `an unverified alias cannot consume a download bridge while declared provenance is unknown`() = runBlocking { + assertUnverifiedAliasBlocksBridge(manualSourceKnown = false) + } + + @Test + fun `an unverified current root alias cannot replace its declared manual source with a bridge`() = runBlocking { + assertUnverifiedAliasBlocksBridge(manualSourceKnown = true, sameRootBridge = true) + } + + @Test + fun `an unverified current root alias cannot use a bridge while declared provenance is unknown`() = runBlocking { + assertUnverifiedAliasBlocksBridge(manualSourceKnown = false, sameRootBridge = true) + } + + @Test + fun `an unverified alias retains managed recovery when neither reference is manual`() = runBlocking { + val song = scannedSong(explicit = false).copy(mediaUri = "content://media/external/audio/media/859470") + val source = ResolvedLocalMediaSource.direct(song, audioUri.toString()) + rememberCurrentBridge(song) + + assertEquals(LocalPlaybackReferenceResolution.Playable(currentAudioUri.toString()), + coordinator.resolvePermittedLocalPlayback(context, song, source.reference, source)) + } + + @Test + fun `verified aliases still wait behind the directory mutation fence`() = runBlocking { + val (song, source) = recoveredLegacySource() + val lease = ManagedDownloadDirectoryMutationFence.closeAndDrain() + try { + assertTrue(coordinator.resolvePermittedLocalPlayback(context, song, source.reference, source) + is LocalPlaybackReferenceResolution.TemporarilyUnavailable) + } finally { + lease.close() + } + } + + @Test + fun `external scan with revoked provider permission stays temporarily unavailable`() = runBlocking { + val scanned = scannedSong() + externalReadGranted = false + + val result = coordinator.resolvePermittedLocalPlayback(context, scanned, scanned.mediaUri) + + assertTrue(result is LocalPlaybackReferenceResolution.TemporarilyUnavailable) + assertTrue((result as LocalPlaybackReferenceResolution.TemporarilyUnavailable).evidence is + ManagedDownloadReferenceLookup.Result.PermissionLost) + assertRemoteIdentity(scanned) + } + + @Test + fun `another reference cannot borrow explicit membership from the same remote song`() = runBlocking { + val scanned = scannedSong() + val otherReference = alternateAudioUri.toString() + val otherSong = scanned.copy(mediaUri = otherReference, localFilePath = null) + + val result = coordinator.resolvePermittedLocalPlayback(context, otherSong, otherReference) + + assertTrue(result !is LocalPlaybackReferenceResolution.Playable) + } + + @Test + fun `external reference without local files membership keeps managed root restrictions`() = runBlocking { + val scanned = scannedSong(explicit = false) + + val result = coordinator.resolvePermittedLocalPlayback(context, scanned, scanned.mediaUri) + + assertTrue(result !is LocalPlaybackReferenceResolution.Playable) + } + + @Test + fun `managed song keeps its current root completion bridge instead of the old reference`() = runBlocking { + val scanned = scannedSong(explicit = false) + val currentReference = currentAudioUri.toString() + rememberCurrentBridge(scanned) + + val result = coordinator.resolvePermittedLocalPlayback(context, scanned, scanned.mediaUri) + + assertEquals(LocalPlaybackReferenceResolution.Playable(currentReference), result) + } + + @Test + fun `managed current root reference still waits behind the directory mutation fence`() = runBlocking { + val scanned = scannedSong(explicit = false) + val currentReference = currentAudioUri.toString() + val lease = ManagedDownloadDirectoryMutationFence.closeAndDrain() + try { + val result = coordinator.resolvePermittedLocalPlayback( + context, + scanned.copy(mediaUri = currentReference, localFilePath = null), + currentReference + ) + + assertTrue(result is LocalPlaybackReferenceResolution.TemporarilyUnavailable) + } finally { + lease.close() + } + } + + @Test + fun `explicit pending reference cannot use external scan playback exemption`() = runBlocking { + val scanned = scannedSong() + val pendingReference = pendingAudioUri.toString() + explicitReferences.add(pendingReference) + val previousAudioQueries = audioQueries.get() + + val result = coordinator.resolvePermittedLocalPlayback( + context, + scanned.copy(mediaUri = pendingReference, localFilePath = null), + pendingReference + ) + + assertTrue(result !is LocalPlaybackReferenceResolution.Playable) + assertEquals(previousAudioQueries, audioQueries.get()) + } + + @Test + fun `unknown membership waits without consuming the bridge and later true uses the selected file`() = runBlocking { + val scanned = scannedSong() + rememberCurrentBridge(scanned) + explicitReferencesKnown = false + val previousAudioQueries = audioQueries.get() + val previousRootQueries = currentRootQueries.get() + + val unknown = coordinator.resolvePermittedLocalPlayback(context, scanned, scanned.mediaUri) + + assertUnknown(unknown) + assertEquals(previousAudioQueries, audioQueries.get()) + assertEquals(previousRootQueries, currentRootQueries.get()) + + explicitReferencesKnown = true + val known = coordinator.resolvePermittedLocalPlayback(context, scanned, scanned.mediaUri) + + assertEquals(LocalPlaybackReferenceResolution.Playable(audioUri.toString()), known) + } + + @Test + fun `unknown membership waits and later false retains normal managed bridge recovery`() = runBlocking { + val scanned = scannedSong(explicit = false) + rememberCurrentBridge(scanned) + explicitReferencesKnown = false + val previousAudioQueries = audioQueries.get() + val previousRootQueries = currentRootQueries.get() + + val unknown = coordinator.resolvePermittedLocalPlayback(context, scanned, scanned.mediaUri) + + assertUnknown(unknown) + assertEquals(previousAudioQueries, audioQueries.get()) + assertEquals(previousRootQueries, currentRootQueries.get()) + + explicitReferencesKnown = true + val known = coordinator.resolvePermittedLocalPlayback(context, scanned, scanned.mediaUri) + + assertEquals(LocalPlaybackReferenceResolution.Playable(currentAudioUri.toString()), known) + } + + @Test + fun `missing explicit source cannot be replaced by a bridge sharing its remote identity`() = runBlocking { + val scanned = scannedSong() + rememberCurrentBridge(scanned) + externalAudioMissing = true + val previousRootQueries = currentRootQueries.get() + + val result = coordinator.resolvePermittedLocalPlayback(context, scanned, scanned.mediaUri) + + assertEquals(LocalPlaybackReferenceResolution.Missing, result) + assertEquals(previousRootQueries, currentRootQueries.get()) + } + + @Test + fun `provider failure for an explicit source cannot fall back to an identity bridge`() = runBlocking { + val scanned = scannedSong() + rememberCurrentBridge(scanned) + externalAudioFailure = IllegalStateException("fixture provider unavailable") + val previousRootQueries = currentRootQueries.get() + + val result = coordinator.resolvePermittedLocalPlayback(context, scanned, scanned.mediaUri) + + assertTrue(result is LocalPlaybackReferenceResolution.TemporarilyUnavailable) + assertTrue((result as LocalPlaybackReferenceResolution.TemporarilyUnavailable).evidence is + ManagedDownloadReferenceLookup.Result.ProviderFailure) + assertEquals(previousRootQueries, currentRootQueries.get()) + } + + @Test + fun `cancelled explicit source stays blocked without probing the provider`() = runBlocking { + val scanned = scannedSong() + val key = scanned.stableKey() + val added = GlobalDownloadManager.cancelledSongKeys.add(key) + val previousAudioQueries = audioQueries.get() + try { + val result = coordinator.resolvePermittedLocalPlayback(context, scanned, scanned.mediaUri) + + assertTrue(result !is LocalPlaybackReferenceResolution.Playable) + assertEquals(previousAudioQueries, audioQueries.get()) + } finally { + if (added) GlobalDownloadManager.cancelledSongKeys.remove(key) + } + } + + @Test + fun `explicit staging reference stays blocked without probing the provider`() = runBlocking { + val scanned = scannedSong() + val stagingReference = stagingAudioUri.toString() + explicitReferences.add(stagingReference) + val previousAudioQueries = audioQueries.get() + + val result = coordinator.resolvePermittedLocalPlayback( + context, + scanned.copy(mediaUri = stagingReference, localFilePath = null), + stagingReference + ) + + assertTrue(result !is LocalPlaybackReferenceResolution.Playable) + assertEquals(previousAudioQueries, audioQueries.get()) + } + + @Test + fun `explicit source stays blocked by an active directory fence without probing`() = runBlocking { + val scanned = scannedSong() + val previousAudioQueries = audioQueries.get() + val lease = ManagedDownloadDirectoryMutationFence.closeAndDrain() + try { + val result = coordinator.resolvePermittedLocalPlayback(context, scanned, scanned.mediaUri) + + assertTrue(result is LocalPlaybackReferenceResolution.TemporarilyUnavailable) + assertEquals(previousAudioQueries, audioQueries.get()) + } finally { + lease.close() + } + } + + private fun assertUnknown(result: LocalPlaybackReferenceResolution) { + assertTrue(result is LocalPlaybackReferenceResolution.TemporarilyUnavailable) + assertEquals(ManagedDownloadReferenceLookup.Result.OutOfScope, + (result as LocalPlaybackReferenceResolution.TemporarilyUnavailable).evidence) + } + + private suspend fun assertExactScannedSourceKeepsFile(directoryUri: String?) { + val song = scannedSong() + configureSelectedRootWithBridge(song, directoryUri) + + assertEquals(LocalPlaybackReferenceResolution.Playable(audioUri.toString()), + coordinator.resolvePermittedLocalPlayback(context, song, song.mediaUri)) + } + + private suspend fun assertExactScannedSourceStaysMissing(directoryUri: String?) { + val song = scannedSong() + configureSelectedRootWithBridge(song, directoryUri) + externalAudioMissing = true + val previousDirectoryQueries = directoryQueries.get() + + assertEquals(LocalPlaybackReferenceResolution.Missing, + coordinator.resolvePermittedLocalPlayback(context, song, song.mediaUri)) + assertEquals(previousDirectoryQueries, directoryQueries.get()) + } + + private suspend fun assertUnknownExactScannedSourceBlocksBridge(directoryUri: String?) { + val song = scannedSong(explicit = false) + unknownExplicitReferences.add(requireNotNull(song.mediaUri)) + configureSelectedRootWithBridge(song, directoryUri) + val previousQueries = audioQueries.get() + + assertUnknown(coordinator.resolvePermittedLocalPlayback(context, song, song.mediaUri)) + assertEquals(previousQueries, audioQueries.get()) + } + + private suspend fun recoveredLegacySource(snapshotMarker: Boolean = false): Pair { + val original = "content://media/external/audio/media/859470" + val song = scannedSong(explicit = false).copy( + mediaUri = original, + localFilePath = "/storage/emulated/0/NeriPlayer-Download2/$AUDIO_NAME", + explicitLocalSourceReference = original.takeIf { snapshotMarker } + ) + explicitReferences.add(original) + return song to requireNotNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + } + + private suspend fun verifiedDeclaredFileSource(reference: String = audio.absolutePath): Pair { + val preferred = Uri.parse("content://media/external/audio/media/859470") + val song = scannedSong(explicit = false).copy(mediaUri = preferred.toString(), localFilePath = reference) + val proofQueries = AtomicInteger() + val resolver = mock(ContentResolver::class.java) + val proofContext = object : ContextWrapper(context) { + override fun getApplicationContext(): Context = this + override fun getContentResolver(): ContentResolver = resolver + } + doAnswer { invocation -> + assertEquals(preferred, invocation.getArgument(0)) + assertEquals(listOf("_data"), invocation.getArgument>(1).toList()) + proofQueries.incrementAndGet() + MatrixCursor(arrayOf("_data")).apply { addRow(arrayOf(audio.canonicalPath)) } + }.`when`(resolver).query(any(), any(), any(), any(), any()) + doAnswer { + throw FileNotFoundException("preferred MediaStore reference is unreadable") + }.`when`(resolver).openAssetFileDescriptor(eq(preferred), eq("r")) + + val source = requireNotNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(proofContext, song, reference)) + + assertTrue("the declared file contains real readable bytes", audio.inputStream().use { it.read() >= 0 }) + assertTrue("the preferred MediaStore row proves the same canonical file", proofQueries.get() > 0) + assertEquals(reference, source.reference) + return song to source + } + + private suspend fun assertUnverifiedAliasBlocksBridge(manualSourceKnown: Boolean, sameRootBridge: Boolean = false) { + val original = "content://media/external/audio/media/859470" + val song = scannedSong(explicit = false).copy(mediaUri = original) + if (manualSourceKnown) explicitReferences.add(original) else unknownExplicitReferences.add(original) + val source = ResolvedLocalMediaSource.direct(song, audioUri.toString()) + if (sameRootBridge) configureSelectedRootWithBridge(song) else rememberCurrentBridge(song) + + assertUnknown(coordinator.resolvePermittedLocalPlayback(context, song, source.reference, source)) + } + + private fun configureSelectedRootWithBridge(song: SongItem, directoryUri: String? = externalRoot.toString()) { + ManagedDownloadStorage.settings.updateDirectoryUri(directoryUri) + ManagedDownloadStorage.snapshotCacheStore.invalidate() + rememberCurrentBridge(song, alternateAudioUri) + } + + private fun rememberCurrentBridge(song: SongItem, uri: Uri = currentAudioUri) { + val reference = uri.toString() + completedReferences.rememberCompletedAudioReference( + song, + ManagedDownloadStorage.StoredEntry( + name = DocumentsContract.getDocumentId(uri).substringAfterLast('/'), + reference = reference, + mediaUri = reference, + localFilePath = null, + sizeBytes = audio.length(), + lastModifiedMs = 1_000L + ) + ) + } + + private suspend fun scannedSong(explicit: Boolean = true): SongItem { + val result = LocalAudioImportManager.scanFolderSongs(context, externalRoot) + assertTrue("the selected folder scan completes", result.completed) + assertEquals(0, result.failedCount) + val song = result.songs.single() + assertEquals(audioUri.toString(), song.mediaUri) + assertRemoteIdentity(song) + if (explicit) explicitReferences.add(requireNotNull(song.mediaUri)) + return song + } + + private fun assertRemoteIdentity(song: SongItem) { + assertEquals(859470L, song.id) + assertEquals("netease", song.channelId) + assertEquals("859470", song.audioId) + assertEquals("859470|netease|", song.sourceStableKey) + } + + private fun readableFile(uri: Uri): File { + checkReadable(uri) + return when (uri) { + audioUri, alternateAudioUri, currentAudioUri, pendingAudioUri, stagingAudioUri -> audio + metadataUri -> metadata + else -> throw FileNotFoundException("fixture document is missing") + } + } + + private fun checkReadable(uri: Uri) { + if (isExternal(uri) && !externalReadGranted) throw SecurityException("fixture read grant revoked") + if (uri == audioUri) externalAudioFailure?.let { throw it } + } + + private fun isExternal(uri: Uri): Boolean = + runCatching { DocumentsContract.getTreeDocumentId(uri) }.getOrNull() == EXTERNAL_ROOT_ID + + private fun valueFor(id: String, column: String): Any? = when (column) { + DocumentsContract.Document.COLUMN_DOCUMENT_ID -> id + OpenableColumns.DISPLAY_NAME -> id.substringAfterLast('/') + DocumentsContract.Document.COLUMN_MIME_TYPE -> when (id) { + CURRENT_ROOT_ID, EXTERNAL_ROOT_ID -> DocumentsContract.Document.MIME_TYPE_DIR + AUDIO_ID, CURRENT_AUDIO_ID, ALTERNATE_AUDIO_ID, PENDING_AUDIO_ID, STAGING_AUDIO_ID -> "audio/flac" + else -> "application/json" + } + OpenableColumns.SIZE -> if (id == METADATA_ID) metadata.length() else audio.length() + DocumentsContract.Document.COLUMN_LAST_MODIFIED -> 1_000L + DocumentsContract.Document.COLUMN_FLAGS -> DocumentsContract.Document.FLAG_SUPPORTS_WRITE + "duration" -> 180_000L + else -> null + } + + private fun grant(uri: Uri, write: Boolean = false): UriPermission = mock(UriPermission::class.java).also { + doReturn(uri).`when`(it).uri + doReturn(true).`when`(it).isReadPermission + doReturn(write).`when`(it).isWritePermission + } + + private companion object { + const val AUTHORITY = "com.android.externalstorage.documents" + const val CURRENT_ROOT_ID = "primary:NeriPlayer-Download" + const val EXTERNAL_ROOT_ID = "primary:NeriPlayer-Download2" + const val AUDIO_NAME = "Scanned Song.flac" + const val METADATA_NAME = "$AUDIO_NAME.npmeta.json" + const val AUDIO_ID = "$EXTERNAL_ROOT_ID/$AUDIO_NAME" + const val METADATA_ID = "$EXTERNAL_ROOT_ID/$METADATA_NAME" + const val CURRENT_AUDIO_ID = "$CURRENT_ROOT_ID/$AUDIO_NAME" + const val ALTERNATE_AUDIO_ID = "$EXTERNAL_ROOT_ID/Another Song.flac" + const val PENDING_AUDIO_ID = "$EXTERNAL_ROOT_ID/$AUDIO_NAME.npdl_pending.operation.flac" + const val STAGING_AUDIO_ID = "$EXTERNAL_ROOT_ID/download_staging/$AUDIO_NAME" + } +} diff --git a/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/player/download/playback/AudioDownloadPlaybackDiagnosticsTest.kt b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/player/download/playback/AudioDownloadPlaybackDiagnosticsTest.kt new file mode 100644 index 0000000000..91577a8ad6 --- /dev/null +++ b/modules/download/runtime/src/test/java/moe/ouom/neriplayer/core/player/download/playback/AudioDownloadPlaybackDiagnosticsTest.kt @@ -0,0 +1,194 @@ +package moe.ouom.neriplayer.core.player.download.playback + +import android.content.Context +import android.os.Environment +import android.util.Log +import java.io.File +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import moe.ouom.neriplayer.core.download.ManagedDownloadStorage +import moe.ouom.neriplayer.core.download.execution.clear.ManagedDownloadDirectoryMutationFence +import moe.ouom.neriplayer.core.download.storage.root.ManagedDownloadRootResolver +import moe.ouom.neriplayer.core.player.download.ownership.AudioDownloadReferenceRegistry +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class AudioDownloadPlaybackDiagnosticsTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private lateinit var context: Context + private lateinit var importedAudio: File + private var previousDirectoryUri: String? = null + private val coordinator = AudioDownloadPlaybackCoordinator( + completedAudioReferenceRegistry = AudioDownloadReferenceRegistry(30_000L, 16), + isSongDownloadActive = { false }, + directoryMutationWaitMs = 1L + ) + + @Before + fun setUp() { + context = mock(Context::class.java) + `when`(context.applicationContext).thenReturn(context) + `when`(context.getExternalFilesDir(Environment.DIRECTORY_MUSIC)) + .thenReturn(temporaryFolder.newFolder("Music")) + `when`(context.filesDir).thenReturn(temporaryFolder.newFolder("files")) + previousDirectoryUri = ManagedDownloadStorage.configuredDirectoryUri() + ManagedDownloadStorage.settings.updateDirectoryUri(null) + importedAudio = File( + ManagedDownloadRootResolver.defaultRootDirectory(context), + "Imports/Imported.flac" + ) + ShadowLog.clear() + } + + @After + fun tearDown() { + ManagedDownloadStorage.settings.updateDirectoryUri(previousDirectoryUri) + } + + @Test + fun `missing independent import reports its terminal evidence at warning level`() = runBlocking { + val result = coordinator.resolvePermittedLocalPlayback( + context, + localSong(), + "content://user:password@media/external/audio/media/42?token=secret#fragment" + ) + + assertEquals(LocalPlaybackReferenceResolution.Missing, result) + val trace = traceLogs().single() + assertEquals(Log.WARN, trace.type) + assertTrue(trace.msg.contains("id=42")) + assertTrue(trace.msg.contains("channel=local")) + assertTrue(trace.msg.contains("albumId=17")) + assertTrue(trace.msg.contains("result=missing")) + assertTrue(trace.msg.contains("reason=independent_import")) + assertTrue(trace.msg.contains("rawEvidence=missing")) + assertTrue(trace.msg.contains("rawProbeExecuted=true")) + assertTrue(trace.msg.contains("rawReference=${importedAudio.canonicalPath}")) + assertTrue(!trace.msg.contains("secret")) + assertTrue(!trace.msg.contains("password")) + } + + @Test + fun `readable independent import reports the selected local copy once`() = runBlocking { + check(importedAudio.parentFile!!.mkdirs()) + importedAudio.writeBytes(byteArrayOf(1, 2, 3)) + + val result = coordinator.resolvePermittedLocalPlayback(context, localSong(), null) + + assertEquals(LocalPlaybackReferenceResolution.Playable(importedAudio.canonicalPath), result) + val trace = traceLogs().single() + assertTrue(trace.msg.contains("result=playable")) + assertTrue(trace.msg.contains("reason=independent_import")) + assertTrue(trace.msg.contains("rawEvidence=present")) + assertTrue(trace.msg.contains("reference=${importedAudio.canonicalPath}")) + } + + @Test + fun `closed directory mutation gate reports timeout before inspecting the provider`() = runBlocking { + val lease = ManagedDownloadDirectoryMutationFence.closeAndDrain() + try { + val result = coordinator.resolvePermittedLocalPlayback( + context, + localSong().copy(channelId = "netease"), + "content://user:password@provider/document/song?token=secret#fragment" + ) + + assertTrue(result is LocalPlaybackReferenceResolution.TemporarilyUnavailable) + val trace = traceLogs().single() + assertEquals(Log.WARN, trace.type) + assertTrue(trace.msg.contains("result=temporary")) + assertTrue(trace.msg.contains("reason=directory_mutation_timeout")) + assertTrue(trace.msg.contains("managedFast=true")) + assertTrue(trace.msg.contains("rawEvidence=not_checked")) + assertTrue(trace.msg.contains("rawProbeExecuted=false")) + assertTrue(trace.msg.contains("fence=timeout")) + assertTrue(!trace.msg.contains("secret")) + assertTrue(!trace.msg.contains("password")) + } finally { + lease.close() + } + } + + @Test + fun `provider failure reports the category without leaking the exception message`() = runBlocking { + val reference = "content://user:password@provider/document/song?token=secret#fragment" + doThrow(IllegalStateException("provider failed for $reference")) + .`when`(context).contentResolver + + val result = coordinator.resolvePermittedLocalPlayback( + context, + localSong().copy(localFilePath = null, mediaUri = reference), + reference + ) + + assertTrue(result is LocalPlaybackReferenceResolution.TemporarilyUnavailable) + val trace = traceLogs().single() + assertEquals(Log.WARN, trace.type) + assertTrue(trace.msg.contains("result=temporary")) + assertTrue(trace.msg.contains("rawEvidence=provider_failure")) + assertTrue(trace.msg.contains("rawErrorType=java.lang.IllegalStateException")) + assertTrue(trace.msg.contains("managedFast=false")) + assertTrue(trace.msg.contains("managedFull=false")) + assertTrue(trace.msg.contains("rawReferenceCompatible=true")) + assertTrue(!trace.msg.contains("provider failed for")) + assertTrue(!trace.msg.contains("secret")) + assertTrue(!trace.msg.contains("password")) + assertEquals(null, trace.throwable) + } + + @Test + fun `provider cancellation still propagates without being turned into a decision`() { + val reference = "content://provider/document/song" + val cancellation = CancellationException("provider request cancelled") + doThrow(cancellation).`when`(context).contentResolver + + val thrown = assertThrows(CancellationException::class.java) { + runBlocking { + coordinator.resolvePermittedLocalPlayback( + context, + localSong().copy(localFilePath = null, mediaUri = reference), + reference + ) + } + } + + assertEquals(cancellation.message, thrown.message) + assertTrue(thrown === cancellation || thrown.cause === cancellation) + assertTrue(traceLogs().isEmpty()) + } + + private fun localSong() = SongItem( + id = 42L, + name = "Imported", + artist = "Artist", + album = "__local_files__", + albumId = 17L, + durationMs = 1_000L, + coverUrl = null, + channelId = "local", + localFilePath = importedAudio.absolutePath, + mediaUri = "content://media/external/audio/media/42" + ) + + private fun traceLogs() = ShadowLog.getLogs().filter { + it.msg.startsWith("LocalPlaybackTrace stage=download_permitted ") + } +} diff --git a/modules/local/README.md b/modules/local/README.md index 0de90b23e1..b7337c64a8 100644 --- a/modules/local/README.md +++ b/modules/local/README.md @@ -11,6 +11,7 @@ Sources are in `src/main/java/moe/ouom/neriplayer/data`. - `local`:媒体、导入与歌单 / media, imports, and playlists +- `local/media/curation`、`local/media/metadata`:批量整理的写入授权、内嵌资源检查与元数据恢复事务 / write authorization, embedded asset inspection, and metadata recovery transactions for batch curation - `local/storage`:占用统计、扫描与清理 / storage accounting, scans, and cleanup - `settings`、`config`:设置与配置,KSP 在本库运行 / settings and configuration, with KSP run in this library - `history`、`stats`、`playlist`:历史与使用统计 / history and usage statistics @@ -29,6 +30,10 @@ Cache cleanup preserves downloaded audio, lyrics, and covers. `ExtraCacheClearRe Local edits preserve lyrics and artwork from before the first edit. Restoration prefers persisted originals, and rescanning keeps those originals separate from current sidecars. Unknown and explicitly empty values remain distinct; missing empty translation and romanization tracks do not create files, and existing lyric files are reused by type. +`LocalMediaWriteAccess` 在写入前规划授权:Android 11 及以上对尚不可写的 MediaStore 条目分批请求系统同意,Android 10 对其他应用创建的文件引导授予所在文件夹。音频与歌词、封面伴随文件的修改由恢复日志记录写入意图、原始备份和对象身份,读回确认后才提交;恢复遇到对象替换或内容冲突时保留日志,避免覆盖外部更改。 + +`LocalMediaWriteAccess` plans authorization before writes: Android 11 and later request system consent in batches for MediaStore items that are not yet writable; Android 10 requests a folder grant for files created by other apps. Recovery journals track write intent, original backups, and object identity for audio and lyric/artwork companions, committing only after read-back verification. Replaced objects or content conflicts retain their journals rather than overwriting external changes. + 大型扫描对仅有 content URI 的音频复用目录行和已知侧载索引,不逐首打开音频或查询文档。延后的身份信息在预览后台补全,提交导入前还会按有界批次核对所选 MP3 的原始身份,立即关闭预览也不会跳过这一步;歌词和封面基线保持原值。 Large scans of content-only audio reuse directory rows and known sidecar indexes without opening each audio file or querying each document. Deferred identity is hydrated in the preview background, and selected MP3s undergo bounded raw identity checks before playlist persistence, even when the preview closes immediately. Existing lyric and artwork baselines are retained. @@ -45,6 +50,16 @@ Track playback statistics use Room as their primary store, without loading all t Playlist play increments carrying event IDs are acknowledged only when Room persists the count and receipt in one transaction. Failed legacy JSON promotion leaves the event pending; recovery confirms the actual primary store before retrying promotion, preventing duplicate counts after process restart. Existing playlist writes without event IDs retain JSON fallback. +## 扫描与本地播放 / Scans and local playback + +文件夹扫描优先保存授权树内的音频引用,不把路径存在当作 MediaStore 读取授权。旧引用不可读时,播放器可在同一存储卷和已授权目录内恢复同一文件的 SAF 引用,编辑回写也复用这一来源。封面和元数据侧载共用恢复事务,失败或取消时回滚;回滚未完成时停止尝试其它音频别名。 + +Folder scans prefer audio references within the granted document tree; an existing path does not establish MediaStore read access. When an old reference is unreadable, playback can recover a SAF reference to the same file within a granted directory on the same volume, and metadata edits reuse that source. Artwork and metadata sidecars share a recovery transaction. Failure or cancellation rolls back the changes, and an unresolved rollback stops further audio alias attempts. + +从扫描预览创建或加入普通歌单时,持久化用户明确选择的本地引用,保留旁车恢复的远端身份。本机播放仅接受与当前首选引用完全一致的来源标记;同步投影清除此标记和本地地址。旧记录没有扫描来源证据时不自动推断,再次显式扫描导入同一远端条目可以更新它的本地来源。 + +Creating or updating an ordinary playlist from scan results persists the explicitly selected local reference while retaining remote identity recovered from sidecars. Local playback accepts provenance only when it exactly matches the current preferred reference; remote sync projections remove provenance and local addresses. Records without scan provenance remain unclassified until the user explicitly imports a scanned source again. + ## 测试 / Tests ```bash diff --git a/modules/local/src/androidTest/java/moe/ouom/neriplayer/data/local/database/store/PlayHistoryRoomStoreTest.kt b/modules/local/src/androidTest/java/moe/ouom/neriplayer/data/local/database/store/PlayHistoryRoomStoreTest.kt index d48306e7be..c6ecf8b4f1 100644 --- a/modules/local/src/androidTest/java/moe/ouom/neriplayer/data/local/database/store/PlayHistoryRoomStoreTest.kt +++ b/modules/local/src/androidTest/java/moe/ouom/neriplayer/data/local/database/store/PlayHistoryRoomStoreTest.kt @@ -6,6 +6,7 @@ import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.test.runTest +import moe.ouom.neriplayer.data.history.toSongItem import moe.ouom.neriplayer.data.model.history.PlayedEntry import moe.ouom.neriplayer.data.local.database.NeriUserDataDatabase import org.junit.Assert.assertEquals @@ -31,11 +32,15 @@ class PlayHistoryRoomStoreTest { album = "netease", durationMs = 180_000L, coverUrl = null, + mediaUri = "content://media/external/audio/media/1", + explicitLocalSourceReference = "content://media/external/audio/media/1", playedAt = 100L ) val second = first.copy( id = 2L, name = "second", + mediaUri = null, + explicitLocalSourceReference = null, playedAt = 50L ) val store = PlayHistoryRoomStore(database) @@ -51,6 +56,8 @@ class PlayHistoryRoomStoreTest { ) assertEquals(listOf(updatedFirst), store.readIfRoomPrimary()) + assertEquals(updatedFirst.explicitLocalSourceReference, + store.readIfRoomPrimary()?.single()?.toSongItem()?.explicitLocalSourceReference) assertEquals(1, database.playHistoryDao().getAll().size) assertTrue( database.syncMetadataDao().getMigrationMetadata( diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/history/PlayHistoryRepository.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/history/PlayHistoryRepository.kt index db07ec0eec..5697c296a1 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/history/PlayHistoryRepository.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/history/PlayHistoryRepository.kt @@ -49,6 +49,7 @@ import moe.ouom.neriplayer.data.local.database.NeriUserDataDatabase import moe.ouom.neriplayer.data.local.database.store.PlayHistoryRoomImportStatus import moe.ouom.neriplayer.data.local.database.store.PlayHistoryRoomStore import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.media.preferredLocalMediaReference import moe.ouom.neriplayer.data.model.SongIdentity import moe.ouom.neriplayer.data.sync.store.preferences.PlayHistoryUpdateMode import moe.ouom.neriplayer.data.sync.store.preferences.SyncPreferences @@ -97,6 +98,7 @@ internal fun SongItem.toPlayedEntry(now: Long): PlayedEntry { audioId = audioId, subAudioId = subAudioId, sourceStableKey = sourceStableKey, + explicitLocalSourceReference = LocalSongSupport.validExplicitLocalSourceReference(this), playedAt = now ) } @@ -110,7 +112,7 @@ fun PlayedEntry.toSongItem(): SongItem { album = album, durationMs = durationMs, coverUrl = coverUrl, - mediaUri = localFilePath ?: mediaUri, + mediaUri = preferredLocalMediaReference(localFilePath, mediaUri) ?: mediaUri, matchedLyric = matchedLyric, matchedTranslatedLyric = matchedTranslatedLyric, matchedRomanizedLyric = matchedRomanizedLyric, @@ -133,7 +135,10 @@ fun PlayedEntry.toSongItem(): SongItem { channelId = channelId, audioId = audioId, subAudioId = subAudioId, - sourceStableKey = sourceStableKey + sourceStableKey = sourceStableKey, + explicitLocalSourceReference = LocalSongSupport.validExplicitLocalSourceReference( + localFilePath, mediaUri, explicitLocalSourceReference + ) ) } @@ -661,6 +666,11 @@ class PlayHistoryRepository private constructor( } private fun PlayedEntry.mergeSongMetadata(song: SongItem, playedAt: Long = this.playedAt): PlayedEntry { + val currentReference = LocalSongSupport.validExplicitLocalSourceReference( + localFilePath, mediaUri, explicitLocalSourceReference + ) + val selectedReference = LocalSongSupport.validExplicitLocalSourceReference(song) + ?: currentReference?.takeIf { it == preferredLocalMediaReference(song.localFilePath, song.mediaUri) } return copy( name = song.name, artist = song.artist, @@ -692,6 +702,7 @@ class PlayHistoryRepository private constructor( audioId = song.audioId, subAudioId = song.subAudioId, sourceStableKey = song.sourceStableKey, + explicitLocalSourceReference = selectedReference, playedAt = playedAt ) } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/identity/SongIdentityExtensions.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/identity/SongIdentityExtensions.kt index d168834826..de55d8a908 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/identity/SongIdentityExtensions.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/identity/SongIdentityExtensions.kt @@ -195,7 +195,7 @@ fun SongItem.isSyncableRemoteSong(context: Context? = null): Boolean { fun SongItem.toSyncableRemoteSongOrNull(context: Context? = null): SongItem? { if (!LocalSongSupport.isLocalSong(this, context)) { - return this + return if (explicitLocalSourceReference == null) this else copy(explicitLocalSourceReference = null) } val sourceIdentity = remoteSourceIdentityOrNull() ?: return null val source = syncableRemoteSource(sourceIdentity) @@ -218,6 +218,7 @@ fun SongItem.toSyncableRemoteSongOrNull(context: Context? = null): SongItem? { channelId = source.channel, audioId = source.audioId, subAudioId = source.subAudioId, + explicitLocalSourceReference = null, streamUrl = null ) } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManagerSetup.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManagerSetup.kt index 106c1cc94f..d34124cd42 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManagerSetup.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportManagerSetup.kt @@ -431,9 +431,9 @@ internal suspend fun LocalAudioImportManager.completeScannedSongs( songs.chunked(COMPLETE_SCAN_BATCH_SIZE).forEach { batch -> val hydratedBatch = batch.map { song -> async(dispatcher) { - val metadataReference = song.mediaUri + val knownReferences = song.mediaUri ?.let(indexedSidecarReferences::get) - ?.metadata + val metadataReference = knownReferences?.metadata val directFile = song.localFilePath ?.takeUnless { it.startsWith("content://", ignoreCase = true) } ?.let(::File) @@ -445,6 +445,12 @@ internal suspend fun LocalAudioImportManager.completeScannedSongs( if (contentOnly) { return@async hydrateLocalSongFromIndexedMetadataSidecar(context, song, metadataReference) } + // 已知目录索引没有侧载时,完整的非 MP3 元数据不需要再逐曲查询 + if (knownReferences != null && metadataReference == null && + !isRawId3IdentityCandidate(song) && !needsLocalIdentityMetadataProbe(song) + ) { + return@async song + } // MediaStore 的可读 Unicode 也可能已被系统误解码,MP3 需要核对原始标签 val canHydrateWithoutAudioProbe = metadataReference != null || !song.localFilePath.isNullOrBlank() || isRawId3IdentityCandidate(song) @@ -824,8 +830,7 @@ internal suspend fun LocalAudioImportManager.scanExternalStorageFolderWithMediaS if (resolvedFile != null) { resolvedPathCount++ } - // MediaStore 的 _data 或 content URI 可能在切换存储权限后暂时失效 - // 目录索引中的同名音频文档作为有界 SAF 回退,避免出现不可播放条目 + // 文件路径存在不代表拥有全媒体权限,保留用户授权目录内的文档引用 val safAudioReference = sidecarResolver.resolveAudioReference( relativePath = rowRelativePath, displayName = displayName @@ -880,15 +885,8 @@ internal suspend fun LocalAudioImportManager.scanExternalStorageFolderWithMediaS relativePath = rowRelativePath, displayName = displayName ) - val sourceReference = if ( - resolvedFile == null && - !hasReadableMediaStoreReference && - !safAudioReference.isNullOrBlank() - ) { - safAudioReference - } else { - contentUri.toString() - } + val sourceReference = safAudioReference?.takeIf(String::isNotBlank) + ?: contentUri.toString() if (sourceReference == safAudioReference) { safAudioFallbackRows++ } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/database/store/PlayHistoryRoomMapper.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/database/store/PlayHistoryRoomMapper.kt index aab5f1d4b0..6f8445b3a5 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/database/store/PlayHistoryRoomMapper.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/database/store/PlayHistoryRoomMapper.kt @@ -8,6 +8,7 @@ import moe.ouom.neriplayer.data.model.lyrics.readLyricSyncPersistence import moe.ouom.neriplayer.data.model.lyrics.toPersistenceJson import moe.ouom.neriplayer.data.model.history.PlayedEntry import moe.ouom.neriplayer.data.local.database.entity.PlayHistoryEntity +import moe.ouom.neriplayer.data.local.media.LocalSongSupport import moe.ouom.neriplayer.data.model.SongIdentity import moe.ouom.neriplayer.data.model.stableKey @@ -74,6 +75,9 @@ internal class PlayHistoryRoomMapper { audioId = audioId, subAudioId = subAudioId, sourceStableKey = sourceStableKey, + explicitLocalSourceReference = LocalSongSupport.validExplicitLocalSourceReference( + localFilePath, mediaUri, explicitLocalSourceReference + ), playedAt = playedAt ) } @@ -115,6 +119,9 @@ private fun PlayedEntry.toEntity(): PlayHistoryEntity { audioId = audioId, subAudioId = subAudioId, sourceStableKey = sourceStableKey, + explicitLocalSourceReference = LocalSongSupport.validExplicitLocalSourceReference( + localFilePath, mediaUri, explicitLocalSourceReference + ), playedAt = playedAt, lyricSyncPayloadJson = LyricSyncPersistence( revision = lyricSyncRevision, edited = lyricSyncEdited, diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupport.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupport.kt index e8a91433f8..cc3f922f5b 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupport.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupport.kt @@ -383,6 +383,7 @@ object LocalMediaSupport { val outcome = writeTransaction.outcome var embeddedTransactionSettled = outcome != LocalMediaMetadataWriteOutcome.SUCCESS var companionTransactionSettled = companionTransaction == null + var rollbackSucceeded = true try { if (!persistAvailableSidecars) { var finalOutcome = outcome @@ -463,6 +464,8 @@ object LocalMediaSupport { } else { true } + val companionReferencesBeforeCover = companionTransaction?.record?.companions + ?.mapTo(mutableSetOf()) { it.reference }.orEmpty() val coverSidecarWritten = if (writeCover) { currentCoroutineContext().ensureActive() writeLocalCoverSidecar( @@ -479,13 +482,10 @@ object LocalMediaSupport { true } val metadataCoverReference = if (writeCover && !coverReference.isNullOrBlank()) { - findNearbyCoverReference( - context = context, - uri = sourceUri, - file = localFile, - displayName = displayName, - parentChildrenForMutation = knownSidecarReferences.mutationParentChildren - ) ?: coverReference + // 目录快照早于侧载创建,采用本次封面阶段确认写入的引用 + companionTransaction?.record?.companions + ?.filter { !it.deferredDelete && it.reference !in companionReferencesBeforeCover } + ?.takeIf { it.isNotEmpty() }?.single()?.reference ?: coverReference } else { null } @@ -513,7 +513,10 @@ object LocalMediaSupport { allowSidecarAuthoritativeFallback = embeddedPropertyMapOverride == null && embeddedPropertyPlanFactory == null ) if (!sidecarsWritten) { - companionTransaction?.rollback() ?: writeTransaction.rollback?.invoke() + rollbackSucceeded = companionTransaction?.rollback() ?: run { + writeTransaction.rollback?.invoke() + true + } embeddedTransactionSettled = true companionTransactionSettled = true NPLogger.w(TAG, "write local metadata sidecar failed for $sourceUri") @@ -526,7 +529,10 @@ object LocalMediaSupport { logEditableMetadataFailure("commit_recovery_record", sourceUri, error) }.isSuccess if (!committed) { - companionTransaction?.rollback() ?: writeTransaction.rollback?.invoke() + rollbackSucceeded = companionTransaction?.rollback() ?: run { + writeTransaction.rollback?.invoke() + true + } finalOutcome = LocalMediaMetadataWriteOutcome.FAILED } embeddedTransactionSettled = true @@ -542,6 +548,7 @@ object LocalMediaSupport { else -> "direct" } ) + if (!rollbackSucceeded) return@withContext LocalMediaMetadataWriteOutcome.FAILED if (finalOutcome == LocalMediaMetadataWriteOutcome.SUCCESS) { return@withContext finalOutcome } @@ -655,64 +662,8 @@ object LocalMediaSupport { writeLyrics: Boolean = false, coverReference: String? = null, clearCoverReference: Boolean = false - ): Boolean = withContext(Dispatchers.IO) { - val candidates = editableLocalMediaUriCandidates(context, song) - if (candidates.isEmpty()) return@withContext false - val written = candidates.any { sourceUri -> - currentCoroutineContext().ensureActive() - val file = resolveEditableSidecarFile(context, sourceUri) - val displayName = song.localFileName - ?.takeIf(String::isNotBlank) - ?: sourceUri.lastPathSegment.orEmpty() - val fastMetadataReference = if (!writeLyrics && - coverReference.isNullOrBlank() && !clearCoverReference && file == null - ) { - resolveCachedEditableMetadataReference(context, sourceUri, displayName) - } else null - val references = if (fastMetadataReference != null) { - ContentSidecarReferences( - metadataReference = fastMetadataReference, - lyricReferences = NearbyLyricReferences(null, null, null) - ) - } else { - resolveContentSidecarReferences( - context = context, - sourceUri = sourceUri, - file = file, - displayName = displayName - ) - } - val knownReference = references.metadataReference - val effectiveCoverReference = if ( - !coverReference.isNullOrBlank() && !clearCoverReference - ) { - findNearbyCoverReference( - context = context, - uri = sourceUri, - file = file, - displayName = displayName - ) ?: coverReference - } else { - coverReference - } - writeLocalLyricsMetadata( - context = context, - sourceUri = sourceUri, - file = file, - displayName = displayName, - song = song, - knownReference = knownReference, - writeFullMetadata = true, - writeLyricFields = writeLyrics, - coverReference = effectiveCoverReference, - clearCoverReference = clearCoverReference, - useVerifiedExistingReference = fastMetadataReference != null - ) - } - if (written) { - clearLyricsLookupCache() - } - written + ): Boolean { + return this.writeLocalMetadataSidecarImpl(context, song, writeLyrics, coverReference, clearCoverReference) } /** diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeMetadata.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeMetadata.kt index 9307fa705a..607504fd6f 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeMetadata.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalMediaSupportFacadeMetadata.kt @@ -1,12 +1,20 @@ package moe.ouom.neriplayer.data.local.media import moe.ouom.neriplayer.data.model.local.LocalMediaDetails +import moe.ouom.neriplayer.data.local.media.LocalMediaSupport.ContentSidecarReferences +import moe.ouom.neriplayer.data.local.media.metadata.LocalMediaCompanionTransaction +import moe.ouom.neriplayer.data.local.media.metadata.LocalMediaMetadataRecoveryStore +import moe.ouom.neriplayer.data.local.media.metadata.editableLocalMediaUriCandidates +import moe.ouom.neriplayer.data.local.media.metadata.resolveEditableSidecarFile +import moe.ouom.neriplayer.data.local.media.metadata.writeLocalCoverSidecar import android.content.Context import android.content.Intent import android.net.Uri import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.data.identity.stableKey as songStableKey @@ -18,6 +26,102 @@ import java.io.IOException import java.util.Locale import androidx.core.net.toUri +internal suspend fun LocalMediaSupport.writeLocalMetadataSidecarImpl( + context: Context, + song: SongItem, + writeLyrics: Boolean = false, + coverReference: String? = null, + clearCoverReference: Boolean = false +): Boolean = withContext(Dispatchers.IO) { + val candidates = editableLocalMediaUriCandidates(context, song) + if (candidates.isEmpty()) return@withContext false + val writesCover = coverReference != null || clearCoverReference + LocalMediaMetadataRecoveryStore.withRecoveredTargets( + context = context.applicationContext, + targetReferences = candidates.map(Uri::toString), + blockedResult = false + ) { + candidates.any { sourceUri -> + currentCoroutineContext().ensureActive() + val file = resolveEditableSidecarFile(context, sourceUri) + val displayName = song.localFileName + ?.takeIf(String::isNotBlank) + ?: sourceUri.lastPathSegment.orEmpty() + val transaction = LocalMediaCompanionTransaction(context, sourceUri.toString()) + var committed = false + var rollbackSucceeded = true + try { + val fastMetadataReference = if (!writeLyrics && !writesCover && file == null) { + resolveCachedEditableMetadataReference(context, sourceUri, displayName) + } else null + val references = if (fastMetadataReference != null) { + ContentSidecarReferences( + metadataReference = fastMetadataReference, + lyricReferences = NearbyLyricReferences(null, null, null) + ) + } else { + resolveContentSidecarReferences( + context = context, + sourceUri = sourceUri, + file = file, + displayName = displayName, + forMutation = true + ) + } + val companionReferencesBeforeCover = transaction.record?.companions + ?.mapTo(mutableSetOf()) { it.reference }.orEmpty() + val coverWritten = !writesCover || writeLocalCoverSidecar( + context = context, + sourceUri = sourceUri, + file = file, + displayName = displayName, + coverReference = coverReference.takeUnless { clearCoverReference }, + stableIdentityKey = song.songStableKey(), + companionTransaction = transaction, + parentChildrenForMutation = references.mutationParentChildren + ) + if (coverWritten) { + val effectiveCoverReference = if (!coverReference.isNullOrBlank() && !clearCoverReference) { + transaction.record?.companions + ?.filter { !it.deferredDelete && it.reference !in companionReferencesBeforeCover } + ?.takeIf { it.isNotEmpty() }?.single()?.reference ?: coverReference + } else coverReference + val metadataWritten = writeLocalLyricsMetadata( + context = context, + sourceUri = sourceUri, + file = file, + displayName = displayName, + song = song, + knownReference = references.metadataReference, + writeFullMetadata = true, + writeLyricFields = writeLyrics, + coverReference = effectiveCoverReference, + clearCoverReference = clearCoverReference, + companionTransaction = transaction, + parentChildrenForMutation = references.mutationParentChildren, + useVerifiedExistingReference = fastMetadataReference != null + ) + if (metadataWritten) { + currentCoroutineContext().ensureActive() + if (transaction.record != null) transaction.commit() + committed = true + } + } + } finally { + if (!committed) { + rollbackSucceeded = runCatching { transaction.rollback() } + .onFailure { error -> logEditableMetadataFailure("rollback_metadata_sidecars", sourceUri, error) } + .getOrDefault(false) + } + clearLyricsLookupCache() + clearCoverLookupCache() + } + if (!rollbackSucceeded) return@withRecoveredTargets false + committed + } + } +} + internal fun LocalMediaSupport.toSongItemImpl(details: LocalMediaDetails): SongItem { val stableSource = details.filePath?.takeIf { it.isNotBlank() } ?: details.sourceUri.toString() val playbackSource = preferredLocalMediaReference( diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalSongSupport.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalSongSupport.kt index 26e6b9d4ab..9cd995daaa 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalSongSupport.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/LocalSongSupport.kt @@ -80,6 +80,18 @@ object LocalSongSupport { fun isLocalMediaUri(mediaUri: String?): Boolean = moe.ouom.neriplayer.data.sync.policy.isLocalMediaUri(mediaUri) + fun validExplicitLocalSourceReference(song: SongItem): String? = validExplicitLocalSourceReference( + song.localFilePath, song.mediaUri, song.explicitLocalSourceReference + ) + + fun validExplicitLocalSourceReference( + localFilePath: String?, + mediaUri: String?, + explicitReference: String? + ): String? = explicitReference?.takeIf { + isLocalMediaUri(it) && preferredLocalMediaReference(localFilePath, mediaUri) == it + } + fun sanitizeMediaUriForSync(mediaUri: String?): String? { return mediaUri?.takeUnless { isLocalMediaUri(it) } } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/source/ResolvedLocalMediaSource.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/source/ResolvedLocalMediaSource.kt new file mode 100644 index 0000000000..a19260db40 --- /dev/null +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/media/source/ResolvedLocalMediaSource.kt @@ -0,0 +1,172 @@ +package moe.ouom.neriplayer.data.local.media.source + +import android.content.Context +import android.content.UriPermission +import android.database.Cursor +import android.net.Uri +import android.provider.DocumentsContract +import androidx.core.net.toUri +import java.io.File +import java.util.Locale +import moe.ouom.neriplayer.common.logging.NPLogger +import moe.ouom.neriplayer.common.logging.playbackLogReference +import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.media.curation.FolderArtworkLocator +import moe.ouom.neriplayer.data.local.media.curation.StorageDocumentPath +import moe.ouom.neriplayer.data.local.media.isReadableLocalFile +import moe.ouom.neriplayer.data.local.media.preferredLocalMediaReference +import moe.ouom.neriplayer.data.model.SongItem + +/** 仅为当前歌曲快照保留已验证的文件来源,不注册可被其他歌曲借用的别名 */ +class ResolvedLocalMediaSource private constructor( + val reference: String, + private val anchorReference: String, + private val sourceSong: SongItem +) { + fun anchorFor(song: SongItem, actualReference: String?): String? = anchorReference.takeIf { + sourceSong == song && reference == actualReference && + anchorReference == preferredLocalMediaReference(song.localFilePath, song.mediaUri) + } + + // 原声明只用于阻止替换,不能证明当前引用属于手动文件 + fun unverifiedDeclaredReferenceFor(song: SongItem, actualReference: String?): String? { + if (sourceSong != song || reference != actualReference) return null + return preferredLocalMediaReference(song.localFilePath, song.mediaUri)?.takeIf { + it != reference && it != anchorReference + } + } + + companion object { + fun direct(song: SongItem, reference: String): ResolvedLocalMediaSource = + ResolvedLocalMediaSource(reference, reference, song) + + fun verifiedReadableDeclaredFile(context: Context, song: SongItem, reference: String): ResolvedLocalMediaSource? { + val anchor = preferredLocalMediaReference(song.localFilePath, song.mediaUri) ?: return null + if (reference == anchor) return null + val file = readableDeclaredDirectFile(song, reference) ?: return null + val anchorFile = referenceFile(context, anchor) ?: return null + if (file != anchorFile || hasConflictingDeclaredFile(song, anchorFile)) return null + return ResolvedLocalMediaSource(reference, anchor, song) + } + + fun recoverFromFolderGrant(context: Context, song: SongItem): ResolvedLocalMediaSource? { + if (!LocalSongSupport.isLocalSong(song, context)) return null + val anchor = preferredLocalMediaReference(song.localFilePath, song.mediaUri) ?: return null + val file = referenceFile(context, anchor) ?: return null + if (hasConflictingDeclaredFile(song, file)) return null + val path = runCatching { FolderArtworkLocator.storagePathOf(file.canonicalPath) } + .getOrNull() ?: return null + val reference = readableGrantedReference(context, song, path) ?: return null + return ResolvedLocalMediaSource(reference, anchor, song) + } + + private fun readableGrantedReference(context: Context, song: SongItem, path: StorageDocumentPath): String? { + val permissions = context.contentResolver.persistedUriPermissions.orEmpty() + logFolderGrants(song, permissions) + // 只恢复同一规范路径的树内文档,目录授权本身不能证明另一个音频别名 + return permissions.asSequence() + .filter { it.isReadPermission } + .map { it.uri } + .filter { it.authority == "com.android.externalstorage.documents" } + .filter { tree -> + runCatching { DocumentsContract.getTreeDocumentId(tree) }.getOrNull() + ?.let(path::isInside) == true + } + .mapNotNull { tree -> + runCatching { + DocumentsContract.buildDocumentUriUsingTree(tree, path.documentId).toString() + }.getOrNull() + } + .firstOrNull { canReadDocument(context, it) } + } + + private fun canReadDocument(context: Context, reference: String): Boolean = runCatching { + context.contentResolver.openAssetFileDescriptor(reference.toUri(), "r")?.use { true } == true + }.getOrDefault(false) + + private fun logFolderGrants(song: SongItem, permissions: List) { + NPLogger.d( + "NERI-LocalPlayback", + "LocalPlaybackTrace stage=folder_grants id=${song.id} count=${permissions.size} " + + "grants=${permissions.take(8).map { permission -> + "${playbackLogReference(permission.uri.toString())}:read=${permission.isReadPermission}:write=${permission.isWritePermission}" + }}" + ) + } + + fun hasConflictingDeclaredFile(context: Context, song: SongItem): Boolean { + val anchor = preferredLocalMediaReference(song.localFilePath, song.mediaUri) ?: return false + val file = referenceFile(context, anchor) ?: return false + return hasConflictingDeclaredFile(song, file) + } + + private fun hasConflictingDeclaredFile(song: SongItem, anchorFile: File): Boolean = + listOf(song.localFilePath, song.mediaUri).mapNotNull(::directFile).any { declared -> + runCatching { declared.canonicalFile != anchorFile }.getOrDefault(true) + } + + private fun referenceFile(context: Context, reference: String): File? = runCatching { + val file = directFile(reference) ?: contentReferenceFile(context, reference.toUri()) + file?.canonicalFile + }.getOrNull() + + private fun readableDeclaredDirectFile(song: SongItem, reference: String): File? { + if (reference !in listOf(song.localFilePath, song.mediaUri)) return null + return runCatching { directFile(reference)?.takeIf(::isReadableLocalFile)?.canonicalFile }.getOrNull() + } + + private fun contentReferenceFile(context: Context, uri: Uri): File? { + if (!uri.scheme.equals("content", ignoreCase = true)) return null + return when (uri.authority) { + "com.android.externalstorage.documents" -> externalStorageFile(uri) + "media" -> mediaStoreFile(context, uri) + else -> null + } + } + + private fun externalStorageFile(uri: Uri): File? { + val documentId = DocumentsContract.getDocumentId(uri) + val volume = documentId.substringBefore(':') + val relative = documentId.substringAfter(':', "").takeIf(String::isNotBlank) ?: return null + val root = externalStorageRoot(volume) ?: return null + val file = File(root, relative).canonicalFile + val path = FolderArtworkLocator.storagePathOf(file.path) ?: return null + return file.takeIf { path.volumeId.equals(volume, ignoreCase = true) && path.relativePath == relative } + } + + private fun externalStorageRoot(volume: String): String? = when { + volume.equals("primary", ignoreCase = true) -> "/storage/emulated/0" + volume.matches(Regex("[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}")) -> + "/storage/${volume.uppercase(Locale.ROOT)}" + else -> null + } + + private fun mediaStoreFile(context: Context, uri: Uri): File? { + if (!isMediaStoreAudioItemPath(uri.pathSegments)) return null + return context.contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use { cursor -> + singleMediaStoreFile(cursor) + } + } + + private fun isMediaStoreAudioItemPath(segments: List): Boolean { + if (segments.size != 4 || segments[1] != "audio" || segments[2] != "media") return false + val id = segments[3].toLongOrNull() ?: return false + return id >= 0 && id.toString() == segments[3] + } + + private fun singleMediaStoreFile(cursor: Cursor): File? { + val column = cursor.getColumnIndex("_data") + if (!cursor.moveToFirst() || column < 0) return null + val path = cursor.getString(column)?.takeIf { it.startsWith('/') } ?: return null + return if (cursor.moveToNext()) null else File(path) + } + + private fun directFile(reference: String?): File? = when { + reference == null -> null + reference.startsWith('/') -> File(reference) + reference.startsWith("file:", ignoreCase = true) -> + runCatching { reference.toUri().path?.let(::File) }.getOrNull() + else -> null + } + } +} diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt index 2938f32776..2fb8dde6cf 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt @@ -60,6 +60,7 @@ import moe.ouom.neriplayer.data.local.database.store.LocalPlaylistRoomStore import moe.ouom.neriplayer.data.local.database.store.LocalPlaylistPreviewAuthority import moe.ouom.neriplayer.data.local.media.LocalMediaSupport import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.media.preferredLocalMediaReference import moe.ouom.neriplayer.data.local.media.localMediaUri import moe.ouom.neriplayer.data.model.playlist.DISPLAY_ORDER_SONG_ORDER_VERSION import moe.ouom.neriplayer.data.model.playlist.LocalPlaylist @@ -69,6 +70,7 @@ import moe.ouom.neriplayer.data.model.netease.playlist.NeteaseRemotePlaylist import moe.ouom.neriplayer.platform.netease.playlist.NeteasePlaylistSync import moe.ouom.neriplayer.platform.netease.playlist.NeteasePlaylistSyncMessage import moe.ouom.neriplayer.data.local.playlist.system.FavoritesPlaylist +import moe.ouom.neriplayer.data.local.playlist.system.LocalFilesPlaylist import moe.ouom.neriplayer.data.local.playlist.system.SystemLocalPlaylists import moe.ouom.neriplayer.data.model.SongIdentity import moe.ouom.neriplayer.data.identity.identity @@ -183,6 +185,22 @@ class LocalPlaylistRepository private constructor( } } + internal fun containsExplicitLocalFileReferenceCached(reference: String): Boolean? { + if (!_initializationReadyFlow.value) return null + if (!LocalSongSupport.isLocalMediaUri(reference)) return false + val playlists = _playlists.value + val localFiles = LocalFilesPlaylist.firstOrNull(playlists) + if (localFiles?.songs?.any { song -> + preferredLocalMediaReference(song.localFilePath, song.mediaUri) == reference + } == true + ) { + return true + } + return playlists.any { playlist -> + playlist.songs.any { song -> song.validExplicitLocalSourceReference() == reference } + } + } + suspend fun awaitInitialized(): Boolean { initialLoad.await() if (initialLoadFailure == null) return true @@ -1195,7 +1213,7 @@ class LocalPlaylistRepository private constructor( return@commitPlaylistMutation false } publishLocked( - playlists = mergeExternalPlaylists(playlists), + playlists = mergeExternalPlaylists(playlists, preserveExplicitLocalSources = true), triggerSync = false, markLocalMutation = false ) @@ -1292,6 +1310,10 @@ class LocalPlaylistRepository private constructor( @Volatile private var INSTANCE: LocalPlaylistRepository? = null + /** 不触发仓库初始化,null 表示尚无可确认的手动导入缓存 */ + fun peekExplicitLocalFileReference(reference: String): Boolean? = + INSTANCE?.containsExplicitLocalFileReferenceCached(reference) + fun getInstance(context: Context): LocalPlaylistRepository { return INSTANCE ?: synchronized(this) { val appContext = context.applicationContext diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryBootstrap.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryBootstrap.kt index 8672bf3ded..21e3724650 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryBootstrap.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryBootstrap.kt @@ -817,7 +817,8 @@ internal fun LocalPlaylistRepository.projectRemoteSourcePlaylistEntries( songs: List ): MutableList { return songs.mapTo(mutableListOf()) { song -> - song.toSyncableRemoteSongOrNull(context) ?: song + if (song.validExplicitLocalSourceReference() != null) song + else song.toSyncableRemoteSongOrNull(context) ?: song } } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryMetadata.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryMetadata.kt index 454d5c91b4..eba2c088f4 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryMetadata.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryMetadata.kt @@ -3,6 +3,9 @@ package moe.ouom.neriplayer.data.local.playlist import moe.ouom.neriplayer.data.local.playlist.LocalPlaylistRepository.SongMetadataUpdate import moe.ouom.neriplayer.data.local.playlist.LocalPlaylistRepository.SongMetadataUpdateIndex import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.media.preferredLocalMediaReference +import moe.ouom.neriplayer.data.identity.identity +import moe.ouom.neriplayer.data.identity.sameIdentityAs import moe.ouom.neriplayer.data.model.playlist.DISPLAY_ORDER_SONG_ORDER_VERSION import moe.ouom.neriplayer.data.model.playlist.LocalPlaylist import moe.ouom.neriplayer.data.local.playlist.system.LocalFilesPlaylist @@ -107,10 +110,18 @@ internal fun LocalPlaylistRepository.mergeSongMetadataForPersistence( currentSong: SongItem, newSongInfo: SongItem, clearCoverUrl: Boolean = false, - clearOriginalCoverUrl: Boolean = false + clearOriginalCoverUrl: Boolean = false, + allowScannedSourceReplacement: Boolean = false ): SongItem { + val currentExplicitReference = currentSong.validExplicitLocalSourceReference() + val updatedExplicitReference = newSongInfo.validExplicitLocalSourceReference() + val updatedPlaybackReference = preferredLocalMediaReference(newSongInfo.localFilePath, newSongInfo.mediaUri) + val replacesUnmarkedSource = allowScannedSourceReplacement && updatedExplicitReference != null && + currentExplicitReference == null && currentSong.sameIdentityAs(newSongInfo) val restoreEmptyBaseCover = newSongInfo.originalCoverUrl == "" && newSongInfo.coverUrl == null val mergedSong = newSongInfo.copy( + explicitLocalSourceReference = updatedExplicitReference + ?: currentExplicitReference?.takeIf { it == updatedPlaybackReference }, addedAt = currentSong.addedAt, sourceModifiedAtMs = currentSong.sourceModifiedAtMs ?: newSongInfo.sourceModifiedAtMs, logicalCreatedAtMs = currentSong.logicalCreatedAtMs @@ -136,7 +147,7 @@ internal fun LocalPlaylistRepository.mergeSongMetadataForPersistence( }, syncMembershipTokens = currentSong.syncMembershipTokens.normalizedSyncCausalTokens() ) - if (!shouldPreserveEntryPlaybackSource(currentSong, newSongInfo)) { + if (replacesUnmarkedSource || !shouldPreserveEntryPlaybackSource(currentSong, newSongInfo)) { return mergedSong } @@ -164,11 +175,15 @@ internal fun LocalPlaylistRepository.mergeSongMetadataForPersistence( subAudioId = currentSong.subAudioId, playlistContextId = currentSong.playlistContextId, sourceStableKey = currentSong.sourceStableKey, + explicitLocalSourceReference = currentExplicitReference, streamUrl = currentSong.streamUrl, neteaseArtists = currentSong.neteaseArtists ) } +internal fun SongItem.validExplicitLocalSourceReference(): String? = + LocalSongSupport.validExplicitLocalSourceReference(this) + internal fun LocalPlaylistRepository.shouldPreserveEntryPlaybackSource( currentSong: SongItem, newSongInfo: SongItem @@ -199,10 +214,41 @@ internal fun LocalPlaylistRepository.saveCoverMapping(newSongInfo: SongItem) { } } -internal fun LocalPlaylistRepository.mergeExternalPlaylists(playlists: List): MutableList { - val preservedLocalFiles = LocalFilesPlaylist.firstOrNull(_playlists.value, context) +internal fun LocalPlaylistRepository.mergeExternalPlaylists( + playlists: List, + preserveExplicitLocalSources: Boolean = false +): MutableList { + val current = _playlists.value + val preservedLocalFiles = LocalFilesPlaylist.firstOrNull(current, context) + val sourcePlaylists = if (preserveExplicitLocalSources) current.associateBy { it.id } else emptyMap() return playlists .filterNot { LocalFilesPlaylist.isSystemPlaylist(it, context) } + .map { playlist -> + val existing = sourcePlaylists[playlist.id] ?: return@map playlist + preserveSyncedLocalSources(playlist, existing) + } .toMutableList() .apply { preservedLocalFiles?.let(::add) } } + +private fun preserveSyncedLocalSources(incoming: LocalPlaylist, current: LocalPlaylist): LocalPlaylist { + val sources = current.songs.filter { it.validExplicitLocalSourceReference() != null } + .associateBy { it.identity() } + if (sources.isEmpty()) return incoming + return incoming.copy(songs = incoming.songs.map { song -> + val source = sources[song.identity()] ?: return@map song + // 只保留当前歌单仍在同步结果中的成员来源,远端编辑和成员顺序沿用同步结果 + song.copy( + mediaUri = source.mediaUri, + localFileName = source.localFileName, + localFilePath = source.localFilePath, + channelId = source.channelId, + audioId = source.audioId, + subAudioId = source.subAudioId, + playlistContextId = source.playlistContextId, + sourceStableKey = source.sourceStableKey, + explicitLocalSourceReference = source.explicitLocalSourceReference, + streamUrl = source.streamUrl + ) + }.toMutableList()) +} diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryOrdering.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryOrdering.kt index 7ef7bc7b17..c6d5103b58 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryOrdering.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepositoryOrdering.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.withContext import moe.ouom.neriplayer.data.local.audioimport.LocalAudioImportManager import moe.ouom.neriplayer.data.local.audioimport.hydrateScannedSongRawIdentity import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.media.preferredLocalMediaReference import moe.ouom.neriplayer.data.model.playlist.DISPLAY_ORDER_SONG_ORDER_VERSION import moe.ouom.neriplayer.data.model.playlist.LocalPlaylist import moe.ouom.neriplayer.data.local.playlist.system.LocalFilesPlaylist @@ -152,7 +153,11 @@ internal suspend fun LocalPlaylistRepository.hydrateScannedSongIdentitiesForPers hydrated += batch.map { song -> async(identityDispatcher) { if (LocalSongSupport.isLocalSong(song, context)) { - LocalAudioImportManager.hydrateScannedSongRawIdentity(context, song) + val hydratedSong = LocalAudioImportManager.hydrateScannedSongRawIdentity(context, song) + val selectedReference = preferredLocalMediaReference( + hydratedSong.localFilePath, hydratedSong.mediaUri + )?.takeIf(LocalSongSupport::isLocalMediaUri) + hydratedSong.copy(explicitLocalSourceReference = selectedReference) } else { song } @@ -399,6 +404,10 @@ internal fun LocalPlaylistRepository.mergeScannedMetadataIntoExistingSongs( return existingSongs.mapTo(mutableListOf()) existingSong@{ existingSong -> val scannedSong = scannedSongIndex.firstMatch(existingSong) ?: return@existingSong existingSong - mergeSongMetadataForPersistence(existingSong, scannedSong) + mergeSongMetadataForPersistence( + existingSong, + scannedSong, + allowScannedSourceReplacement = true + ) } } diff --git a/modules/local/src/main/java/moe/ouom/neriplayer/data/sync/mapping/SyncLocalRestoreMapping.kt b/modules/local/src/main/java/moe/ouom/neriplayer/data/sync/mapping/SyncLocalRestoreMapping.kt index a5d137ed6e..530fe68590 100644 --- a/modules/local/src/main/java/moe/ouom/neriplayer/data/sync/mapping/SyncLocalRestoreMapping.kt +++ b/modules/local/src/main/java/moe/ouom/neriplayer/data/sync/mapping/SyncLocalRestoreMapping.kt @@ -4,14 +4,18 @@ import android.content.Context import moe.ouom.neriplayer.data.history.toSongItem import moe.ouom.neriplayer.data.history.toPlayedEntry import moe.ouom.neriplayer.data.identity.identity +import moe.ouom.neriplayer.data.identity.remoteSourceIdentityOrNull import moe.ouom.neriplayer.data.sync.identity.identity import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.playlist.validExplicitLocalSourceReference import moe.ouom.neriplayer.data.local.playlist.system.SystemLocalPlaylists +import moe.ouom.neriplayer.data.model.SongIdentity import moe.ouom.neriplayer.data.model.history.PlayedEntry import moe.ouom.neriplayer.data.model.playlist.DISPLAY_ORDER_SONG_ORDER_VERSION import moe.ouom.neriplayer.data.model.playlist.LocalPlaylist import moe.ouom.neriplayer.data.model.sync.SyncData import moe.ouom.neriplayer.data.model.sync.SyncPlaylist +import moe.ouom.neriplayer.data.model.sync.SyncRecentPlay import moe.ouom.neriplayer.data.model.sync.SyncSong import moe.ouom.neriplayer.data.model.sync.toLegacyLyricRecoveryCandidateOrNull @@ -61,33 +65,47 @@ internal class SyncLocalRestoreMapping( descriptor?.currentName ?: playlist.name private fun preservedLocalSongs(playlist: LocalPlaylist?): List = - playlist?.songs.orEmpty().filter { LocalSongSupport.isLocalSong(it, localizedContext) } + playlist?.songs.orEmpty().filter { + LocalSongSupport.isLocalSong(it, localizedContext) && + !(it.validExplicitLocalSourceReference() != null && it.remoteSourceIdentityOrNull() != null) + } fun history(data: SyncData, current: List): List { val candidates = mutableListOf() - val existingEntries = current.associateBy { - val song = it.toSongItem() - song.toLegacyLyricRecoveryCandidateOrNull()?.let(candidates::add) - song.identity() - } - val syncedHistory = data.recentPlays.mapNotNull { syncPlay -> - if (LocalSongSupport.isLocalSong(syncPlay.song.album, syncPlay.song.mediaUri, syncPlay.song.albumId, localizedContext)) { - return@mapNotNull null - } - - val song = syncPlay.song.toSongItem() - syncPlay.song.toSongItem(existingEntries[song.identity()]?.toSongItem()) - .toPlayedEntry(syncPlay.playedAt) - .copy(resumePositionMs = syncPlay.resumePositionMs) - } - val localOnlyHistory = current.filter { - LocalSongSupport.isLocalSong(it.album, it.mediaUri, it.albumId, localizedContext) - } + val existingEntries = indexExistingHistory(current, candidates) + val syncedHistory = data.recentPlays.mapNotNull { restoreHistoryEntry(it, existingEntries) } + val localOnlyHistory = current.filter(::isLocalOnlyHistory) val playHistory = mergeLocalOnlyHistory(syncedHistory, localOnlyHistory) retainLegacyLyrics(candidates) return playHistory } + private fun indexExistingHistory( + current: List, + candidates: MutableList + ): Map = current.associateBy { + val song = it.toSongItem() + song.toLegacyLyricRecoveryCandidateOrNull()?.let(candidates::add) + song.identity() + } + + private fun restoreHistoryEntry( + syncPlay: SyncRecentPlay, + existing: Map + ): PlayedEntry? { + val source = syncPlay.song + if (LocalSongSupport.isLocalSong(source.album, source.mediaUri, source.albumId, localizedContext)) { + return null + } + val song = source.toSongItem() + return source.toSongItem(existing[song.identity()]?.toSongItem()) + .toPlayedEntry(syncPlay.playedAt) + .copy(resumePositionMs = syncPlay.resumePositionMs) + } + + private fun isLocalOnlyHistory(entry: PlayedEntry): Boolean = + !entry.localFilePath.isNullOrBlank() || + LocalSongSupport.isLocalSong(entry.album, entry.mediaUri, entry.albumId, localizedContext) private fun mergeLocalOnlySongs( syncedSongs: List, diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/history/PlayHistoryRepositoryPersistenceTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/history/PlayHistoryRepositoryPersistenceTest.kt index f3a7da5811..83003234ce 100644 --- a/modules/local/src/test/java/moe/ouom/neriplayer/data/history/PlayHistoryRepositoryPersistenceTest.kt +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/history/PlayHistoryRepositoryPersistenceTest.kt @@ -25,6 +25,7 @@ import moe.ouom.neriplayer.data.model.sync.SyncRecentPlayDeletion import moe.ouom.neriplayer.data.sync.store.github.SecureTokenStorage import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -40,6 +41,57 @@ import org.mockito.Mockito.`when` class PlayHistoryRepositoryPersistenceTest { @get:Rule val temporary = TemporaryFolder() + @Test + fun `recording and updating scanned history keeps its selected source after Room reload`() = runTest { + val reference = "content://media/external/audio/media/3" + val source = entry(3).toSongItem().copy( + mediaUri = reference, channelId = "netease", audioId = "3", + explicitLocalSourceReference = reference + ) + val room = mockPlayHistoryRoomStore() + var primary = listOf(source.copy(explicitLocalSourceReference = null).toPlayedEntry(1L)) + `when`(room.readIfRoomPrimary()).thenAnswer { primary } + doAnswer { primary = it.getArgument(1); Unit }.`when`(room).writeIncremental(anyList(), anyList(), anyLong()) + + Fixture(room, testScope = this).use { fixture -> + fixture.repository.record(source, now = 2L) + runCurrent() + assertEquals(reference, fixture.repository.historyFlow.value.single().toSongItem().explicitLocalSourceReference) + + fixture.repository.record(source.copy(name = "Updated title", explicitLocalSourceReference = null), now = 3L) + runCurrent() + assertEquals(reference, primary.single().toSongItem().explicitLocalSourceReference) + Fixture(room).use { restarted -> + assertEquals(reference, restarted.repository.historyFlow.value.single().toSongItem().explicitLocalSourceReference) + assertEquals("Updated title", restarted.repository.historyFlow.value.single().name) + } + } + } + + @Test + fun `changing a history source clears both valid old and stale unrelated provenance`() = runTest { + val originalReference = "content://media/external/audio/media/3" + val replacementReference = "content://media/external/audio/media/4" + val source = entry(3).toSongItem().copy(mediaUri = originalReference, + explicitLocalSourceReference = originalReference) + listOf(originalReference, replacementReference).forEach { oldMarker -> + val room = mockPlayHistoryRoomStore() + var primary = listOf(source.toPlayedEntry(1L).copy(explicitLocalSourceReference = oldMarker)) + `when`(room.readIfRoomPrimary()).thenAnswer { primary } + doAnswer { primary = it.getArgument(1); Unit }.`when`(room).writeIncremental(anyList(), anyList(), anyLong()) + + Fixture(room, testScope = this).use { fixture -> + val replacement = source.copy(mediaUri = replacementReference, explicitLocalSourceReference = null) + fixture.repository.updateSongMetadata(source, replacement, triggerSync = false) + runCurrent() + + assertEquals(replacementReference, primary.single().mediaUri) + assertNull(primary.single().explicitLocalSourceReference) + assertNull(primary.single().toSongItem().explicitLocalSourceReference) + } + } + } + @Test fun `unreadable Room primary cannot replace history from a stale JSON snapshot`() = runTest { val room = mockPlayHistoryRoomStore() diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/history/PlayHistoryRepositoryPolicyTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/history/PlayHistoryRepositoryPolicyTest.kt index 03d0ca758b..5b802a4571 100644 --- a/modules/local/src/test/java/moe/ouom/neriplayer/data/history/PlayHistoryRepositoryPolicyTest.kt +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/history/PlayHistoryRepositoryPolicyTest.kt @@ -1,11 +1,14 @@ package moe.ouom.neriplayer.data.history +import com.google.gson.Gson import moe.ouom.neriplayer.data.identity.stableKey import moe.ouom.neriplayer.data.sync.mapping.toSongItem import moe.ouom.neriplayer.data.model.stableKey import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.history.PlayedEntry import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -26,6 +29,65 @@ class PlayHistoryRepositoryPolicyTest { ) } + @Test + fun `played entry keeps the preferred granted uri beside its filesystem alias`() { + val reference = "content://com.android.externalstorage.documents/tree/primary%3AMusic/document/primary%3AMusic%2Fsong.flac" + val source = SongItem( + id = 42L, name = "Local title", artist = "Local artist", album = "Local album", + albumId = 0L, durationMs = 1_000L, coverUrl = null, + mediaUri = reference, localFilePath = "/storage/emulated/0/Music/song.flac", + channelId = "netease", audioId = "42", sourceStableKey = "42|netease|", + explicitLocalSourceReference = reference + ) + + val restored = source.toPlayedEntry(now = 1_000L).toSongItem() + + assertEquals(reference, restored.mediaUri) + assertEquals(reference, restored.explicitLocalSourceReference) + assertEquals(source.localFilePath, restored.localFilePath) + assertEquals(source.stableKey(), restored.stableKey()) + } + + @Test + fun `history JSON preserves a selected local source without requiring playlist membership`() { + val reference = "content://media/external/audio/media/42" + val source = SongItem( + id = 42L, name = "Scanned title", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 1_000L, coverUrl = null, mediaUri = reference, + channelId = "netease", audioId = "42", explicitLocalSourceReference = reference + ) + val gson = Gson() + val restored = gson.fromJson(gson.toJson(source.toPlayedEntry(1_000L)), PlayedEntry::class.java).toSongItem() + + assertEquals(reference, restored.explicitLocalSourceReference) + assertEquals(reference, restored.mediaUri) + assertEquals(source.stableKey(), restored.stableKey()) + } + + @Test + fun `legacy history does not infer selected provenance from a local URI`() { + val entry = Gson().fromJson("""{ + "id":42,"name":"Legacy","artist":"Artist","album":"Album","albumId":0, + "durationMs":1000,"mediaUri":"content://media/external/audio/media/42","playedAt":1 + }""", PlayedEntry::class.java) + + assertNull(entry.explicitLocalSourceReference) + assertNull(entry.toSongItem().explicitLocalSourceReference) + } + + @Test + fun `history cannot restore a source marker belonging to a different preferred URI`() { + val source = SongItem( + id = 42L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 1_000L, coverUrl = null, mediaUri = "content://media/external/audio/media/42", + explicitLocalSourceReference = "content://media/external/audio/media/43" + ) + + assertNull(source.toPlayedEntry(1_000L).toSongItem().explicitLocalSourceReference) + val persisted = source.toPlayedEntry(1_000L).copy(explicitLocalSourceReference = source.explicitLocalSourceReference) + assertNull(persisted.toSongItem().explicitLocalSourceReference) + } + @Test fun `played entry round trip keeps local cover identity fields`() { val sourceSong = SongItem( diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportMediaStoreSafReferenceTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportMediaStoreSafReferenceTest.kt new file mode 100644 index 0000000000..d8c99dc17f --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/audioimport/LocalAudioImportMediaStoreSafReferenceTest.kt @@ -0,0 +1,166 @@ +package moe.ouom.neriplayer.data.local.audioimport + +import android.content.ContentResolver +import android.content.Context +import android.content.ContextWrapper +import android.database.MatrixCursor +import android.net.Uri +import android.provider.DocumentsContract +import android.provider.MediaStore +import androidx.test.core.app.ApplicationProvider +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.runBlocking +import moe.ouom.neriplayer.data.local.media.source.CrashLogCleanup +import moe.ouom.neriplayer.data.local.media.source.LocalMediaCoverAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaDownloadAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaHostAccess +import moe.ouom.neriplayer.data.testing.FakeLibrarySnapshot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class LocalAudioImportMediaStoreSafReferenceTest { + @get:Rule val directory = TemporaryFolder() + + @Test + fun `folder grants keep indexed SAF audio even when data paths exist and MediaStore opens are denied`() = runBlocking { + assertAuthorizedReferences(songCount = 3) + } + + @Test + fun `large folder scans reuse known empty sidecar indexes for complete non MP3 identities`() = runBlocking { + assertAuthorizedReferences(songCount = 257, requireBoundedProviderQueries = true) + } + + @Test + fun `large folder scans still probe missing identity metadata`() = runBlocking { + assertAuthorizedReferences(songCount = 257, metadataComplete = false) + } + + private suspend fun assertAuthorizedReferences( + songCount: Int, + requireBoundedProviderQueries: Boolean = false, + metadataComplete: Boolean = true + ) { + val resolver = mock(ContentResolver::class.java) + val context = object : ContextWrapper(ApplicationProvider.getApplicationContext()) { + override fun getApplicationContext(): Context = this + override fun getContentResolver(): ContentResolver = resolver + override fun getCacheDir(): File = directory.root + } + val downloads = mock(LocalMediaDownloadAccess::class.java) + doReturn(FakeLibrarySnapshot()).`when`(downloads).cachedDownloadLibrarySnapshot(context, true) + doReturn(emptyList()).`when`(downloads).candidateFileNameTemplates(null) + LocalMediaHostAccess.bind(downloads, mock(LocalMediaCoverAccess::class.java), CrashLogCleanup { false }) + + val authority = "com.android.externalstorage.documents" + val folderId = "primary:Music/SafOnly" + val folder = DocumentsContract.buildTreeDocumentUri(authority, folderId) + val children = DocumentsContract.buildChildDocumentsUriUsingTree(folder, folderId) + val files = (1..songCount).map { directory.newFile("song-$it.flac").apply { writeBytes(byteArrayOf(1)) } } + val treeReferences = files.map { file -> + DocumentsContract.buildDocumentUriUsingTree(folder, "$folderId/${file.name}").toString() + } + val directoryQueries = AtomicInteger() + val individualDocumentQueries = AtomicInteger() + val mediaStoreOpenAttempts = AtomicInteger() + doAnswer { invocation -> + val projection = invocation.getArgument>(1) + MatrixCursor(projection).apply { + files.forEachIndexed { index, file -> + addRow(projection.map { column -> + when (column) { + MediaStore.Audio.Media._ID -> index + 1L + MediaStore.Audio.Media.TITLE -> "Song ${index + 1}" + MediaStore.Audio.Media.ARTIST -> "Artist" + MediaStore.Audio.Media.ALBUM -> "Named album".takeIf { metadataComplete } + MediaStore.Audio.Media.DURATION -> 180_000L + MediaStore.MediaColumns.DISPLAY_NAME -> file.name + MediaStore.MediaColumns.RELATIVE_PATH -> "Music/SafOnly/" + "_data" -> file.absolutePath + else -> null + } + }) + } + } + }.`when`(resolver).query(any(), any(), any(), any(), any(), any()) + doAnswer { invocation -> + val uri = invocation.getArgument(0) + val projection = invocation.getArgument>(1) + MatrixCursor(projection).apply { + if (uri == children) { + directoryQueries.incrementAndGet() + files.forEach { file -> + addRow(projection.map { column -> + when (column) { + DocumentsContract.Document.COLUMN_DOCUMENT_ID -> "$folderId/${file.name}" + DocumentsContract.Document.COLUMN_DISPLAY_NAME -> file.name + DocumentsContract.Document.COLUMN_MIME_TYPE -> "audio/flac" + else -> null + } + }) + } + } else if (uri.authority == authority && uri.toString().contains("/document/")) { + individualDocumentQueries.incrementAndGet() + } + } + }.`when`(resolver).query(any(), any(), any(), any(), any()) + doAnswer { + mediaStoreOpenAttempts.incrementAndGet() + throw SecurityException("READ_MEDIA_AUDIO is not granted") + }.`when`(resolver).openAssetFileDescriptor(any(), eq("r")) + doThrow(SecurityException("READ_MEDIA_AUDIO is not granted")) + .`when`(resolver).openFileDescriptor(any(), eq("r")) + + mockStatic(MediaStore::class.java).use { mediaStore -> + mediaStore.`when`> { MediaStore.getExternalVolumeNames(context) } + .thenReturn(setOf(MediaStore.VOLUME_EXTERNAL_PRIMARY)) + + val result = requireNotNull( + LocalAudioImportManager.scanExternalStorageFolderWithMediaStore( + context, + folder, + LocalAudioScanProgressEmitter(scanId = 1L, startedAt = 0L) {} + ) + ) + + assertEquals(treeReferences.toSet(), result.songs.map { it.mediaUri }.toSet()) + assertEquals(files.map(File::getAbsolutePath).toSet(), result.songs.map { it.localFilePath }.toSet()) + val mediaStoreCollection = MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val expectedIds = files.mapIndexed { index, file -> + file.name to LocalAudioImportManager.computeStableSongId( + Uri.withAppendedPath(mediaStoreCollection, (index + 1).toString()).toString() + ) + }.toMap() + assertEquals(expectedIds, result.songs.associate { it.localFileName to it.id }) + assertTrue(result.songs.all { it.channelId == "local" }) + assertTrue(mediaStoreOpenAttempts.get() > 0) + if (requireBoundedProviderQueries) { + assertEquals( + "the folder index confirms absent sidecars; directory queries=${directoryQueries.get()}", + 0, + individualDocumentQueries.get() + ) + assertTrue("directory queries stay bounded", directoryQueries.get() <= 3) + } + if (!metadataComplete) { + assertTrue("missing identity still needs metadata probing", individualDocumentQueries.get() > 0) + } + } + } +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/database/store/PlayHistoryRoomMapperTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/database/store/PlayHistoryRoomMapperTest.kt index 55c9b835be..a5e331165b 100644 --- a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/database/store/PlayHistoryRoomMapperTest.kt +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/database/store/PlayHistoryRoomMapperTest.kt @@ -2,14 +2,51 @@ package moe.ouom.neriplayer.data.local.database.store import moe.ouom.neriplayer.data.local.database.store.stats.toDomain +import moe.ouom.neriplayer.data.history.toPlayedEntry +import moe.ouom.neriplayer.data.history.toSongItem +import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.data.model.history.PlayedEntry import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test class PlayHistoryRoomMapperTest { private val mapper = PlayHistoryRoomMapper() + @Test + fun `Room history keeps selected local provenance alongside remote metadata identity`() { + val reference = "content://media/external/audio/media/42" + val source = SongItem( + id = 42L, name = "Scanned title", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 1_000L, coverUrl = null, mediaUri = reference, + channelId = "netease", audioId = "42", explicitLocalSourceReference = reference + ) + + val restored = mapper.toDomain(mapper.toEntities(listOf(source.toPlayedEntry(100L)))).single().toSongItem() + + assertEquals(reference, restored.explicitLocalSourceReference) + assertEquals(reference, restored.mediaUri) + assertEquals("netease", restored.channelId) + assertEquals("42", restored.audioId) + } + + @Test + fun `Room history rejects provenance for another source on both write and read`() { + val reference = "content://media/external/audio/media/42" + val source = SongItem( + id = 42L, name = "Scanned title", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 1_000L, coverUrl = null, mediaUri = reference, + explicitLocalSourceReference = reference + ).toPlayedEntry(100L) + val unrelated = "content://media/external/audio/media/43" + + val written = mapper.toEntities(listOf(source.copy(explicitLocalSourceReference = unrelated))).single() + assertNull(written.explicitLocalSourceReference) + val malformedRow = mapper.toEntities(listOf(source)).single().copy(explicitLocalSourceReference = unrelated) + assertNull(mapper.toDomain(listOf(malformedRow)).single().explicitLocalSourceReference) + } + @Test fun `round trip keeps identity resume position and metadata`() { val entries = listOf( diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricTransactionTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricTransactionTest.kt index 5ba8e0ffcf..21982ee410 100644 --- a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricTransactionTest.kt +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalMediaLyricTransactionTest.kt @@ -21,10 +21,16 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import moe.ouom.neriplayer.data.local.media.metadata.LocalMediaMetadataRecoveryStore +import moe.ouom.neriplayer.data.identity.stableKey +import moe.ouom.neriplayer.data.local.media.source.CrashLogCleanup +import moe.ouom.neriplayer.data.local.media.source.LocalMediaCoverAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaDownloadAccess +import moe.ouom.neriplayer.data.local.media.source.LocalMediaHostAccess import moe.ouom.neriplayer.data.model.SongItem import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule @@ -40,6 +46,7 @@ import org.mockito.Mockito.doReturn import org.mockito.Mockito.mock import org.mockito.Mockito.mockConstruction import org.mockito.Mockito.mockStatic +import org.mockito.stubbing.Answer class LocalMediaLyricTransactionTest { @get:Rule @@ -47,6 +54,8 @@ class LocalMediaLyricTransactionTest { private val resolver = mock(ContentResolver::class.java) private val context = mock(Context::class.java) + private val mediaAccessField = LocalMediaHostAccess::class.java.getDeclaredField("access").apply { isAccessible = true } + private val previousMediaAccess = mediaAccessField.get(null) private val documents = linkedMapOf() private val uris = linkedMapOf() private val descriptors = IdentityHashMap() @@ -54,9 +63,11 @@ class LocalMediaLyricTransactionTest { private val treeUri = uri("content://$AUTHORITY/tree/root") private val rootChildren = uri("content://$AUTHORITY/tree/root/document/root/children") private val lyricChildren = uri("content://$AUTHORITY/tree/root/document/lyrics/children") + private val coverChildren = uri("content://$AUTHORITY/tree/root/document/covers/children") private var createFailure: Throwable? = null private var beforeCreateFailure: (() -> Unit)? = null private var translationCreateAttempts = 0 + private var translationCreationReturnsNull = false private var writeFailure: Throwable? = null private var failureTarget = "translated" private var beforeWriteFailure: (() -> Unit)? = null @@ -69,6 +80,7 @@ class LocalMediaLyricTransactionTest { LocalMediaSupport.invalidateSafReadCaches() LocalMediaSupport.clearLyricsLookupCache() LocalMediaMetadataRecoveryStore.resetRecoveryForTest() + mediaAccessField.set(null, previousMediaAccess) } @Test @@ -258,8 +270,214 @@ class LocalMediaLyricTransactionTest { } } + @Test + fun `editable metadata stops audio alias fallback when sidecar rollback is unresolved`() = runBlocking(Dispatchers.IO) { + fixture { + val alias = add("source-alias", "Song.flac", "root", "audio") + translationCreationReturnsNull = true + beforeCreateFailure = { + documents.getValue("original").file!!.writeText("external owner") + } + + val outcome = withSuccessfulEmbeddedWrites { + LocalMediaSupport.writeEditableMetadataInternal( + context = context, + song = song(), + writeCover = false, + writeLyrics = true, + candidates = listOf(documents.getValue("source").uri, alias.uri) + ) + } + + assertEquals(LocalMediaMetadataWriteOutcome.FAILED, outcome) + assertEquals(1, translationCreateAttempts) + assertEquals("external owner", documents.getValue("original").file!!.readText()) + val journals = LocalMediaMetadataRecoveryStore.stagingDirectory(context) + .listFiles { file -> file.name.startsWith("recovery-") && file.extension == "json" }.orEmpty() + assertEquals(1, journals.size) + assertEquals("ROLLBACK_FAILED", JSONObject(journals.single().readText()).getString("stage")) + } + } + + @Test + fun `editable metadata permits audio alias fallback after a completed sidecar rollback`() = runBlocking(Dispatchers.IO) { + fixture { + val alias = add("source-alias", "Song.flac", "root", "audio") + translationCreationReturnsNull = true + + val outcome = withSuccessfulEmbeddedWrites { + LocalMediaSupport.writeEditableMetadataInternal( + context = context, + song = song(), + writeCover = false, + writeLyrics = true, + candidates = listOf(documents.getValue("source").uri, alias.uri) + ) + } + + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, outcome) + assertEquals(2, translationCreateAttempts) + assertEquals("new original", documents.getValue("original").file!!.readText()) + assertEquals("new translation", documents.getValue("translated").file!!.readText()) + } + } + + @Test + fun `editable metadata records the published SAF cover instead of the selected input`() = runBlocking(Dispatchers.IO) { + fixture { + add("covers", "Covers", "root") + val cover = temporaryFolder.newFile("selected.jpg").apply { writeBytes(JPEG_BYTES) } + uri(cover.absolutePath) + + val outcome = withSuccessfulEmbeddedWrites { + LocalMediaSupport.writeEditableMetadataInternal( + context = context, + song = song(), + coverReference = cover.absolutePath, + writeCover = true, + writeLyrics = true, + candidates = listOf(documents.getValue("source").uri) + ) + } + + assertEquals(LocalMediaMetadataWriteOutcome.SUCCESS, outcome) + val sidecar = documents.getValue("cover-sidecar") + val metadata = JSONObject(documents.getValue("metadata").file!!.readText()) + assertEquals(sidecar.uri.toString(), metadata.getString("coverPath")) + assertEquals("new original", documents.getValue("original").file!!.readText()) + assertArrayEquals(JPEG_BYTES, sidecar.file!!.readBytes()) + } + } + + @Test + fun `sidecar metadata save publishes the cover and metadata together without changing audio`() = runBlocking(Dispatchers.IO) { + fixture { + add("covers", "Covers", "root") + val metadata = add("metadata", "Song.flac.npmeta.json", "root", "{\"unrelated\":17}").file!! + val cover = temporaryFolder.newFile("selected.jpg").apply { writeBytes(JPEG_BYTES) } + uri(cover.absolutePath) + + assertTrue(LocalMediaSupport.writeLocalMetadataSidecar(context, song(), coverReference = cover.absolutePath)) + + assertTrue("the selected cover must be published beside the audio", documents.containsKey("cover-sidecar")) + val sidecar = documents.getValue("cover-sidecar") + assertArrayEquals(JPEG_BYTES, sidecar.file!!.readBytes()) + assertEquals(sidecar.uri.toString(), JSONObject(metadata.readText()).getString("coverPath")) + assertEquals(17, JSONObject(metadata.readText()).getInt("unrelated")) + assertEquals("audio", documents.getValue("source").file!!.readText()) + } + } + + @Test + fun `sidecar metadata failure restores the previous cover and metadata`() = runBlocking(Dispatchers.IO) { + fixture { + add("covers", "Covers", "root") + val coverName = LocalMediaSupport.localCoverSidecarName("Song", "jpg", song().stableKey()) + val previousCover = add("cover-sidecar", coverName, "covers", "old cover").file!! + val metadata = add("metadata", "Song.flac.npmeta.json", "root", "{\"customName\":\"keep me\"}").file!! + val previousMetadata = metadata.readText() + val cover = temporaryFolder.newFile("selected.jpg").apply { writeBytes(JPEG_BYTES) } + uri(cover.absolutePath) + val failure = SecurityException("metadata write permission revoked") + failureTarget = "metadata" + writeFailure = failure + + val error = runCatching { + LocalMediaSupport.writeLocalMetadataSidecar(context, song(), coverReference = cover.absolutePath) + }.exceptionOrNull() + + assertPropagated(failure, error) + assertEquals("old cover", previousCover.readText()) + assertEquals(previousMetadata, metadata.readText()) + assertEquals("audio", documents.getValue("source").file!!.readText()) + } + } + + @Test + fun `sidecar cover clear removes the cover only with the metadata update`() = runBlocking(Dispatchers.IO) { + fixture { + add("covers", "Covers", "root") + val coverName = LocalMediaSupport.localCoverSidecarName("Song", "jpg", song().stableKey()) + val cover = add("cover-sidecar", coverName, "covers", "old cover") + val metadata = add("metadata", "Song.flac.npmeta.json", "root", + "{\"coverPath\":\"${cover.uri}\",\"unrelated\":17}").file!! + + assertTrue(LocalMediaSupport.writeLocalMetadataSidecar(context, song(), clearCoverReference = true)) + + assertFalse(cover.file!!.exists()) + assertFalse(JSONObject(metadata.readText()).has("coverPath")) + assertEquals(17, JSONObject(metadata.readText()).getInt("unrelated")) + assertEquals("audio", documents.getValue("source").file!!.readText()) + } + } + + @Test + fun `cancelled sidecar metadata save restores a cover written earlier in the attempt`() = runBlocking(Dispatchers.IO) { + fixture { + add("covers", "Covers", "root") + val coverName = LocalMediaSupport.localCoverSidecarName("Song", "jpg", song().stableKey()) + val previousCover = add("cover-sidecar", coverName, "covers", "old cover").file!! + val metadata = add("metadata", "Song.flac.npmeta.json", "root", "{\"unrelated\":17}").file!! + val previousMetadata = metadata.readText() + val cover = temporaryFolder.newFile("selected.jpg").apply { writeBytes(JPEG_BYTES) } + uri(cover.absolutePath) + val cancellation = CancellationException("metadata write cancelled") + failureTarget = "metadata" + writeFailure = cancellation + + val error = runCatching { + LocalMediaSupport.writeLocalMetadataSidecar(context, song(), coverReference = cover.absolutePath) + }.exceptionOrNull() + + assertPropagated(cancellation, error) + assertEquals("old cover", previousCover.readText()) + assertEquals(previousMetadata, metadata.readText()) + assertEquals("audio", documents.getValue("source").file!!.readText()) + } + } + + @Test + fun `failed sidecar metadata save keeps a cover pending deletion`() = runBlocking(Dispatchers.IO) { + fixture { + add("covers", "Covers", "root") + val coverName = LocalMediaSupport.localCoverSidecarName("Song", "jpg", song().stableKey()) + val cover = add("cover-sidecar", coverName, "covers", "old cover") + val metadata = add("metadata", "Song.flac.npmeta.json", "root", + "{\"coverPath\":\"${cover.uri}\",\"unrelated\":17}").file!! + val previousMetadata = metadata.readText() + val failure = SecurityException("metadata write permission revoked") + failureTarget = "metadata" + writeFailure = failure + + val error = runCatching { + LocalMediaSupport.writeLocalMetadataSidecar(context, song(), clearCoverReference = true) + }.exceptionOrNull() + + assertPropagated(failure, error) + assertEquals("old cover", cover.file!!.readText()) + assertEquals(previousMetadata, metadata.readText()) + assertEquals("audio", documents.getValue("source").file!!.readText()) + } + } + + private suspend fun withSuccessfulEmbeddedWrites(block: suspend () -> T): T = + mockStatic(Class.forName("moe.ouom.neriplayer.data.local.media.LocalMediaSupportLyricsKt"), + Answer { invocation -> + when (invocation.method.name) { + "writeEditableMetadataThroughStagedContentCopy" -> LocalMediaSupport.EditableMetadataWriteTransaction( + LocalMediaMetadataWriteOutcome.SUCCESS + ) + else -> invocation.callRealMethod() + } + }).use { block() } + private suspend fun fixture(block: suspend () -> Unit) { LocalMediaMetadataRecoveryStore.resetRecoveryForTest() + LocalMediaHostAccess.bind( + downloads = mock(LocalMediaDownloadAccess::class.java), + covers = mock(LocalMediaCoverAccess::class.java), + crashLogs = CrashLogCleanup { false } + ) doReturn(context).`when`(context).applicationContext doReturn(resolver).`when`(context).contentResolver doReturn(temporaryFolder.newFolder("no-backup")).`when`(context).noBackupFilesDir @@ -273,6 +491,7 @@ class LocalMediaLyricTransactionTest { val rows = when (uri) { rootChildren -> documents.values.filter { it.parent == "root" && it.exists() } lyricChildren -> documents.values.filter { it.parent == "lyrics" && it.exists() } + coverChildren -> documents.values.filter { it.parent == "covers" && it.exists() } else -> documents.values.filter { it.uri == uri && it.exists() } } cursor(rows) @@ -303,7 +522,11 @@ class LocalMediaLyricTransactionTest { contract.`when` { DocumentsContract.buildDocumentUriUsingTree(any(), anyString()) } .thenAnswer { documents.getValue(it.getArgument(1)).uri } contract.`when` { DocumentsContract.buildChildDocumentsUriUsingTree(any(), anyString()) } - .thenAnswer { if (it.getArgument(1) == "root") rootChildren else lyricChildren } + .thenAnswer { when (it.getArgument(1)) { + "root" -> rootChildren + "covers" -> coverChildren + else -> lyricChildren + } } contract.`when` { DocumentsContract.findDocumentPath(any(), any()) }.thenAnswer { val target = document(it.getArgument(1)) mock(DocumentsContract.Path::class.java).also { path -> @@ -314,6 +537,11 @@ class LocalMediaLyricTransactionTest { val name = it.getArgument(3) if (name == "Song_trans.lrc") { translationCreateAttempts++ + if (translationCreationReturnsNull) { + translationCreationReturnsNull = false + beforeCreateFailure?.invoke() + return@thenAnswer null + } createFailure?.let { failure -> createFailure = null beforeCreateFailure?.invoke() @@ -323,7 +551,7 @@ class LocalMediaLyricTransactionTest { val id = when (name) { "Song.lrc" -> "original" "Song_trans.lrc" -> "translated" - else -> "metadata" + else -> if (name.endsWith(".jpg")) "cover-sidecar" else "metadata" } add(id, name, document(it.getArgument(1)).id, "").uri } @@ -394,7 +622,11 @@ class LocalMediaLyricTransactionTest { private fun add(id: String, name: String, parent: String?, content: String? = null): Document { val file = content?.let { - val directory = if (parent == "lyrics") File(temporaryFolder.root, "Lyrics").apply { mkdirs() } else temporaryFolder.root + val directory = when (parent) { + "lyrics" -> File(temporaryFolder.root, "Lyrics").apply { mkdirs() } + "covers" -> File(temporaryFolder.root, "Covers").apply { mkdirs() } + else -> temporaryFolder.root + } File(directory, name).apply { writeText(content) } } return Document(id, name, parent, uri("content://$AUTHORITY/tree/root/document/$id"), file) @@ -439,5 +671,6 @@ class LocalMediaLyricTransactionTest { private companion object { const val AUTHORITY = "com.android.externalstorage.documents" + val JPEG_BYTES = byteArrayOf(0xff.toByte(), 0xd8.toByte(), 0xff.toByte(), 0xe0.toByte(), 1, 2, 3) } } diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalSongSupportDuplicateKeysTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalSongSupportDuplicateKeysTest.kt index bdbcbfe82d..141e9d977a 100644 --- a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalSongSupportDuplicateKeysTest.kt +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/LocalSongSupportDuplicateKeysTest.kt @@ -4,6 +4,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import moe.ouom.neriplayer.data.model.SongItem import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -90,6 +91,52 @@ class LocalSongSupportDuplicateKeysTest { assertFalse(LocalSongSupport.isLocalSong("Local Files", "https://cdn.example.com/a.mp3", 0L, names)) } + @Test + fun `explicit source evidence survives in snapshots only for their exact preferred local reference`() { + listOf( + "/music/selected.mp3", + "file:///music/selected.mp3", + "content://media/external/audio/media/7", + "android.resource://pkg/raw/song" + ).forEach { reference -> + val selected = song(mediaUri = reference).copy(explicitLocalSourceReference = reference) + + assertEquals(reference, LocalSongSupport.validExplicitLocalSourceReference(selected)) + assertEquals(reference, LocalSongSupport.validExplicitLocalSourceReference(null, reference, reference)) + } + } + + @Test + fun `an alternate declared file cannot borrow snapshot evidence from the preferred content source`() { + val preferred = "content://media/external/audio/media/7" + val file = "/music/selected.mp3" + val selected = song(localFilePath = file, mediaUri = preferred) + + assertNull(LocalSongSupport.validExplicitLocalSourceReference(selected.copy(explicitLocalSourceReference = file))) + assertNull(LocalSongSupport.validExplicitLocalSourceReference(file, preferred, file)) + assertEquals(preferred, + LocalSongSupport.validExplicitLocalSourceReference(selected.copy(explicitLocalSourceReference = preferred))) + assertEquals(file, LocalSongSupport.validExplicitLocalSourceReference(file, null, file)) + } + + @Test + fun `missing stale relative network and case changed snapshot markers establish no local evidence`() { + val preferred = "content://media/external/audio/media/7" + listOf(null, "", " ", "content://media/external/audio/media/8", "Content://media/external/audio/media/7") + .forEach { marker -> + val candidate = song(mediaUri = preferred).copy(explicitLocalSourceReference = marker) + assertNull(LocalSongSupport.validExplicitLocalSourceReference(candidate)) + assertNull(LocalSongSupport.validExplicitLocalSourceReference(null, preferred, marker)) + } + listOf("relative/selected.mp3", "https://cdn.example.com/selected.mp3").forEach { reference -> + assertNull(LocalSongSupport.validExplicitLocalSourceReference( + song(mediaUri = reference).copy(explicitLocalSourceReference = reference) + )) + assertNull(LocalSongSupport.validExplicitLocalSourceReference(null, reference, reference)) + } + assertNull(LocalSongSupport.validExplicitLocalSourceReference(null, null, preferred)) + } + private fun song( album: String = "Album", name: String = "Song", diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/ResolvedLocalMediaSourceTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/ResolvedLocalMediaSourceTest.kt new file mode 100644 index 0000000000..38146fdd7c --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/media/ResolvedLocalMediaSourceTest.kt @@ -0,0 +1,546 @@ +package moe.ouom.neriplayer.data.local.media + +import android.content.ContentResolver +import android.content.Context +import android.content.UriPermission +import android.content.res.AssetFileDescriptor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.DocumentsContract +import java.io.File +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class ResolvedLocalMediaSourceTest { + @get:Rule + val directory = TemporaryFolder() + + private val context = mock(Context::class.java) + private val resolver = mock(ContentResolver::class.java) + private val original = "content://media/external/audio/media/7" + private val path = "/storage/emulated/0/Music/Album/song.mp3" + private val song = SongItem( + id = 7L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 180_000L, coverUrl = null, mediaUri = original, localFilePath = path, + channelId = "netease", audioId = "7", sourceStableKey = "7|netease|" + ) + + @Test + fun `direct sources only prove their exact stored reference`() { + val source = ResolvedLocalMediaSource.direct(song, original) + + assertEquals(original, source.reference) + assertEquals(original, source.anchorFor(song, original)) + assertNull(source.anchorFor(song, path)) + assertNull(ResolvedLocalMediaSource.direct(song, path).anchorFor(song, path)) + val unaddressed = song.copy(localFilePath = null, mediaUri = null) + assertNull(ResolvedLocalMediaSource.direct(unaddressed, path).unverifiedDeclaredReferenceFor(unaddressed, path)) + } + + @Test + fun `source contexts cannot be borrowed by another song or source snapshot`() { + val source = ResolvedLocalMediaSource.direct(song, original) + + assertNull(source.anchorFor(song.copy(id = 8L), original)) + assertNull(source.anchorFor(song.copy(localFilePath = "$path.other"), original)) + assertNull(source.anchorFor(song.copy(mediaUri = "content://media/external/audio/media/8"), original)) + } + + @Test + fun `unverified declarations can only deny replacement for the same snapshot and actual reference`() { + val source = ResolvedLocalMediaSource.direct(song, path) + + assertEquals(original, source.unverifiedDeclaredReferenceFor(song, path)) + assertNull(source.unverifiedDeclaredReferenceFor(song.copy(id = 8L), path)) + assertNull(source.unverifiedDeclaredReferenceFor(song.copy(localFilePath = "$path.other"), path)) + assertNull(source.unverifiedDeclaredReferenceFor(song, original)) + assertNull(ResolvedLocalMediaSource.direct(song, original).unverifiedDeclaredReferenceFor(song, original)) + } + + @Test + fun `readable declared raw and file references retain the proven MediaStore preferred anchor`() { + val file = directory.newFile("declared.mp3").apply { writeText("audio") } + listOf(file.absolutePath, Uri.fromFile(file).toString()).forEach { reference -> + val candidate = readableDeclaredSong(file).copy(localFilePath = reference) + + val source = requireNotNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, candidate, reference)) + + assertEquals(reference, source.reference) + assertEquals(original, source.anchorFor(candidate, reference)) + assertNull(source.unverifiedDeclaredReferenceFor(candidate, reference)) + } + } + + @Test + fun `readable declared aliases compare complete canonical paths rather than their spelling`() { + val file = directory.newFile("canonical.mp3").apply { writeText("audio") } + val child = directory.newFolder("child") + val reference = File(child, "../${file.name}").absolutePath + val candidate = readableDeclaredSong(file).copy(localFilePath = reference) + + val source = requireNotNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, candidate, reference)) + + assertEquals(original, source.anchorFor(candidate, reference)) + } + + @Test + fun `readable declared alias provenance remains bound to its complete song and actual reference`() { + val file = directory.newFile("bound.mp3").apply { writeText("audio") } + val candidate = readableDeclaredSong(file) + val source = requireNotNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, candidate, file.path)) + + listOf(candidate.copy(id = 8L), candidate.copy(localFilePath = "${file.path}.other"), + candidate.copy(mediaUri = "content://media/external/audio/media/8")).forEach { other -> + assertNull(source.anchorFor(other, file.path)) + assertNull(source.unverifiedDeclaredReferenceFor(other, file.path)) + } + assertNull(source.anchorFor(candidate, original)) + assertNull(source.anchorFor(candidate, Uri.fromFile(file).toString())) + } + + @Test + fun `an undeclared file or alternate spelling cannot acquire preferred provenance`() { + val file = directory.newFile("undeclared.mp3").apply { writeText("audio") } + val candidate = readableDeclaredSong(file) + + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, + candidate.copy(localFilePath = null), file.path)) + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, + candidate, Uri.fromFile(file).toString())) + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, candidate, original)) + } + + @Test + fun `unknown unsupported or absent preferred paths cannot prove a readable declared alias`() { + val file = directory.newFile("unknown.mp3").apply { writeText("audio") } + val candidate = readableDeclaredSong(file) + listOf(null, " ", "content://other.documents/document/7", "https://example.org/song.mp3") + .forEach { preferred -> + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, + candidate.copy(mediaUri = preferred), file.path)) + } + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, + candidate.copy(localFilePath = null, mediaUri = null), file.path)) + doReturn(null).`when`(resolver).query(eq(Uri.parse(original)), any(), any(), any(), any()) + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, candidate, file.path)) + } + + @Test + fun `Provider failures cannot prove a readable declared file alias`() { + val file = directory.newFile("provider-failure.mp3").apply { writeText("audio") } + val candidate = readableDeclaredSong(file) + listOf(SecurityException("read denied"), IllegalStateException("provider unavailable")).forEach { failure -> + doAnswer { throw failure }.`when`(resolver).query(eq(Uri.parse(original)), any(), any(), any(), any()) + + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, candidate, file.path)) + } + } + + @Test + fun `collections and multiple MediaStore rows cannot prove a readable declared file alias`() { + val file = directory.newFile("ambiguous.mp3").apply { writeText("audio") } + val collection = "content://media/external/audio/media" + val candidate = readableDeclaredSong(file) + mediaStorePath(file.path, collection) + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, + candidate.copy(mediaUri = collection), file.path)) + val cursor = MatrixCursor(arrayOf("_data")).apply { + addRow(arrayOf(file.path)) + addRow(arrayOf(file.path)) + } + doReturn(cursor).`when`(resolver).query(eq(Uri.parse(original)), any(), any(), any(), any()) + + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, candidate, file.path)) + assertTrue(cursor.isClosed) + } + + @Test + fun `missing or relative MediaStore data cannot prove a readable declared file alias`() { + val file = directory.newFile("missing-data.mp3").apply { writeText("audio") } + val candidate = readableDeclaredSong(file) + listOf(MatrixCursor(arrayOf("_data")), + MatrixCursor(arrayOf("_data")).apply { addRow(arrayOf("Music/song.mp3")) }, + MatrixCursor(arrayOf("_data")).apply { addRow(arrayOfNulls(1)) }).forEach { cursor -> + doReturn(cursor).`when`(resolver).query(eq(Uri.parse(original)), any(), any(), any(), any()) + + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, candidate, file.path)) + assertTrue(cursor.isClosed) + } + } + + @Test + fun `a different preferred file cannot lend provenance to a readable declared file`() { + val file = directory.newFile("actual.mp3").apply { writeText("audio") } + val other = directory.newFile("preferred.mp3").apply { writeText("other audio") } + val candidate = readableDeclaredSong(file) + mediaStorePath(other.path) + + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, candidate, file.path)) + val document = DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", "primary:Music/Album/song.mp3" + ).toString() + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, + candidate.copy(mediaUri = document), file.path)) + } + + @Test + fun `missing files and directories cannot acquire readable declared alias provenance`() { + listOf(File(directory.root, "missing.mp3"), directory.newFolder("not-audio")).forEach { file -> + val candidate = readableDeclaredSong(file) + + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, candidate, file.path)) + } + } + + @Test + fun `a readable canonical target cannot prove an unopenable declared path`() { + val file = directory.newFile("canonical-target.mp3").apply { writeText("audio") } + val reference = File(File(directory.root, "missing-directory"), "../${file.name}") + val candidate = readableDeclaredSong(file).copy(localFilePath = reference.path) + assertEquals(file.canonicalFile, reference.canonicalFile) + assertFalse(reference.isFile) + + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, candidate, reference.path)) + } + + @Test + fun `a declared file must actually open before it can carry preferred provenance`() { + val file = directory.newFile("unreadable.mp3").apply { writeText("audio") } + val candidate = readableDeclaredSong(file) + try { + assertTrue(file.setReadable(false, false)) + assertFalse("测试文件必须实际不可读", file.canRead()) + + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, candidate, file.path)) + } finally { + check(file.setReadable(true, false)) + } + } + + @Test + fun `declared provider references and malformed files cannot become ordinary file aliases`() { + val file = directory.newFile("invalid-reference.mp3").apply { writeText("audio") } + val candidate = readableDeclaredSong(file) + listOf("content://other.documents/document/7", "https://example.org/song.mp3", "relative.mp3", "file:", + "${file.path}\u0000").forEach { reference -> + assertNull(ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, + candidate.copy(localFilePath = reference), reference)) + } + } + + @Test + fun `readable folder recovery carries the original reference for the same complete file path`() { + val recovered = grant("primary:Music") + + val source = requireNotNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + + assertEquals(recovered.toString(), source.reference) + assertEquals(original, source.anchorFor(song, source.reference)) + assertNull(source.anchorFor(song, original)) + assertNull(source.anchorFor(song.copy(localFilePath = "$path.other"), source.reference)) + assertNull(source.anchorFor(song.copy(id = 8L), source.reference)) + assertNull(source.unverifiedDeclaredReferenceFor(song, source.reference)) + } + + @Test + fun `an external storage document proves its own complete path without a Provider query`() { + val recovered = grant("primary:Music") + val document = DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", "primary:Music/Album/song.mp3" + ).toString() + val documentSong = song.copy(mediaUri = document) + + val source = requireNotNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, documentSong)) + + assertEquals(recovered.toString(), source.reference) + assertEquals(document, source.anchorFor(documentSong, source.reference)) + verify(resolver, never()).query(any(), any(), any(), any(), any()) + } + + @Test + fun `folder recovery requires persisted read access to the exact volume and directory`() { + grant("primary:Music", readGranted = false) + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + grant("1234-ABCD:Music") + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + grant("primary:Music/AlbumOther") + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + grant("primary:Music", authority = "other.documents") + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + } + + @Test + fun `a folder grant without an openable document cannot mint alias provenance`() { + grant("primary:Music", readable = false) + + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + } + + @Test + fun `canonical parent traversal cannot borrow a narrower folder grant`() { + grant("primary:Music/Album") + + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant( + context, song.copy(localFilePath = "/storage/emulated/0/Music/Album/../Other/song.mp3") + )) + } + + @Test + fun `an external storage document cannot lend its provenance to a different declared file`() { + grant("primary:Music") + val document = DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", "primary:Music/Album/original.mp3" + ).toString() + + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song.copy(mediaUri = document))) + } + + @Test + fun `a MediaStore record cannot lend its provenance to a conflicting declared file`() { + grant("primary:Music") + mediaStorePath("/storage/emulated/0/Music/Album/original.mp3") + + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + } + + @Test + fun `an unknown MediaStore path cannot be inferred from the declared file`() { + grant("primary:Music") + doReturn(null).`when`(resolver).query(eq(Uri.parse(original)), any(), any(), any(), any()) + + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + } + + @Test + fun `a MediaStore collection cannot prove one file from its first query row`() { + grant("primary:Music") + val collection = "content://media/external/audio/media" + mediaStorePath(path, collection) + + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song.copy(mediaUri = collection))) + } + + @Test + fun `a MediaStore item returning multiple files cannot mint alias provenance`() { + grant("primary:Music") + doAnswer { + MatrixCursor(arrayOf("_data")).apply { + addRow(arrayOf(path)) + addRow(arrayOf("/storage/emulated/0/Music/Album/another.mp3")) + } + }.`when`(resolver).query(eq(Uri.parse(original)), any(), any(), any(), any()) + + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + } + + @Test + fun `missing preferred references and remote songs cannot acquire folder aliases`() { + listOf(null, " ").forEach { reference -> + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song.copy( + localFilePath = null, mediaUri = reference, album = LocalSongSupport.LOCAL_ALBUM_IDENTITY + ))) + } + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song.copy( + localFilePath = null, mediaUri = "https://example.org/song.mp3", albumId = 1L + ))) + } + + @Test + fun `raw and file references recover the same readable document`() { + val recovered = grant("primary:Music") + listOf(path, "file://$path").forEach { reference -> + val candidate = song.copy(localFilePath = null, mediaUri = reference) + val source = requireNotNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, candidate)) + + assertEquals(recovered.toString(), source.reference) + assertEquals(reference, source.anchorFor(candidate, source.reference)) + } + } + + @Test + fun `unsupported references and paths outside external storage cannot acquire folder aliases`() { + grant("primary:Music") + listOf("invalid", "file:", "/tmp/song.mp3", "content://other.documents/document/7", + "content:///document/7", "content:/document/7").forEach { reference -> + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song.copy( + localFilePath = reference, mediaUri = null + ))) + } + } + + @Test + fun `declared file conflicts require a resolvable preferred path`() { + grant("primary:Music") + assertFalse(ResolvedLocalMediaSource.hasConflictingDeclaredFile(context, song)) + assertTrue(ResolvedLocalMediaSource.hasConflictingDeclaredFile(context, song.copy(localFilePath = "$path.other"))) + assertFalse(ResolvedLocalMediaSource.hasConflictingDeclaredFile(context, + song.copy(localFilePath = null, mediaUri = null))) + assertFalse(ResolvedLocalMediaSource.hasConflictingDeclaredFile(context, + song.copy(mediaUri = "content://other.documents/document/7"))) + val invalid = song.copy(localFilePath = "$path\u0000") + assertTrue(ResolvedLocalMediaSource.hasConflictingDeclaredFile(context, invalid)) + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, invalid)) + } + + @Test + fun `removable storage documents require their exact volume and complete canonical path`() { + val documentId = "1234-ABCD:Music/Album/song.mp3" + val recovered = grant("1234-ABCD:Music", documentId = documentId) + val document = DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", "1234-abcd:Music/Album/song.mp3" + ).toString() + val candidate = song.copy(mediaUri = document, + localFilePath = "/storage/1234-ABCD/Music/Album/song.mp3") + + val source = requireNotNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, candidate)) + + assertEquals(recovered.toString(), source.reference) + assertEquals(document, source.anchorFor(candidate, source.reference)) + } + + @Test + fun `external storage document syntax cannot escape its volume or normalize a different document`() { + grant("primary:Music") + listOf("unknown:Music/song.mp3", "primary:", "primary", "1234:Music/song.mp3", + "primary:Music/Album/../Other/song.mp3", "primary:/Music/Album/song.mp3", + "primary:../../1234-ABCD/Music/song.mp3", "primary:../../../tmp/song.mp3").forEach { documentId -> + val reference = DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", documentId + ).toString() + assertNull(documentId, ResolvedLocalMediaSource.recoverFromFolderGrant(context, + song.copy(localFilePath = null, mediaUri = reference))) + } + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song.copy( + localFilePath = null, mediaUri = "content://com.android.externalstorage.documents/invalid" + ))) + } + + @Test + fun `only canonical MediaStore audio item paths can be queried for alias provenance`() { + grant("primary:Music") + listOf("video/media/7", "audio/artists/7", "audio/media/7/extra", "audio/media/not-an-id", + "audio/media/-1", "audio/media/+7", "audio/media/007", "audio/media/9223372036854775808") + .forEach { itemPath -> + val reference = "content://media/external/$itemPath" + mediaStorePath(path, reference) + assertNull(itemPath, ResolvedLocalMediaSource.recoverFromFolderGrant(context, + song.copy(mediaUri = reference))) + } + verify(resolver, never()).query(any(), any(), any(), any(), any()) + } + + @Test + fun `missing or nonabsolute MediaStore data never proves a declared file and closes its cursor`() { + grant("primary:Music") + val cursors = listOf( + MatrixCursor(arrayOf("_data")), + MatrixCursor(arrayOf("name")).apply { addRow(arrayOf("song.mp3")) }, + MatrixCursor(arrayOf("_data")).apply { addRow(arrayOfNulls(1)) }, + MatrixCursor(arrayOf("_data")).apply { addRow(arrayOf("Music/Album/song.mp3")) } + ) + cursors.forEach { cursor -> + doReturn(cursor).`when`(resolver).query(eq(Uri.parse(original)), any(), any(), any(), any()) + + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + assertTrue(cursor.isClosed) + } + } + + @Test + fun `a single MediaStore data row proves the file and is closed after recovery`() { + val recovered = grant("primary:Music") + val cursor = MatrixCursor(arrayOf("_data")).apply { addRow(arrayOf(path)) } + doReturn(cursor).`when`(resolver).query(eq(Uri.parse(original)), any(), any(), any(), any()) + + val source = requireNotNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + + assertEquals(recovered.toString(), source.reference) + assertTrue(cursor.isClosed) + } + + @Test + fun `MediaStore permission and Provider failures cannot prove a folder alias`() { + grant("primary:Music") + listOf(SecurityException("read denied"), IllegalStateException("provider unavailable")).forEach { failure -> + doAnswer { throw failure }.`when`(resolver).query(eq(Uri.parse(original)), any(), any(), any(), any()) + + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + } + } + + @Test + fun `missing and malformed persisted grants cannot prove a readable alias`() { + grant("primary:Music") + doReturn(emptyList()).`when`(resolver).persistedUriPermissions + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + doReturn(null).`when`(resolver).persistedUriPermissions + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + val invalid = mock(UriPermission::class.java) + doReturn(true).`when`(invalid).isReadPermission + doReturn(Uri.parse("content://com.android.externalstorage.documents/document/primary%3AMusic")) + .`when`(invalid).uri + doReturn(listOf(invalid)).`when`(resolver).persistedUriPermissions + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + } + + @Test + fun `lost read access and Provider failures while opening the recovered document cannot mint aliases`() { + val recovered = grant("primary:Music") + listOf(SecurityException("grant revoked"), IllegalStateException("provider unavailable")).forEach { failure -> + doAnswer { throw failure }.`when`(resolver).openAssetFileDescriptor(recovered, "r") + + assertNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, song)) + } + } + + private fun readableDeclaredSong(file: File): SongItem { + doReturn(resolver).`when`(context).contentResolver + mediaStorePath(file.canonicalPath) + return song.copy(localFilePath = file.path) + } + + private fun grant( + treeId: String, + readGranted: Boolean = true, + readable: Boolean = true, + authority: String = "com.android.externalstorage.documents", + documentId: String = "primary:Music/Album/song.mp3" + ): Uri { + val tree = DocumentsContract.buildTreeDocumentUri(authority, treeId) + val recovered = DocumentsContract.buildDocumentUriUsingTree(tree, documentId) + val permission = mock(UriPermission::class.java) + doReturn(resolver).`when`(context).contentResolver + doReturn(listOf(permission)).`when`(resolver).persistedUriPermissions + doReturn(tree).`when`(permission).uri + doReturn(readGranted).`when`(permission).isReadPermission + doReturn(if (readable) mock(AssetFileDescriptor::class.java) else null) + .`when`(resolver).openAssetFileDescriptor(recovered, "r") + mediaStorePath(path) + return recovered + } + + private fun mediaStorePath(resolvedPath: String, reference: String = original) { + doAnswer { + MatrixCursor(arrayOf("_data")).apply { addRow(arrayOf(resolvedPath)) } + }.`when`(resolver).query(eq(Uri.parse(reference)), any(), any(), any(), any()) + } +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistExplicitLocalReferenceTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistExplicitLocalReferenceTest.kt new file mode 100644 index 0000000000..8630effc98 --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistExplicitLocalReferenceTest.kt @@ -0,0 +1,130 @@ +package moe.ouom.neriplayer.data.local.playlist + +import com.google.gson.Gson +import java.io.File +import java.io.IOException +import moe.ouom.neriplayer.data.local.playlist.system.LocalFilesPlaylist +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.playlist.DISPLAY_ORDER_SONG_ORDER_VERSION +import moe.ouom.neriplayer.data.model.playlist.LocalPlaylist +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.clearInvocations +import org.mockito.Mockito.verifyNoInteractions + +class LocalPlaylistExplicitLocalReferenceTest : LocalPlaylistRepositoryTestSupport() { + @Test + fun `cold singleton lookup remains unknown without creating a repository`() { + assertNull(LocalPlaylistRepository.peekExplicitLocalFileReference("content://provider/document/song")) + assertNull(LocalPlaylistRepository.peekExplicitLocalFileReference("")) + } + + @Test + fun `manual local member proves its exact preferred reference regardless of remote identity`() { + val reference = "content://provider/tree/imported/document/Imported.flac" + val imported = localSong(42).copy( + mediaUri = reference, + localFilePath = null, + channelId = "netease", + audioId = "42", + sourceStableKey = "42|netease|" + ) + val context = mockContext() + val storage = GuardedStorage(Gson().toJson(listOf(playlist(LocalFilesPlaylist.SYSTEM_ID, imported)))) + val repository = LocalPlaylistRepository.createForTest( + context = context, + file = File(tempFolder.root, "explicit_reference.json"), + storage = storage, + syncMutationStore = RecordingSyncMutationStore() + ) + assertTrue(repository.initializationReadyFlow.value) + storage.rejectAccess = true + clearInvocations(context) + + assertEquals(true, repository.containsExplicitLocalFileReferenceCached(reference)) + assertEquals(false, repository.containsExplicitLocalFileReferenceCached(reference + "?alias=other")) + assertEquals(false, repository.containsExplicitLocalFileReferenceCached("content://provider/document/other-42")) + assertEquals(false, repository.containsExplicitLocalFileReferenceCached("")) + + verifyNoInteractions(context) + } + + @Test + fun `non preferred aliases and unrelated playlists cannot prove manual import membership`() { + val preferred = "content://provider/document/selected" + val stalePath = "/music/stale-alias.flac" + val filePath = "/music/selected.flac" + val fileAlias = "file:///music/selected.flac" + val ordinaryPlaylistReference = "content://provider/document/ordinary-playlist" + val referenceCases = listOf( + Triple(localSong(1).copy(mediaUri = preferred, localFilePath = stalePath), preferred, true), + Triple(localSong(1).copy(mediaUri = preferred, localFilePath = stalePath), stalePath, false), + Triple(localSong(2).copy(mediaUri = fileAlias, localFilePath = filePath), filePath, true), + Triple(localSong(2).copy(mediaUri = fileAlias, localFilePath = filePath), fileAlias, false), + Triple(localSong(3).copy(mediaUri = "https://example.test/song", localFilePath = null), "https://example.test/song", false) + ) + referenceCases.forEachIndexed { index, (member, reference, expected) -> + val storage = RecordingStorage(Gson().toJson(listOf( + playlist(LocalFilesPlaylist.SYSTEM_ID, member), + playlist(12L, localSong(4).copy(mediaUri = ordinaryPlaylistReference, localFilePath = null)) + .copy(name = "Local Files") + ))) + val repository = LocalPlaylistRepository.createForTest( + context = mockContext(), + file = File(tempFolder.root, "preferred_reference_$index.json"), + storage = storage, + syncMutationStore = RecordingSyncMutationStore() + ) + + assertEquals(reference, expected, repository.containsExplicitLocalFileReferenceCached(reference)) + assertEquals(false, repository.containsExplicitLocalFileReferenceCached(ordinaryPlaylistReference)) + } + } + + @Test + fun `failed initialization leaves even matching cached members unknown without retrying storage`() { + val reference = "content://provider/document/unconfirmed" + val member = localSong(42).copy(mediaUri = reference, localFilePath = null) + val storage = GuardedStorage(Gson().toJson(listOf(playlist(LocalFilesPlaylist.SYSTEM_ID, member)))) + val repository = LocalPlaylistRepository.createForTest( + context = mockContext(), + file = File(tempFolder.root, "failed_reference.json"), + storage = storage, + normalizePlaylists = { throw IOException("unconfirmed source") }, + syncMutationStore = RecordingSyncMutationStore() + ) + repository._playlists.value = listOf(playlist(LocalFilesPlaylist.SYSTEM_ID, member)) + storage.rejectAccess = true + + assertNull(repository.containsExplicitLocalFileReferenceCached(reference)) + } + + private fun playlist(id: Long, vararg songs: SongItem) = LocalPlaylist( + id = id, + name = if (id == LocalFilesPlaylist.SYSTEM_ID) "Local Files" else "Other", + songs = songs.toMutableList(), + modifiedAt = 1_000L, + songOrderVersion = DISPLAY_ORDER_SONG_ORDER_VERSION + ) + + private class GuardedStorage(initial: String) : LocalPlaylistStorage { + private val delegate = RecordingStorage(primary = initial) + var rejectAccess = false + + override fun readPrimary(): String? = checked { delegate.readPrimary() } + override fun readBackup(): String? = checked { delegate.readBackup() } + override fun readPendingSyncMutation(): String? = checked { delegate.readPendingSyncMutation() } + override fun quarantinePrimary(): File? = checked { delegate.quarantinePrimary() } + + override fun commit(text: String, rotateBackup: Boolean, replaceBackupWithCommittedPrimary: Boolean) { + checked { delegate.commit(text, rotateBackup, replaceBackupWithCommittedPrimary) } + } + + private fun checked(action: () -> T): T { + check(!rejectAccess) { "cached reference lookup must not touch storage" } + return action() + } + } +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistScannedSourceProvenanceTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistScannedSourceProvenanceTest.kt new file mode 100644 index 0000000000..62af0095e8 --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistScannedSourceProvenanceTest.kt @@ -0,0 +1,516 @@ +package moe.ouom.neriplayer.data.local.playlist + +import android.content.Context +import android.content.SharedPreferences +import android.content.res.Configuration +import android.content.res.Resources +import android.os.LocaleList +import com.google.gson.Gson +import java.io.File +import java.util.Locale +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf +import moe.ouom.neriplayer.data.identity.toSyncableRemoteSongOrNull +import moe.ouom.neriplayer.data.local.database.store.LocalPlaylistRoomMapper +import moe.ouom.neriplayer.data.local.database.store.LocalPlaylistPreviewAuthority +import moe.ouom.neriplayer.data.local.database.store.mockLocalPlaylistRoomStore +import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.playlist.system.LocalFilesPlaylist +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.playlist.DISPLAY_ORDER_SONG_ORDER_VERSION +import moe.ouom.neriplayer.data.model.playlist.LocalPlaylist +import moe.ouom.neriplayer.data.model.sync.SyncData +import moe.ouom.neriplayer.data.model.sync.SyncPlaylist +import moe.ouom.neriplayer.data.model.sync.SyncSong +import moe.ouom.neriplayer.data.sync.mapping.SyncLocalRestoreMapping +import moe.ouom.neriplayer.data.sync.mapping.fromLocalPlaylist +import moe.ouom.neriplayer.data.sync.mapping.fromSongItemOrNull +import moe.ouom.neriplayer.data.sync.model.SyncCausalToken +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.mock +import org.mockito.Mockito.anyList +import org.mockito.Mockito.anyLong +import org.mockito.Mockito.anyString +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.`when` + +class LocalPlaylistScannedSourceProvenanceTest : LocalPlaylistRepositoryTestSupport() { + private val reference = "content://provider/tree/scanned/document/Imported.flac" + + @Test + fun `creating an ordinary playlist from scans persists the exact selected source`() = runTest { + val storage = storage() + val repository = repository(storage) + + val created = repository.createPlaylistWithScannedSongs("Scanned", listOf(scannedSong())) + + assertSelectedSource(created.songs.single()) + assertSelectedSource(requireNotNull(repository.readFastPlaylist(created.id)).songs.single()) + assertEquals(true, repository.containsExplicitLocalFileReferenceCached(reference)) + val reopened = repository(storage) + assertSelectedSource(reopened.playlists.value.single { it.id == created.id }.songs.single()) + assertEquals(true, reopened.containsExplicitLocalFileReferenceCached(reference)) + } + + @Test + fun `adding scans to an ordinary playlist preserves source evidence after reopening`() = runTest { + val storage = storage(playlist(12L)) + val repository = repository(storage) + + val added = repository.addScannedSongsToPlaylistWithResult(12L, listOf(scannedSong())) + + assertEquals(1, added.addedCount) + assertSelectedSource(added.addedSongs.single()) + assertSelectedSource(requireNotNull(repository.readFastPlaylist(12L)).songs.single()) + assertEquals(true, repository(storage).containsExplicitLocalFileReferenceCached(reference)) + } + + @Test + fun `explicit rescanning replaces an existing remote source without adding a duplicate`() = runTest { + val oldRemote = scannedSong().copy(mediaUri = null, localFileName = null) + val storage = storage(playlist(12L, oldRemote)) + val repository = repository(storage) + val before = repository.playlists.value.single { it.id == 12L }.songs.single() + + val added = repository.addScannedSongsToPlaylistWithResult(12L, listOf(scannedSong())) + + assertEquals(0, added.addedCount) + val restored = requireNotNull(repository.readFastPlaylist(12L)).songs.single() + assertSelectedSource(restored) + assertEquals(before.addedAt, restored.addedAt) + assertEquals(before.syncMembershipTokens, restored.syncMembershipTokens) + assertEquals(true, repository(storage).containsExplicitLocalFileReferenceCached(reference)) + } + + @Test + fun `explicit rescanning replaces an unmarked legacy local source for the same remote identity`() = runTest { + val previous = legacyLocalSongWithRemoteIdentity() + val repository = roomRepository(playlist(12L, previous)) + assertEquals(previous.mediaUri, repository.playlists.value.single { it.id == 12L }.songs.single().mediaUri) + + val added = repository.addScannedSongsToPlaylistWithResult(12L, listOf(scannedSong())) + + assertEquals(0, added.addedCount) + val restored = requireNotNull(repository.readFastPlaylist(12L)).songs.single() + assertSelectedSource(restored) + assertNull(restored.localFilePath) + assertEquals(previous.addedAt, restored.addedAt) + assertEquals(previous.membershipAddedAtMs, restored.membershipAddedAtMs) + assertEquals(previous.syncMembershipTokens, restored.syncMembershipTokens) + assertEquals(true, repository.containsExplicitLocalFileReferenceCached(reference)) + assertEquals(false, repository.containsExplicitLocalFileReferenceCached(requireNotNull(previous.mediaUri))) + } + + @Test + fun `explicit rescanning replaces a legacy local source whose saved marker is invalid`() = runTest { + val previous = legacyLocalSongWithRemoteIdentity().copy(explicitLocalSourceReference = reference) + val repository = roomRepository(playlist(12L, previous)) + assertEquals(previous.mediaUri, repository.playlists.value.single { it.id == 12L }.songs.single().mediaUri) + assertEquals(false, repository.containsExplicitLocalFileReferenceCached(reference)) + + val added = repository.addScannedSongsToPlaylistWithResult(12L, listOf(scannedSong())) + + assertEquals(0, added.addedCount) + val restored = requireNotNull(repository.readFastPlaylist(12L)).songs.single() + assertSelectedSource(restored) + assertNull(restored.localFilePath) + assertEquals(previous.addedAt, restored.addedAt) + assertEquals(previous.membershipAddedAtMs, restored.membershipAddedAtMs) + assertEquals(previous.syncMembershipTokens, restored.syncMembershipTokens) + assertEquals(true, repository.containsExplicitLocalFileReferenceCached(reference)) + } + + @Test + fun `prepared imports cannot replace an unmarked legacy local source using a selected file marker`() = runTest { + val previous = legacyLocalSongWithRemoteIdentity() + val repository = roomRepository(playlist(12L, previous)) + + val added = repository.addPreparedSongsToPlaylistWithResult( + 12L, listOf(scannedSong().copy(explicitLocalSourceReference = reference)) + ) + + assertEquals(0, added.addedCount) + val restored = requireNotNull(repository.readFastPlaylist(12L)).songs.single() + assertNull(restored.mediaUri) + assertNull(restored.localFilePath) + assertNull(restored.explicitLocalSourceReference) + assertEquals(previous.addedAt, restored.addedAt) + assertEquals(previous.membershipAddedAtMs, restored.membershipAddedAtMs) + assertEquals(previous.syncMembershipTokens, restored.syncMembershipTokens) + assertEquals(false, repository.containsExplicitLocalFileReferenceCached(reference)) + } + + @Test + fun `rescanning another local file keeps the previously selected source and its evidence`() = runTest { + val storage = storage() + val repository = repository(storage) + val created = repository.createPlaylistWithScannedSongs("Selected", listOf(scannedSong())) + val before = requireNotNull(repository.readFastPlaylist(created.id)).songs.single() + val replacementReference = "content://provider/tree/other/document/Replacement.flac" + + val added = repository.addScannedSongsToPlaylistWithResult( + created.id, + listOf(scannedSong().copy(mediaUri = replacementReference, localFileName = "Replacement.flac")) + ) + + assertEquals(0, added.addedCount) + val restored = requireNotNull(repository.readFastPlaylist(created.id)).songs.single() + assertSelectedSource(restored) + assertEquals(before.addedAt, restored.addedAt) + assertEquals(before.membershipAddedAtMs, restored.membershipAddedAtMs) + assertEquals(before.syncMembershipTokens, restored.syncMembershipTokens) + val reopened = repository(storage) + assertSelectedSource(reopened.playlists.value.single { it.id == created.id }.songs.single()) + assertEquals(true, reopened.containsExplicitLocalFileReferenceCached(reference)) + assertEquals(false, reopened.containsExplicitLocalFileReferenceCached(replacementReference)) + } + + @Test + fun `ordinary metadata refresh retains evidence for the same selected file`() { + val repository = repository(storage()) + val selected = scannedSong().copy(explicitLocalSourceReference = reference) + + val refreshed = repository.mergeSongMetadataForPersistence( + selected, + selected.copy(name = "Updated title", explicitLocalSourceReference = null) + ) + + assertEquals("Updated title", refreshed.name) + assertSelectedSource(refreshed) + } + + @Test + fun `same remote identity cannot transfer selected source evidence to another local file`() { + val repository = repository(storage()) + val managedReference = "content://provider/tree/old-managed/document/Downloaded.flac" + val managed = scannedSong().copy(mediaUri = managedReference) + val selected = scannedSong().copy(explicitLocalSourceReference = reference) + + val merged = repository.mergeSongMetadataForPersistence(managed, selected) + + assertEquals(managedReference, merged.mediaUri) + assertNull(merged.explicitLocalSourceReference) + } + + @Test + fun `generic metadata updates cannot replace a remote source using another saved local marker`() { + val repository = repository(storage()) + val remote = scannedSong().copy(mediaUri = null, localFileName = null) + val selected = scannedSong().copy(explicitLocalSourceReference = reference) + + val merged = repository.mergeSongMetadataForPersistence(remote, selected) + + assertNull(merged.mediaUri) + assertNull(merged.localFilePath) + assertNull(merged.explicitLocalSourceReference) + } + + @Test + fun `generic imports cannot convert remote playlist members into explicit local sources`() = runTest { + val oldRemote = scannedSong().copy(mediaUri = null, localFileName = null) + val repository = repository(storage(playlist(12L, oldRemote))) + + repository.addPreparedSongsToPlaylistWithResult(12L, listOf(scannedSong())) + + val stored = requireNotNull(repository.readFastPlaylist(12L)).songs.single() + assertNull(stored.mediaUri) + assertNull(stored.explicitLocalSourceReference) + assertEquals(false, repository.containsExplicitLocalFileReferenceCached(reference)) + } + + @Test + fun `ordinary unmarked stale and network references never establish scan evidence`() { + val oldReference = "content://provider/tree/old-managed/document/Old.flac" + val network = "https://example.test/audio.flac" + val cases = listOf( + scannedSong(), + scannedSong().copy(channelId = "local", sourceStableKey = null), + scannedSong().copy(explicitLocalSourceReference = oldReference), + scannedSong().copy(mediaUri = network, explicitLocalSourceReference = network) + ) + cases.forEach { candidate -> + val repository = repository(storage(playlist(12L, candidate))) + assertEquals(false, repository.containsExplicitLocalFileReferenceCached(reference)) + assertEquals(false, repository.containsExplicitLocalFileReferenceCached(oldReference)) + assertEquals(false, repository.containsExplicitLocalFileReferenceCached(network)) + } + } + + @Test + fun `room member and track payloads preserve selected sources without new columns`() { + val mapper = LocalPlaylistRoomMapper() + val selected = scannedSong().copy(explicitLocalSourceReference = reference) + val snapshot = mapper.toSnapshot(listOf(playlist(12L, selected))) + + val fromMember = mapper.toDomain(snapshot.playlists, emptyList(), snapshot.members, snapshot.memberTokens) + val fromTrack = mapper.toDomain( + snapshot.playlists, snapshot.tracks, + snapshot.members.map { it.copy(memberPayloadJson = "null") }, snapshot.memberTokens + ) + + assertSelectedSource(fromMember.single().songs.single()) + assertSelectedSource(fromTrack.single().songs.single()) + val legacy = mapper.toSnapshot(listOf(playlist(12L, scannedSong()))) + assertNull(mapper.toDomain(legacy.playlists, legacy.tracks, legacy.members, legacy.memberTokens) + .single().songs.single().explicitLocalSourceReference) + } + + @Test + fun `remote synchronization discards file provenance along with the local reference`() { + val selected = scannedSong().copy(explicitLocalSourceReference = reference) + + val projected = requireNotNull(selected.toSyncableRemoteSongOrNull()) + + assertNull(projected.mediaUri) + assertNull(projected.localFilePath) + assertNull(projected.explicitLocalSourceReference) + assertEquals("netease", projected.channelId) + assertNull(projected.copy(explicitLocalSourceReference = reference) + .toSyncableRemoteSongOrNull()?.explicitLocalSourceReference) + } + + @Test + fun `serialized sync songs exclude scan provenance and every local source address`() { + val localPath = "/storage/emulated/0/Imported/selected.flac" + val selected = scannedSong().copy( + localFilePath = localPath, + explicitLocalSourceReference = reference + ) + + val exported = requireNotNull(SyncSong.fromSongItemOrNull(selected)) + val serialized = Gson().toJson(exported) + + assertEquals(859470L, exported.id) + assertEquals("netease", exported.channelId) + assertNull(exported.mediaUri) + assertFalse(serialized.contains("explicitLocalSourceReference")) + assertFalse(serialized.contains(reference)) + assertFalse(serialized.contains(localPath)) + } + + @Test + fun `sync roundtrip retains a scanned source for the same ordinary playlist member`() = runTest { + val storage = storage() + val syncStore = RecordingSyncMutationStore() + val repository = repository(storage, syncStore) + val localPath = "/storage/emulated/0/Imported/selected.flac" + val created = repository.createPlaylistWithScannedSongs( + "Selected", listOf(scannedSong().copy(localFilePath = localPath)) + ) + val exported = SyncPlaylist.fromLocalPlaylist(created, modifiedAt = 2_000L) + val remote = exported.copy(songs = exported.songs.map { + it.copy(customName = "Remote edit", userLyricOffsetMs = 81L) + }) + + applySyncRoundTrip(repository, syncStore, remote) + + val stored = requireNotNull(repository.readFastPlaylist(created.id)).songs.single() + assertSelectedSource(stored) + assertEquals(localPath, stored.localFilePath) + assertEquals("Imported.flac", stored.localFileName) + assertEquals("Remote edit", stored.customName) + assertEquals(81L, stored.userLyricOffsetMs) + assertEquals(remote.songs.single().syncMembershipTokens, stored.syncMembershipTokens) + val reopened = repository(storage) + assertSelectedSource(requireNotNull(reopened.readFastPlaylist(created.id)).songs.single()) + assertEquals(true, reopened.containsExplicitLocalFileReferenceCached(reference)) + val reexported = Gson().toJson(SyncPlaylist.fromLocalPlaylist(reopened.playlists.value.single { + it.id == created.id + })) + assertFalse(reexported.contains("explicitLocalSourceReference")) + assertFalse(reexported.contains(reference)) + assertFalse(reexported.contains(localPath)) + } + + @Test + fun `sync member deletion removes a scanned remote member while retaining local only songs`() = runTest { + val storage = storage() + val syncStore = RecordingSyncMutationStore() + val repository = repository(storage, syncStore) + val localOnlyReference = "content://provider/tree/scanned/document/LocalOnly.flac" + val localOnly = scannedSong().copy( + id = 17L, + album = LocalSongSupport.LOCAL_ALBUM_IDENTITY, + mediaUri = localOnlyReference, + channelId = "local", + audioId = "17", + sourceStableKey = null + ) + val created = repository.createPlaylistWithScannedSongs("Selected", listOf(scannedSong(), localOnly)) + val exported = SyncPlaylist.fromLocalPlaylist(created, modifiedAt = 2_000L) + assertEquals(1, exported.songs.size) + + applySyncRoundTrip(repository, syncStore, exported.copy(songs = emptyList())) + + val stored = requireNotNull(repository.readFastPlaylist(created.id)).songs.single() + assertEquals(17L, stored.id) + assertEquals(localOnlyReference, stored.mediaUri) + assertEquals(false, repository.containsExplicitLocalFileReferenceCached(reference)) + assertEquals(true, repository.containsExplicitLocalFileReferenceCached(localOnlyReference)) + assertEquals(listOf(17L), requireNotNull(repository(storage).readFastPlaylist(created.id)).songs.map { it.id }) + } + + @Test + fun `sync playlist deletion cannot lend a scanned source to another playlist`() = runTest { + val storage = storage() + val syncStore = RecordingSyncMutationStore() + val repository = repository(storage, syncStore) + val created = repository.createPlaylistWithScannedSongs("Selected", listOf(scannedSong())) + val exported = SyncPlaylist.fromLocalPlaylist(created, modifiedAt = 2_000L) + val otherPlaylistId = created.id + 1L + + applySyncRoundTrip( + repository, syncStore, + exported.copy(isDeleted = true), + exported.copy(id = otherPlaylistId, name = "Other") + ) + + assertNull(repository.readFastPlaylist(created.id)) + val other = requireNotNull(repository.readFastPlaylist(otherPlaylistId)).songs.single() + assertNull(other.mediaUri) + assertNull(other.localFilePath) + assertNull(other.explicitLocalSourceReference) + val reopened = repository(storage) + assertNull(reopened.readFastPlaylist(created.id)) + assertEquals(false, reopened.containsExplicitLocalFileReferenceCached(reference)) + } + + @Test + fun `sync roundtrip leaves unmarked managed members as remote projections`() = runTest { + val storage = storage(playlist(12L, scannedSong())) + val syncStore = RecordingSyncMutationStore() + val repository = repository(storage, syncStore) + val existing = requireNotNull(repository.readFastPlaylist(12L)) + val exported = SyncPlaylist.fromLocalPlaylist(existing, modifiedAt = 2_000L) + + applySyncRoundTrip(repository, syncStore, exported) + + val stored = requireNotNull(repository.readFastPlaylist(12L)).songs.single() + assertEquals(859470L, stored.id) + assertEquals("netease", stored.channelId) + assertNull(stored.mediaUri) + assertNull(stored.localFilePath) + assertNull(stored.explicitLocalSourceReference) + assertEquals(false, repository(storage).containsExplicitLocalFileReferenceCached(reference)) + } + + @OptIn(ExperimentalSerializationApi::class) + private suspend fun applySyncRoundTrip( + repository: LocalPlaylistRepository, + syncStore: RecordingSyncMutationStore, + vararg playlists: SyncPlaylist + ) { + val data = ProtoBuf.decodeFromByteArray( + ProtoBuf.encodeToByteArray(SyncData(playlists = playlists.toList())) + ) + val originalLocale = Locale.getDefault() + try { + val restored = SyncLocalRestoreMapping(syncMappingContext()).playlists(data, repository.playlists.value) + assertTrue(repository.applySyncedPlaylistsIfUnchanged(restored, syncStore.mutationVersion)) + } finally { + Locale.setDefault(originalLocale) + } + } + + private fun syncMappingContext(): Context { + val preferences = mock(SharedPreferences::class.java) + val resources = mock(Resources::class.java) + val configuration = mock(Configuration::class.java) + val locales = mock(LocaleList::class.java) + `when`(preferences.getString("selected_language", "")).thenReturn("") + `when`(resources.configuration).thenReturn(configuration) + `when`(configuration.locales).thenReturn(locales) + `when`(locales.size()).thenReturn(1) + `when`(locales[0]).thenReturn(Locale.getDefault()) + return mockContext().apply { + `when`(applicationContext).thenReturn(mock(Context::class.java)) + `when`(getSharedPreferences("language_settings", Context.MODE_PRIVATE)).thenReturn(preferences) + `when`(this.resources).thenReturn(resources) + `when`(createConfigurationContext(any(Configuration::class.java))).thenReturn(this) + } + } + + private fun assertSelectedSource(song: SongItem) { + assertEquals(reference, song.mediaUri) + assertEquals(reference, song.explicitLocalSourceReference) + assertEquals("netease", song.channelId) + assertEquals("859470|netease|", song.sourceStableKey) + } + + private fun scannedSong() = SongItem( + id = 859470L, + name = "Imported title", + artist = "Imported artist", + album = "Imported album", + albumId = 0L, + durationMs = 1_000L, + coverUrl = null, + mediaUri = reference, + localFileName = "Imported.flac", + channelId = "netease", + audioId = "859470", + sourceStableKey = "859470|netease|", + addedAt = 1_000L + ) + + private fun legacyLocalSongWithRemoteIdentity() = scannedSong().copy( + mediaUri = "content://provider/tree/old-managed/document/Legacy.flac", + localFileName = "Legacy.flac", + localFilePath = "/storage/emulated/0/OldManaged/Legacy.flac", + addedAt = 123L, + membershipAddedAtMs = 456L, + syncMembershipTokens = listOf(SyncCausalToken(deviceId = "legacy-device", counter = 42L)) + ) + + private suspend fun roomRepository(initial: LocalPlaylist): LocalPlaylistRepository { + val room = mockLocalPlaylistRoomStore() + var persisted = listOf(playlist(LocalFilesPlaylist.SYSTEM_ID), initial) + `when`(room.readIfRoomPrimary()).thenAnswer { persisted } + `when`(room.readFastPlaylistAuthority(anyLong())).thenAnswer { invocation -> + LocalPlaylistPreviewAuthority.RoomPrimary(persisted.firstOrNull { + it.id == invocation.getArgument(0) + }) + } + doAnswer { invocation -> + persisted = invocation.getArgument(1) + Unit + }.`when`(room).writeIncremental(anyList(), anyList(), anyString(), anyLong()) + return LocalPlaylistRepository.createForTest( + context = mockContext(), + file = File(tempFolder.root, "legacy_scanned_source.json"), + storage = RecordingStorage(primary = null), + syncMutationStore = RecordingSyncMutationStore(), + roomStore = room + ) + } + + private fun playlist(id: Long, vararg songs: SongItem) = LocalPlaylist( + id = id, + name = if (id == LocalFilesPlaylist.SYSTEM_ID) "Local Files" else "Other", + songs = songs.toMutableList(), + modifiedAt = 1_000L, + songOrderVersion = DISPLAY_ORDER_SONG_ORDER_VERSION + ) + + private fun storage(vararg playlists: LocalPlaylist) = RecordingStorage( + primary = Gson().toJson(listOf(playlist(LocalFilesPlaylist.SYSTEM_ID)) + playlists) + ) + + private fun repository( + storage: RecordingStorage, + syncStore: RecordingSyncMutationStore = RecordingSyncMutationStore() + ) = LocalPlaylistRepository.createForTest( + context = mockContext(), + file = File(tempFolder.root, "scanned_source.json"), + storage = storage, + syncMutationStore = syncStore + ) +} diff --git a/modules/local/src/test/java/moe/ouom/neriplayer/data/sync/mapping/SyncHistoryLocalSourceTest.kt b/modules/local/src/test/java/moe/ouom/neriplayer/data/sync/mapping/SyncHistoryLocalSourceTest.kt new file mode 100644 index 0000000000..b67aca0aee --- /dev/null +++ b/modules/local/src/test/java/moe/ouom/neriplayer/data/sync/mapping/SyncHistoryLocalSourceTest.kt @@ -0,0 +1,85 @@ +package moe.ouom.neriplayer.data.sync.mapping + +import android.content.Context +import com.google.gson.Gson +import moe.ouom.neriplayer.data.history.toPlayedEntry +import moe.ouom.neriplayer.data.history.toSongItem +import moe.ouom.neriplayer.data.backup.BackupMetadataMapper +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.sync.SyncData +import moe.ouom.neriplayer.data.model.sync.SyncRecentPlay +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mock + +class SyncHistoryLocalSourceTest { + private val context = mock(Context::class.java) + private val reference = "content://media/external/audio/media/42" + private val selected = SongItem( + id = 42L, name = "Scanned title", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 1_000L, coverUrl = null, mediaUri = reference, + channelId = "netease", audioId = "42", explicitLocalSourceReference = reference + ) + + @Test + fun `history sync and backup projections never export local source provenance`() { + val entry = selected.toPlayedEntry(1L) + + assertTrue(buildRecentPlaySyncSnapshots(listOf(entry), { "device" }, context).isEmpty()) + val backupJson = Gson().toJson(BackupMetadataMapper.toSyncRecentPlay(entry)) + assertFalse(backupJson.contains("explicitLocalSourceReference")) + assertFalse(backupJson.contains(reference)) + } + + @Test + fun `remote history cannot inject local provenance while current device local history keeps its selection`() { + val remote = Gson().fromJson("""{ + "songId":7,"playedAt":2,"deviceId":"remote","song":{ + "id":7,"name":"Remote","artist":"Artist","album":"netease","albumId":1, + "durationMs":1000,"explicitLocalSourceReference":"$reference" + } + }""", SyncRecentPlay::class.java) + val local = selected.toPlayedEntry(1L) + + val restored = SyncLocalRestoreMapping(context).history(SyncData(recentPlays = listOf(remote)), listOf(local)) + + assertEquals(reference, restored.single { it.id == 42L }.toSongItem().explicitLocalSourceReference) + assertNull(restored.single { it.id == 7L }.explicitLocalSourceReference) + assertNull(restored.single { it.id == 7L }.toSongItem().explicitLocalSourceReference) + assertNull(BackupMetadataMapper.toPlayedEntry(remote, context)?.explicitLocalSourceReference) + } + + @Test + fun `same device sync keeps path only local history and its selected source when recent plays are empty`() { + val path = "/storage/emulated/0/Music/Album/selected.flac" + val local = selected.copy( + album = "Real album", albumId = 99L, mediaUri = null, localFilePath = path, + explicitLocalSourceReference = path + ).toPlayedEntry(1L) + assertTrue(buildRecentPlaySyncSnapshots(listOf(local), { "device" }, context).isEmpty()) + + val restored = SyncLocalRestoreMapping(context).history(SyncData(recentPlays = emptyList()), listOf(local)) + + assertEquals(listOf(local), restored) + assertEquals(path, restored.single().toSongItem().explicitLocalSourceReference) + } + + @Test + fun `blank paths neither preserve remote history nor grant local source provenance`() { + listOf(null, "", " \t ").forEach { path -> + val remote = selected.copy( + album = "Real album", albumId = 99L, mediaUri = null, localFilePath = path, + explicitLocalSourceReference = path + ).toPlayedEntry(1L) + assertNull(remote.explicitLocalSourceReference) + assertNull(remote.copy(explicitLocalSourceReference = path).toSongItem().explicitLocalSourceReference) + + val restored = SyncLocalRestoreMapping(context).history(SyncData(recentPlays = emptyList()), listOf(remote)) + + assertTrue(restored.isEmpty()) + } + } +} diff --git a/modules/model/src/main/java/moe/ouom/neriplayer/data/model/SongItem.kt b/modules/model/src/main/java/moe/ouom/neriplayer/data/model/SongItem.kt index c999518776..c72c36d332 100644 --- a/modules/model/src/main/java/moe/ouom/neriplayer/data/model/SongItem.kt +++ b/modules/model/src/main/java/moe/ouom/neriplayer/data/model/SongItem.kt @@ -51,5 +51,7 @@ data class SongItem( val sourceModifiedAtMs: Long? = null, val lyricSyncRevision: Long = 0L, // null 表示旧数据尚未区分用户修改与网络缓存 - val lyricSyncEdited: Boolean? = null + val lyricSyncEdited: Boolean? = null, + // 记录用户显式扫描选中的文件,不能由远端身份或侧载元数据推断 + val explicitLocalSourceReference: String? = null ) : Parcelable diff --git a/modules/model/src/main/java/moe/ouom/neriplayer/data/model/history/PlayedEntry.kt b/modules/model/src/main/java/moe/ouom/neriplayer/data/model/history/PlayedEntry.kt index 1a3c0e9043..0016e3ac67 100644 --- a/modules/model/src/main/java/moe/ouom/neriplayer/data/model/history/PlayedEntry.kt +++ b/modules/model/src/main/java/moe/ouom/neriplayer/data/model/history/PlayedEntry.kt @@ -33,5 +33,6 @@ data class PlayedEntry( val matchedSongId: String? = null, val userLyricOffsetMs: Long = 0L, val lyricSyncRevision: Long = 0L, - val lyricSyncEdited: Boolean? = null + val lyricSyncEdited: Boolean? = null, + val explicitLocalSourceReference: String? = null ) diff --git a/modules/model/src/main/java/moe/ouom/neriplayer/data/model/playback/PersistedPlayerState.kt b/modules/model/src/main/java/moe/ouom/neriplayer/data/model/playback/PersistedPlayerState.kt index 21c254826f..e76db8e070 100644 --- a/modules/model/src/main/java/moe/ouom/neriplayer/data/model/playback/PersistedPlayerState.kt +++ b/modules/model/src/main/java/moe/ouom/neriplayer/data/model/playback/PersistedPlayerState.kt @@ -34,7 +34,8 @@ data class PersistedSongItem( val matchedRomanizedLyric: String? = null, val originalRomanizedLyric: String? = null, val lyricSyncRevision: Long = 0L, - val lyricSyncEdited: Boolean? = null + val lyricSyncEdited: Boolean? = null, + val explicitLocalSourceReference: String? = null ) data class PersistedState( diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt index ad89810849..cc9476a969 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt @@ -215,6 +215,7 @@ import moe.ouom.neriplayer.core.player.watchdog.cancelPlaybackStartupWatchdog import moe.ouom.neriplayer.core.player.watchdog.clearActivePlaybackCandidates import moe.ouom.neriplayer.core.player.watchdog.shouldTreatReadyAtStartAsUnhealthyPrepared import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource import moe.ouom.neriplayer.data.local.playlist.LocalPlaylistRepository import moe.ouom.neriplayer.data.model.playlist.LocalPlaylist import moe.ouom.neriplayer.data.model.stableKey @@ -1278,6 +1279,9 @@ object PlayerManager { return LocalPlaybackMediaResolver.source(song, application) } + internal fun localMediaSourceContext(song: SongItem): ResolvedLocalMediaSource? = + LocalPlaybackMediaResolver.sourceContext(song, application) + internal fun toPlayableLocalUrl(mediaUri: String?): String? = LocalPlaybackMediaResolver.playableUrl(mediaUri) @@ -1832,7 +1836,7 @@ object PlayerManager { internal fun applyRemoteQueueUpdate(queue: List, currentIndexInQueue: Int) = applyRemoteQueueUpdateImpl(queue, currentIndexInQueue) - fun resumeRestoredPlaybackIfNeeded(): Long? = resumeRestoredPlaybackIfNeededImpl() + suspend fun resumeRestoredPlaybackIfNeeded(): Long? = resumeRestoredPlaybackIfNeededImpl() fun suppressFutureAutoResumeForCurrentSession(forcePersist: Boolean = false) = suppressFutureAutoResumeForCurrentSessionImpl(forcePersist) diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/audio/output/PlayerManagerPlaybackTransportPort.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/audio/output/PlayerManagerPlaybackTransportPort.kt index f1b77a3d10..d48cdca1c0 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/audio/output/PlayerManagerPlaybackTransportPort.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/audio/output/PlayerManagerPlaybackTransportPort.kt @@ -7,6 +7,7 @@ import moe.ouom.neriplayer.core.player.runtime.transport.UsbFocusSnapshot import moe.ouom.neriplayer.core.player.usb.transport.hasHealthyTransport import android.os.Looper +import androidx.core.net.toUri import androidx.media3.common.AudioAttributes import androidx.media3.common.C import kotlinx.coroutines.Job @@ -20,6 +21,7 @@ import moe.ouom.neriplayer.core.player.usb.path.UsbExclusiveAudioPathTracker import moe.ouom.neriplayer.core.player.usb.session.UsbExclusiveSessionController import moe.ouom.neriplayer.core.player.usb.transport.usbRuntimeMetrics import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.local.media.LocalSongSupport internal object PlayerManagerPlaybackTransportPort : PlaybackTransportPort { override fun ensureInitialized() = PlayerManager.ensureInitialized() @@ -63,8 +65,18 @@ internal object PlayerManagerPlaybackTransportPort : PlaybackTransportPort { } private fun isRestorableCandidate(song: SongItem): Boolean { - if (!PlayerManager.isLocalSong(song)) return true - return PlayerManager.isRestorableLocalSong(song) + if (!LocalSongSupport.isLocalSong(song)) return true + // 这里只判断是否需要恢复准入,文件可读性由异步恢复流程确认 + return listOfNotNull(song.localFilePath, song.mediaUri).any(::hasPotentialLocalReference) + } + + private fun hasPotentialLocalReference(reference: String): Boolean { + if (reference.isBlank()) return false + if (reference.startsWith("/")) return true + val uri = reference.toUri() + val scheme = uri.scheme.orEmpty().lowercase() + if (scheme == "file") return !uri.path.isNullOrBlank() + return scheme in setOf("", "content", "android.resource") } override fun usbFocusSnapshot(includeHealth: Boolean, includeOpenGate: Boolean): UsbFocusSnapshot { diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/host/PlayerDownloadAccess.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/host/PlayerDownloadAccess.kt index 0bc8ca2407..ac5b409adf 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/host/PlayerDownloadAccess.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/host/PlayerDownloadAccess.kt @@ -3,6 +3,7 @@ package moe.ouom.neriplayer.core.player.host import android.content.Context import kotlinx.coroutines.flow.StateFlow import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource import moe.ouom.neriplayer.data.model.playback.storage.PlayerLocalPlaybackResolution import moe.ouom.neriplayer.data.model.playback.storage.PlayerDownloadedLyrics @@ -18,8 +19,8 @@ interface PlayerDownloadAccess { fun isLikelyManagedDownloadSongFast(context: Context, song: SongItem): Boolean fun scanLocalFiles(context: Context, forceRefresh: Boolean) fun getLocalPlaybackUri(context: Context, song: SongItem): String? - suspend fun resolvePermittedLocalPlaybackUri(context: Context, song: SongItem, rawLocalReference: String?): String? - suspend fun resolvePermittedLocalPlayback(context: Context, song: SongItem, rawLocalReference: String?): PlayerLocalPlaybackResolution + suspend fun resolvePermittedLocalPlaybackUri(context: Context, song: SongItem, rawLocalReference: String?, sourceContext: ResolvedLocalMediaSource? = null): String? + suspend fun resolvePermittedLocalPlayback(context: Context, song: SongItem, rawLocalReference: String?, sourceContext: ResolvedLocalMediaSource? = null): PlayerLocalPlaybackResolution fun resolveIndexedLocalPlaybackReference(context: Context, song: SongItem): PlayerLocalPlaybackResolution fun hasRecentlyCompletedAudio(song: SongItem): Boolean fun invalidateCompletedAudioReference(song: SongItem) diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackMediaResolver.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackMediaResolver.kt index 547e7e0b5e..d862127789 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackMediaResolver.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackMediaResolver.kt @@ -8,19 +8,61 @@ import android.os.Process import androidx.core.net.toUri import java.io.File import java.io.RandomAccessFile +import moe.ouom.neriplayer.common.logging.NPLogger +import moe.ouom.neriplayer.common.logging.playbackLogReference import moe.ouom.neriplayer.core.player.policy.storage.RestorableLocalMediaState import moe.ouom.neriplayer.core.player.policy.storage.resolveRestorableLocalMediaState import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource import moe.ouom.neriplayer.data.local.media.preferredLocalMediaReference import moe.ouom.neriplayer.data.model.SongItem internal object LocalPlaybackMediaResolver { - fun source(song: SongItem, context: Context): String? { + fun source(song: SongItem, context: Context): String? = sourceContext(song, context)?.reference + + fun sourceContext(song: SongItem, context: Context): ResolvedLocalMediaSource? { val preferred = preferredLocalMediaReference(song.localFilePath, song.mediaUri) - return listOfNotNull(preferred, song.localFilePath, song.mediaUri) + val readable = listOfNotNull(preferred, song.localFilePath, song.mediaUri) .distinct() - .firstOrNull { isReadable(it, context) } - ?: preferred + .firstOrNull { reference -> + isReadable(reference, context).also { result -> + NPLogger.d( + "NERI-LocalPlayback", + "LocalPlaybackTrace stage=candidate id=${song.id} readable=$result " + + "reference=${playbackLogReference(reference)}" + ) + } + } + val resolved = resolveReadableSource(song, context, preferred, readable) + val selected = resolved ?: preferred?.let { ResolvedLocalMediaSource.direct(song, it) } + logSourceSelection(song, resolved, selected) + return selected + } + + private fun resolveReadableSource( + song: SongItem, + context: Context, + preferred: String?, + readable: String? + ): ResolvedLocalMediaSource? { + if (readable == null) return ResolvedLocalMediaSource.recoverFromFolderGrant(context, song) + if (readable != preferred && ResolvedLocalMediaSource.hasConflictingDeclaredFile(context, song)) return null + return ResolvedLocalMediaSource.verifiedReadableDeclaredFile(context, song, readable) + ?: ResolvedLocalMediaSource.direct(song, readable) + } + + private fun logSourceSelection( + song: SongItem, + resolved: ResolvedLocalMediaSource?, + selected: ResolvedLocalMediaSource? + ) { + val message = "LocalPlaybackTrace stage=source id=${song.id} channel=${song.channelId} " + + "result=${if (resolved != null) "readable" else "no_readable_source"} " + + "mediaUri=${playbackLogReference(song.mediaUri)} " + + "localPath=${playbackLogReference(song.localFilePath)} " + + "selected=${playbackLogReference(selected?.reference)}" + if (resolved != null) NPLogger.d("NERI-LocalPlayback", message) + else NPLogger.w("NERI-LocalPlayback", message) } fun playableUrl(mediaUri: String?): String? { @@ -102,7 +144,7 @@ internal object LocalPlaybackMediaResolver { val preferred = preferredLocalMediaReference(song.localFilePath, song.mediaUri) return listOfNotNull(preferred, song.localFilePath, song.mediaUri) .distinct() - .any { isRestorable(it, context) } + .any { isRestorable(it, context) } || ResolvedLocalMediaSource.recoverFromFolderGrant(context, song) != null } fun sanitizeRestoredPlaylist(playlist: List, context: Context): List = @@ -112,15 +154,29 @@ internal object LocalPlaybackMediaResolver { private fun canOpenFile(file: File): Boolean { if (!file.exists() || !file.isFile) return false - return runCatching { RandomAccessFile(file, "r").use { true } }.getOrDefault(false) + return readableProbe("file_probe", file.absolutePath) { + RandomAccessFile(file, "r").use { true } + } } private fun isReadableFileUri(uri: Uri): Boolean = uri.path?.let(::File)?.let(::canOpenFile) == true - private fun canReadProviderUri(uri: Uri, context: Context): Boolean = runCatching { - context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { true } ?: false - }.getOrDefault(false) + private fun canReadProviderUri(uri: Uri, context: Context): Boolean = + readableProbe("provider_probe", uri.toString()) { + context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { true } ?: false + } + + private fun readableProbe(stage: String, reference: String, probe: () -> Boolean): Boolean = + runCatching(probe).onFailure { logProbeFailure(stage, reference, it) }.getOrDefault(false) + + private fun logProbeFailure(stage: String, reference: String, error: Throwable) { + NPLogger.w( + "NERI-LocalPlayback", + "LocalPlaybackTrace stage=$stage result=error errorType=${error.javaClass.name} " + + "causeType=${error.cause?.javaClass?.name} reference=${playbackLogReference(reference)}" + ) + } private fun parseUri(reference: String): Uri? = runCatching { reference.toUri() }.getOrNull() diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PersistedPlayerStateMapping.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PersistedPlayerStateMapping.kt index e08fedd17a..be2f8e13ec 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PersistedPlayerStateMapping.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PersistedPlayerStateMapping.kt @@ -63,7 +63,8 @@ internal fun SongItem.toPersistedSongItem(includeLyrics: Boolean = true): Persis audioId = audioId, subAudioId = subAudioId, playlistContextId = playlistContextId, - streamUrl = streamUrl + streamUrl = streamUrl, + explicitLocalSourceReference = LocalSongSupport.validExplicitLocalSourceReference(this) ) } @@ -131,6 +132,9 @@ fun PersistedSongItem.toSongItem(): SongItem { audioId = inferredAudioId, subAudioId = subAudioId, playlistContextId = playlistContextId, - streamUrl = streamUrl + streamUrl = streamUrl, + explicitLocalSourceReference = LocalSongSupport.validExplicitLocalSourceReference( + localFilePath, mediaUri, explicitLocalSourceReference + ) ) } diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlaybackQueueRoomStore.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlaybackQueueRoomStore.kt index 5a517a4b65..a5d9477efa 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlaybackQueueRoomStore.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlaybackQueueRoomStore.kt @@ -15,6 +15,7 @@ import moe.ouom.neriplayer.data.local.database.entity.PLAYBACK_QUEUE_SHUFFLE_RES import moe.ouom.neriplayer.data.local.database.entity.PLAYBACK_QUEUE_STATE_ID import moe.ouom.neriplayer.data.local.database.entity.PlaybackQueueSongEntity import moe.ouom.neriplayer.data.local.database.entity.PlaybackQueueStateEntity +import moe.ouom.neriplayer.data.local.media.LocalSongSupport internal interface PlaybackQueueStateStore { suspend fun replaceSnapshot( @@ -207,6 +208,9 @@ private fun PersistedSongItem.toEntity( subAudioId = subAudioId, playlistContextId = playlistContextId, streamUrl = streamUrl, + explicitLocalSourceReference = LocalSongSupport.validExplicitLocalSourceReference( + localFilePath, mediaUri, explicitLocalSourceReference + ), lyricSyncPayloadJson = LyricSyncPersistence( revision = lyricSyncRevision, edited = lyricSyncEdited, romanized = matchedRomanizedLyric, originalRomanized = originalRomanizedLyric @@ -250,7 +254,10 @@ private fun PlaybackQueueSongEntity.toPersistedSong(): PersistedSongItem { audioId = audioId, subAudioId = subAudioId, playlistContextId = playlistContextId, - streamUrl = streamUrl + streamUrl = streamUrl, + explicitLocalSourceReference = LocalSongSupport.validExplicitLocalSourceReference( + localFilePath, mediaUri, explicitLocalSourceReference + ) ) } diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.kt index 67b0f54fc2..a5fcdc7c87 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerPersistenceExtensions.kt @@ -45,20 +45,16 @@ import moe.ouom.neriplayer.core.player.metadata.shouldWriteLocalCoverMetadata import moe.ouom.neriplayer.core.player.metadata.toBasicSongDetails import moe.ouom.neriplayer.core.player.metadata.withUpdatedLyricsPreservingOriginal import moe.ouom.neriplayer.data.model.playback.PersistedState -import moe.ouom.neriplayer.data.model.playback.RestoredPlaybackState import moe.ouom.neriplayer.core.player.playback.BiliVideoSkipPlaybackController import moe.ouom.neriplayer.core.player.playback.playAtIndex import moe.ouom.neriplayer.core.player.playlist.PlayerFavoritesController import moe.ouom.neriplayer.data.model.playback.PlaybackCommandSource import moe.ouom.neriplayer.core.player.source.toSongItem -import moe.ouom.neriplayer.data.ltw.playback.ListenTogetherRestoredPlaybackAction -import moe.ouom.neriplayer.data.ltw.playback.resolveListenTogetherRestoredPlaybackAction import moe.ouom.neriplayer.data.local.media.LocalMediaMetadataWriteOutcome import moe.ouom.neriplayer.data.local.media.LocalMediaSupport import moe.ouom.neriplayer.data.local.media.LocalSongSupport import moe.ouom.neriplayer.data.local.media.CustomSongCoverStorage import moe.ouom.neriplayer.data.local.media.isMediaStoreCoverReference -import moe.ouom.neriplayer.data.local.media.isReadableLocalFile import moe.ouom.neriplayer.data.local.database.NeriUserDataDatabase import moe.ouom.neriplayer.data.model.auth.SavedCookieAuthState import moe.ouom.neriplayer.lyrics.offset.rebaseLyricUserOffsetMs @@ -774,71 +770,6 @@ internal fun PlayerManager.restoreState() { applyRestoredStateSnapshot(snapshot) } -internal fun PlayerManager.resumeRestoredPlaybackIfNeededImpl(): Long? { - ensureInitialized() - if (!initialized) { - NPLogger.d("NERI-PlayerManager", "resumeRestoredPlaybackIfNeeded(): skipped, manager not initialized") - return null - } - val restored = restoredPlaybackSnapshot() - if (restored !is RestoredPlaybackState.ResumePending) { - NPLogger.d("NERI-PlayerManager", "resumeRestoredPlaybackIfNeeded(): skipped, restoredShouldResumePlayback=false") - return null - } - when ( - resolveListenTogetherRestoredPlaybackAction( - restoredPlaybackRequested = true, - listenTogetherSessionActive = isListenTogetherActive(), - currentUserIsController = isCurrentUserControllerInListenTogether() - ) - ) { - ListenTogetherRestoredPlaybackAction.SKIP -> return null - ListenTogetherRestoredPlaybackAction.RESUME_LOCAL_PLAYBACK -> Unit - ListenTogetherRestoredPlaybackAction.WAIT_FOR_AUTHORITATIVE_ROOM_STATE -> { - NPLogger.d( - "NERI-PlayerManager", - "resumeRestoredPlaybackIfNeeded(): defer active Listen Together listener until room state is authoritative" - ) - if (!consumeRestoredPlayback(restored)) return null - scheduleStatePersist( - positionMs = _playbackPositionMs.value.coerceAtLeast(0L), - shouldResumePlayback = false, - debounceMs = 0L - ) - return null - } - } - if (currentPlaylist.isEmpty() || currentIndex !in currentPlaylist.indices) { - NPLogger.w( - "NERI-PlayerManager", - "resumeRestoredPlaybackIfNeeded(): skipped, queueSize=${currentPlaylist.size}, currentIndex=$currentIndex" - ) - return null - } - val resumeIndex = currentIndex - val resumeSong = currentPlaylist[resumeIndex] - if (isLocalSong(resumeSong) && !isRestorableLocalSong(resumeSong)) { - NPLogger.w( - "NERI-PlayerManager", - "resumeRestoredPlaybackIfNeeded(): keep restored local progress because media is not readable yet, song=${resumeSong.name}" - ) - return null - } - val resumePositionMs = restored.positionMs - NPLogger.d( - "NERI-PlayerManager", - "resumeRestoredPlaybackIfNeeded(): resumeIndex=$resumeIndex, positionMs=$resumePositionMs, song=${resumeSong.name}, stack=[${debugStackHint()}]" - ) - if (!consumeRestoredPlayback(restored)) return null - lastStatePersistAtMs = SystemClock.elapsedRealtime() - playAtIndex( - resumeIndex, - resumePositionMs = resumePositionMs, - forceStartupProtectionFade = true - ) - return resumePositionMs -} - internal fun PlayerManager.suppressFutureAutoResumeForCurrentSessionImpl( forcePersist: Boolean = false ) { @@ -1106,21 +1037,14 @@ internal suspend fun PlayerManager.writeLocalEditableMetadata( private suspend fun PlayerManager.resolveLocalMetadataWriteReference( song: SongItem ): String? { - val mediaUri = song.mediaUri - val directReference = when { - mediaUri?.startsWith("content://", ignoreCase = true) == true -> mediaUri - song.localFilePath?.let(::File)?.let(::isReadableLocalFile) == true -> { - song.localFilePath - } - mediaUri?.startsWith("/", ignoreCase = false) == true && - isReadableLocalFile(File(mediaUri)) -> mediaUri - else -> null - } + val sourceContext = localMediaSourceContext(song) + val directReference = sourceContext?.reference if (directReference != null) { return PlayerDependencies.downloads.resolvePermittedLocalPlaybackUri( context = application, song = song, - rawLocalReference = directReference + rawLocalReference = directReference, + sourceContext = sourceContext ) } return PlayerDependencies.downloads.getLocalPlaybackUri(application, song) @@ -1406,17 +1330,6 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( ) return@runSongMetadataMutation false } - val coverSidecarWritten = if (shouldWriteCoverToAudio) { - LocalMediaSupport.writeLocalCoverSidecar( - context = application, - song = sidecarSong, - coverReference = coverWriteReference, - writeCover = true, - stableIdentityKey = sidecarSong.stableKey() - ) - } else { - true - } val metadataSidecarWritten = LocalMediaSupport.writeLocalMetadataSidecar( context = application, song = sidecarSong, @@ -1425,11 +1338,10 @@ internal suspend fun PlayerManager.updateSongCustomInfoImpl( clearCoverReference = shouldWriteCoverToAudio && coverWriteReference.isNullOrBlank() ) - if (!coverSidecarWritten || !metadataSidecarWritten) { + if (!metadataSidecarWritten) { NPLogger.w( "PlayerManager", - "本地侧载元数据写入未完成: cover=$coverSidecarWritten, " + - "metadata=$metadataSidecarWritten" + "本地侧载元数据写入未完成" ) showLocalEditableMetadataWriteFeedback( outcome = LocalMediaMetadataWriteOutcome.FAILED, diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerRestoredPlaybackResume.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerRestoredPlaybackResume.kt new file mode 100644 index 0000000000..4908de2688 --- /dev/null +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerRestoredPlaybackResume.kt @@ -0,0 +1,135 @@ +package moe.ouom.neriplayer.core.player.persistence + +import android.os.SystemClock +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import moe.ouom.neriplayer.common.logging.NPLogger +import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.core.player.host.PlayerDependencies +import moe.ouom.neriplayer.core.player.playback.playAtIndex +import moe.ouom.neriplayer.data.ltw.playback.ListenTogetherRestoredPlaybackAction +import moe.ouom.neriplayer.data.ltw.playback.resolveListenTogetherRestoredPlaybackAction +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.playback.RestoredPlaybackState +import moe.ouom.neriplayer.data.model.playback.storage.PlayerLocalPlaybackResolution +import kotlin.coroutines.coroutineContext + +private data class RestoredPlaybackResumeCandidate( + val restored: RestoredPlaybackState.ResumePending, + val song: SongItem, + val index: Int, + val requestToken: Long +) + +internal fun PlayerManager.scheduleRestoredPlaybackResume( + scope: CoroutineScope, + onSourceUnavailable: () -> Unit = {}, + onResumed: () -> Unit +): Job = + scope.launch { + if (resumeRestoredPlaybackIfNeededImpl(onSourceUnavailable) != null) onResumed() + } + +internal fun PlayerManager.scheduleRestoredLocalSourceValidation( + scope: CoroutineScope, + onSourceUnavailable: () -> Unit +): Job = scope.launch(Dispatchers.Main.immediate) { + val candidate = pendingRestoredPlaybackResume() ?: return@launch + if (!isLocalSong(candidate.song) || isListenTogetherActive()) return@launch + val sourceAvailable = probeCurrentRestoredSource(candidate) ?: return@launch + if (!sourceAvailable && !isListenTogetherActive()) onSourceUnavailable() +} + +internal suspend fun PlayerManager.resumeRestoredPlaybackIfNeededImpl( + onSourceUnavailable: () -> Unit = {} +): Long? = withContext(Dispatchers.Main.immediate) { + ensureInitialized() + val candidate = pendingRestoredPlaybackResume() ?: return@withContext null + if (!canResumeInCurrentListenTogetherRoom(candidate.restored)) return@withContext null + val sourceAvailable = probeCurrentRestoredSource(candidate) ?: return@withContext null + if (!canResumeInCurrentListenTogetherRoom(candidate.restored)) return@withContext null + if (!sourceAvailable) { + onSourceUnavailable() + return@withContext null + } + val resumePositionMs = candidate.restored.positionMs + NPLogger.d( + "NERI-PlayerManager", + "resumeRestoredPlaybackIfNeeded(): resumeIndex=${candidate.index}, positionMs=$resumePositionMs, " + + "song=${candidate.song.name}, stack=[${debugStackHint()}]" + ) + if (!consumeRestoredPlayback(candidate.restored)) return@withContext null + lastStatePersistAtMs = SystemClock.elapsedRealtime() + playAtIndex(candidate.index, resumePositionMs = resumePositionMs, forceStartupProtectionFade = true) + resumePositionMs +} + +private fun PlayerManager.pendingRestoredPlaybackResume(): RestoredPlaybackResumeCandidate? { + if (!initialized) return null + val restored = restoredPlaybackSnapshot() as? RestoredPlaybackState.ResumePending ?: return null + val index = currentIndex + val song = currentPlaylist.getOrNull(index) ?: return null + return RestoredPlaybackResumeCandidate(restored, song, index, playbackRequestToken) +} + +private suspend fun PlayerManager.probeCurrentRestoredSource(candidate: RestoredPlaybackResumeCandidate): Boolean? { + val sourceAvailable = restoredSourceAvailable(candidate.song) + coroutineContext.ensureActive() + // 探测期间用户可能换歌或暂停,旧结果不能处理新的恢复请求 + if (!isCurrentRestoredPlaybackResume(candidate)) return null + return sourceAvailable +} + +private suspend fun PlayerManager.restoredSourceAvailable(song: SongItem): Boolean { + if (!isLocalSong(song)) return true + return try { + withContext(Dispatchers.IO) { + val source = localMediaSourceContext(song) + val resolution = PlayerDependencies.downloads.resolvePermittedLocalPlayback( + application, song, source?.reference, source + ) + resolution is PlayerLocalPlaybackResolution.Playable && isReadableLocalMediaUri(resolution.reference) + }.also { available -> + if (!available) NPLogger.w( + "NERI-PlayerManager", + "resumeRestoredPlaybackIfNeeded(): keep restored local progress because media is not readable yet, song=${song.name}" + ) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + NPLogger.w("NERI-PlayerManager", "Restored local source probe failed: ${error.javaClass.name}") + false + } +} + +private fun PlayerManager.isCurrentRestoredPlaybackResume(candidate: RestoredPlaybackResumeCandidate): Boolean = + initialized && restoredPlaybackSnapshot() === candidate.restored && + playbackRequestToken == candidate.requestToken && currentIndex == candidate.index && + currentPlaylist.getOrNull(candidate.index) == candidate.song && _currentSongFlow.value == candidate.song + +private fun PlayerManager.canResumeInCurrentListenTogetherRoom(restored: RestoredPlaybackState.ResumePending): Boolean { + return when (resolveListenTogetherRestoredPlaybackAction( + restoredPlaybackRequested = true, + listenTogetherSessionActive = isListenTogetherActive(), + currentUserIsController = isCurrentUserControllerInListenTogether() + )) { + ListenTogetherRestoredPlaybackAction.SKIP -> false + ListenTogetherRestoredPlaybackAction.RESUME_LOCAL_PLAYBACK -> true + ListenTogetherRestoredPlaybackAction.WAIT_FOR_AUTHORITATIVE_ROOM_STATE -> { + if (consumeRestoredPlayback(restored)) { + scheduleStatePersist( + positionMs = _playbackPositionMs.value.coerceAtLeast(0L), + shouldResumePlayback = false, + debounceMs = 0L + ) + } + false + } + } +} diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlaybackFailureRecoveryOwner.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlaybackFailureRecoveryOwner.kt index 5fe1923c76..d2da6b9a74 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlaybackFailureRecoveryOwner.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/playback/PlaybackFailureRecoveryOwner.kt @@ -8,6 +8,7 @@ import moe.ouom.neriplayer.core.player.PlayerManager import moe.ouom.neriplayer.core.player.network.holdPlaybackForNetworkIfOffline import moe.ouom.neriplayer.core.player.prefetch.clearPlaybackDemandCacheKey import moe.ouom.neriplayer.core.player.url.invalidateCachedResourceForPlaybackRecovery +import moe.ouom.neriplayer.core.player.url.playbackFailureSongDescription import moe.ouom.neriplayer.data.model.playback.PlaybackCommandSource import moe.ouom.neriplayer.data.model.playback.PlayerEvent import moe.ouom.neriplayer.common.R as CoreCommonR @@ -78,7 +79,7 @@ internal val playbackFailureRecoveryOwner = PlaybackFailureRecoveryOwner( override fun reportResolutionFailure(failure: PlaybackResolutionFailure) { val songDescription = PlayerManager._currentSongFlow.value?.let { - "id=${it.id}, source=${it.album}" + playbackFailureSongDescription(it, PlayerManager.playbackRequestToken) } ?: "no current song" when (failure) { PlaybackResolutionFailure.LOGIN_REQUIRED -> { @@ -97,7 +98,7 @@ internal val playbackFailureRecoveryOwner = PlaybackFailureRecoveryOwner( PlayerManager.clearPlaybackDemandCacheKey("play_at_index_failure") NPLogger.e( "NERI-PlayerManager", - "获取播放地址失败: $songDescription" + "获取播放地址失败: LocalPlaybackTrace stage=url_failure $songDescription" ) } } diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/resolver/netease/PlayerManagerNeteaseLocalFallback.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/resolver/netease/PlayerManagerNeteaseLocalFallback.kt index aeb09ae53e..9b89b44fcf 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/resolver/netease/PlayerManagerNeteaseLocalFallback.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/resolver/netease/PlayerManagerNeteaseLocalFallback.kt @@ -29,10 +29,12 @@ internal suspend fun PlayerManager.tryResolveNeteaseMatchedLocalSource(song: Son if (candidates.isEmpty()) return null for (candidate in candidates) { + val sourceContext = localMediaSourceContext(candidate) val localMediaUri = PlayerDependencies.downloads.resolvePermittedLocalPlaybackUri( context = application, song = candidate, - rawLocalReference = localMediaSource(candidate) + rawLocalReference = sourceContext?.reference, + sourceContext = sourceContext ) ?: continue if (!isReadableLocalMediaUri(localMediaUri)) continue val playableUrl = toPlayableLocalUrl(localMediaUri) ?: localMediaUri diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/AudioPlayerService.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/AudioPlayerService.kt index c653f1c9b0..2c93697325 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/AudioPlayerService.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/AudioPlayerService.kt @@ -108,6 +108,8 @@ import moe.ouom.neriplayer.core.player.lifecycle.scheduleUsbExclusivePlaybackRes import moe.ouom.neriplayer.core.player.lifecycle.stopPlaybackAfterUsbExclusiveNativeFailure import moe.ouom.neriplayer.core.player.persistence.persistStateNow import moe.ouom.neriplayer.core.player.persistence.preloadRestoredStateSnapshot +import moe.ouom.neriplayer.core.player.persistence.scheduleRestoredPlaybackResume +import moe.ouom.neriplayer.core.player.persistence.scheduleRestoredLocalSourceValidation import moe.ouom.neriplayer.core.player.persistence.scheduleStatePersist import moe.ouom.neriplayer.core.player.playback.suppressPlaybackForAudioRouteLoss import moe.ouom.neriplayer.core.player.usb.path.UsbExclusiveAudioPathTracker @@ -198,20 +200,6 @@ internal fun shouldStopServiceForExternalPauseCommand( return stopServiceRequested && source != MEDIA_SESSION_STOP_SOURCE } -internal fun shouldUseForegroundServiceStart( - sdkInt: Int, - forceForeground: Boolean, - shouldRunPlaybackServiceInForeground: Boolean, - callerHasResumedUi: Boolean -): Boolean { - if (callerHasResumedUi) { - return false - } - return sdkInt >= Build.VERSION_CODES.O || - forceForeground || - shouldRunPlaybackServiceInForeground -} - internal fun shouldStartPlaybackWidgetActionInForeground( serviceInstanceActive: Boolean, serviceForegroundActive: Boolean, @@ -242,32 +230,6 @@ private fun Intent.usbDeviceExtra(): UsbDevice? { } } -fun canUseDirectPlaybackServiceStart( - isFinishing: Boolean, - isDestroyed: Boolean, - lifecycleState: Lifecycle.State?, - hasWindowFocus: Boolean -): Boolean { - if (isFinishing || isDestroyed) { - return false - } - return lifecycleState?.isAtLeast(Lifecycle.State.RESUMED) == true && hasWindowFocus -} - -internal fun isServiceStartNotAllowedFailure(error: Throwable): Boolean { - if (error !is IllegalStateException) { - return false - } - val simpleName = error::class.java.simpleName - if ( - simpleName == "BackgroundServiceStartNotAllowedException" || - simpleName == "ForegroundServiceStartNotAllowedException" - ) { - return true - } - return error.message?.contains("Not allowed to start service") == true -} - internal fun shouldSkipRedundantSyncServiceStart( source: String, lastSuccessfulSource: String?, @@ -275,7 +237,7 @@ internal fun shouldSkipRedundantSyncServiceStart( nowElapsedRealtime: Long, dedupeWindowMs: Long = 1500L ): Boolean { - if (source != "app_bootstrap") { + if (source != APP_BOOTSTRAP_START_SOURCE) { return false } if (lastSuccessfulStartElapsedRealtime <= 0L) { @@ -670,11 +632,22 @@ class AudioPlayerService : Service() { debounceMs = 0L, ) flushPlaybackStatsSafely("service_idle_shutdown", "idle shutdown") + stopServiceKeepingPlayerRuntime(scheduledStartId, "idle_timeout") + } + + private fun stopServiceForUnavailableRestoredSource(startId: Int) { + if (startId != latestStartId || !isEligibleForIdleShutdown()) return + stopServiceKeepingPlayerRuntime(startId, "restored_source_unavailable") + } + + private fun stopServiceKeepingPlayerRuntime(startId: Int, reason: String) { + idleShutdownCoordinator.cancel() allowServiceRestart = false + hasReceivedStartCommand = false keepPlayerRuntimeAfterServiceStop = true - stopForegroundIfStarted("idle_timeout") - if (scheduledStartId > 0) { - stopSelfResult(scheduledStartId) + stopForegroundIfStarted(reason) + if (startId > 0) { + stopSelfResult(startId) } else { stopSelf() } @@ -1413,11 +1386,17 @@ class AudioPlayerService : Service() { return START_NOT_STICKY } if (action == null) { - val restoredPlaybackPositionMs = PlayerManager.resumeRestoredPlaybackIfNeeded() - if (restoredPlaybackPositionMs != null) { + PlayerManager.scheduleRestoredPlaybackResume( + serviceScope, + onSourceUnavailable = { stopServiceForUnavailableRestoredSource(startId) } + ) { NPLogger.w("NERI-APS", "Restored playback after process restart") updateAll() } + } else if (action == ACTION_SYNC && startSource == APP_BOOTSTRAP_START_SOURCE) { + PlayerManager.scheduleRestoredLocalSourceValidation(serviceScope) { + stopServiceForUnavailableRestoredSource(startId) + } } } else if (hasPlaybackSurfaceContent()) { ensureForegroundStarted() @@ -1692,7 +1671,7 @@ class AudioPlayerService : Service() { private fun releasePlayerRuntimeAfterServiceStop(preservePlaybackForRestart: Boolean) { if (keepPlayerRuntimeAfterServiceStop) { - NPLogger.i("NERI-APS", "Keeping paused player runtime after idle service shutdown") + NPLogger.i("NERI-APS", "Keeping player runtime after service shutdown") return } releasePlayerRuntimeUnlessKept(preservePlaybackForRestart) diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/PlaybackServiceStartPolicy.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/PlaybackServiceStartPolicy.kt new file mode 100644 index 0000000000..40b1259484 --- /dev/null +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/service/PlaybackServiceStartPolicy.kt @@ -0,0 +1,46 @@ +package moe.ouom.neriplayer.core.player.service + +import android.os.Build +import androidx.lifecycle.Lifecycle + +internal const val APP_BOOTSTRAP_START_SOURCE = "app_bootstrap" + +internal fun shouldUseForegroundServiceStart( + sdkInt: Int, + forceForeground: Boolean, + shouldRunPlaybackServiceInForeground: Boolean, + callerHasResumedUi: Boolean +): Boolean { + if (callerHasResumedUi) { + return false + } + return sdkInt >= Build.VERSION_CODES.O || + forceForeground || + shouldRunPlaybackServiceInForeground +} + +fun canUseDirectPlaybackServiceStart( + isFinishing: Boolean, + isDestroyed: Boolean, + lifecycleState: Lifecycle.State?, + hasWindowFocus: Boolean +): Boolean { + if (isFinishing || isDestroyed) { + return false + } + return lifecycleState?.isAtLeast(Lifecycle.State.RESUMED) == true && hasWindowFocus +} + +internal fun isServiceStartNotAllowedFailure(error: Throwable): Boolean { + if (error !is IllegalStateException) { + return false + } + val simpleName = error::class.java.simpleName + if ( + simpleName == "BackgroundServiceStartNotAllowedException" || + simpleName == "ForegroundServiceStartNotAllowedException" + ) { + return true + } + return error.message?.contains("Not allowed to start service") == true +} diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/url/PlayerLocalPlaybackDiagnostics.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/url/PlayerLocalPlaybackDiagnostics.kt new file mode 100644 index 0000000000..40af1a5a0a --- /dev/null +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/url/PlayerLocalPlaybackDiagnostics.kt @@ -0,0 +1,65 @@ +package moe.ouom.neriplayer.core.player.url + +import moe.ouom.neriplayer.common.logging.NPLogger +import moe.ouom.neriplayer.common.logging.playbackLogReference +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.playback.storage.PlayerLocalPlaybackResolution + +internal fun playbackFailureSongDescription(song: SongItem, requestToken: Long): String = + "id=${song.id}, channel=${song.channelId}, albumId=${song.albumId}, " + + "audioIdPresent=${!song.audioId.isNullOrBlank()}, " + + "sourceStableKeyPresent=${!song.sourceStableKey.isNullOrBlank()}, " + + "mediaUri=${playbackLogReference(song.mediaUri)}, " + + "localPath=${playbackLogReference(song.localFilePath)}, requestToken=$requestToken" + +internal fun logSongUrlResolutionRequest( + song: SongItem, + forceRefresh: Boolean, + currentUrl: String?, + requestToken: Long, + stackHint: String +) { + NPLogger.d( + "NERI-PlayerManager", + "resolveSongUrl: id=${song.id}, channel=${song.channelId}, albumId=${song.albumId}, " + + "song=${song.name}, source=${song.album}, forceRefresh=$forceRefresh, " + + "requestToken=$requestToken, streamUrl=${playbackLogReference(song.streamUrl)}, " + + "currentUrl=${playbackLogReference(currentUrl)}, stack=[$stackHint]" + ) +} + +internal fun logLocalPlaybackResolution( + song: SongItem, + requestToken: Long, + rawReference: String?, + resolution: PlayerLocalPlaybackResolution +) { + val selected = (resolution as? PlayerLocalPlaybackResolution.Playable)?.reference ?: rawReference + val message = "LocalPlaybackTrace stage=resolve_result id=${song.id} channel=${song.channelId} " + + "requestToken=$requestToken result=${localPlaybackResolutionLabel(resolution)} " + + "selected=${playbackLogReference(selected)}" + if (resolution is PlayerLocalPlaybackResolution.Playable) NPLogger.d("NERI-PlayerManager", message) + else NPLogger.w("NERI-PlayerManager", message) +} + +internal fun logLocalPlaybackRetry( + song: SongItem, + resolution: PlayerLocalPlaybackResolution, + retryNumber: Int, + retryCount: Int, + delayMs: Long, + mode: String +) { + NPLogger.d( + "NERI-PlayerManager", + "LocalPlaybackTrace stage=retry id=${song.id} mode=$mode " + + "retry=$retryNumber/$retryCount delayMs=$delayMs result=${localPlaybackResolutionLabel(resolution)}" + ) +} + +private fun localPlaybackResolutionLabel(resolution: PlayerLocalPlaybackResolution): String = when (resolution) { + is PlayerLocalPlaybackResolution.Playable -> "playable" + PlayerLocalPlaybackResolution.Missing -> "missing" + PlayerLocalPlaybackResolution.NotIndexed -> "not_indexed" + is PlayerLocalPlaybackResolution.TemporarilyUnavailable -> "temporarily_unavailable" +} diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/url/PlayerLocalPlaybackRecovery.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/url/PlayerLocalPlaybackRecovery.kt new file mode 100644 index 0000000000..0ae4c6d44d --- /dev/null +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/url/PlayerLocalPlaybackRecovery.kt @@ -0,0 +1,25 @@ +package moe.ouom.neriplayer.core.player.url + +import moe.ouom.neriplayer.data.identity.recoverNeteaseRemoteSourceFromStaleLocalCopy +import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource +import moe.ouom.neriplayer.data.local.playlist.LocalPlaylistRepository +import moe.ouom.neriplayer.data.model.SongItem + +internal fun recoverNeteaseSourceAfterMissingLocal( + song: SongItem, + rawLocalReference: String?, + sourceContext: ResolvedLocalMediaSource? = null, + explicitLocalFileReference: (String) -> Boolean? = LocalPlaylistRepository::peekExplicitLocalFileReference +): SongItem? { + val reference = rawLocalReference ?: return null + val snapshotReference = LocalSongSupport.validExplicitLocalSourceReference(song) + fun explicitEvidence(candidate: String): Boolean? = + if (snapshotReference == candidate) true else explicitLocalFileReference(candidate) + + val unverifiedDeclaredReference = sourceContext?.unverifiedDeclaredReferenceFor(song, reference) + if (unverifiedDeclaredReference != null && explicitEvidence(unverifiedDeclaredReference) != false) return null + val provenanceReference = sourceContext?.anchorFor(song, reference) ?: reference + if (explicitEvidence(provenanceReference) != false) return null + return song.recoverNeteaseRemoteSourceFromStaleLocalCopy() +} diff --git a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/url/PlayerManagerUrlExtensions.kt b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/url/PlayerManagerUrlExtensions.kt index f00723451c..199468c07f 100644 --- a/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/url/PlayerManagerUrlExtensions.kt +++ b/modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/url/PlayerManagerUrlExtensions.kt @@ -67,13 +67,14 @@ import moe.ouom.neriplayer.core.player.watchdog.configureActivePlaybackCandidate import moe.ouom.neriplayer.core.player.watchdog.currentPlaybackCandidate import moe.ouom.neriplayer.core.player.watchdog.resetPlaybackProgressAdvanceBaseline import moe.ouom.neriplayer.core.player.watchdog.schedulePlaybackStartupWatchdog -import moe.ouom.neriplayer.data.identity.recoverNeteaseRemoteSourceFromStaleLocalCopy import moe.ouom.neriplayer.data.model.bilibili.playback.BiliAudioStreamInfo import moe.ouom.neriplayer.data.model.bilibili.skip.BiliVideoSkipTarget import moe.ouom.neriplayer.platform.youtube.api.transport.extractYouTubeMusicVideoId import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.common.logging.NPLogger +import moe.ouom.neriplayer.common.logging.playbackLogReference import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource import moe.ouom.neriplayer.data.model.stableKey import moe.ouom.neriplayer.data.ltw.mapping.MAX_LISTEN_TOGETHER_STREAM_URL_CANDIDATES import moe.ouom.neriplayer.core.player.ltw.toListenTogetherTrackOrNull @@ -170,9 +171,9 @@ internal suspend fun PlayerManager.resolveSongUrl( playbackRequestTokenOverride: Long? = null, shouldApplyCacheMutation: () -> Boolean = { true } ): SongUrlResult { - NPLogger.d( - "NERI-PlayerManager", - "resolveSongUrl: song=${song.name}, source=${song.album}, forceRefresh=$forceRefresh, streamUrl=${song.streamUrl}, currentUrl=${_currentMediaUrl.value}, stack=[${debugStackHint()}]" + logSongUrlResolutionRequest( + song, forceRefresh, _currentMediaUrl.value, + playbackRequestTokenOverride ?: playbackRequestToken, debugStackHint() ) val initialListenTogetherFallback = listenTogetherFallbackResult(song) val suppressListenTogetherResolverErrors = shouldSuppressListenTogetherResolverError( @@ -185,9 +186,14 @@ internal suspend fun PlayerManager.resolveSongUrl( listenerAudioLinkSharingActive = isListenTogetherAudioLinkFallbackEnabled() ) if (isLocalSong(song)) { + val sourceContext = localMediaSourceContext(song) + val rawLocalReference = sourceContext?.reference val localResolution = resolvePermittedLocalPlaybackWithRetry( song = song, - rawLocalReference = localMediaSource(song) + sourceContext = sourceContext + ) + logLocalPlaybackResolution( + song, playbackRequestTokenOverride ?: playbackRequestToken, rawLocalReference, localResolution ) if (localResolution is LocalPlaybackReferenceResolution.Playable) { val localMediaUri = localResolution.reference @@ -201,11 +207,11 @@ internal suspend fun PlayerManager.resolveSongUrl( ) } if (localResolution is LocalPlaybackReferenceResolution.Missing) { - song.recoverNeteaseRemoteSourceFromStaleLocalCopy()?.let { recoveredSong -> + recoverNeteaseSourceAfterMissingLocal(song, rawLocalReference, sourceContext)?.let { recoveredSong -> NPLogger.w( "NERI-PlayerManager", "Downloaded reference is explicitly missing, retry as Netease: " + - "song=${song.name}, stale=${localMediaSource(song)}" + "id=${song.id}, song=${song.name}, stale=${playbackLogReference(rawLocalReference)}" ) return resolveSongUrl( song = recoveredSong, @@ -221,7 +227,7 @@ internal suspend fun PlayerManager.resolveSongUrl( NPLogger.w( "NERI-PlayerManager", "Local reference is temporarily unavailable; remote fallback is blocked: " + - "song=${song.name}, resolution=$localResolution" + "id=${song.id}, song=${song.name}; see LocalPlaybackTrace for storage evidence" ) } sideEffects.emitError { @@ -1336,12 +1342,14 @@ private suspend fun PlayerManager.applyResolvedMediaItem( private suspend fun PlayerManager.resolvePermittedLocalPlaybackWithRetry( song: SongItem, - rawLocalReference: String? + sourceContext: ResolvedLocalMediaSource? ): LocalPlaybackReferenceResolution { + val rawLocalReference = sourceContext?.reference var resolution = PlayerDependencies.downloads.resolvePermittedLocalPlayback( context = application, song = song, - rawLocalReference = rawLocalReference + rawLocalReference = rawLocalReference, + sourceContext = sourceContext ) repeat(LOCAL_PLAYBACK_RESOLUTION_RETRY_COUNT) { retryIndex -> if (!shouldRetryLocalPlaybackResolution(song, resolution)) { @@ -1350,17 +1358,15 @@ private suspend fun PlayerManager.resolvePermittedLocalPlaybackWithRetry( val retryNumber = retryIndex + 1 val delayMs = (LOCAL_PLAYBACK_RESOLUTION_RETRY_DELAY_MS * retryNumber) .coerceAtMost(LOCAL_PLAYBACK_RESOLUTION_RETRY_MAX_DELAY_MS) - NPLogger.d( - "NERI-PlayerManager", - "本地播放引用暂未稳定,等待重试: song=${song.name}, " + - "retry=$retryNumber/$LOCAL_PLAYBACK_RESOLUTION_RETRY_COUNT, delayMs=$delayMs, " + - "resolution=$resolution" + logLocalPlaybackRetry( + song, resolution, retryNumber, LOCAL_PLAYBACK_RESOLUTION_RETRY_COUNT, delayMs, "direct" ) delay(delayMs.milliseconds) resolution = PlayerDependencies.downloads.resolvePermittedLocalPlayback( context = application, song = song, - rawLocalReference = rawLocalReference + rawLocalReference = rawLocalReference, + sourceContext = sourceContext ) } return resolution @@ -1446,11 +1452,8 @@ private suspend fun resolveIndexedLocalPlaybackWithRetry( val retryNumber = retryIndex + 1 val delayMs = (LOCAL_PLAYBACK_RESOLUTION_RETRY_DELAY_MS * retryNumber) .coerceAtMost(LOCAL_PLAYBACK_RESOLUTION_RETRY_MAX_DELAY_MS) - NPLogger.d( - "NERI-PlayerManager", - "索引本地播放引用暂未稳定,等待重试: song=${song.name}, " + - "retry=$retryNumber/$LOCAL_PLAYBACK_RESOLUTION_RETRY_COUNT, delayMs=$delayMs, " + - "resolution=$resolution" + logLocalPlaybackRetry( + song, resolution, retryNumber, LOCAL_PLAYBACK_RESOLUTION_RETRY_COUNT, delayMs, "indexed" ) delay(delayMs.milliseconds) resolution = PlayerDependencies.downloads.resolveIndexedLocalPlaybackReference(context, song) diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/audio/output/PlayerManagerPlaybackTransportProviderIsolationTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/audio/output/PlayerManagerPlaybackTransportProviderIsolationTest.kt new file mode 100644 index 0000000000..102b9d66e1 --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/audio/output/PlayerManagerPlaybackTransportProviderIsolationTest.kt @@ -0,0 +1,140 @@ +package moe.ouom.neriplayer.core.player.audio.output + +import android.app.Application +import android.content.ContentResolver +import android.content.UriPermission +import android.content.pm.PackageManager +import android.content.res.AssetFileDescriptor +import android.content.Intent +import android.os.Looper +import android.provider.DocumentsContract +import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.core.player.media.LocalPlaybackMediaResolver +import moe.ouom.neriplayer.core.player.policy.storage.RestorableLocalMediaState +import moe.ouom.neriplayer.data.local.media.LocalSongSupport +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.playback.RestoredPlaybackState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class PlayerManagerPlaybackTransportProviderIsolationTest { + @Test + fun `restored raw path bootstrap snapshots do not open its folder provider`() { + assertSnapshotKeepsRecoveryOffProvider("/storage/emulated/0/Music/Album/restore.mp3") + } + + @Test + fun `restored file uri bootstrap snapshots do not open its folder provider`() { + assertSnapshotKeepsRecoveryOffProvider("file:///storage/emulated/0/Music/Album/restore.mp3") + } + + @Test + fun `relative paths remain candidates without touching the provider`() { + assertSnapshotKeepsRecoveryOffProvider("unavailable-relative-music/restore.mp3") + } + + @Test + fun `absolute paths containing a colon remain candidates without touching the provider`() { + assertSnapshotKeepsRecoveryOffProvider("/storage/emulated/0/Music/Artist:Title.flac") + } + + @Test + fun `file uri scheme matching is case insensitive without touching the provider`() { + assertSnapshotKeepsRecoveryOffProvider("FILE:///storage/emulated/0/Music/Album/restore.mp3") + } + + @Test + fun `content and resource references keep their unknown restore admission without provider opens`() { + listOf("content://media/external/audio/media/551", "android.resource://moe.ouom.neriplayer/raw/restore") + .forEach { assertSnapshotKeepsRecoveryOffProvider(it, expectedState = RestorableLocalMediaState.UNKNOWN) } + } + + @Test + fun `blank unsupported and pathless file references cannot bootstrap local restored playback`() { + listOf("", " ", "https://example.invalid/audio", "file:", "file://missing") + .forEach { assertSnapshotKeepsRecoveryOffProvider(it, expectedCandidate = false) } + } + + private fun assertSnapshotKeepsRecoveryOffProvider( + reference: String, + expectedCandidate: Boolean = true, + expectedState: RestorableLocalMediaState = RestorableLocalMediaState.REVOKED + ) { + assertSame(Looper.getMainLooper(), Looper.myLooper()) + val manager = PlayerManager + val applicationField = PlayerManager::class.java.getDeclaredField("application").apply { isAccessible = true } + val previousApplication = applicationField.get(null) + val previousInitialized = manager.initialized + val previousSong = manager._currentSongFlow.value + val previousQueue = manager.currentQueueSnapshot() + val previousRestore = manager.restoredPlaybackSnapshot() + val application = mock(Application::class.java) + val resolver = mock(ContentResolver::class.java) + val permission = mock(UriPermission::class.java) + val descriptor = mock(AssetFileDescriptor::class.java) + val tree = DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", "primary:Music") + val document = DocumentsContract.buildDocumentUriUsingTree(tree, "primary:Music/Album/restore.mp3") + val providerCalls = mutableListOf() + doReturn(resolver).`when`(application).contentResolver + doReturn(PackageManager.PERMISSION_DENIED).`when`(application) + .checkUriPermission(any(), anyInt(), anyInt(), org.mockito.ArgumentMatchers.eq(Intent.FLAG_GRANT_READ_URI_PERMISSION)) + doReturn(listOf(permission)).`when`(resolver).persistedUriPermissions + doReturn(tree).`when`(permission).uri + doReturn(true).`when`(permission).isReadPermission + doAnswer { + providerCalls += "openAssetFileDescriptor" + descriptor + }.`when`(resolver).openAssetFileDescriptor(document, "r") + val song = SongItem( + id = 551L, name = "Restored local source", artist = "Test artist", album = LocalSongSupport.LOCAL_ALBUM_IDENTITY, + albumId = 0L, durationMs = 180_000L, coverUrl = null, + mediaUri = reference, localFilePath = reference + ) + assertEquals("文件夹具必须命中失效文件分支,content 则保持原有 UNKNOWN 语义", + expectedState, LocalPlaybackMediaResolver.restorableState(reference, application)) + + try { + manager.application = application + manager.initialized = true + manager.publishCurrentQueue(listOf(song), 0) + manager._currentSongFlow.value = song + manager.setRestoredPlayback(positionMs = 12_345L, shouldResume = true) + val pending = manager.restoredPlaybackSnapshot() + + // 初始启动和 Compose 的服务启动重试都只能读取内存中的恢复状态 + repeat(2) { + val snapshot = PlayerManagerPlaybackTransportPort.transportSnapshot(includeRestore = true) + assertTrue(snapshot.hasCurrentSong) + assertEquals(expectedCandidate, snapshot.restoredPlaybackCanAutoResume) + } + + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertTrue(manager.restoredShouldResumePlayback) + assertEquals(listOf(song), manager.currentQueueSnapshot().playlist) + assertEquals(0, manager.currentQueueSnapshot().currentIndex) + assertEquals("同步启动快照不得访问 DocumentsProvider", emptyList(), providerCalls) + } finally { + manager.publishCurrentQueue(previousQueue.playlist, previousQueue.currentIndex) + manager._currentSongFlow.value = previousSong + manager.initialized = previousInitialized + applicationField.set(null, previousApplication) + when (previousRestore) { + RestoredPlaybackState.None -> manager.clearRestoredPlayback() + is RestoredPlaybackState.Paused -> manager.setRestoredPlayback(previousRestore.positionMs, false) + is RestoredPlaybackState.ResumePending -> manager.setRestoredPlayback(previousRestore.positionMs, true) + } + } + } +} diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackDiagnosticsTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackDiagnosticsTest.kt new file mode 100644 index 0000000000..5c836c56a2 --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackDiagnosticsTest.kt @@ -0,0 +1,64 @@ +package moe.ouom.neriplayer.core.player.media + +import android.content.ContentResolver +import android.content.Context +import android.content.res.AssetFileDescriptor +import android.net.Uri +import android.util.Log +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class LocalPlaybackDiagnosticsTest { + private val context = mock(Context::class.java) + private val resolver = mock(ContentResolver::class.java) + private val reference = "content://provider.example/document/song.flac?token=secret#private" + + @Test + fun `provider denial reports the failed stage without exception secrets or extra probes`() { + `when`(context.contentResolver).thenReturn(resolver) + `when`(resolver.openAssetFileDescriptor(Uri.parse(reference), "r")) + .thenThrow(SecurityException("token=secret password=private")) + ShadowLog.clear() + + assertEquals(reference, LocalPlaybackMediaResolver.source(song(), context)) + + val traces = ShadowLog.getLogs().filter { it.msg.contains("LocalPlaybackTrace") } + assertTrue(traces.any { it.type == Log.WARN && it.msg.contains("stage=provider_probe") && it.msg.contains("java.lang.SecurityException") }) + assertTrue(traces.any { it.type == Log.WARN && it.msg.contains("id=42") && it.msg.contains("result=no_readable_source") }) + assertFalse(traces.any { it.msg.contains("secret") || it.msg.contains("private") }) + verify(resolver, times(1)).openAssetFileDescriptor(Uri.parse(reference), "r") + } + + @Test + fun `readable source records the selected reference without changing it`() { + `when`(context.contentResolver).thenReturn(resolver) + `when`(resolver.openAssetFileDescriptor(Uri.parse(reference), "r")) + .thenReturn(mock(AssetFileDescriptor::class.java)) + ShadowLog.clear() + + assertEquals(reference, LocalPlaybackMediaResolver.source(song(), context)) + + val traces = ShadowLog.getLogs().filter { it.msg.contains("LocalPlaybackTrace") } + assertTrue(traces.any { it.msg.contains("stage=source") && it.msg.contains("id=42") && it.msg.contains("result=readable") }) + assertFalse(traces.any { it.msg.contains("secret") || it.msg.contains("private") }) + verify(resolver, times(1)).openAssetFileDescriptor(Uri.parse(reference), "r") + } + + private fun song() = SongItem( + id = 42L, name = "Local song", artist = "Artist", album = "Named album", albumId = 0L, + durationMs = 1_000L, coverUrl = null, mediaUri = reference, channelId = "local" + ) +} diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackMediaResolverSourceConflictTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackMediaResolverSourceConflictTest.kt new file mode 100644 index 0000000000..aad7423b19 --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackMediaResolverSourceConflictTest.kt @@ -0,0 +1,117 @@ +package moe.ouom.neriplayer.core.player.media + +import android.content.ContentResolver +import android.content.Context +import android.database.MatrixCursor +import android.net.Uri +import android.provider.DocumentsContract +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class LocalPlaybackMediaResolverSourceConflictTest { + @get:Rule + val directory = TemporaryFolder() + + @Test + fun `a readable declared file cannot replace a different external storage document`() { + assertConflictingFileKeepsPreferred(DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", "primary:Music/Album/original.mp3" + )) + } + + @Test + fun `a readable declared file cannot replace a different queried MediaStore file`() { + assertConflictingFileKeepsPreferred(Uri.parse("content://media/external/audio/media/7")) + } + + @Test + fun `a readable raw file verified against MediaStore retains the preferred source anchor`() { + assertReadableAliasKeepsPreferred(fileUri = false) + } + + @Test + fun `a readable file uri verified against MediaStore retains the preferred source anchor`() { + assertReadableAliasKeepsPreferred(fileUri = true) + } + + @Test + fun `an unknown preferred provider cannot grant provenance to a readable fallback`() { + val context = mock(Context::class.java) + val resolver = mock(ContentResolver::class.java) + doReturn(resolver).`when`(context).contentResolver + val fallback = directory.newFile("unknown.mp3").apply { writeText("audio") } + val song = SongItem( + id = 7L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 180_000L, coverUrl = null, mediaUri = "content://unknown.provider/audio/7", + localFilePath = fallback.absolutePath + ) + + val source = requireNotNull(LocalPlaybackMediaResolver.sourceContext(song, context)) + + assertEquals(fallback.absolutePath, source.reference) + assertNull(source.anchorFor(song, source.reference)) + assertEquals(song.mediaUri, source.unverifiedDeclaredReferenceFor(song, source.reference)) + } + + private fun assertReadableAliasKeepsPreferred(fileUri: Boolean) { + val context = mock(Context::class.java) + val resolver = mock(ContentResolver::class.java) + doReturn(resolver).`when`(context).contentResolver + val fallback = directory.newFile("selected.mp3").apply { writeText("audio") } + val preferred = Uri.parse("content://media/external/audio/media/7") + doAnswer { + MatrixCursor(arrayOf("_data")).apply { addRow(arrayOf(fallback.absolutePath)) } + }.`when`(resolver).query(eq(preferred), any(), any(), any(), any()) + val reference = if (fileUri) Uri.fromFile(fallback).toString() else fallback.absolutePath + val song = SongItem( + id = 7L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 180_000L, coverUrl = null, mediaUri = preferred.toString(), localFilePath = reference, + channelId = "netease", audioId = "7", sourceStableKey = "7|netease|" + ) + + val source = requireNotNull(LocalPlaybackMediaResolver.sourceContext(song, context)) + + assertEquals(reference, source.reference) + assertEquals(preferred.toString(), source.anchorFor(song, source.reference)) + assertNull(source.unverifiedDeclaredReferenceFor(song, source.reference)) + assertNull(source.anchorFor(song.copy(name = "Changed song"), source.reference)) + } + + private fun assertConflictingFileKeepsPreferred(original: Uri) { + val context = mock(Context::class.java) + val resolver = mock(ContentResolver::class.java) + doReturn(resolver).`when`(context).contentResolver + doReturn(emptyList()).`when`(resolver).persistedUriPermissions + doAnswer { + MatrixCursor(arrayOf("_data")).apply { + addRow(arrayOf("/storage/emulated/0/Music/Album/original.mp3")) + } + }.`when`(resolver).query(eq(original), any(), any(), any(), any()) + val other = directory.newFile("different.mp3").apply { writeText("audio") } + val song = SongItem( + id = 7L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 180_000L, coverUrl = null, mediaUri = original.toString(), + localFilePath = other.absolutePath, channelId = "netease", audioId = "7", + sourceStableKey = "7|netease|" + ) + + val source = requireNotNull(LocalPlaybackMediaResolver.sourceContext(song, context)) + + assertEquals(original.toString(), source.reference) + assertEquals(original.toString(), source.anchorFor(song, source.reference)) + } +} diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackMediaResolverTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackMediaResolverTest.kt index 6472c58c59..ce09530ea0 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackMediaResolverTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/media/LocalPlaybackMediaResolverTest.kt @@ -5,14 +5,18 @@ import android.content.Intent import android.content.UriPermission import android.content.pm.PackageManager import android.content.res.AssetFileDescriptor +import android.database.Cursor import android.net.Uri +import android.provider.DocumentsContract import java.nio.file.Files import moe.ouom.neriplayer.core.player.policy.storage.RestorableLocalMediaState import moe.ouom.neriplayer.data.model.SongItem import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.anyInt import org.mockito.ArgumentMatchers.eq import org.mockito.Mockito.`when` @@ -86,6 +90,124 @@ class LocalPlaybackMediaResolverTest { } } + @Test + fun `folder grant recovers a media store reference without global audio access`() { + assertFolderGrantFallback("primary:Music", readGranted = true, expectedFallback = true) + } + + @Test + fun `folder fallback requires read access to the same volume and directory`() { + assertFolderGrantFallback("primary:Music", readGranted = false, expectedFallback = false) + assertFolderGrantFallback("1234-ABCD:Music", readGranted = true, expectedFallback = false) + assertFolderGrantFallback("primary:Music/AlbumOther", readGranted = true, expectedFallback = false) + } + + @Test + fun `restoring a path only song keeps audio accessible through its folder grant`() { + assertFolderGrantFallback("primary:Music", readGranted = true, expectedFallback = true, mediaStoreReference = false) + assertFolderGrantFallback("primary:Music", readGranted = false, expectedFallback = false, mediaStoreReference = false) + } + + @Test + fun `raw and file uri records recover through the same folder grant`() { + val path = "/storage/emulated/0/Music/Album/song.mp3" + listOf(path, "file://$path").forEach { reference -> + assertFolderGrantFallback("primary:Music", readGranted = true, expectedFallback = true, storedMediaReference = reference) + } + } + + @Test + fun `missing paths and invalid references never manufacture a folder source`() { + val resolver = mock(android.content.ContentResolver::class.java) + val uri = mock(Uri::class.java) + val invalidFileUri = mock(Uri::class.java) + `when`(context.contentResolver).thenReturn(resolver) + `when`(uri.scheme).thenReturn("content") + `when`(uri.authority).thenReturn("media") + `when`(invalidFileUri.scheme).thenReturn("file") + val reference = "content://media/external/audio/media/7" + mockStatic(Uri::class.java).use { uris -> + uris.`when` { Uri.parse(reference) }.thenReturn(uri) + uris.`when` { Uri.parse("file:") }.thenReturn(invalidFileUri) + uris.`when` { Uri.parse("invalid") }.thenThrow(IllegalArgumentException("bad uri")) + assertEquals(null, LocalPlaybackMediaResolver.source(song(), context)) + assertEquals(reference, LocalPlaybackMediaResolver.source(song(mediaUri = reference), context)) + assertEquals("invalid", LocalPlaybackMediaResolver.source(song(mediaUri = "invalid"), context)) + assertEquals("file:", LocalPlaybackMediaResolver.source(song(mediaUri = "file:"), context)) + } + } + + private fun assertFolderGrantFallback( + treeId: String, + readGranted: Boolean, + expectedFallback: Boolean, + mediaStoreReference: Boolean = true, + storedMediaReference: String? = null + ) { + val reference = "content://media/external/audio/media/7" + val recoveredReference = "content://com.android.externalstorage.documents/tree/primary%3AMusic/document/primary%3AMusic%2FAlbum%2Fsong.mp3" + val mediaUri = mock(Uri::class.java) + val treeUri = mock(Uri::class.java) + val documentUri = mock(Uri::class.java) + val resolver = mock(android.content.ContentResolver::class.java) + val permission = mock(UriPermission::class.java) + val descriptor = mock(AssetFileDescriptor::class.java) + val fileUri = mock(Uri::class.java) + val cursor = mock(Cursor::class.java) + `when`(context.contentResolver).thenReturn(resolver) + `when`(resolver.persistedUriPermissions).thenReturn(listOf(permission)) + `when`(permission.uri).thenReturn(treeUri) + `when`(permission.isReadPermission).thenReturn(readGranted) + `when`(treeUri.authority).thenReturn("com.android.externalstorage.documents") + `when`(mediaUri.scheme).thenReturn("content") + `when`(mediaUri.authority).thenReturn("media") + `when`(mediaUri.pathSegments).thenReturn(listOf("external", "audio", "media", "7")) + `when`(documentUri.scheme).thenReturn("content") + `when`(documentUri.toString()).thenReturn(recoveredReference) + `when`(fileUri.scheme).thenReturn("file") + `when`(fileUri.path).thenReturn("/storage/emulated/0/Music/Album/song.mp3") + `when`(resolver.openAssetFileDescriptor(mediaUri, "r")).thenThrow(SecurityException("audio permission denied")) + `when`(resolver.openAssetFileDescriptor(documentUri, "r")).thenReturn(descriptor) + `when`(resolver.query(eq(mediaUri), any(), any(), any(), any())).thenReturn(cursor) + `when`(cursor.moveToFirst()).thenReturn(true) + `when`(cursor.getColumnIndex("_data")).thenReturn(0) + `when`(cursor.getString(0)).thenReturn("/storage/emulated/0/Music/Album/song.mp3") + + mockStatic(Uri::class.java).use { uris -> + mockStatic(DocumentsContract::class.java).use { documents -> + uris.`when` { Uri.parse(reference) }.thenReturn(mediaUri) + uris.`when` { Uri.parse(recoveredReference) }.thenReturn(documentUri) + uris.`when` { Uri.parse("file:///storage/emulated/0/Music/Album/song.mp3") }.thenReturn(fileUri) + documents.`when` { DocumentsContract.getTreeDocumentId(treeUri) }.thenReturn(treeId) + documents.`when` { + DocumentsContract.buildDocumentUriUsingTree(treeUri, "primary:Music/Album/song.mp3") + }.thenReturn(documentUri) + + val song = song( + localFilePath = "/storage/emulated/0/Music/Album/song.mp3".takeIf { storedMediaReference == null }, + mediaUri = storedMediaReference ?: reference.takeIf { mediaStoreReference } + ) + assertEquals( + if (expectedFallback) recoveredReference else song.mediaUri ?: song.localFilePath, + LocalPlaybackMediaResolver.source(song, context) + ) + val sourceContext = requireNotNull(LocalPlaybackMediaResolver.sourceContext(song, context)) + assertEquals( + if (expectedFallback) recoveredReference else song.mediaUri ?: song.localFilePath, + sourceContext.reference + ) + assertEquals(song.mediaUri ?: song.localFilePath, sourceContext.anchorFor(song, sourceContext.reference)) + assertNull(sourceContext.anchorFor(song.copy(id = song.id + 1), sourceContext.reference)) + if (!mediaStoreReference || storedMediaReference != null) { + assertEquals( + if (expectedFallback) listOf(song) else emptyList(), + LocalPlaybackMediaResolver.sanitizeRestoredPlaylist(listOf(song), context) + ) + } + } + } + } + @Test fun `content permission determines restorable state without requiring open descriptor`() { val reference = "content://provider.example/document/audio" diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PersistedLocalSourceProvenanceTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PersistedLocalSourceProvenanceTest.kt new file mode 100644 index 0000000000..a9a90d1c81 --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PersistedLocalSourceProvenanceTest.kt @@ -0,0 +1,80 @@ +package moe.ouom.neriplayer.core.player.persistence + +import com.google.gson.Gson +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.playback.PersistedSongItem +import moe.ouom.neriplayer.data.model.playback.PersistedState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PersistedLocalSourceProvenanceTest { + private val gson = Gson() + private val preferred = "content://media/external/audio/media/7" + private val song = SongItem( + id = 7L, name = "Scanned", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 180_000L, coverUrl = null, mediaUri = preferred, + localFilePath = "/storage/emulated/0/Music/scanned.flac", localFileName = "scanned.flac", + channelId = "netease", audioId = "7", explicitLocalSourceReference = preferred + ) + + @Test + fun `queue persistence retains only the selected explicit local source beside remote identity`() { + listOf( + song, + song.copy(mediaUri = null, explicitLocalSourceReference = song.localFilePath), + song.copy(mediaUri = "file:///storage/emulated/0/Music/scanned.flac", localFilePath = null, + explicitLocalSourceReference = "file:///storage/emulated/0/Music/scanned.flac") + ).forEach { selected -> + val restored = selected.toPersistedSongItem(includeLyrics = false).toSongItem() + + assertEquals(selected.explicitLocalSourceReference, restored.explicitLocalSourceReference) + assertEquals(selected.mediaUri, restored.mediaUri) + assertEquals(selected.localFilePath, restored.localFilePath) + assertEquals(selected.channelId, restored.channelId) + assertEquals(selected.audioId, restored.audioId) + } + } + + @Test + fun `serialized main and shuffle queues preserve explicit sources after reconstruction`() { + val state = PersistedState( + playlist = listOf(song.toPersistedSongItem()), index = 0, positionMs = 1_500L, + shouldResumePlayback = true, shuffleEnabled = true, + shuffleRestorePlaylist = listOf(song.toPersistedSongItem()), shuffleRestoreIndex = 0 + ) + + val restored = gson.fromJson(gson.toJson(state), PersistedState::class.java) + + assertEquals(preferred, restored.playlist.single().toSongItem().explicitLocalSourceReference) + assertEquals(preferred, restored.shuffleRestorePlaylist?.single()?.toSongItem()?.explicitLocalSourceReference) + assertEquals(1_500L, restored.positionMs) + assertEquals(true, restored.shouldResumePlayback) + } + + @Test + fun `legacy queue JSON does not infer explicit selection from local paths`() { + val payload = gson.toJsonTree(song.toPersistedSongItem()).asJsonObject.apply { + remove("explicitLocalSourceReference") + } + + val restored = gson.fromJson(payload, PersistedSongItem::class.java).toSongItem() + + assertNull(restored.explicitLocalSourceReference) + assertEquals(song.mediaUri, restored.mediaUri) + assertEquals(song.localFilePath, restored.localFilePath) + } + + @Test + fun `stale alternate and remote markers cannot acquire explicit queue provenance`() { + listOf(song.localFilePath, "content://media/external/audio/media/8", "https://example.org/song.flac", " ") + .forEach { marker -> + val selected = song.copy(explicitLocalSourceReference = marker) + assertNull(selected.toPersistedSongItem().toSongItem().explicitLocalSourceReference) + val payload = gson.toJsonTree(song.toPersistedSongItem()).asJsonObject.apply { + addProperty("explicitLocalSourceReference", marker) + } + assertNull(gson.fromJson(payload, PersistedSongItem::class.java).toSongItem().explicitLocalSourceReference) + } + } +} diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlaybackQueueLegacyStoreSnapshotTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlaybackQueueLegacyStoreSnapshotTest.kt index b0e5f5c854..f2aa01760e 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlaybackQueueLegacyStoreSnapshotTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlaybackQueueLegacyStoreSnapshotTest.kt @@ -2,6 +2,7 @@ package moe.ouom.neriplayer.core.player.persistence import com.google.gson.Gson import java.io.File +import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.data.model.music.MusicPlatform import moe.ouom.neriplayer.data.model.playback.PersistedPlaybackState import moe.ouom.neriplayer.data.model.playback.PersistedSongItem @@ -78,6 +79,28 @@ class PlaybackQueueLegacyStoreSnapshotTest { assertEquals(42_000L, restored?.positionMs) } + @Test + fun `legacy disk snapshots retain explicit sources while overlaying the saved position`() { + val reference = "content://media/external/audio/media/7" + val scanned = SongItem( + id = 7L, name = "Scanned", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 180_000L, coverUrl = null, mediaUri = reference, + localFilePath = "/storage/emulated/0/Music/scanned.flac", channelId = "netease", audioId = "7", + explicitLocalSourceReference = reference + ) + val state = queue.copy(playlist = listOf(scanned.toPersistedSongItem())) + stateFile.writeText(gson.toJson(state)) + playbackFile.writeText(gson.toJson(playback)) + + val restored = requireNotNull(store.read()) + + assertEquals(reference, restored.playlist.single().toSongItem().explicitLocalSourceReference) + assertEquals(scanned.mediaUri, restored.playlist.single().mediaUri) + assertEquals(scanned.localFilePath, restored.playlist.single().localFilePath) + assertEquals(42_000L, restored.positionMs) + assertEquals(true, restored.shouldResumePlayback) + } + @Test fun `an unreadable playback snapshot falls back to the queue snapshot`() { stateFile.writeText(gson.toJson(queue)) diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlaybackQueueRoomStoreRobolectricTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlaybackQueueRoomStoreRobolectricTest.kt index 088ee60aec..2fd8191dd1 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlaybackQueueRoomStoreRobolectricTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlaybackQueueRoomStoreRobolectricTest.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.runBlocking import moe.ouom.neriplayer.data.local.database.NeriUserDataDatabase import moe.ouom.neriplayer.data.local.database.entity.PLAYBACK_QUEUE_MAIN import moe.ouom.neriplayer.data.model.music.MusicPlatform +import moe.ouom.neriplayer.data.model.SongItem import moe.ouom.neriplayer.data.model.playback.PersistedPlaybackState import moe.ouom.neriplayer.data.model.playback.PersistedSongItem import moe.ouom.neriplayer.data.model.playback.PersistedState @@ -71,6 +72,45 @@ class PlaybackQueueRoomStoreRobolectricTest { assertEquals(20L, snapshot?.updatedAt) } + @Test + fun `room main and shuffle queues retain explicit sources across incremental playback updates`() = runBlocking { + val reference = "content://media/external/audio/media/7" + val scanned = SongItem( + id = 7L, name = "Scanned", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 180_000L, coverUrl = null, mediaUri = reference, + localFilePath = "/storage/emulated/0/Music/scanned.flac", channelId = "netease", audioId = "7", + explicitLocalSourceReference = reference + ) + store.replaceSnapshot(PersistedState( + playlist = listOf(scanned.toPersistedSongItem()), index = 0, shuffleEnabled = true, + shuffleRestorePlaylist = listOf(scanned.toPersistedSongItem()), shuffleRestoreIndex = 0 + ), now = 21L) + store.updatePlaybackState(PersistedPlaybackState(index = 0, positionMs = 1_500L, + shouldResumePlayback = true, shuffleEnabled = true), now = 22L) + + val restored = requireNotNull(store.readIfRoomPrimary()) + + assertEquals(reference, restored.playlist.single().toSongItem().explicitLocalSourceReference) + assertEquals(reference, restored.shuffleRestorePlaylist?.single()?.toSongItem()?.explicitLocalSourceReference) + assertEquals(1_500L, restored.positionMs) + assertTrue(restored.shouldResumePlayback) + } + + @Test + fun `room drops stale explicit markers on both write and read boundaries`() = runBlocking { + val reference = "content://media/external/audio/media/7" + val stale = "content://media/external/audio/media/8" + val selected = song(7L, null).copy(mediaUri = reference, explicitLocalSourceReference = stale) + + store.replaceSnapshot(PersistedState(playlist = listOf(selected), index = 0), now = 23L) + + assertNull(store.readIfRoomPrimary()?.playlist?.single()?.explicitLocalSourceReference) + val dao = database.playbackQueueDao() + assertNull(dao.getSongs(PLAYBACK_QUEUE_MAIN).single().explicitLocalSourceReference) + dao.upsertSongs(dao.getSongs(PLAYBACK_QUEUE_MAIN).map { it.copy(explicitLocalSourceReference = stale) }) + assertNull(store.readIfRoomPrimary()?.playlist?.single()?.explicitLocalSourceReference) + } + @Test fun `playback updates keep the shuffle restore index only while shuffle stays on`() = runBlocking { store.replaceSnapshot( diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalMetadataEditRegressionTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalMetadataEditRegressionTest.kt index 10bd76dfe9..e1c160f0ff 100644 --- a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalMetadataEditRegressionTest.kt +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerManagerLocalMetadataEditRegressionTest.kt @@ -1,9 +1,15 @@ package moe.ouom.neriplayer.core.player.persistence import android.app.Application +import android.content.ContentResolver +import android.content.UriPermission +import android.content.res.AssetFileDescriptor +import android.database.MatrixCursor import android.graphics.Bitmap import android.graphics.Color +import android.net.Uri import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract import android.system.Os import android.system.StructStat import androidx.test.core.app.ApplicationProvider @@ -25,6 +31,7 @@ import moe.ouom.neriplayer.data.local.audioimport.LocalAudioImportManager import moe.ouom.neriplayer.data.local.media.curation.LocalCoverBaselineInspector import moe.ouom.neriplayer.data.local.media.LocalMediaSupport import moe.ouom.neriplayer.data.local.media.LocalMediaMetadataWriteOutcome +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource import moe.ouom.neriplayer.data.local.media.source.CrashLogCleanup import moe.ouom.neriplayer.data.local.media.source.LocalMediaCoverAccess import moe.ouom.neriplayer.data.local.media.source.LocalMediaDownloadAccess @@ -47,7 +54,9 @@ import org.junit.runner.RunWith import org.mockito.Answers import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.eq import org.mockito.Mockito.CALLS_REAL_METHODS +import org.mockito.Mockito.doAnswer import org.mockito.Mockito.doReturn import org.mockito.Mockito.mock import org.mockito.Mockito.mockStatic @@ -87,6 +96,8 @@ class PlayerManagerLocalMetadataEditRegressionTest { private lateinit var application: Application private var lyricProbeFailure: RuntimeException? = null private var lyricProbeCount = 0 + private var denyLocalPlayback = false + private var editSourceContext: ResolvedLocalMediaSource? = null @Before fun setUp() { @@ -97,7 +108,10 @@ class PlayerManagerLocalMetadataEditRegressionTest { `when`(repositories.playlistUsageRepo).thenReturn(mock(PlaylistUsageRepository::class.java)) val downloads = mock(PlayerDownloadAccess::class.java, Answer { invocation -> when (invocation.method.name) { - "resolvePermittedLocalPlaybackUri" -> invocation.getArgument(2) + "resolvePermittedLocalPlaybackUri" -> { + editSourceContext = invocation.getArgument(3) + if (denyLocalPlayback) null else invocation.getArgument(2) + } "downloadedSongs" -> emptyList() else -> Answers.RETURNS_DEFAULTS.answer(invocation) } @@ -140,6 +154,56 @@ class PlayerManagerLocalMetadataEditRegressionTest { persistenceGenerationField.set(manager.statePersistenceCoordinator, previousPersistenceGeneration) } + @Test + fun `local metadata editing uses a readable source instead of an inaccessible media store reference`() = runTest { + val audio = temporaryFolder.newFile("editable-source.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy(mediaUri = "content://media/external/audio/media/7") + + val writable = manager.resolveLocalSidecarWriteSong(song) + + assertEquals(audio.absolutePath, writable?.mediaUri) + assertEquals(audio.absolutePath, writable?.localFilePath) + assertEquals(song.id, writable?.id) + assertEquals(song.localFileName, writable?.localFileName) + } + + @Test + fun `local metadata editing keeps the completion gate for a recovered source`() = runTest { + val audio = temporaryFolder.newFile("blocked-editable-source.mp3").apply { writeText("audio") } + val song = localSong(audio.path).copy(mediaUri = "content://media/external/audio/media/7") + denyLocalPlayback = true + + assertNull(manager.resolveLocalSidecarWriteSong(song)) + } + + @Test + fun `local metadata editing carries verified folder provenance through the download host`() = runTest { + val original = "content://media/external/audio/media/7" + val song = localSong("/storage/emulated/0/Music/Album/song.mp3").copy( + mediaUri = original, channelId = "netease", audioId = "7", sourceStableKey = "7|netease|" + ) + val tree = DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", "primary:Music") + val document = DocumentsContract.buildDocumentUriUsingTree(tree, "primary:Music/Album/song.mp3") + val resolver = mock(ContentResolver::class.java) + val permission = mock(UriPermission::class.java) + doReturn(resolver).`when`(application).contentResolver + doReturn(listOf(permission)).`when`(resolver).persistedUriPermissions + doReturn(tree).`when`(permission).uri + doReturn(true).`when`(permission).isReadPermission + doReturn(mock(AssetFileDescriptor::class.java)).`when`(resolver).openAssetFileDescriptor(document, "r") + doAnswer { + MatrixCursor(arrayOf("_data")).apply { addRow(arrayOf(song.localFilePath)) } + }.`when`(resolver).query(eq(Uri.parse(original)), any(), any(), any(), any()) + + val writable = manager.resolveLocalSidecarWriteSong(song) + + assertEquals(document.toString(), writable?.mediaUri) + val sourceContext = requireNotNull(editSourceContext) + assertEquals(document.toString(), sourceContext.reference) + assertEquals(original, sourceContext.anchorFor(song, sourceContext.reference)) + assertNull(sourceContext.anchorFor(song.copy(id = 8L), sourceContext.reference)) + } + @Test fun `later cover replacement preserves a no-artwork baseline loaded from metadata`() = runTest { val audio = temporaryFolder.newFile("song.mp3").apply { writeText("audio") } @@ -156,12 +220,14 @@ class PlayerManagerLocalMetadataEditRegressionTest { ) manager.publishCurrentQueue(listOf(song), 0) - assertTrue(manager.updateSongCustomInfoImpl( - originalSong = song, - customCoverUrl = newCover.toURI().toString(), - customName = null, - customArtist = null - )) + withRegularFileDescriptors { + assertTrue(manager.updateSongCustomInfoImpl( + originalSong = song, + customCoverUrl = newCover.toURI().toString(), + customName = null, + customArtist = null + )) + } assertEquals("", manager.currentPlaylist.single().originalCoverUrl) assertEquals("", JSONObject(File(audio.path + ".npmeta.json").readText()).getString("originalCoverUrl")) @@ -552,7 +618,7 @@ class PlayerManagerLocalMetadataEditRegressionTest { ) private suspend fun withConfirmedCoverAbsence(confirmed: Boolean, block: suspend () -> Unit) = - withContext(Dispatchers.IO) { + withRegularFileDescriptors { mockStatic(LocalCoverBaselineInspector::class.java, Answer { invocation -> if (invocation.method.name == "hasConfirmedNoOriginalCover") confirmed else invocation.callRealMethod() diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerRestoredPlaybackResumeAdmissionTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerRestoredPlaybackResumeAdmissionTest.kt new file mode 100644 index 0000000000..ec97ab19ca --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/persistence/PlayerRestoredPlaybackResumeAdmissionTest.kt @@ -0,0 +1,989 @@ +package moe.ouom.neriplayer.core.player.persistence + +import android.app.Application +import android.content.ContentResolver +import android.content.UriPermission +import android.content.res.AssetFileDescriptor +import android.os.Looper +import android.provider.DocumentsContract +import java.io.FileNotFoundException +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withContext +import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.core.player.host.PlayerDependencies +import moe.ouom.neriplayer.core.player.host.PlayerDownloadAccess +import moe.ouom.neriplayer.core.player.host.PlayerEnvironment +import moe.ouom.neriplayer.core.player.host.PlayerListenTogetherAccess +import moe.ouom.neriplayer.core.player.host.PlayerPresentationHost +import moe.ouom.neriplayer.core.player.host.PlayerRepositoryDependencies +import moe.ouom.neriplayer.core.player.playback.playAtIndex +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.ltw.room.ListenTogetherRoomState +import moe.ouom.neriplayer.data.model.ltw.session.ListenTogetherSessionState +import moe.ouom.neriplayer.data.model.playback.RestoredPlaybackState +import moe.ouom.neriplayer.data.model.playback.storage.PlayerLocalPlaybackResolution +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.Answers +import org.mockito.stubbing.Answer +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +@OptIn(ExperimentalCoroutinesApi::class) +class PlayerRestoredPlaybackResumeAdmissionTest { + @get:Rule + val directory = TemporaryFolder() + + @Test + fun `folder recovery runs on IO before consuming progress and starting playback on main`() = withFixture { + assertEquals(PositionMs, manager.resumeRestoredPlaybackIfNeeded()) + + assertTrue(providerThreads.isNotEmpty()) + assertTrue(providerThreads.none { it }) + assertEquals(listOf(true), playbackThreads) + assertSame(RestoredPlaybackState.None, manager.restoredPlaybackSnapshot()) + assertEquals(listOf(song), manager.currentQueueSnapshot().playlist) + } + + @Test + fun `an IO caller still commits restored playback on the main dispatcher`() = withFixture { + assertEquals(PositionMs, withContext(Dispatchers.IO) { manager.resumeRestoredPlaybackIfNeeded() }) + + assertTrue(providerThreads.none { it }) + assertEquals(listOf(true), playbackThreads) + assertSame(RestoredPlaybackState.None, manager.restoredPlaybackSnapshot()) + } + + @Test + fun `remote restored playback does not inspect the local provider`() = withFixture { + val remote = song.copy(localFilePath = null, mediaUri = null) + manager.publishCurrentQueue(listOf(remote), 0) + manager._currentSongFlow.value = remote + + assertEquals(PositionMs, manager.resumeRestoredPlaybackIfNeeded()) + + assertTrue(providerThreads.isEmpty()) + assertEquals(listOf(true), playbackThreads) + } + + @Test + fun `unreadable provider keeps progress pending and can resume after authorization recovers`() = withFixture { + readable.set(false) + val pending = manager.restoredPlaybackSnapshot() + + assertNull(manager.resumeRestoredPlaybackIfNeeded()) + + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertEquals(PositionMs, manager._playbackPositionMs.value) + assertEquals(listOf(song), manager.currentQueueSnapshot().playlist) + assertTrue(playbackThreads.isEmpty()) + + readable.set(true) + assertEquals(PositionMs, manager.resumeRestoredPlaybackIfNeeded()) + assertEquals(1, playbackThreads.size) + } + + @Test + fun `provider permission failure does not consume the pending restore`() = withFixture { + permissionFailure.set(SecurityException("grant unavailable")) + val pending = manager.restoredPlaybackSnapshot() + + assertNull(manager.resumeRestoredPlaybackIfNeeded()) + + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertEquals(PositionMs, manager._playbackPositionMs.value) + assertTrue(playbackThreads.isEmpty()) + } + + @Test + fun `cancelling a blocked provider probe does not consume the pending restore`() = withFixture { + val barrier = ProbeBarrier() + beforeOpen = barrier::awaitRelease + val pending = manager.restoredPlaybackSnapshot() + val request = async(start = CoroutineStart.UNDISPATCHED) { manager.resumeRestoredPlaybackIfNeeded() } + try { + barrier.entered.await() + request.cancel() + } finally { + barrier.release.countDown() + } + request.cancelAndJoin() + + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertEquals(PositionMs, manager._playbackPositionMs.value) + assertTrue(playbackThreads.isEmpty()) + } + + @Test + fun `a manual playback request during the probe rejects the old restore token`() = withFixture { + assertRejectedAfterProbe { manager.playbackRequestToken += 1 } + } + + @Test + fun `replacing the same song identity during the probe rejects the old source snapshot`() = withFixture { + assertRejectedAfterProbe { + val replacement = song.copy(mediaUri = "/storage/emulated/0/Music/Other/replacement.mp3") + manager.publishCurrentQueue(listOf(replacement), 0) + manager._currentSongFlow.value = replacement + } + } + + @Test + fun `changing queue index during the probe rejects the old queue selection`() = withFixture { + assertRejectedAfterProbe { + manager.publishCurrentQueue(listOf(song, song.copy(id = 552L)), 1) + } + } + + @Test + fun `pausing during the probe preserves the paused restore instead of resuming it`() = withFixture { + assertRejectedAfterProbe { manager.suppressRestoredAutoResume() } + assertEquals(RestoredPlaybackState.Paused(PositionMs), manager.restoredPlaybackSnapshot()) + } + + @Test + fun `an equal replacement restore state cannot be consumed by the old probe`() = withFixture { + assertRejectedAfterProbe { manager.setRestoredPlayback(PositionMs, true) } + assertEquals(RestoredPlaybackState.ResumePending(PositionMs), manager.restoredPlaybackSnapshot()) + } + + @Test + fun `release during the probe prevents playback admission`() = withFixture { + assertRejectedAfterProbe { manager.initialized = false } + } + + @Test + fun `concurrent resume requests consume the same pending restore only once`() = withFixture { + val barrier = ProbeBarrier(expectedProbes = 2) + beforeOpen = barrier::awaitRelease + val first = async(start = CoroutineStart.UNDISPATCHED) { manager.resumeRestoredPlaybackIfNeeded() } + val second = async(start = CoroutineStart.UNDISPATCHED) { manager.resumeRestoredPlaybackIfNeeded() } + try { + barrier.entered.await() + } finally { + barrier.release.countDown() + } + val results = listOf(first.await(), second.await()) + + assertEquals(1, results.count { it == PositionMs }) + assertEquals(1, results.count { it == null }) + assertEquals(1, playbackThreads.size) + } + + @Test + fun `an existing room listener defers immediately without probing the local provider`() = withFixture { + session.value = ListenTogetherSessionState(roomId = "room", role = "listener") + + assertNull(manager.resumeRestoredPlaybackIfNeeded()) + + assertTrue(providerThreads.isEmpty()) + assertTrue(playbackThreads.isEmpty()) + assertSame(RestoredPlaybackState.None, manager.restoredPlaybackSnapshot()) + assertEquals(listOf(false), persistedResumeIntent) + } + + @Test + fun `becoming a room listener during the probe defers playback and persists a paused intent`() = withFixture { + val barrier = ProbeBarrier() + beforeOpen = barrier::awaitRelease + val request = async(start = CoroutineStart.UNDISPATCHED) { manager.resumeRestoredPlaybackIfNeeded() } + try { + barrier.entered.await() + session.value = ListenTogetherSessionState(roomId = "room", role = "listener") + } finally { + barrier.release.countDown() + } + + assertNull(request.await()) + assertTrue(playbackThreads.isEmpty()) + assertSame(RestoredPlaybackState.None, manager.restoredPlaybackSnapshot()) + assertEquals(listOf(false), persistedResumeIntent) + } + + @Test + fun `the service scheduling entry reports only an admitted restore`() = withFixture { + scheduleResume().join() + + assertEquals(1, resumedCallbacks) + assertEquals(1, playbackThreads.size) + assertTrue(sourceUnavailableThreads.isEmpty()) + } + + @Test + fun `the service scheduling entry reports unavailable once and allows an authorization retry`() = withFixture { + readable.set(false) + assertUnavailableKeepsPending() + + readable.set(true) + scheduleResume().join() + + assertEquals(listOf(true), sourceUnavailableThreads) + assertEquals(1, resumedCallbacks) + assertEquals(1, playbackThreads.size) + assertSame(RestoredPlaybackState.None, manager.restoredPlaybackSnapshot()) + assertTrue(persistedResumeIntent.isEmpty()) + } + + @Test + fun `a service restore reports lost provider permission without changing its pending state`() = withFixture { + permissionFailure.set(SecurityException("grant unavailable")) + + assertUnavailableKeepsPending() + } + + @Test + fun `a service restore reports a provider exception without changing its pending state`() = withFixture { + permissionFailure.set(IllegalStateException("provider unavailable")) + + assertUnavailableKeepsPending() + } + + @Test + fun `a failed old service probe cannot notify after a newer playback request`() = withFixture { + assertRejectedAfterProbe(unavailable = true) { manager.playbackRequestToken += 1 } + } + + @Test + fun `a failed old service probe cannot notify after the selected queue index changes`() = withFixture { + assertRejectedAfterProbe(unavailable = true) { + manager.publishCurrentQueue(listOf(song, song.copy(id = 552L)), 1) + } + } + + @Test + fun `a failed old service probe cannot notify after its queued source changes`() = withFixture { + assertRejectedAfterProbe(unavailable = true) { + val replacement = song.copy(localFilePath = "/storage/emulated/0/Music/Other/replacement.mp3") + manager.publishCurrentQueue(listOf(replacement), 0) + manager._currentSongFlow.value = replacement + } + } + + @Test + fun `a failed old service probe cannot notify after only the current song changes`() = withFixture { + assertRejectedAfterProbe(unavailable = true) { + manager._currentSongFlow.value = song.copy(name = "New current song") + } + } + + @Test + fun `a failed old service probe cannot notify for an equal replacement pending state`() = withFixture { + assertRejectedAfterProbe(unavailable = true) { manager.setRestoredPlayback(PositionMs, true) } + } + + @Test + fun `a failed service probe cannot notify after the user pauses`() = withFixture { + assertRejectedAfterProbe(unavailable = true) { manager.suppressRestoredAutoResume() } + assertEquals(RestoredPlaybackState.Paused(PositionMs), manager.restoredPlaybackSnapshot()) + } + + @Test + fun `a failed service probe cannot notify after the player is released`() = withFixture { + assertRejectedAfterProbe(unavailable = true) { manager.initialized = false } + } + + @Test + fun `joining a listener room during a failed probe defers without an unavailable notification`() = withFixture { + readable.set(false) + val barrier = ProbeBarrier() + beforeOpen = barrier::awaitRelease + val request = scheduleResume() + try { + barrier.entered.await() + session.value = ListenTogetherSessionState(roomId = "room", role = "listener") + } finally { + barrier.release.countDown() + } + request.join() + + assertTrue(sourceUnavailableThreads.isEmpty()) + assertEquals(0, resumedCallbacks) + assertTrue(playbackThreads.isEmpty()) + assertSame(RestoredPlaybackState.None, manager.restoredPlaybackSnapshot()) + assertEquals(listOf(false), persistedResumeIntent) + } + + @Test + fun `cancelling a failed service probe cannot notify that its source is unavailable`() = withFixture { + readable.set(false) + val barrier = ProbeBarrier() + beforeOpen = barrier::awaitRelease + val pending = manager.restoredPlaybackSnapshot() + val request = scheduleResume() + try { + barrier.entered.await() + request.cancel() + } finally { + barrier.release.countDown() + } + request.cancelAndJoin() + + assertTrue(sourceUnavailableThreads.isEmpty()) + assertEquals(0, resumedCallbacks) + assertTrue(playbackThreads.isEmpty()) + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertEquals(listOf(song), manager.currentQueueSnapshot().playlist) + assertEquals(song, manager._currentSongFlow.value) + assertEquals(PositionMs, manager._playbackPositionMs.value) + assertTrue(persistedResumeIntent.isEmpty()) + } + + @Test + fun `absent paused and unselected queues do not probe the provider`() = withFixture { + manager.clearRestoredPlayback() + assertNull(manager.resumeRestoredPlaybackIfNeeded()) + scheduleResume().join() + manager.setRestoredPlayback(PositionMs, false) + assertNull(manager.resumeRestoredPlaybackIfNeeded()) + scheduleResume().join() + manager.setRestoredPlayback(PositionMs, true) + manager.publishCurrentQueue(emptyList(), -1) + assertNull(manager.resumeRestoredPlaybackIfNeeded()) + scheduleResume().join() + + assertTrue(providerThreads.isEmpty()) + assertTrue(playbackThreads.isEmpty()) + assertTrue(sourceUnavailableThreads.isEmpty()) + assertEquals(0, resumedCallbacks) + } + + @Test + fun `bootstrap source validation checks readable media on IO without resuming or consuming it`() = withFixture { + val pending = manager.restoredPlaybackSnapshot() + val persistedAt = manager.lastStatePersistAtMs + + scheduleValidation().join() + + assertTrue(providerThreads.isNotEmpty()) + assertTrue(providerThreads.none { it }) + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertEquals(PositionMs, manager._playbackPositionMs.value) + assertEquals(listOf(song), manager.currentQueueSnapshot().playlist) + assertEquals(song, manager.currentSongFlow.value) + assertEquals(persistedAt, manager.lastStatePersistAtMs) + assertTrue(sourceUnavailableThreads.isEmpty()) + assertTrue(playbackThreads.isEmpty()) + assertTrue(persistedResumeIntent.isEmpty()) + } + + @Test + fun `bootstrap source validation reports unavailable media without changing pending progress`() = withFixture { + readable.set(false) + + assertUnavailableKeepsPending { scheduleValidation() } + } + + @Test + fun `bootstrap source validation reports an unreadable file URI without changing pending progress`() = withFixture { + readable.set(false) + val fileSong = song.copy(localFilePath = "file://${song.localFilePath}") + manager.publishCurrentQueue(listOf(fileSong), 0) + manager._currentSongFlow.value = fileSong + + assertUnavailableKeepsPending { scheduleValidation() } + } + + @Test + fun `bootstrap source validation reports permission failures without consuming the pending intent`() = withFixture { + permissionFailure.set(SecurityException("grant unavailable")) + + assertUnavailableKeepsPending { scheduleValidation() } + } + + @Test + fun `bootstrap source validation reports provider exceptions without consuming the pending intent`() = withFixture { + permissionFailure.set(IllegalStateException("provider unavailable")) + + assertUnavailableKeepsPending { scheduleValidation() } + } + + @Test + fun `bootstrap source validation dispatches its unavailable callback on main even from an IO scope`() = withFixture { + readable.set(false) + + assertUnavailableKeepsPending { scheduleValidation(CoroutineScope(coroutineContext + Dispatchers.IO)) } + + assertTrue(providerThreads.isNotEmpty()) + assertTrue(providerThreads.none { it }) + } + + @Test + fun `bootstrap validation can recheck authorization without automatically playing readable media`() = withFixture { + readable.set(false) + assertUnavailableKeepsPending { scheduleValidation() } + val pending = manager.restoredPlaybackSnapshot() + + readable.set(true) + scheduleValidation().join() + + assertEquals(listOf(true), sourceUnavailableThreads) + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertEquals(PositionMs, manager._playbackPositionMs.value) + assertTrue(playbackThreads.isEmpty()) + assertTrue(persistedResumeIntent.isEmpty()) + } + + @Test + fun `bootstrap validation opens granted content before treating it as available`() { + contentOpenFailures().forEach { failure -> + withFixture { + selectGrantedContentSource() + readable.set(false) + openFailure.set(failure) + + assertUnavailableKeepsPending { scheduleValidation() } + + assertContentOpenedOnIO() + } + } + } + + @Test + fun `restored resume preserves granted content progress when the document cannot open`() { + contentOpenFailures().forEach { failure -> + withFixture { + selectGrantedContentSource() + readable.set(false) + openFailure.set(failure) + + assertUnavailableKeepsPending() + + assertContentOpenedOnIO() + } + } + } + + @Test + fun `granted readable content validates without consuming progress and resumes on request`() = withFixture { + selectGrantedContentSource() + val pending = manager.restoredPlaybackSnapshot() + + scheduleValidation().join() + + assertContentOpenedOnIO() + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertTrue(sourceUnavailableThreads.isEmpty()) + assertTrue(playbackThreads.isEmpty()) + assertTrue(persistedResumeIntent.isEmpty()) + assertEquals(PositionMs, manager.resumeRestoredPlaybackIfNeeded()) + assertContentOpenedOnIO() + assertSame(RestoredPlaybackState.None, manager.restoredPlaybackSnapshot()) + assertEquals(listOf(true), playbackThreads) + assertTrue(sourceUnavailableThreads.isEmpty()) + } + + @Test + fun `restored managed playback accepts a readable replacement from the permitted resolver`() = withFixture { + readable.set(false) + val replacement = directory.newFile("migrated.mp3").apply { writeText("audio") } + permittedResolution.set(PlayerLocalPlaybackResolution.Playable(replacement.path)) + + scheduleResume().join() + + assertEquals(listOf(true), playbackThreads) + assertSame(RestoredPlaybackState.None, manager.restoredPlaybackSnapshot()) + assertTrue(sourceUnavailableThreads.isEmpty()) + assertPermittedResolutionOnIO() + val request = resolutionRequests.single() + assertEquals(song, request.song) + assertEquals(song.localFilePath, request.reference) + assertEquals(song.localFilePath, request.source?.anchorFor(song, request.reference)) + } + + @Test + fun `bootstrap validation accepts managed rebinds without playing or consuming progress`() = withFixture { + readable.set(false) + val replacement = directory.newFile("bootstrap-migrated.mp3").apply { writeText("audio") } + permittedResolution.set(PlayerLocalPlaybackResolution.Playable(replacement.path)) + val pending = manager.restoredPlaybackSnapshot() + + scheduleValidation().join() + + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertTrue(sourceUnavailableThreads.isEmpty()) + assertTrue(playbackThreads.isEmpty()) + assertTrue(persistedResumeIntent.isEmpty()) + assertPermittedResolutionOnIO() + } + + @Test + fun `a readable raw source cannot bypass unavailable permitted playback admission`() { + unavailablePermittedResolutions().forEach { resolution -> + withFixture { + permittedResolution.set(resolution) + + assertUnavailableKeepsPending() + + assertPermittedResolutionOnIO() + } + } + } + + @Test + fun `bootstrap validation honors unavailable permitted playback admission`() { + unavailablePermittedResolutions().forEach { resolution -> + withFixture { + permittedResolution.set(resolution) + + assertUnavailableKeepsPending { scheduleValidation() } + + assertPermittedResolutionOnIO() + } + } + } + + @Test + fun `a permitted replacement still has to open before restored progress is consumed`() = withFixture { + val replacement = directory.root.resolve("missing-replacement.mp3") + permittedResolution.set(PlayerLocalPlaybackResolution.Playable(replacement.path)) + + assertUnavailableKeepsPending() + + assertPermittedResolutionOnIO() + } + + @Test + fun `permitted playback resolver failures retain restored progress`() { + listOf(SecurityException("catalog grant unavailable"), IllegalStateException("catalog unavailable")).forEach { failure -> + withFixture { + permittedFailure.set(failure) + + assertUnavailableKeepsPending { scheduleValidation() } + + assertPermittedResolutionOnIO() + } + } + } + + @Test + fun `a newer request during managed resolution prevents stale service cleanup`() = withFixture { + permittedResolution.set(PlayerLocalPlaybackResolution.Missing) + val barrier = ProbeBarrier() + beforeResolution = barrier::awaitRelease + val pending = manager.restoredPlaybackSnapshot() + val request = scheduleValidation() + try { + barrier.entered.await() + manager.playbackRequestToken += 1 + } finally { + barrier.release.countDown() + } + request.join() + + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertTrue(sourceUnavailableThreads.isEmpty()) + assertTrue(playbackThreads.isEmpty()) + } + + @Test + fun `bootstrap source validation skips remote media without consuming pending progress`() = withFixture { + val pending = manager.restoredPlaybackSnapshot() + val remote = song.copy(localFilePath = null, mediaUri = null) + manager.publishCurrentQueue(listOf(remote), 0) + manager._currentSongFlow.value = remote + + scheduleValidation().join() + + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertTrue(providerThreads.isEmpty()) + assertTrue(sourceUnavailableThreads.isEmpty()) + assertTrue(playbackThreads.isEmpty()) + assertTrue(persistedResumeIntent.isEmpty()) + } + + @Test + fun `bootstrap source validation skips absent paused and unselected restore states`() = withFixture { + manager.clearRestoredPlayback() + scheduleValidation().join() + assertSame(RestoredPlaybackState.None, manager.restoredPlaybackSnapshot()) + manager.setRestoredPlayback(PositionMs, false) + val paused = manager.restoredPlaybackSnapshot() + scheduleValidation().join() + assertSame(paused, manager.restoredPlaybackSnapshot()) + manager.setRestoredPlayback(PositionMs, true) + val pending = manager.restoredPlaybackSnapshot() + manager.publishCurrentQueue(emptyList(), -1) + scheduleValidation().join() + assertSame(pending, manager.restoredPlaybackSnapshot()) + + assertTrue(providerThreads.isEmpty()) + assertTrue(sourceUnavailableThreads.isEmpty()) + assertTrue(playbackThreads.isEmpty()) + assertTrue(persistedResumeIntent.isEmpty()) + } + + @Test + fun `a newer playback token rejects a failed bootstrap validation`() = withFixture { + assertRejectedAfterProbe(unavailable = true, validationOnly = true) { manager.playbackRequestToken += 1 } + } + + @Test + fun `a different selected index rejects a failed bootstrap validation`() = withFixture { + assertRejectedAfterProbe(unavailable = true, validationOnly = true) { + manager.publishCurrentQueue(listOf(song, song.copy(id = 552L)), 1) + } + } + + @Test + fun `a replacement queued source rejects a failed bootstrap validation`() = withFixture { + assertRejectedAfterProbe(unavailable = true, validationOnly = true) { + manager.publishCurrentQueue(listOf(song.copy(localFilePath = "/new/source.mp3")), 0) + } + } + + @Test + fun `a replacement current song rejects a failed bootstrap validation`() = withFixture { + assertRejectedAfterProbe(unavailable = true, validationOnly = true) { + manager._currentSongFlow.value = song.copy(localFilePath = "/new/source.mp3") + } + } + + @Test + fun `an equal replacement pending state rejects a failed bootstrap validation`() = withFixture { + assertRejectedAfterProbe(unavailable = true, validationOnly = true) { + manager.setRestoredPlayback(PositionMs, true) + } + } + + @Test + fun `pausing rejects a failed bootstrap validation without consuming the paused state`() = withFixture { + assertRejectedAfterProbe(unavailable = true, validationOnly = true) { manager.suppressRestoredAutoResume() } + assertEquals(RestoredPlaybackState.Paused(PositionMs), manager.restoredPlaybackSnapshot()) + } + + @Test + fun `releasing the player rejects a failed bootstrap validation`() = withFixture { + assertRejectedAfterProbe(unavailable = true, validationOnly = true) { manager.initialized = false } + } + + @Test + fun `bootstrap source validation skips active rooms without consuming their pending restore`() { + listOf("listener", "controller").forEach { role -> + withFixture { + session.value = ListenTogetherSessionState(roomId = "room", role = role) + val pending = manager.restoredPlaybackSnapshot() + + scheduleValidation().join() + + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertTrue(providerThreads.isEmpty()) + assertTrue(sourceUnavailableThreads.isEmpty()) + assertTrue(playbackThreads.isEmpty()) + assertTrue(persistedResumeIntent.isEmpty()) + } + } + } + + @Test + fun `joining a room during bootstrap validation protects the service without consuming pending progress`() { + listOf("listener", "controller").forEach { role -> + withFixture { + assertRejectedAfterProbe(unavailable = true, validationOnly = true) { + session.value = ListenTogetherSessionState(roomId = "room", role = role) + } + } + } + } + + @Test + fun `cancelling bootstrap source validation cannot report an unavailable source`() = withFixture { + readable.set(false) + val barrier = ProbeBarrier() + beforeOpen = barrier::awaitRelease + val pending = manager.restoredPlaybackSnapshot() + val request = scheduleValidation() + try { + barrier.entered.await() + request.cancel() + } finally { + barrier.release.countDown() + } + request.cancelAndJoin() + + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertEquals(PositionMs, manager._playbackPositionMs.value) + assertEquals(listOf(song), manager.currentQueueSnapshot().playlist) + assertTrue(sourceUnavailableThreads.isEmpty()) + assertTrue(playbackThreads.isEmpty()) + assertTrue(persistedResumeIntent.isEmpty()) + } + + private fun withFixture(block: suspend ResumeFixture.() -> Unit) = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val manager = PlayerManager + val applicationField = PlayerManager::class.java.getDeclaredField("application").apply { isAccessible = true } + val previousApplication = applicationField.get(null) + val previousInitialized = manager.initialized + val previousSong = manager._currentSongFlow.value + val previousQueue = manager.currentQueueSnapshot() + val previousRestore = manager.restoredPlaybackSnapshot() + val previousPosition = manager._playbackPositionMs.value + val previousToken = manager.playbackRequestToken + val previousPersistTime = manager.lastStatePersistAtMs + val registry = PlayerDependencies::class.java.getDeclaredField("registry").apply { isAccessible = true }.get(null) + val environmentField = registry.javaClass.getDeclaredField("environment").apply { isAccessible = true } + val previousEnvironment = environmentField.get(registry) + val fixture = ResumeFixture(this) + val playbackClass = Class.forName("moe.ouom.neriplayer.core.player.playback.PlayerManagerPlaybackExtensionsKt") + val persistenceClass = Class.forName("moe.ouom.neriplayer.core.player.persistence.PlayerManagerStatePersistenceExtensionsKt") + mockStatic(playbackClass).use { playback -> + mockStatic(persistenceClass).use { persistence -> + playback.`when` { + manager.playAtIndex(0, resumePositionMs = PositionMs, forceStartupProtectionFade = true) + }.thenAnswer { + fixture.playbackThreads += Looper.myLooper() == Looper.getMainLooper() + null + } + persistence.`when` { + manager.scheduleStatePersist(PositionMs, false, 0L) + }.thenAnswer { + fixture.persistedResumeIntent += false + null + } + try { + environmentField.set(registry, null) + PlayerDependencies.install(fixture.environment()) + manager.application = fixture.application + manager.initialized = true + manager.publishCurrentQueue(listOf(fixture.song), 0) + manager._currentSongFlow.value = fixture.song + manager._playbackPositionMs.value = PositionMs + manager.setRestoredPlayback(PositionMs, true) + fixture.block() + } finally { + manager.publishCurrentQueue(previousQueue.playlist, previousQueue.currentIndex) + manager._currentSongFlow.value = previousSong + manager._playbackPositionMs.value = previousPosition + manager.playbackRequestToken = previousToken + manager.lastStatePersistAtMs = previousPersistTime + manager.initialized = previousInitialized + applicationField.set(null, previousApplication) + environmentField.set(registry, previousEnvironment) + when (previousRestore) { + RestoredPlaybackState.None -> manager.clearRestoredPlayback() + is RestoredPlaybackState.Paused -> manager.setRestoredPlayback(previousRestore.positionMs, false) + is RestoredPlaybackState.ResumePending -> manager.setRestoredPlayback(previousRestore.positionMs, true) + } + } + } + } + } finally { + Dispatchers.resetMain() + } + } + + private fun contentOpenFailures(): List = listOf( + null, FileNotFoundException("document deleted"), SecurityException("document access denied"), + IllegalStateException("provider unavailable") + ) + + private fun unavailablePermittedResolutions(): List = listOf( + PlayerLocalPlaybackResolution.NotIndexed, PlayerLocalPlaybackResolution.Missing, + PlayerLocalPlaybackResolution.TemporarilyUnavailable("catalog warming up") + ) + + private data class LocalResolutionRequest( + val song: SongItem, + val reference: String?, + val source: ResolvedLocalMediaSource?, + val onMain: Boolean + ) + + private class ResumeFixture(private val scope: CoroutineScope) : CoroutineScope by scope { + val manager = PlayerManager + val application = mock(Application::class.java) + val readable = AtomicBoolean(true) + val permissionFailure = AtomicReference() + val openFailure = AtomicReference() + val providerThreads = ConcurrentLinkedQueue() + private val openProbeThreads = ConcurrentLinkedQueue() + private val permission = mock(UriPermission::class.java) + private val tree = DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", "primary:Music") + private val document = DocumentsContract.buildDocumentUriUsingTree(tree, "primary:Music/Album/restore.mp3") + val playbackThreads = mutableListOf() + val persistedResumeIntent = mutableListOf() + val sourceUnavailableThreads = mutableListOf() + val permittedResolution = AtomicReference() + val permittedFailure = AtomicReference() + val resolutionRequests = ConcurrentLinkedQueue() + var beforeResolution: (() -> Unit)? = null + var resumedCallbacks = 0 + val session = MutableStateFlow(ListenTogetherSessionState()) + var beforeOpen: (() -> Unit)? = null + val song = SongItem( + id = 551L, name = "Restored local source", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 180_000L, coverUrl = null, localFilePath = "/storage/emulated/0/Music/Album/restore.mp3" + ) + + init { + val resolver = mock(ContentResolver::class.java) + doReturn(resolver).`when`(application).contentResolver + doReturn(tree).`when`(permission).uri + doReturn(true).`when`(permission).isReadPermission + doAnswer { + providerThreads += Looper.myLooper() == Looper.getMainLooper() + permissionFailure.get()?.let { throw it } + listOf(permission) + }.`when`(resolver).persistedUriPermissions + doAnswer { + providerThreads += Looper.myLooper() == Looper.getMainLooper() + openProbeThreads += Looper.myLooper() == Looper.getMainLooper() + beforeOpen?.invoke() + openFailure.get()?.let { throw it } + if (readable.get()) mock(AssetFileDescriptor::class.java) else null + }.`when`(resolver).openAssetFileDescriptor(document, "r") + } + + fun selectGrantedContentSource() { + doReturn(document).`when`(permission).uri + val contentSong = song.copy(localFilePath = null, mediaUri = document.toString()) + manager.publishCurrentQueue(listOf(contentSong), 0) + manager._currentSongFlow.value = contentSong + } + + fun assertContentOpenedOnIO() { + assertTrue("必须实际打开文档而非只检查授权", openProbeThreads.isNotEmpty()) + assertTrue(openProbeThreads.none { it }) + } + + fun assertPermittedResolutionOnIO() { + assertTrue("必须经过受准入策略保护的本地播放解析", resolutionRequests.isNotEmpty()) + assertTrue(resolutionRequests.none { it.onMain }) + } + + fun environment(): PlayerEnvironment { + val listenTogether = mock(PlayerListenTogetherAccess::class.java) + doReturn(session).`when`(listenTogether).sessionState + doReturn(MutableStateFlow(null)).`when`(listenTogether).roomState + return PlayerEnvironment( + application, mock(PlayerRepositoryDependencies::class.java), mock(PlayerDownloadAccess::class.java, Answer { invocation -> + if (invocation.method.name != "resolvePermittedLocalPlayback") { + Answers.RETURNS_DEFAULTS.answer(invocation) + } else { + val reference = invocation.getArgument(2) + resolutionRequests += LocalResolutionRequest( + invocation.getArgument(1), reference, invocation.getArgument(3), + Looper.myLooper() == Looper.getMainLooper() + ) + beforeResolution?.invoke() + permittedFailure.get()?.let { throw it } + permittedResolution.get() ?: reference?.let(PlayerLocalPlaybackResolution::Playable) + ?: PlayerLocalPlaybackResolution.NotIndexed + } + }), + listenTogether, mock(PlayerPresentationHost::class.java), { true }, { Job().apply { complete() } } + ) + } + + fun scheduleResume(): Job = manager.scheduleRestoredPlaybackResume( + this, + onSourceUnavailable = { sourceUnavailableThreads += Looper.myLooper() == Looper.getMainLooper() } + ) { resumedCallbacks++ } + + fun scheduleValidation(scope: CoroutineScope = this): Job = manager.scheduleRestoredLocalSourceValidation(scope) { + sourceUnavailableThreads += Looper.myLooper() == Looper.getMainLooper() + } + + suspend fun assertUnavailableKeepsPending(schedule: () -> Job = { scheduleResume() }) { + val pending = manager.restoredPlaybackSnapshot() + val queue = manager.currentQueueSnapshot() + val current = manager._currentSongFlow.value + val position = manager._playbackPositionMs.value + val persisted = persistedResumeIntent.toList() + + schedule().join() + + assertEquals(listOf(true), sourceUnavailableThreads) + assertEquals(0, resumedCallbacks) + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertEquals(queue, manager.currentQueueSnapshot()) + assertEquals(current, manager._currentSongFlow.value) + assertEquals(position, manager._playbackPositionMs.value) + assertEquals(persisted, persistedResumeIntent) + assertTrue(playbackThreads.isEmpty()) + } + + suspend fun assertRejectedAfterProbe( + unavailable: Boolean = false, + validationOnly: Boolean = false, + mutate: () -> Unit + ) { + if (unavailable) readable.set(false) + val barrier = ProbeBarrier() + beforeOpen = barrier::awaitRelease + val request = async(start = CoroutineStart.UNDISPATCHED) { + if (unavailable || validationOnly) { + if (validationOnly) scheduleValidation().join() else scheduleResume().join() + null + } else manager.resumeRestoredPlaybackIfNeeded() + } + try { + barrier.entered.await() + mutate() + val restoredAfterMutation = manager.restoredPlaybackSnapshot() + val queueAfterMutation = manager.currentQueueSnapshot() + val currentAfterMutation = manager._currentSongFlow.value + val persistedAfterMutation = persistedResumeIntent.toList() + barrier.release.countDown() + assertNull(request.await()) + assertSame(restoredAfterMutation, manager.restoredPlaybackSnapshot()) + assertEquals(queueAfterMutation, manager.currentQueueSnapshot()) + assertEquals(currentAfterMutation, manager._currentSongFlow.value) + assertEquals(persistedAfterMutation, persistedResumeIntent) + assertTrue(playbackThreads.isEmpty()) + assertTrue(sourceUnavailableThreads.isEmpty()) + assertEquals(0, resumedCallbacks) + assertEquals(PositionMs, manager._playbackPositionMs.value) + } finally { + barrier.release.countDown() + } + } + } + + private class ProbeBarrier(private val expectedProbes: Int = 1) { + val entered = CompletableDeferred() + val release = CountDownLatch(1) + private val count = AtomicInteger() + + fun awaitRelease() { + if (count.incrementAndGet() == expectedProbes) entered.complete(Unit) + check(release.await(5, TimeUnit.SECONDS)) { "provider probe was not released" } + } + } + + private companion object { + const val PositionMs = 12_345L + } +} diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/AudioPlayerServiceRestoreFailureTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/AudioPlayerServiceRestoreFailureTest.kt new file mode 100644 index 0000000000..80cd1940c8 --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/service/AudioPlayerServiceRestoreFailureTest.kt @@ -0,0 +1,337 @@ +package moe.ouom.neriplayer.core.player.service + +import android.app.Application +import android.app.Notification +import androidx.media3.common.Player +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import moe.ouom.neriplayer.core.player.PlayerManager +import moe.ouom.neriplayer.core.player.host.PlayerDependencies +import moe.ouom.neriplayer.core.player.host.PlayerDownloadAccess +import moe.ouom.neriplayer.core.player.host.PlayerEnvironment +import moe.ouom.neriplayer.core.player.host.PlayerListenTogetherAccess +import moe.ouom.neriplayer.core.player.host.PlayerPresentationHost +import moe.ouom.neriplayer.core.player.host.PlayerRepositoryDependencies +import moe.ouom.neriplayer.core.player.persistence.scheduleStatePersist +import moe.ouom.neriplayer.core.player.runtime.service.PlaybackServiceIdleShutdownCoordinator +import moe.ouom.neriplayer.core.player.timer.SleepTimerManager +import moe.ouom.neriplayer.core.player.usb.session.UsbExclusiveSessionController +import moe.ouom.neriplayer.data.model.SongItem +import moe.ouom.neriplayer.data.model.ltw.room.ListenTogetherRoomState +import moe.ouom.neriplayer.data.model.ltw.session.ListenTogetherSessionState +import moe.ouom.neriplayer.data.model.playback.RestoredPlaybackState +import moe.ouom.neriplayer.data.model.playback.usb.UsbExclusiveNativeState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +@OptIn(ExperimentalCoroutinesApi::class) +class AudioPlayerServiceRestoreFailureTest { + @Test + fun `unavailable restored source removes foreground service while keeping pending progress`() = withFixture { + idleShutdown.refresh() + val scheduledIdleJob = idleJob() + assertTrue(scheduledIdleJob?.isActive == true) + + stopUnavailableSource() + + assertTrue(shadow.isStoppedBySelf) + assertEquals(StartId, shadow.stopSelfResultId) + assertTrue(shadow.isForegroundStopped) + assertTrue(shadow.notificationShouldRemoved) + assertFalse(shadow.isLastForegroundNotificationAttached) + assertFalse(serviceFlag("allowServiceRestart")) + assertFalse(serviceFlag("hasReceivedStartCommand")) + assertFalse(serviceFlag("isForegroundStarted")) + assertFalse(AudioPlayerService.isForegroundActiveForDiagnostics()) + assertTrue(serviceFlag("keepPlayerRuntimeAfterServiceStop")) + assertTrue(scheduledIdleJob?.isCancelled == true) + assertNull(idleJob()) + + invokeService("releasePlayerRuntimeAfterServiceStop", false) + + assertTrue(manager.initialized) + assertPendingProgressUnchanged() + assertTrue(persistedResumeIntent.isEmpty()) + } + + @Test + fun `a newer service start protects its foreground notification from a late restore failure`() = withFixture { + setServiceField("latestStartId", StartId + 1) + + stopUnavailableSource() + + assertServiceStillRunning() + assertPendingProgressUnchanged() + } + + @Test + fun `manual playback started during validation protects the service from the old failure`() = withFixture { + manager._isPlayingFlow.value = true + manager._playWhenReadyFlow.value = true + + stopUnavailableSource() + + assertServiceStillRunning() + assertTrue(manager.isPlayingFlow.value) + assertTrue(manager.playWhenReadyFlow.value) + assertPendingProgressUnchanged() + } + + @Test + fun `an in flight manual playback request protects the service before audio starts`() = withFixture { + manager.playJob = Job() + + stopUnavailableSource() + + assertServiceStillRunning() + assertTrue(manager.playJob?.isActive == true) + assertPendingProgressUnchanged() + } + + @Test + fun `an active listen together session protects the service from a local restore failure`() = withFixture { + session.value = ListenTogetherSessionState(roomId = "active-room", role = "listener") + + stopUnavailableSource() + + assertServiceStillRunning() + assertPendingProgressUnchanged() + } + + @Test + fun `an opened or transitioning USB session protects the service from a local restore failure`() { + listOf(UsbExclusiveNativeState(opened = true), UsbExclusiveNativeState(transitioning = true)).forEach { state -> + withFixture { + nativeState.value = state + + stopUnavailableSource() + + assertServiceStillRunning() + assertPendingProgressUnchanged() + } + } + } + + @Test + fun `an active sleep timer protects its service from a local restore failure`() = withFixture { + manager.sleepTimerManager.startFinishCurrent() + + stopUnavailableSource() + + assertServiceStillRunning() + assertTrue(manager.sleepTimerManager.timerState.value.isActive) + assertPendingProgressUnchanged() + } + + @Test + fun `a service already returned to binding only is not stopped by a late restore failure`() = withFixture { + setServiceField("hasReceivedStartCommand", false) + + stopUnavailableSource() + + assertServiceStillRunning(receivedStart = false) + assertPendingProgressUnchanged() + } + + private fun withFixture(block: RestoreFailureFixture.() -> Unit) = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + RestoreFailureFixture(this).use { fixture -> + val persistenceClass = Class.forName( + "moe.ouom.neriplayer.core.player.persistence.PlayerManagerStatePersistenceExtensionsKt" + ) + mockStatic(persistenceClass).use { persistence -> + persistence.`when` { + fixture.manager.scheduleStatePersist(PositionMs, false, 0L) + }.thenAnswer { + fixture.persistedResumeIntent += false + null + } + fixture.block() + } + } + } finally { + Dispatchers.resetMain() + } + } + + private class RestoreFailureFixture(scope: CoroutineScope) : AutoCloseable { + val manager = PlayerManager + private val applicationField = managerField("application") + private val previousApplication = applicationField.get(null) + private val previousInitialized = manager.initialized + private val previousQueue = manager.currentQueueSnapshot() + private val previousSong = manager.currentSongFlow.value + private val previousPosition = manager.playbackPositionFlow.value + private val previousRestore = manager.restoredPlaybackSnapshot() + private val previousPlaying = manager.isPlayingFlow.value + private val previousPlayWhenReady = manager.playWhenReadyFlow.value + private val previousPlayerState = manager.playerPlaybackStateFlow.value + private val previousResumeRequested = manager.resumePlaybackRequested + private val previousPlayJob = manager.playJob + private val previousPauseJob = manager.pendingPauseJob + private val previousSleepTimer = manager.sleepTimerManager + private val registry = PlayerDependencies::class.java.getDeclaredField("registry").apply { + isAccessible = true + }.get(null) + private val environmentField = registry.javaClass.getDeclaredField("environment").apply { + isAccessible = true + } + private val previousEnvironment = environmentField.get(registry) + private val foregroundField = serviceField("isServiceForegroundActive") + private val previousForegroundActive = foregroundField.getBoolean(null) + private val application: Application = RuntimeEnvironment.getApplication() + val session = MutableStateFlow(ListenTogetherSessionState()) + val persistedResumeIntent = mutableListOf() + // 只替换状态流,不打开原生 USB 会话 + @Suppress("UNCHECKED_CAST") + val nativeState = UsbExclusiveSessionController::class.java.getDeclaredField("_state").apply { + isAccessible = true + }.get(null) as MutableStateFlow + private val previousNativeState = nativeState.value + val service = Robolectric.buildService(AudioPlayerService::class.java).get() + val shadow = shadowOf(service) + val idleShutdown = serviceField("idleShutdownCoordinator").get(service) as PlaybackServiceIdleShutdownCoordinator + private val song = SongItem( + id = 551L, name = "Unavailable restored source", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 180_000L, coverUrl = null, localFilePath = "/missing/restored-source.mp3" + ) + private val pending: RestoredPlaybackState + + init { + val listenTogether = mock(PlayerListenTogetherAccess::class.java) + doReturn(session).`when`(listenTogether).sessionState + doReturn(MutableStateFlow(null)).`when`(listenTogether).roomState + environmentField.set(registry, null) + PlayerDependencies.install(PlayerEnvironment( + application, mock(PlayerRepositoryDependencies::class.java), mock(PlayerDownloadAccess::class.java), + listenTogether, mock(PlayerPresentationHost::class.java), { true }, { Job().apply { complete() } } + )) + manager.application = application + manager.initialized = true + manager.publishCurrentQueue(listOf(song), 0) + manager._currentSongFlow.value = song + manager._playbackPositionMs.value = PositionMs + manager._isPlayingFlow.value = false + manager._playWhenReadyFlow.value = false + manager._playerPlaybackStateFlow.value = Player.STATE_IDLE + managerField("resumePlaybackRequested").setBoolean(null, false) + manager.playJob = null + manager.pendingPauseJob = null + manager.sleepTimerManager = SleepTimerManager(scope, {}) + manager.setRestoredPlayback(PositionMs, true) + pending = manager.restoredPlaybackSnapshot() + nativeState.value = UsbExclusiveNativeState() + + service.startForeground(1, Notification.Builder(application, "restore-test") + .setSmallIcon(android.R.drawable.ic_media_play).build()) + setServiceField("playerRuntimeReady", true) + setServiceField("hasReceivedStartCommand", true) + setServiceField("isForegroundStarted", true) + setServiceField("latestStartId", StartId) + foregroundField.setBoolean(null, true) + } + + fun stopUnavailableSource() = invokeService("stopServiceForUnavailableRestoredSource", StartId) + + fun invokeService(name: String, argument: Any) { + val type = when (argument) { + is Int -> Int::class.javaPrimitiveType + is Boolean -> Boolean::class.javaPrimitiveType + else -> error("Unsupported service method argument") + } + AudioPlayerService::class.java.getDeclaredMethod(name, type).apply { + isAccessible = true + }.invoke(service, argument) + } + + fun setServiceField(name: String, value: Any) = serviceField(name).set(service, value) + + fun serviceFlag(name: String): Boolean = serviceField(name).getBoolean(service) + + fun idleJob(): Job? = PlaybackServiceIdleShutdownCoordinator::class.java + .getDeclaredField("shutdownJob").apply { isAccessible = true }.get(idleShutdown) as Job? + + fun assertServiceStillRunning(receivedStart: Boolean = true) { + assertFalse(shadow.isStoppedBySelf) + assertFalse(shadow.isForegroundStopped) + assertTrue(shadow.isLastForegroundNotificationAttached) + assertTrue(serviceFlag("allowServiceRestart")) + assertEquals(receivedStart, serviceFlag("hasReceivedStartCommand")) + assertTrue(serviceFlag("isForegroundStarted")) + assertTrue(AudioPlayerService.isForegroundActiveForDiagnostics()) + assertFalse(serviceFlag("keepPlayerRuntimeAfterServiceStop")) + assertTrue(persistedResumeIntent.isEmpty()) + } + + fun assertPendingProgressUnchanged() { + assertSame(pending, manager.restoredPlaybackSnapshot()) + assertEquals(PositionMs, manager.playbackPositionFlow.value) + assertEquals(listOf(song), manager.currentQueueSnapshot().playlist) + assertEquals(0, manager.currentQueueSnapshot().currentIndex) + assertSame(song, manager.currentSongFlow.value) + } + + override fun close() { + (serviceField("serviceScope").get(service) as CoroutineScope).cancel() + if (manager.playJob !== previousPlayJob) manager.playJob?.cancel() + manager.sleepTimerManager.cancel(notifyStateChanged = false) + manager.sleepTimerManager = previousSleepTimer + manager.playJob = previousPlayJob + manager.pendingPauseJob = previousPauseJob + managerField("resumePlaybackRequested").setBoolean(null, previousResumeRequested) + manager._isPlayingFlow.value = previousPlaying + manager._playWhenReadyFlow.value = previousPlayWhenReady + manager._playerPlaybackStateFlow.value = previousPlayerState + manager.publishCurrentQueue(previousQueue.playlist, previousQueue.currentIndex) + manager._currentSongFlow.value = previousSong + manager._playbackPositionMs.value = previousPosition + manager.initialized = previousInitialized + applicationField.set(null, previousApplication) + environmentField.set(registry, previousEnvironment) + nativeState.value = previousNativeState + foregroundField.setBoolean(null, previousForegroundActive) + when (previousRestore) { + RestoredPlaybackState.None -> manager.clearRestoredPlayback() + is RestoredPlaybackState.Paused -> manager.setRestoredPlayback(previousRestore.positionMs, false) + is RestoredPlaybackState.ResumePending -> manager.setRestoredPlayback(previousRestore.positionMs, true) + } + } + } + + private companion object { + const val StartId = 41 + const val PositionMs = 12_345L + + fun serviceField(name: String) = AudioPlayerService::class.java.getDeclaredField(name).apply { + isAccessible = true + } + + fun managerField(name: String) = PlayerManager::class.java.getDeclaredField(name).apply { + isAccessible = true + } + } +} diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/url/PlayerLocalPlaybackRecoveryTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/url/PlayerLocalPlaybackRecoveryTest.kt new file mode 100644 index 0000000000..3f9d5da896 --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/url/PlayerLocalPlaybackRecoveryTest.kt @@ -0,0 +1,116 @@ +package moe.ouom.neriplayer.core.player.url + +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class PlayerLocalPlaybackRecoveryTest { + private val selectedReference = "content://provider/tree/Download2/document/Download2/song.flac" + + @Test + fun `missing manually selected local audio cannot recover a remote source`() { + assertNull(recoverNeteaseSourceAfterMissingLocal(song(), selectedReference) { true }) + } + + @Test + fun `unknown local membership cannot recover a remote source`() { + assertNull(recoverNeteaseSourceAfterMissingLocal(song(), selectedReference) { null }) + } + + @Test + fun `confirmed non manual legacy download still recovers its remote source`() { + assertEquals(expectedRemoteSong(), recoverNeteaseSourceAfterMissingLocal(song(), selectedReference) { false }) + } + + @Test + fun `membership evidence belongs to the exact failed reference rather than a song alias`() { + val failedReference = "content://provider/tree/old-download/document/old-download/song.flac" + val queriedReferences = mutableListOf() + + val recovered = recoverNeteaseSourceAfterMissingLocal(song(), failedReference) { reference -> + queriedReferences += reference + reference == selectedReference + } + + assertEquals(expectedRemoteSong(), recovered) + assertEquals(listOf(failedReference), queriedReferences) + } + + @Test + fun `missing reference cannot establish permission for remote recovery`() { + assertNull(recoverNeteaseSourceAfterMissingLocal(song(), null) { false }) + } + + @Test + fun `a history snapshot keeps its exact manual source after playlist membership is removed`() { + val selected = song().copy(explicitLocalSourceReference = selectedReference) + + assertNull(recoverNeteaseSourceAfterMissingLocal(selected, selectedReference) { false }) + } + + @Test + fun `a valid snapshot source does not depend on unknown playlist membership`() { + val selected = song().copy(explicitLocalSourceReference = selectedReference) + val queried = mutableListOf() + + val recovered = recoverNeteaseSourceAfterMissingLocal(selected, selectedReference) { + queried += it + null + } + + assertNull(recovered) + assertEquals(emptyList(), queried) + } + + @Test + fun `a snapshot marker cannot establish manual evidence for another failed reference`() { + val selected = song().copy(explicitLocalSourceReference = selectedReference) + val failedReference = "content://provider/document/another-file.flac" + val queried = mutableListOf() + + val recovered = recoverNeteaseSourceAfterMissingLocal(selected, failedReference) { + queried += it + false + } + + assertNotNull(recovered) + assertEquals(listOf(failedReference), queried) + } + + @Test + fun `stale and invalid snapshot markers retain ordinary nonmanual recovery`() { + listOf("content://provider/document/old-file.flac", "https://example.com/song.flac", "", " ").forEach { marker -> + val stale = song().copy(explicitLocalSourceReference = marker) + + assertNotNull(recoverNeteaseSourceAfterMissingLocal(stale, selectedReference) { false }) + } + } + + private fun song() = SongItem( + id = 859470L, + name = "Scanned Song", + artist = "Artist", + album = "Album", + albumId = 17L, + durationMs = 180_000L, + coverUrl = null, + mediaUri = selectedReference, + localFileName = "song.flac", + channelId = "netease", + audioId = "859470", + sourceStableKey = "859470|netease|" + ) + + private fun expectedRemoteSong() = song().copy( + album = "Netease", + albumId = 0L, + mediaUri = null, + localFileName = null, + localFilePath = null, + sourceStableKey = null, + streamUrl = null, + subAudioId = null + ) +} diff --git a/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/url/PlayerLocalPlaybackSourceRecoveryTest.kt b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/url/PlayerLocalPlaybackSourceRecoveryTest.kt new file mode 100644 index 0000000000..cbb9931e17 --- /dev/null +++ b/modules/playback/runtime/src/test/java/moe/ouom/neriplayer/core/player/url/PlayerLocalPlaybackSourceRecoveryTest.kt @@ -0,0 +1,140 @@ +package moe.ouom.neriplayer.core.player.url + +import android.content.ContentResolver +import android.content.Context +import android.content.UriPermission +import android.content.res.AssetFileDescriptor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.DocumentsContract +import moe.ouom.neriplayer.data.local.media.source.ResolvedLocalMediaSource +import moe.ouom.neriplayer.data.model.SongItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.eq +import org.mockito.Mockito.doAnswer +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], manifest = Config.NONE) +class PlayerLocalPlaybackSourceRecoveryTest { + private val original = "content://media/external/audio/media/7" + private val song = SongItem( + id = 7L, name = "Song", artist = "Artist", album = "Album", albumId = 0L, + durationMs = 180_000L, coverUrl = null, mediaUri = original, + localFilePath = "/storage/emulated/0/Music/Album/song.mp3", + channelId = "netease", audioId = "7", sourceStableKey = "7|netease|" + ) + + @Test + fun `missing verified aliases consult the original manual source and never recover remotely`() { + val source = recoveredSource() + val queried = mutableListOf() + + val result = recoverNeteaseSourceAfterMissingLocal(song, source.reference, source) { + queried += it + it == original + } + + assertNull(result) + assertEquals(listOf(original), queried) + } + + @Test + fun `unknown verified alias provenance blocks remote recovery`() { + val source = recoveredSource() + + assertNull(recoverNeteaseSourceAfterMissingLocal(song, source.reference, source) { + if (it == original) null else false + }) + } + + @Test + fun `another song snapshot cannot borrow the same recovered reference context`() { + val source = recoveredSource() + + assertNotNull(recoverNeteaseSourceAfterMissingLocal(song.copy(id = 8L), source.reference, source) { + it == original + }) + assertNull(recoverNeteaseSourceAfterMissingLocal(song, null, source) { false }) + } + + @Test + fun `an unverified declared alias cannot replace a manual source remotely`() { + val source = ResolvedLocalMediaSource.direct(song, requireNotNull(song.localFilePath)) + + assertNull(recoverNeteaseSourceAfterMissingLocal(song, source.reference, source) { it == original }) + } + + @Test + fun `an unverified declared alias cannot recover remotely while provenance is unknown`() { + val source = ResolvedLocalMediaSource.direct(song, requireNotNull(song.localFilePath)) + + assertNull(recoverNeteaseSourceAfterMissingLocal(song, source.reference, source) { + if (it == original) null else false + }) + } + + @Test + fun `unverified declarations retain remote recovery when neither reference is manual`() { + val source = ResolvedLocalMediaSource.direct(song, requireNotNull(song.localFilePath)) + + assertNotNull(recoverNeteaseSourceAfterMissingLocal(song, source.reference, source) { false }) + } + + @Test + fun `a verified history alias retains its manual snapshot after all playlist membership is gone`() { + val selected = song.copy(explicitLocalSourceReference = original) + val source = recoveredSource(selected) + val queried = mutableListOf() + + val recovered = recoverNeteaseSourceAfterMissingLocal(selected, source.reference, source) { + queried += it + false + } + + assertNull(recovered) + assertEquals(emptyList(), queried) + } + + @Test + fun `an unverified alias cannot bypass a manual snapshot after playlist membership is gone`() { + val selected = song.copy(explicitLocalSourceReference = original) + val source = ResolvedLocalMediaSource.direct(selected, requireNotNull(selected.localFilePath)) + + assertNull(recoverNeteaseSourceAfterMissingLocal(selected, source.reference, source) { false }) + } + + @Test + fun `a different song snapshot cannot borrow an old verified alias with a manual marker`() { + val selected = song.copy(explicitLocalSourceReference = original) + val source = recoveredSource(selected) + val changed = selected.copy(id = 8L) + + assertNotNull(recoverNeteaseSourceAfterMissingLocal(changed, source.reference, source) { false }) + } + + private fun recoveredSource(sourceSong: SongItem = song): ResolvedLocalMediaSource { + val context = mock(Context::class.java) + val resolver = mock(ContentResolver::class.java) + val permission = mock(UriPermission::class.java) + val tree = DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", "primary:Music") + val document = DocumentsContract.buildDocumentUriUsingTree(tree, "primary:Music/Album/song.mp3") + doReturn(resolver).`when`(context).contentResolver + doReturn(listOf(permission)).`when`(resolver).persistedUriPermissions + doReturn(tree).`when`(permission).uri + doReturn(true).`when`(permission).isReadPermission + doReturn(mock(AssetFileDescriptor::class.java)).`when`(resolver).openAssetFileDescriptor(document, "r") + doAnswer { + MatrixCursor(arrayOf("_data")).apply { addRow(arrayOf(song.localFilePath)) } + }.`when`(resolver).query(eq(Uri.parse(original)), any(), any(), any(), any()) + return requireNotNull(ResolvedLocalMediaSource.recoverFromFolderGrant(context, sourceSong)) + } +}