diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d9bddc..7dde40c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,8 +35,8 @@ android { applicationId = "dev.amenhancer.module" minSdk = 26 targetSdk = 37 - versionCode = 95 - versionName = "1.3.8" + versionCode = 96 + versionName = "1.3.9" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/dev/amenhancer/module/config/CustomLyricsManifestPolicy.kt b/app/src/main/java/dev/amenhancer/module/config/CustomLyricsManifestPolicy.kt index f78094e..2670992 100644 --- a/app/src/main/java/dev/amenhancer/module/config/CustomLyricsManifestPolicy.kt +++ b/app/src/main/java/dev/amenhancer/module/config/CustomLyricsManifestPolicy.kt @@ -16,6 +16,7 @@ internal object CustomLyricsManifestPolicy { CustomLyricsSources.MANUAL, CustomLyricsSources.AMLL, CustomLyricsSources.NETEASE, + CustomLyricsSources.AM_LYRICS, ) fun sanitize(manifest: CustomLyricsManifest): CustomLyricsManifest { diff --git a/app/src/main/java/dev/amenhancer/module/hook/OnlineLyricClients.kt b/app/src/main/java/dev/amenhancer/module/hook/OnlineLyricClients.kt index 64cac0e..0c5a221 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/OnlineLyricClients.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/OnlineLyricClients.kt @@ -3,11 +3,11 @@ package dev.amenhancer.module.hook import dev.amenhancer.module.lyrics.LyricDocument import dev.amenhancer.module.lyrics.NeteaseEapi import dev.amenhancer.module.lyrics.YrcParser +import java.net.URLEncoder import org.json.JSONObject /** - * AMLL TTML DB client. Direct, fixed URL per Adam ID; a 404 (or any HTTP - * failure) simply falls through to the next source. + * AMLL TTML DB client. Direct, fixed URL per Adam ID; failures return null. */ internal class AmllTtmlClient(private val transport: LyricHttpTransport) { fun fetch(adamId: Long): String? = @@ -19,6 +19,44 @@ internal class AmllTtmlClient(private val transport: LyricHttpTransport) { } } +/** User-owned TTML repository indexed by Apple Music Adam ID; settings process only. */ +internal class AmLyricsClient(private val transport: LyricHttpTransport) { + fun fetch(adamId: Long): String? { + if (adamId <= 0L) return null + val index = transport.get(AM_LYRICS_INDEX_URL) ?: return null + val path = resolvePath(index, adamId) ?: return null + return transport.get("$AM_LYRICS_BASE/$path") + } + + private fun resolvePath(indexJson: String, adamId: Long): String? = runCatching { + val entries = JSONObject(indexJson).optJSONArray("entries") ?: return@runCatching null + for (index in 0 until entries.length()) { + val entry = entries.optJSONObject(index) ?: continue + if (entry.optLong("appleMusicId", 0L) != adamId) continue + if (!entry.optBoolean("enabled", true)) continue + val path = entry.optString("path").takeIf(String::isNotBlank) + ?: return@runCatching null + return@runCatching encodePath(path) + } + null + }.getOrNull() + + private fun encodePath(path: String): String? { + if (!path.startsWith(AM_LYRICS_ROOT) || path.contains('\\')) return null + val segments = path.split('/') + if (segments.any { it.isEmpty() || it == "." || it == ".." }) return null + return segments.joinToString("/") { segment -> + URLEncoder.encode(segment, Charsets.UTF_8.name()).replace("+", "%20") + } + } + + companion object { + const val AM_LYRICS_BASE = "https://raw.githubusercontent.com/Zennmn/am-lyrics/main" + const val AM_LYRICS_INDEX_URL = "$AM_LYRICS_BASE/index.json" + private const val AM_LYRICS_ROOT = "am-lyrics/" + } +} + /** * NetEase lyric client. * diff --git a/app/src/main/java/dev/amenhancer/module/lyrics/CustomLyricsOnlineImporter.kt b/app/src/main/java/dev/amenhancer/module/lyrics/CustomLyricsOnlineImporter.kt index d85fe54..d16f0ba 100644 --- a/app/src/main/java/dev/amenhancer/module/lyrics/CustomLyricsOnlineImporter.kt +++ b/app/src/main/java/dev/amenhancer/module/lyrics/CustomLyricsOnlineImporter.kt @@ -14,6 +14,7 @@ internal sealed interface CustomLyricsOnlineImportResult { /** User-triggered online imports. Playback hooks never call this class. */ internal class CustomLyricsOnlineImporter( private val fetchAmll: (Long) -> String?, + private val fetchAmLyrics: (Long) -> String?, private val fetchNeteaseYrc: (Long) -> LyricDocument?, ) { fun importAmll(appleMusicId: Long): CustomLyricsOnlineImportResult { @@ -24,6 +25,16 @@ internal class CustomLyricsOnlineImporter( return CustomLyricsOnlineImportResult.Imported(ttml, CustomLyricsSources.AMLL) } + fun importAmLyrics(appleMusicId: Long): CustomLyricsOnlineImportResult { + if (appleMusicId <= 0L) return CustomLyricsOnlineImportResult.Failed( + "Apple Music ID 必须是正整数", + ) + val ttml = runCatching { fetchAmLyrics(appleMusicId) }.getOrNull() + ?.takeIf(TtmlInputPolicy::isAcceptable) + ?: return CustomLyricsOnlineImportResult.Failed("GitHub 未找到可用 TTML") + return CustomLyricsOnlineImportResult.Imported(ttml, CustomLyricsSources.AM_LYRICS) + } + fun importNetease( neteaseSongId: Long, title: String, diff --git a/app/src/main/java/dev/amenhancer/module/model/ModuleModels.kt b/app/src/main/java/dev/amenhancer/module/model/ModuleModels.kt index a1a832b..8d25403 100644 --- a/app/src/main/java/dev/amenhancer/module/model/ModuleModels.kt +++ b/app/src/main/java/dev/amenhancer/module/model/ModuleModels.kt @@ -57,6 +57,7 @@ object CustomLyricsSources { const val MANUAL = "manual" const val AMLL = "amll-ttml-db" const val NETEASE = "netease-yrc" + const val AM_LYRICS = "am-lyrics" } enum class FeatureState { diff --git a/app/src/main/java/dev/amenhancer/module/ui/SettingsActivity.kt b/app/src/main/java/dev/amenhancer/module/ui/SettingsActivity.kt index 9a314b7..544c9bd 100644 --- a/app/src/main/java/dev/amenhancer/module/ui/SettingsActivity.kt +++ b/app/src/main/java/dev/amenhancer/module/ui/SettingsActivity.kt @@ -35,6 +35,7 @@ import dev.amenhancer.module.XposedServiceSnapshot import dev.amenhancer.module.config.ConfigStore import dev.amenhancer.module.font.FontImportResult import dev.amenhancer.module.font.SafFontImporter +import dev.amenhancer.module.hook.AmLyricsClient import dev.amenhancer.module.hook.AmllTtmlClient import dev.amenhancer.module.hook.HttpLyricTransport import dev.amenhancer.module.hook.NeteaseLyricClient @@ -931,6 +932,16 @@ class SettingsActivity : Activity() { }, LinearLayout.LayoutParams(0, dp(44), 1f), ) + addView(spacer(8), LinearLayout.LayoutParams(dp(8), dp(1))) + addView( + fontActionButton("从 GitHub 导入", true) { + importFromAmLyrics(appleMusicId, ttml) { importedSource -> + source = importedSource + updateSourceLabel() + } + }, + LinearLayout.LayoutParams(0, dp(44), 1f), + ) }) form.addView(ttml, LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -1037,6 +1048,12 @@ class SettingsActivity : Activity() { } } + private fun onlineLyricsImporter(): CustomLyricsOnlineImporter = CustomLyricsOnlineImporter( + fetchAmll = AmllTtmlClient(HttpLyricTransport())::fetch, + fetchAmLyrics = AmLyricsClient(HttpLyricTransport())::fetch, + fetchNeteaseYrc = NeteaseLyricClient(HttpLyricTransport())::fetchYrc, + ) + private fun importFromAmll( appleMusicIdInput: EditText, ttmlInput: EditText, @@ -1048,10 +1065,23 @@ class SettingsActivity : Activity() { return } backgroundExecutor.execute { - val result = CustomLyricsOnlineImporter( - fetchAmll = AmllTtmlClient(HttpLyricTransport())::fetch, - fetchNeteaseYrc = NeteaseLyricClient(HttpLyricTransport())::fetchYrc, - ).importAmll(appleMusicId) + val result = onlineLyricsImporter().importAmll(appleMusicId) + showOnlineImportResult(result, ttmlInput, onImported) + } + } + + private fun importFromAmLyrics( + appleMusicIdInput: EditText, + ttmlInput: EditText, + onImported: (String) -> Unit, + ) { + val appleMusicId = parsePositiveId(appleMusicIdInput.text.toString()) + if (appleMusicId == null) { + appleMusicIdInput.error = "请输入正整数 Apple Music ID" + return + } + backgroundExecutor.execute { + val result = onlineLyricsImporter().importAmLyrics(appleMusicId) showOnlineImportResult(result, ttmlInput, onImported) } } @@ -1067,11 +1097,12 @@ class SettingsActivity : Activity() { neteaseIdInput.error = "请输入正整数网易云歌曲 ID" return } + val displayName = displayNameInput.text.toString() backgroundExecutor.execute { - val result = CustomLyricsOnlineImporter( - fetchAmll = AmllTtmlClient(HttpLyricTransport())::fetch, - fetchNeteaseYrc = NeteaseLyricClient(HttpLyricTransport())::fetchYrc, - ).importNetease(neteaseSongId, displayNameInput.text.toString()) + val result = onlineLyricsImporter().importNetease( + neteaseSongId, + displayName, + ) showOnlineImportResult(result, ttmlInput, onImported) } } @@ -1218,6 +1249,7 @@ class SettingsActivity : Activity() { private fun customLyricsSourceName(source: String): String = when (source) { CustomLyricsSources.AMLL -> "AMLL" CustomLyricsSources.NETEASE -> "网易云 YRC" + CustomLyricsSources.AM_LYRICS -> "AM-Lyrics 仓库" else -> "手动 TTML" } diff --git a/app/src/test/java/dev/amenhancer/module/config/CustomLyricsManifestPolicyTest.kt b/app/src/test/java/dev/amenhancer/module/config/CustomLyricsManifestPolicyTest.kt index 7364b7f..ce9fa4f 100644 --- a/app/src/test/java/dev/amenhancer/module/config/CustomLyricsManifestPolicyTest.kt +++ b/app/src/test/java/dev/amenhancer/module/config/CustomLyricsManifestPolicyTest.kt @@ -49,12 +49,25 @@ class CustomLyricsManifestPolicyTest { assertEquals(false, CustomLyricsManifestPolicy.isValidSha256("")) } - private fun entry(appleMusicId: Long, fileId: String) = CustomLyricsEntry( + @Test + fun `sanitize keeps the am lyrics source`() { + val sanitized = CustomLyricsManifestPolicy.sanitize( + CustomLyricsManifest(listOf(entry(42L, "lyrics_am", CustomLyricsSources.AM_LYRICS))), + ) + + assertEquals(CustomLyricsSources.AM_LYRICS, sanitized.entries.single().source) + } + + private fun entry( + appleMusicId: Long, + fileId: String, + source: String = CustomLyricsSources.MANUAL, + ) = CustomLyricsEntry( appleMusicId = appleMusicId, displayName = "Song $appleMusicId", fileId = fileId, sizeBytes = 42L, sha256 = sha256, - source = CustomLyricsSources.MANUAL, + source = source, ) } diff --git a/app/src/test/java/dev/amenhancer/module/hook/CurrentSongIdentityStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/CurrentSongIdentityStructuralRegressionTest.kt index d647389..91b47c8 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/CurrentSongIdentityStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/CurrentSongIdentityStructuralRegressionTest.kt @@ -29,6 +29,7 @@ class CurrentSongIdentityStructuralRegressionTest { assertFalse(target.contains("SharedPreferences")) assertFalse(target.contains("openRemoteFile")) assertFalse(target.contains("HttpLyricTransport")) + assertFalse(target.contains("AmLyricsClient")) assertFalse(target.contains("java.io.File")) assertFalse(target.contains("embedded-payload")) assertFalse(target.contains("com.apple.android.music.amplus")) diff --git a/app/src/test/java/dev/amenhancer/module/hook/OnlineLyricClientsTest.kt b/app/src/test/java/dev/amenhancer/module/hook/OnlineLyricClientsTest.kt index 203c25d..a39fa07 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/OnlineLyricClientsTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/OnlineLyricClientsTest.kt @@ -11,6 +11,7 @@ class OnlineLyricClientsTest { private class FakeTransport( var getResult: String? = null, + val getResults: MutableList = mutableListOf(), var postResult: String? = null, ) : LyricHttpTransport { val getUrls = mutableListOf() @@ -18,7 +19,7 @@ class OnlineLyricClientsTest { override fun get(url: String): String? { getUrls += url - return getResult + return if (getResults.isNotEmpty()) getResults.removeAt(0) else getResult } override fun postForm( @@ -55,6 +56,59 @@ class OnlineLyricClientsTest { assertNull(AmllTtmlClient(transport).fetch(42L)) } + @Test + fun `am lyrics client resolves an id and encodes the indexed path`() { + val index = """ + {"version":1,"layout":"artist-title-id","entries":[ + {"appleMusicId":1609445854,"enabled":true, + "path":"am-lyrics/八神纯子 - みずいろの雨 - 1609445854.ttml"} + ]} + """.trimIndent() + val transport = FakeTransport( + getResults = mutableListOf(index, "lyrics"), + ) + + assertEquals("lyrics", AmLyricsClient(transport).fetch(1609445854L)) + assertEquals( + listOf( + "https://raw.githubusercontent.com/Zennmn/am-lyrics/main/index.json", + "https://raw.githubusercontent.com/Zennmn/am-lyrics/main/" + + "am-lyrics/%E5%85%AB%E7%A5%9E%E7%BA%AF%E5%AD%90%20-%20" + + "%E3%81%BF%E3%81%9A%E3%81%84%E3%82%8D%E3%81%AE%E9%9B%A8%20-%20" + + "1609445854.ttml", + ), + transport.getUrls, + ) + } + + @Test + fun `am lyrics client fails open for missing ids and malformed paths`() { + val missing = FakeTransport( + getResult = """{"entries":[{"appleMusicId":42,"path":"am-lyrics/42.ttml"}]}""", + ) + assertNull(AmLyricsClient(missing).fetch(43L)) + assertEquals(listOf(AmLyricsClient.AM_LYRICS_INDEX_URL), missing.getUrls) + + val malformed = FakeTransport( + getResult = """{"entries":[{"appleMusicId":42,"path":"../outside.ttml"}]}""", + ) + assertNull(AmLyricsClient(malformed).fetch(42L)) + assertEquals(listOf(AmLyricsClient.AM_LYRICS_INDEX_URL), malformed.getUrls) + } + + @Test + fun `am lyrics client fails open for malformed index and disabled entries`() { + val malformed = FakeTransport(getResult = "not json") + assertNull(AmLyricsClient(malformed).fetch(42L)) + assertEquals(listOf(AmLyricsClient.AM_LYRICS_INDEX_URL), malformed.getUrls) + + val disabled = FakeTransport( + getResult = """{"entries":[{"appleMusicId":42,"enabled":false,"path":"am-lyrics/42.ttml"}]}""", + ) + assertNull(AmLyricsClient(disabled).fetch(42L)) + assertEquals(listOf(AmLyricsClient.AM_LYRICS_INDEX_URL), disabled.getUrls) + } + @Test fun `netease yrc uses the eapi post with the fixed url and headers`() { val transport = FakeTransport(postResult = eapiResponseJson) diff --git a/app/src/test/java/dev/amenhancer/module/lyrics/CustomLyricsOnlineImporterTest.kt b/app/src/test/java/dev/amenhancer/module/lyrics/CustomLyricsOnlineImporterTest.kt index b7a22ce..63f5056 100644 --- a/app/src/test/java/dev/amenhancer/module/lyrics/CustomLyricsOnlineImporterTest.kt +++ b/app/src/test/java/dev/amenhancer/module/lyrics/CustomLyricsOnlineImporterTest.kt @@ -13,6 +13,7 @@ class CustomLyricsOnlineImporterTest { var requestedId = 0L val importer = CustomLyricsOnlineImporter( fetchAmll = { id -> requestedId = id; ttml }, + fetchAmLyrics = { error("must not fetch AM-Lyrics") }, fetchNeteaseYrc = { error("must not fetch NetEase") }, ) @@ -30,6 +31,7 @@ class CustomLyricsOnlineImporterTest { var requestedId = 0L val importer = CustomLyricsOnlineImporter( fetchAmll = { error("must not fetch AMLL") }, + fetchAmLyrics = { error("must not fetch AM-Lyrics") }, fetchNeteaseYrc = { id -> requestedId = id LyricDocument(listOf(LyricLine(0, 1_000, listOf(LyricWord("word", 0, 1_000))))) @@ -42,4 +44,34 @@ class CustomLyricsOnlineImporterTest { assertTrue(result is CustomLyricsOnlineImportResult.Imported) assertEquals(CustomLyricsSources.NETEASE, (result as CustomLyricsOnlineImportResult.Imported).source) } + + @Test + fun `am lyrics import uses the supplied apple music id and source`() { + var requestedId = 0L + val importer = CustomLyricsOnlineImporter( + fetchAmll = { error("must not fetch AMLL") }, + fetchAmLyrics = { id -> requestedId = id; ttml }, + fetchNeteaseYrc = { error("must not fetch NetEase") }, + ) + + val result = importer.importAmLyrics(7335408332109193189L) + + assertEquals(7335408332109193189L, requestedId) + assertEquals( + CustomLyricsOnlineImportResult.Imported(ttml, CustomLyricsSources.AM_LYRICS), + result, + ) + } + + @Test + fun `am lyrics import fails open for invalid ids and invalid ttml`() { + val importer = CustomLyricsOnlineImporter( + fetchAmll = { error("must not fetch AMLL") }, + fetchAmLyrics = { "not ttml" }, + fetchNeteaseYrc = { error("must not fetch NetEase") }, + ) + + assertTrue(importer.importAmLyrics(0L) is CustomLyricsOnlineImportResult.Failed) + assertTrue(importer.importAmLyrics(42L) is CustomLyricsOnlineImportResult.Failed) + } } diff --git a/app/src/test/java/dev/amenhancer/module/ui/SettingsUiStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/ui/SettingsUiStructuralRegressionTest.kt index 2f909ed..0b0c1d2 100644 --- a/app/src/test/java/dev/amenhancer/module/ui/SettingsUiStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/ui/SettingsUiStructuralRegressionTest.kt @@ -103,11 +103,14 @@ class SettingsUiStructuralRegressionTest { assertTrue(activity.contains("从 AMLL 导入")) assertTrue(activity.contains("从网易云导入")) + assertTrue(activity.contains("从 GitHub 导入")) assertTrue(activity.contains("不会在播放时联网识歌")) assertTrue(manifest.contains("android.permission.INTERNET")) assertTrue(target.contains("session.start()")) assertFalse(target.contains("HttpLyricTransport")) + assertFalse(target.contains("AmLyricsClient")) assertFalse(session.contains("HttpLyricTransport")) + assertFalse(session.contains("AmLyricsClient")) assertFalse(session.contains("config.settings()")) assertTrue(session.contains("files and native parsing are prepared off-hook")) } @@ -140,6 +143,7 @@ class SettingsUiStructuralRegressionTest { assertTrue(requester.contains("TIMEOUT_MILLIS")) assertFalse(requester.contains("SharedPreferences")) assertFalse(requester.contains("HttpLyricTransport")) + assertFalse(requester.contains("AmLyricsClient")) } @Test