Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/android_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,18 @@ 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 &&
adb shell settings --user 0 put secure anr_show_background 0 &&
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; } &&
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Context>()

@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
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading