From 39e9b08e1bbdc5e7d4c90469fa09df51522f6d85 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:28:48 +0800 Subject: [PATCH 01/33] feat: isolate CJK karaoke animation feature --- .../dev/amenhancer/module/ModuleConstants.kt | 1 + .../module/config/ModuleSettingsSchema.kt | 7 + .../AppleMusicCjkKaraokeAnimationTarget.kt | 196 ++++++++++++++++++ .../module/hook/CjkKaraokeAnimationFeature.kt | 21 ++ .../module/hook/FeatureInstallation.kt | 1 + .../module/hook/TargetAdaptation.kt | 8 + .../amenhancer/module/hook/TargetSymbols.kt | 57 +++++ .../amenhancer/module/model/ModuleModels.kt | 2 + .../module/ui/EmbeddedSettingsHost.kt | 12 ++ .../amenhancer/module/ui/SettingsActivity.kt | 9 + .../module/config/ModuleSettingsSchemaTest.kt | 27 +++ .../config/OrdinarySettingsWritePolicyTest.kt | 1 + .../hook/CjkKaraokeAnimationFeatureTest.kt | 74 +++++++ .../hook/CjkKaraokeAnimationTargetTest.kt | 76 +++++++ ...edSettingsEntryStructuralRegressionTest.kt | 1 + .../ui/SettingsUiStructuralRegressionTest.kt | 3 + 16 files changed, 496 insertions(+) create mode 100644 app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt create mode 100644 app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt create mode 100644 app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeatureTest.kt create mode 100644 app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt diff --git a/app/src/main/java/dev/amenhancer/module/ModuleConstants.kt b/app/src/main/java/dev/amenhancer/module/ModuleConstants.kt index 2590784..a74e038 100644 --- a/app/src/main/java/dev/amenhancer/module/ModuleConstants.kt +++ b/app/src/main/java/dev/amenhancer/module/ModuleConstants.kt @@ -10,6 +10,7 @@ object ModuleConstants { const val FEATURE_EDITORIAL_VIDEO = "editorial_video" const val FEATURE_PHONE_LIQUID_GLASS = "phone_liquid_glass" const val FEATURE_FUTURE_BLUR = "future_blur" + const val FEATURE_CJK_KARAOKE_ANIMATION = "cjk_karaoke_animation" const val FEATURE_LYRICS_TYPEFACE = "lyrics_typeface" const val FEATURE_CUSTOM_LYRICS = "custom_lyrics" const val FEATURE_CURRENT_SONG_IDENTITY = "current_song_identity" diff --git a/app/src/main/java/dev/amenhancer/module/config/ModuleSettingsSchema.kt b/app/src/main/java/dev/amenhancer/module/config/ModuleSettingsSchema.kt index 1b320b1..c80c62f 100644 --- a/app/src/main/java/dev/amenhancer/module/config/ModuleSettingsSchema.kt +++ b/app/src/main/java/dev/amenhancer/module/config/ModuleSettingsSchema.kt @@ -17,6 +17,10 @@ internal object ModuleSettingsSchema { default = false, ), futureBlurEnabled = values.boolean(KEY_FUTURE_BLUR, default = true), + cjkKaraokeAnimationEnabled = values.boolean( + KEY_CJK_KARAOKE_ANIMATION_ENABLED, + default = true, + ), navigationCompensationEnabled = values.boolean( KEY_NAVIGATION_COMPENSATION, default = false, @@ -62,6 +66,7 @@ internal object ModuleSettingsSchema { KEY_DISABLE_EDITORIAL_VIDEO_ON_TABLET to settings.disableEditorialVideoOnTablet, KEY_PHONE_LIQUID_GLASS to settings.phoneLiquidGlassEnabled, KEY_FUTURE_BLUR to settings.futureBlurEnabled, + KEY_CJK_KARAOKE_ANIMATION_ENABLED to settings.cjkKaraokeAnimationEnabled, KEY_NAVIGATION_COMPENSATION to settings.navigationCompensationEnabled, KEY_LYRIC_BLUR_RADIUS_OFFSET to settings.lyricBlurRadiusOffsetPx.coerceIn( ModuleSettings.MIN_LYRIC_BLUR_RADIUS_OFFSET_PX, @@ -186,6 +191,7 @@ internal object ModuleSettingsSchema { KEY_DISABLE_EDITORIAL_VIDEO_ON_TABLET, KEY_PHONE_LIQUID_GLASS, KEY_FUTURE_BLUR, + KEY_CJK_KARAOKE_ANIMATION_ENABLED, KEY_NAVIGATION_COMPENSATION, KEY_LYRIC_BLUR_RADIUS_OFFSET, KEY_TITLE_CORRECTION_ENABLED, @@ -212,6 +218,7 @@ internal object ModuleSettingsSchema { "disable_editorial_video_on_tablet" private const val KEY_PHONE_LIQUID_GLASS = "phone_liquid_glass_enabled" private const val KEY_FUTURE_BLUR = "future_blur_enabled" + private const val KEY_CJK_KARAOKE_ANIMATION_ENABLED = "cjk_karaoke_animation_enabled" private const val KEY_NAVIGATION_COMPENSATION = "navigation_compensation_enabled" private const val KEY_LYRIC_BLUR_RADIUS_OFFSET = "lyric_blur_radius_offset_px" private const val KEY_TITLE_CORRECTION_ENABLED = "title_correction_enabled" diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt new file mode 100644 index 0000000..e31dc4e --- /dev/null +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -0,0 +1,196 @@ +package dev.amenhancer.module.hook + +import java.util.concurrent.atomic.AtomicInteger + +/** + * Narrow Apple Music 6.5.2/1586 adapter for the native karaoke rush-gradient + * path. The host's `z.a0` call is the only scope in which the UnicodeBlock + * helper result is adjusted; normal `z.g0` layout calls therefore retain their + * original CJK behavior. + */ +internal class AppleMusicCjkKaraokeAnimationTarget( + private val symbols: TargetSymbolResolver, +) : CjkKaraokeAnimationTarget { + private val a0Depth: ThreadLocal = ThreadLocal.withInitial { 0 } + private val rewriteLogCount = AtomicInteger() + + override fun install(): TargetCapabilityInstall { + val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) + val helperResolution = symbols.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod) + val a0 = a0Resolution.valueOrNull() + val helper = helperResolution.valueOrNull() + if (a0 == null || helper == null) { + return TargetCapabilityInstall.Degraded( + listOf(a0Resolution, helperResolution) + .filterNot { it is TargetResolution.Found<*> } + .joinToString { it.summary }, + ) + } + + val failures = mutableListOf() + val a0Installed = try { + ModernXposedRuntime.hookMethod(a0, object : ModernMethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + enterA0Scope() + } + + override fun afterHookedMethod(param: MethodHookParam) { + // The host callback runs even when z.a0 throws; always + // release this thread's scope so a later g0 call cannot + // inherit a stale override. + leaveA0Scope() + } + }).also { installed -> + if (!installed) failures += "z.a0 hook was rejected" + } + } catch (error: Throwable) { + failures += "z.a0 hook failed: ${error.cjkShortMessage()}" + ModernXposedRuntime.log("CJK karaoke z.a0 hook failed", error) + false + } + + val helperInstalled = try { + ModernXposedRuntime.hookMethod(helper, object : ModernMethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + try { + rewriteCjkK0Result(param) + } catch (error: Throwable) { + // A malformed host call must never break its original + // helper result. This is deliberately fail-open. + ModernXposedRuntime.log("CJK karaoke UnicodeBlock helper failed open", error) + } + } + }).also { installed -> + if (!installed) failures += "I0\$a.a hook was rejected" + } + } catch (error: Throwable) { + failures += "I0\$a.a hook failed: ${error.cjkShortMessage()}" + ModernXposedRuntime.log("CJK karaoke I0\$a.a hook failed", error) + false + } + + if (!a0Installed || !helperInstalled) { + return TargetCapabilityInstall.Degraded( + failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, + ) + } + + return TargetCapabilityInstall.Active( + "Installed exact Apple Music 6.5.2/1586 z.a0 + I0\$a.a CJK j0/k0 rush-gradient scope", + ) + } + + private fun rewriteCjkK0Result(param: ModernMethodHook.MethodHookParam) { + if (!isInA0Scope()) return + + val text = param.args.getOrNull(0) as? CharSequence ?: return + if (!containsCjkKaraokeScript(text)) return + + val languageSet = param.args.getOrNull(1) as? Set<*> ?: return + when { + isK0Set(languageSet) && param.result == true -> { + // CJK is normally classified into k0, which blocks the + // rush branch. Make that one result look like a default + // script only while a0 is running. + param.result = false + logRewrite(text, "k0 true -> false") + } + isJ0Set(languageSet) && containsHangul(text) && param.result != true -> { + // i0 receives the j0 hit as its split/rush eligibility bit. + // Hangul belongs to k0 but not j0, so opt it into the same + // Apple animation only inside a0; g0 remains untouched. + param.result = true + logRewrite(text, "j0 false -> true") + } + else -> return + } + } + + private fun logRewrite(text: CharSequence, change: String) { + if (rewriteLogCount.getAndIncrement() < MAX_REWRITE_LOGS) { + ModernXposedRuntime.log( + "CJK karaoke a0 scope: I0\$a.a(${text.length} chars, $change); g0 unchanged", + ) + } + } + + private fun isK0Set(languageSet: Set<*>): Boolean = runCatching { + // z.k0 always contains HANGUL_SYLLABLES. This marker distinguishes + // it from the host's j0/l0 sets without touching either set globally. + languageSet.contains(Character.UnicodeBlock.HANGUL_SYLLABLES) + }.getOrElse { error -> + ModernXposedRuntime.log("CJK karaoke k0 marker check failed open", error) + false + } + + private fun isJ0Set(languageSet: Set<*>): Boolean = runCatching { + languageSet.contains(Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS) && + languageSet.contains(Character.UnicodeBlock.HIRAGANA) && + languageSet.contains(Character.UnicodeBlock.KATAKANA) && + !languageSet.contains(Character.UnicodeBlock.HANGUL_SYLLABLES) && + !languageSet.contains(Character.UnicodeBlock.THAI) + }.getOrElse { error -> + ModernXposedRuntime.log("CJK karaoke j0 marker check failed open", error) + false + } + + private fun enterA0Scope() { + runCatching { a0Depth.set((a0Depth.get() ?: 0) + 1) } + .onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth enter failed open", error) } + } + + private fun leaveA0Scope() { + runCatching { + val depth = a0Depth.get() ?: 0 + if (depth <= 1) { + a0Depth.remove() + } else { + a0Depth.set(depth - 1) + } + }.onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth cleanup failed open", error) } + } + + private fun isInA0Scope(): Boolean = runCatching { (a0Depth.get() ?: 0) > 0 } + .getOrElse { error -> + ModernXposedRuntime.log("CJK karaoke a0 depth read failed open", error) + false + } + + private companion object { + const val MAX_REWRITE_LOGS = 3 + } +} + +/** Returns true for the CJK blocks used by the host's karaoke classifier. */ +internal fun containsCjkKaraokeScript(text: CharSequence): Boolean { + for (index in text.indices) { + when (Character.UnicodeBlock.of(text[index])) { + Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS, + Character.UnicodeBlock.HIRAGANA, + Character.UnicodeBlock.KATAKANA, + Character.UnicodeBlock.HANGUL_SYLLABLES, + Character.UnicodeBlock.HANGUL_JAMO, + Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO, + -> return true + } + } + return false +} + +private fun containsHangul(text: CharSequence): Boolean { + for (index in text.indices) { + when (Character.UnicodeBlock.of(text[index])) { + Character.UnicodeBlock.HANGUL_SYLLABLES, + Character.UnicodeBlock.HANGUL_JAMO, + Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO, + -> return true + else -> Unit + } + } + return false +} + +private fun Throwable.cjkShortMessage(): String = buildString { + append(javaClass.simpleName.ifBlank { javaClass.name }) + message?.takeIf(String::isNotBlank)?.let { append(": ").append(it.take(180)) } +} diff --git a/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt b/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt new file mode 100644 index 0000000..3486833 --- /dev/null +++ b/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt @@ -0,0 +1,21 @@ +package dev.amenhancer.module.hook + +import dev.amenhancer.module.ModuleConstants + +/** + * Installs the narrow Apple Music 6.5.2 karaoke animation adaptation. + * + * The target adapter owns all version and symbol checks; unsupported hosts + * therefore report a degraded health result without touching existing lyric + * layout or blur paths. + */ +internal class CjkKaraokeAnimationFeature : FeatureHook { + override val key: String = ModuleConstants.FEATURE_CJK_KARAOKE_ANIMATION + + override fun install(context: HookContext): FeatureInstallResult { + if (!context.config.settings().cjkKaraokeAnimationEnabled) { + return FeatureInstallResult.disabled("CJK 长尾歌词动画已关闭") + } + return context.target.cjkKaraokeAnimation.install().toFeatureInstallResult() + } +} diff --git a/app/src/main/java/dev/amenhancer/module/hook/FeatureInstallation.kt b/app/src/main/java/dev/amenhancer/module/hook/FeatureInstallation.kt index 824cb12..0e70adf 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/FeatureInstallation.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/FeatureInstallation.kt @@ -276,6 +276,7 @@ private fun productionFeatureInstallationModule( feature = FutureLyricBlurFeature(), registerResources = { LyricCreditsRowResourceHook.install() }, ), + FeatureInstallationPlan(feature = CjkKaraokeAnimationFeature()), FeatureInstallationPlan( feature = LyricsTypefaceFeature(), registerResources = lyricsTypefaceSession::registerResources, diff --git a/app/src/main/java/dev/amenhancer/module/hook/TargetAdaptation.kt b/app/src/main/java/dev/amenhancer/module/hook/TargetAdaptation.kt index 89abfa3..7e61886 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TargetAdaptation.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TargetAdaptation.kt @@ -16,6 +16,9 @@ internal data class TargetAdaptation( val dualPane: DualPaneTarget, val editorialVideo: EditorialVideoTarget, val bidirectionalLyricBlur: BidirectionalLyricBlurTarget, + val cjkKaraokeAnimation: CjkKaraokeAnimationTarget = CjkKaraokeAnimationTarget { + TargetCapabilityInstall.Degraded("CJK karaoke animation target was not configured") + }, val lyricsTypeface: LyricsTypefaceTarget = LyricsTypefaceTarget { TargetCapabilityInstall.Degraded("Lyrics typeface target was not configured") }, @@ -84,6 +87,7 @@ internal data class TargetAdaptation( dualPane = AppleMusicDualPaneTarget(resolver, build), editorialVideo = AppleMusicEditorialVideoTarget(application, resolver), bidirectionalLyricBlur = AppleMusicBidirectionalLyricBlurTarget(resolver), + cjkKaraokeAnimation = AppleMusicCjkKaraokeAnimationTarget(resolver), lyricsTypeface = AppleMusicLyricsTypefaceTarget( symbols = resolver, session = lyricsTypefaceSession, @@ -130,6 +134,10 @@ internal fun interface BidirectionalLyricBlurTarget { fun install(): TargetCapabilityInstall } +internal fun interface CjkKaraokeAnimationTarget { + fun install(): TargetCapabilityInstall +} + internal fun interface LyricsTypefaceTarget { fun install(): TargetCapabilityInstall } diff --git a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt index 95ad8b8..63e0a8c 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt @@ -261,6 +261,10 @@ internal enum class TargetSymbolId { MEDIA_ENTITY_TO_SONG_CONVERTER, STORE_FRONT_LANGUAGE_ARRAY_OWNER, STORE_FRONT_LANGUAGE_ARRAY_METHOD, + CJK_KARAOKE_ANIMATION_OWNER, + CJK_KARAOKE_ANIMATION_METHOD, + CJK_UNICODE_BLOCK_HELPER_OWNER, + CJK_UNICODE_BLOCK_HELPER_METHOD, } private object AppleMusicProfiles { @@ -378,12 +382,16 @@ private object AppleMusicProfiles { TargetSymbolId.LYRICS_AVAILABILITY_OWNER to "com.apple.android.music.player.e1", TargetSymbolId.MEDIA_ENTITY_TO_SONG_CONVERTER to "y8.B", TargetSymbolId.STORE_FRONT_LANGUAGE_ARRAY_OWNER to "J5.a", + TargetSymbolId.CJK_KARAOKE_ANIMATION_OWNER to "com.apple.android.music.player.z", + TargetSymbolId.CJK_UNICODE_BLOCK_HELPER_OWNER to "com.apple.android.music.utils.I0\$a", ), exactMethods = mapOf( TargetSymbolId.PLAYER_ACTIVITY_CREATE_STACKED_NAVIGATION_HOLDER to "k1", TargetSymbolId.PLAYER_ACTIVITY_ROOT to "n0", TargetSymbolId.LYRICS_ITEM_UPDATE_METHOD to "o2", TargetSymbolId.STORE_FRONT_LANGUAGE_ARRAY_METHOD to "b", + TargetSymbolId.CJK_KARAOKE_ANIMATION_METHOD to "a0", + TargetSymbolId.CJK_UNICODE_BLOCK_HELPER_METHOD to "a", ), exactFields = mapOf( TargetSymbolId.PLAYER_ACTIVITY_BEHAVIOR_FIELD to "c1", @@ -402,6 +410,36 @@ private object AppleMusicProfiles { } internal object AppleMusicSymbols { + /** + * Apple Music 6.5.2/1586's karaoke transition entry point + * (`com.apple.android.music.player.z.a0(z$a, int, int, int, boolean)`). + * This is intentionally profile-only: a structural match on another + * obfuscated build could alter the host's animation state machine. + */ + val CjkKaraokeAnimationMethod = methodSymbol( + id = "cjk-karaoke-animation-method", + profileOwner = TargetSymbolId.CJK_KARAOKE_ANIMATION_OWNER, + profilePolicy = ProfilePolicy.EXACT_REQUIRED, + exactMethodId = TargetSymbolId.CJK_KARAOKE_ANIMATION_METHOD, + fallbackOwner = { false }, + contract = ::isCjkKaraokeAnimationMethod, + ) + + /** + * Apple Music 6.5.2/1586's UnicodeBlock-set predicate + * (`com.apple.android.music.utils.I0$a.a(CharSequence, Set): boolean`). + * Like the animation entry point, this must never fall back to a guessed + * helper on 6.5.0/6.5.1 or an unknown host build. + */ + val CjkUnicodeBlockPredicateMethod = methodSymbol( + id = "cjk-unicode-block-predicate-method", + profileOwner = TargetSymbolId.CJK_UNICODE_BLOCK_HELPER_OWNER, + profilePolicy = ProfilePolicy.EXACT_REQUIRED, + exactMethodId = TargetSymbolId.CJK_UNICODE_BLOCK_HELPER_METHOD, + fallbackOwner = { false }, + contract = ::isCjkUnicodeBlockPredicateMethod, + ) + val PlayerController = classSymbol( id = "player-controller", profileId = TargetSymbolId.PLAYER_CONTROLLER, @@ -1741,6 +1779,25 @@ internal object AppleMusicSymbols { } +private fun isCjkKaraokeAnimationMethod(method: Method): Boolean = + !Modifier.isStatic(method.modifiers) && + method.name == "a0" && + method.parameterTypes.size == 5 && + method.parameterTypes[0].name.endsWith("\$a") && + method.parameterTypes[1] == Int::class.javaPrimitiveType && + method.parameterTypes[2] == Int::class.javaPrimitiveType && + method.parameterTypes[3] == Int::class.javaPrimitiveType && + method.parameterTypes[4] == Boolean::class.javaPrimitiveType && + method.returnType == Void.TYPE + +private fun isCjkUnicodeBlockPredicateMethod(method: Method): Boolean = + Modifier.isStatic(method.modifiers) && + method.name == "a" && + method.parameterTypes.contentEquals( + arrayOf(CharSequence::class.java, java.util.Set::class.java), + ) && + method.returnType == Boolean::class.javaPrimitiveType + private fun classSymbol( id: String, profileId: TargetSymbolId? = null, 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 a10c0c4..c1209dd 100644 --- a/app/src/main/java/dev/amenhancer/module/model/ModuleModels.kt +++ b/app/src/main/java/dev/amenhancer/module/model/ModuleModels.kt @@ -9,6 +9,8 @@ data class ModuleSettings( val disableEditorialVideoOnTablet: Boolean = true, val phoneLiquidGlassEnabled: Boolean = false, val futureBlurEnabled: Boolean = true, + /** Enables the native rush-gradient adaptation for CJK karaoke lyrics. */ + val cjkKaraokeAnimationEnabled: Boolean = true, val navigationCompensationEnabled: Boolean = false, val lyricBlurRadiusOffsetPx: Int = 0, val titleCorrectionEnabled: Boolean = false, diff --git a/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt b/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt index 2b7dbcb..831aa4c 100644 --- a/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt +++ b/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt @@ -2237,6 +2237,18 @@ internal class EmbeddedSettingsHost private constructor( ), ) { onSettingsChanged(settings.copy(futureBlurEnabled = it)) }) addView(embeddedDivider(activity)) + addView(embeddedSettingRow( + activity, + "CJK 长尾歌词动画", + "CJK 歌词启用原生 rush-gradient 动画 · 重开 Apple Music 后生效", + settings.cjkKaraokeAnimationEnabled, + iconTint = EmbeddedSettingsPalette.accent, + iconDrawable = EmbeddedGlyphDrawable( + EmbeddedGlyphKind.Music, + EmbeddedSettingsPalette.accent, + ), + ) { onSettingsChanged(settings.copy(cjkKaraokeAnimationEnabled = it)) }) + addView(embeddedDivider(activity)) addView(embeddedSettingRow( activity, "歌曲名显示修正", 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 aebde4e..0bd2bee 100644 --- a/app/src/main/java/dev/amenhancer/module/ui/SettingsActivity.kt +++ b/app/src/main/java/dev/amenhancer/module/ui/SettingsActivity.kt @@ -410,6 +410,15 @@ class SettingsActivity : Activity() { store.saveSettings(store.settings().copy(futureBlurEnabled = enabled)) }) addView(insetDivider()) + addView(settingRow( + title = "CJK 长尾歌词动画", + summary = "CJK 歌词启用原生 rush-gradient 动画 · 修改后重开 Apple Music", + checked = settings.cjkKaraokeAnimationEnabled, + enabled = writable, + ) { enabled -> + store.saveSettings(store.settings().copy(cjkKaraokeAnimationEnabled = enabled)) + }) + addView(insetDivider()) addView(blurRadiusOffsetRow( offsetPx = settings.lyricBlurRadiusOffsetPx, enabled = writable, diff --git a/app/src/test/java/dev/amenhancer/module/config/ModuleSettingsSchemaTest.kt b/app/src/test/java/dev/amenhancer/module/config/ModuleSettingsSchemaTest.kt index 5985e8a..b63cb36 100644 --- a/app/src/test/java/dev/amenhancer/module/config/ModuleSettingsSchemaTest.kt +++ b/app/src/test/java/dev/amenhancer/module/config/ModuleSettingsSchemaTest.kt @@ -17,6 +17,7 @@ class ModuleSettingsSchemaTest { disableEditorialVideoOnTablet = true, phoneLiquidGlassEnabled = false, futureBlurEnabled = true, + cjkKaraokeAnimationEnabled = true, navigationCompensationEnabled = false, lyricBlurRadiusOffsetPx = 0, titleCorrectionEnabled = false, @@ -45,6 +46,7 @@ class ModuleSettingsSchemaTest { "disable_editorial_video_on_tablet" to false, "phone_liquid_glass_enabled" to true, "future_blur_enabled" to false, + "cjk_karaoke_animation_enabled" to true, "navigation_compensation_enabled" to false, "lyric_blur_radius_offset_px" to 6, "title_correction_enabled" to false, @@ -78,6 +80,7 @@ class ModuleSettingsSchemaTest { "disable_editorial_video_on_tablet" to true, "phone_liquid_glass_enabled" to true, "future_blur_enabled" to true, + "cjk_karaoke_animation_enabled" to true, "navigation_compensation_enabled" to false, "lyric_blur_radius_offset_px" to 0, "title_correction_enabled" to false, @@ -141,6 +144,7 @@ class ModuleSettingsSchemaTest { disableEditorialVideoOnTablet = false, phoneLiquidGlassEnabled = false, futureBlurEnabled = false, + cjkKaraokeAnimationEnabled = true, navigationCompensationEnabled = false, lyricBlurRadiusOffsetPx = 0, titleCorrectionEnabled = false, @@ -246,6 +250,29 @@ class ModuleSettingsSchemaTest { ) } + @Test + fun `cjk karaoke animation defaults on and round trips`() { + assertEquals( + true, + ModuleSettingsSchema.decode(emptyMap()).cjkKaraokeAnimationEnabled, + ) + assertEquals( + true, + ModuleSettingsSchema.decode( + mapOf("cjk_karaoke_animation_enabled" to "not-a-boolean"), + ).cjkKaraokeAnimationEnabled, + ) + + val encoded = ModuleSettingsSchema.encodeOrdinarySettings( + ModuleSettings(cjkKaraokeAnimationEnabled = false), + ) + assertEquals(false, encoded["cjk_karaoke_animation_enabled"]) + assertEquals( + false, + ModuleSettingsSchema.decode(encoded).cjkKaraokeAnimationEnabled, + ) + } + @Test fun `an old online lyric setting migrates to the custom lyrics gate`() { val upgraded = ModuleSettingsSchema.upgrade( diff --git a/app/src/test/java/dev/amenhancer/module/config/OrdinarySettingsWritePolicyTest.kt b/app/src/test/java/dev/amenhancer/module/config/OrdinarySettingsWritePolicyTest.kt index b57a99b..8f3164f 100644 --- a/app/src/test/java/dev/amenhancer/module/config/OrdinarySettingsWritePolicyTest.kt +++ b/app/src/test/java/dev/amenhancer/module/config/OrdinarySettingsWritePolicyTest.kt @@ -105,6 +105,7 @@ class OrdinarySettingsWritePolicyTest { "disable_editorial_video_on_tablet" to false, "phone_liquid_glass_enabled" to true, "future_blur_enabled" to false, + "cjk_karaoke_animation_enabled" to true, "navigation_compensation_enabled" to false, "lyric_blur_radius_offset_px" to 6, "title_correction_enabled" to false, diff --git a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeatureTest.kt b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeatureTest.kt new file mode 100644 index 0000000..7619b79 --- /dev/null +++ b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeatureTest.kt @@ -0,0 +1,74 @@ +package dev.amenhancer.module.hook + +import android.content.SharedPreferences +import dev.amenhancer.module.config.TargetConfigClient +import dev.amenhancer.module.model.FeatureState +import java.lang.reflect.Proxy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CjkKaraokeAnimationFeatureTest { + @Test + fun `disabled setting does not install the target hook`() { + var installed = false + val result = CjkKaraokeAnimationFeature().install( + HookContext( + config = config(false), + target = target { + installed = true + TargetCapabilityInstall.Active("installed") + }, + ), + ) + + assertEquals(FeatureState.DISABLED, result.state) + assertFalse(installed) + } + + @Test + fun `missing setting defaults to enabled and installs the target hook`() { + var installed = false + val result = CjkKaraokeAnimationFeature().install( + HookContext( + config = config(null), + target = target { + installed = true + TargetCapabilityInstall.Active("installed") + }, + ), + ) + + assertEquals(FeatureState.ACTIVE, result.state) + assertTrue(installed) + } + + private fun target(install: () -> TargetCapabilityInstall): TargetAdaptation = + TargetAdaptation( + identity = "test", + dualPane = DualPaneTarget { TargetCapabilityInstall.Active("unused") }, + editorialVideo = EditorialVideoTarget { TargetCapabilityInstall.Active("unused") }, + bidirectionalLyricBlur = BidirectionalLyricBlurTarget { + TargetCapabilityInstall.Active("unused") + }, + cjkKaraokeAnimation = CjkKaraokeAnimationTarget(install), + ) + + private fun config(enabled: Boolean?): TargetConfigClient = TargetConfigClient( + Proxy.newProxyInstance( + SharedPreferences::class.java.classLoader, + arrayOf(SharedPreferences::class.java), + ) { _, method, _ -> + when (method.name) { + "getAll" -> enabled?.let { + mapOf("cjk_karaoke_animation_enabled" to it) + } ?: emptyMap() + "toString" -> "cjk-karaoke-feature-test-preferences" + "hashCode" -> 1 + "equals" -> false + else -> null + } + } as SharedPreferences, + ) +} diff --git a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt new file mode 100644 index 0000000..c01c415 --- /dev/null +++ b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt @@ -0,0 +1,76 @@ +package dev.amenhancer.module.hook + +import dev.amenhancer.module.ModuleConstants +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CjkKaraokeAnimationTargetTest { + @Test + fun `predicate recognizes host CJK script blocks but not latin text`() { + assertTrue(containsCjkKaraokeScript("漢")) + assertTrue(containsCjkKaraokeScript("ひ")) + assertTrue(containsCjkKaraokeScript("カ")) + assertTrue(containsCjkKaraokeScript("한")) + assertTrue(!containsCjkKaraokeScript("lyrics")) + } + + @Test + fun `karaoke symbols are exact profile only and reject 650 and unknown builds`() { + val source = FixtureTargetClassSource( + classes = mapOf( + "com.apple.android.music.player.z" to CjkAnimationFixture::class.java, + "com.apple.android.music.utils.I0\$a" to CjkHelperFixture::class.java, + ), + ) + + val exact652 = IndexedTargetSymbolResolver( + TargetBuild(ModuleConstants.TARGET_PACKAGE, "6.5.2", 1586L), + source, + ) + val exactMethods = listOf( + exact652.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod), + exact652.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod), + ) + exactMethods.forEach { resolution -> + assertTrue(resolution is TargetResolution.Found<*>) + assertEquals(SymbolMatch.VERSION_PROFILE, (resolution as TargetResolution.Found<*>).match) + } + + val old651 = IndexedTargetSymbolResolver( + TargetBuild(ModuleConstants.TARGET_PACKAGE, "6.5.1", 1583L), + source, + ) + assertTrue(old651.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) is TargetResolution.Missing) + assertTrue(old651.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod) is TargetResolution.Missing) + + val unknown = IndexedTargetSymbolResolver(TargetBuild.UNKNOWN, source) + assertTrue(unknown.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) is TargetResolution.Missing) + assertTrue(unknown.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod) is TargetResolution.Missing) + } +} + +private class FixtureTargetClassSource( + private val classes: Map>, +) : TargetClassSource { + override fun classNames(): List = classes.keys.toList() + + override fun loadClass(name: String): Class<*>? = classes[name] +} + +private class CjkAnimationFixture { + class a + + @Suppress("UNUSED_PARAMETER") + fun a0(holder: a, first: Int, second: Int, third: Int, background: Boolean) { + // Signature-only fixture for the profile resolver. + } +} + +private class CjkHelperFixture { + companion object { + @JvmStatic + @Suppress("UNUSED_PARAMETER") + fun a(text: CharSequence, blocks: Set<*>): Boolean = true + } +} diff --git a/app/src/test/java/dev/amenhancer/module/ui/EmbeddedSettingsEntryStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/ui/EmbeddedSettingsEntryStructuralRegressionTest.kt index 71058d1..749f61b 100644 --- a/app/src/test/java/dev/amenhancer/module/ui/EmbeddedSettingsEntryStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/ui/EmbeddedSettingsEntryStructuralRegressionTest.kt @@ -383,6 +383,7 @@ class EmbeddedSettingsEntryStructuralRegressionTest { VisibleSetting("平板底栏补偿", "navigationCompensationEnabled"), VisibleSetting("手机液态玻璃底栏", "phoneLiquidGlassEnabled"), VisibleSetting("双向歌词模糊", "futureBlurEnabled"), + VisibleSetting("CJK 长尾歌词动画", "cjkKaraokeAnimationEnabled"), VisibleSetting("歌词模糊半径偏移", "lyricBlurRadiusOffsetPx"), VisibleSetting("歌曲名显示修正", "titleCorrectionEnabled"), VisibleSetting("目标语言", "titleCorrectionTargetLanguage"), 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 dda8aa9..4cf220b 100644 --- a/app/src/test/java/dev/amenhancer/module/ui/SettingsUiStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/ui/SettingsUiStructuralRegressionTest.kt @@ -49,6 +49,9 @@ class SettingsUiStructuralRegressionTest { assertTrue(activity.contains("title = \"平板底栏补偿\"")) assertTrue(activity.contains("summary = \"如果底栏显示异常开启该选项\"")) assertTrue(activity.contains("navigationCompensationEnabled = enabled")) + assertTrue(activity.contains("title = \"CJK 长尾歌词动画\"")) + assertTrue(activity.contains("cjkKaraokeAnimationEnabled = enabled")) + assertTrue(activity.contains("原生 rush-gradient 动画")) assertTrue(activity.contains("minimumHeight = dp(84)")) assertTrue(activity.contains("contentDescription = title")) assertTrue(activity.contains("歌词模糊半径偏移")) From 38f923c4c21161cc0bdb75d171d839358c37a32d Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:08:23 +0800 Subject: [PATCH 02/33] docs: add CJK karaoke animation handoff --- docs/cjk-karaoke-animation-handoff.md | 77 +++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/cjk-karaoke-animation-handoff.md diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md new file mode 100644 index 0000000..4468eb9 --- /dev/null +++ b/docs/cjk-karaoke-animation-handoff.md @@ -0,0 +1,77 @@ +# CJK Karaoke Animation 交接文档 + +## 目的 + +本分支保存 Apple Music 6.5.2/1586 的 CJK 长尾歌词动画实验。它不属于 `main` 稳定线,后续维护、设备验证或替换为 AM++ 自绘动画都从本分支继续。 + +## 当前分支与远端 + +- 分支:`codex/cjk-karaoke-animation` +- 当前提交:`39e9b08 feat: isolate CJK karaoke animation feature` +- 远端:[Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation](https://github.com/Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation) +- 主分支基线:`main`(当前不包含本实验功能) +- 当前分支已推送到 `origin`;不要把本分支代码直接合并到 `main`,除非完成新的设备验收。 + +## 已实现内容 + +1. 仅对 Apple Music `6.5.2/1586` 解析并 Hook: + - `com.apple.android.music.player.z.a0` + - `com.apple.android.music.utils.I0$a.a(CharSequence, Set)` +2. 在 `z.a0` 的线程局部调用范围内,临时放开 CJK 的 `k0/j0` 分类结果,以复用 Apple 原生 rush-gradient;不会全局修改 Apple 的静态字符集合,也不会改变 `g0` 的原始 CJK 排版路径。 +3. 设置页和嵌入式设置页都有独立开关: + - key:`cjk_karaoke_animation_enabled` + - 字段:`ModuleSettings.cjkKaraokeAnimationEnabled` + - 默认值:`true` + - 关闭后需要重启 Apple Music,feature 才不会注册 Hook。 + +## 关键代码位置 + +- Hook 实现:`app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt` +- Feature gate:`app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt` +- 版本符号:`app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt` +- Feature 注册:`app/src/main/java/dev/amenhancer/module/hook/FeatureInstallation.kt` +- 设置模型/schema:`app/src/main/java/dev/amenhancer/module/model/ModuleModels.kt`、`app/src/main/java/dev/amenhancer/module/config/ModuleSettingsSchema.kt` +- 设置 UI:`app/src/main/java/dev/amenhancer/module/ui/SettingsActivity.kt`、`app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt` + +## 已知边界 + +- 当前方案是“复用 Apple 原生动画分支”,不是 AM++ 自己绘制的稳定实现;用户已观察到 CJK 仍可能出现动画错位、跳动或宽度问题。 +- 后续更稳妥的路线是:在 Apple adapter 完成逐词 View 绑定后,由 AM++ 自己创建 `ValueAnimator`/渐变遮罩,并处理 View 回收、行切换、混排字符和取消旧动画。 +- 该自绘路线尚未实现。不要只在整行 RecyclerView 上加动画;必须拿到实际词 View 和可靠的逐词时序。 +- Apple 版本、混淆类名和方法签名是私有契约;新增版本必须重新做 exact profile 和设备验证。 + +## 继续维护步骤 + +```powershell +git fetch origin +git switch codex/cjk-karaoke-animation +git pull --ff-only +``` + +修改后至少运行: + +```powershell +.\gradlew.bat :app:testDebugUnitTest :app:lintDebug :app:lintVitalRelease :app:assembleRelease +git diff --check +``` + +如需回到稳定线: + +```powershell +git switch main +``` + +## 现有证据与过程记录 + +- 逆向结论、DEX/smali/native 符号和行为矩阵:仓库根目录 `findings.md` +- 时间线、测试、构建和分支操作记录:仓库根目录 `progress.md` +- 当前阶段计划:仓库根目录 `task_plan.md` +- Apple Music 6.5.2 输入包:工作区未跟踪的 `Apple+Music_6.5.2_APKPure.xapk`;不要把它或 `androguard.db*` 误加入提交。 + +## 建议后续调用的 skills + +- `planning-with-files-zh`:继续维护多步骤逆向/实现计划。 +- `diagnosing-bugs`:设备上出现跳动、错位、回收复用或动画残留时建立诊断回路。 +- `tdd`:为脚本分类、时序门槛、View 回收和动画取消策略补回归测试。 +- `code-review`:准备把实验功能合并回主线前,审查版本边界和 fail-open 行为。 +- `github:github`:查看远端分支、创建 PR 或发布后续提交。 From cbe1d108dc322c6c704fbfb082bfd7aa1886e657 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:09:03 +0800 Subject: [PATCH 03/33] docs: correct CJK handoff commit reference --- docs/cjk-karaoke-animation-handoff.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index 4468eb9..e521749 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -7,7 +7,8 @@ ## 当前分支与远端 - 分支:`codex/cjk-karaoke-animation` -- 当前提交:`39e9b08 feat: isolate CJK karaoke animation feature` +- 当前 HEAD:`38f923c docs: add CJK karaoke animation handoff` +- 功能提交:`39e9b08 feat: isolate CJK karaoke animation feature` - 远端:[Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation](https://github.com/Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation) - 主分支基线:`main`(当前不包含本实验功能) - 当前分支已推送到 `origin`;不要把本分支代码直接合并到 `main`,除非完成新的设备验收。 From b9cdf945ea82307fd026e64a2aef3016941568bc Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:49:56 +0800 Subject: [PATCH 04/33] feat: drive CJK lyric words with AM++ ValueAnimator --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 438 ++++++++++----- .../module/hook/CjkKaraokeAnimationFeature.kt | 5 +- .../module/hook/CjkLyricValueAnimator.kt | 516 ++++++++++++++++++ .../amenhancer/module/hook/TargetSymbols.kt | 13 +- .../module/hook/CjkLyricValueAnimatorTest.kt | 47 ++ docs/cjk-karaoke-animation-handoff.md | 45 +- 6 files changed, 907 insertions(+), 157 deletions(-) create mode 100644 app/src/main/java/dev/amenhancer/module/hook/CjkLyricValueAnimator.kt create mode 100644 app/src/test/java/dev/amenhancer/module/hook/CjkLyricValueAnimatorTest.kt diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index e31dc4e..3dbcbf2 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -1,195 +1,383 @@ package dev.amenhancer.module.hook -import java.util.concurrent.atomic.AtomicInteger +import android.animation.Animator +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.util.IdentityHashMap +import java.util.Map /** - * Narrow Apple Music 6.5.2/1586 adapter for the native karaoke rush-gradient - * path. The host's `z.a0` call is the only scope in which the UnicodeBlock - * helper result is adjusted; normal `z.g0` layout calls therefore retain their - * original CJK behavior. + * Apple Music 6.5.2 adapter for AM++'s own per-word karaoke animation. + * + * The old experiment changed Apple's Unicode-block predicate while `z.a0` + * was running. That made the host's private animation state machine decide + * whether CJK should be animated and was consequently hard to cancel when a + * lyric row was recycled. This target still uses the verified `z.a0` entry + * point, but only as a data seam: its arguments identify the current + * `wordId`, duration and background flag. The holder's exact word map then + * leads to the generated binding's `CustomTextView`, where AM++ starts and + * owns a `ValueAnimator`. For CJK foreground words the host's `e.o` animator + * is cancelled before/after the call so it cannot fight the AM++ properties. */ internal class AppleMusicCjkKaraokeAnimationTarget( private val symbols: TargetSymbolResolver, ) : CjkKaraokeAnimationTarget { - private val a0Depth: ThreadLocal = ThreadLocal.withInitial { 0 } - private val rewriteLogCount = AtomicInteger() + private val valueAnimator = CjkLyricValueAnimator() override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) - val helperResolution = symbols.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod) val a0 = a0Resolution.valueOrNull() - val helper = helperResolution.valueOrNull() - if (a0 == null || helper == null) { - return TargetCapabilityInstall.Degraded( - listOf(a0Resolution, helperResolution) - .filterNot { it is TargetResolution.Found<*> } - .joinToString { it.summary }, - ) - } + ?: return TargetCapabilityInstall.Degraded(a0Resolution.summary) val failures = mutableListOf() val a0Installed = try { ModernXposedRuntime.hookMethod(a0, object : ModernMethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { - enterA0Scope() + runCatching { suppressNativeWordAnimator(param) } + .onFailure { error -> + Log.w(TAG, "CJK native animator suppression failed open", error) + } } override fun afterHookedMethod(param: MethodHookParam) { - // The host callback runs even when z.a0 throws; always - // release this thread's scope so a later g0 call cannot - // inherit a stale override. - leaveA0Scope() + runCatching { animateCurrentWord(param) } + .onFailure { error -> + // A host binding change must never break Apple's + // original callback or lyric layout. + Log.w(TAG, "CJK word View animation failed open", error) + } } }).also { installed -> if (!installed) failures += "z.a0 hook was rejected" } } catch (error: Throwable) { failures += "z.a0 hook failed: ${error.cjkShortMessage()}" - ModernXposedRuntime.log("CJK karaoke z.a0 hook failed", error) + Log.w(TAG, "CJK word View animation hook failed", error) false } - val helperInstalled = try { - ModernXposedRuntime.hookMethod(helper, object : ModernMethodHook() { - override fun afterHookedMethod(param: MethodHookParam) { - try { - rewriteCjkK0Result(param) - } catch (error: Throwable) { - // A malformed host call must never break its original - // helper result. This is deliberately fail-open. - ModernXposedRuntime.log("CJK karaoke UnicodeBlock helper failed open", error) - } - } - }).also { installed -> - if (!installed) failures += "I0\$a.a hook was rejected" - } - } catch (error: Throwable) { - failures += "I0\$a.a hook failed: ${error.cjkShortMessage()}" - ModernXposedRuntime.log("CJK karaoke I0\$a.a hook failed", error) - false - } + // g0 removes and rebuilds the flexbox children. Clearing here avoids + // a detached/recycled CustomTextView retaining an in-flight animator + // if the platform keeps the object in its pool for a moment. + installWordLayoutRecycleHook(a0.declaringClass, failures) + installHolderRecycleHook(a0.declaringClass, a0.parameterTypes.firstOrNull(), failures) + installFragmentDestroyHook(failures) - if (!a0Installed || !helperInstalled) { + if (!a0Installed) { return TargetCapabilityInstall.Degraded( - failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, + failures.joinToString("; ").ifBlank { "CJK ValueAnimator hook was not installed" }, ) } return TargetCapabilityInstall.Active( - "Installed exact Apple Music 6.5.2/1586 z.a0 + I0\$a.a CJK j0/k0 rush-gradient scope", + "Installed AM++ per-word ValueAnimator on exact 6.5.2/1586 z.a0 binding View", ) } - private fun rewriteCjkK0Result(param: ModernMethodHook.MethodHookParam) { - if (!isInA0Scope()) return + private fun suppressNativeWordAnimator(param: ModernMethodHook.MethodHookParam) { + val background = param.args.getOrNull(4) as? Boolean ?: return + if (background) return + val holder = param.args.getOrNull(0) ?: return + val wordId = (param.args.getOrNull(2) as? Number)?.toInt() ?: return + val entry = findWordEntry(holder, wordId, background = false) ?: return + val wordText = findWordText(holder, wordId, background = false) + if (wordText?.let(::containsCjkKaraokeScript) != true) return + cancelNativeWordAnimator(entry) + } + + private fun animateCurrentWord(param: ModernMethodHook.MethodHookParam) { + // z.a0(z$a holder, int lineId, int wordId, int duration, boolean bg) + val background = param.args.getOrNull(4) as? Boolean ?: return + val holder = param.args.getOrNull(0) ?: return + val wordId = (param.args.getOrNull(2) as? Number)?.toInt() ?: return + val duration = (param.args.getOrNull(3) as? Number)?.toLong() ?: return - val text = param.args.getOrNull(0) as? CharSequence ?: return - if (!containsCjkKaraokeScript(text)) return + // Background vocals have a separate binding map and are intentionally + // left to the host. The foreground map is z$a.G in this exact build. + if (background) return + val entry = findWordEntry(holder, wordId, background = false) ?: return + val seenTextViews = IdentityHashMap() + val textViews = findWordBindings(holder, wordId, background = false) + .flatMap(::findBindingTextViews) + .filter { view -> seenTextViews.put(view, true) == null } + if (textViews.isEmpty()) return - val languageSet = param.args.getOrNull(1) as? Set<*> ?: return - when { - isK0Set(languageSet) && param.result == true -> { - // CJK is normally classified into k0, which blocks the - // rush branch. Make that one result look like a default - // script only while a0 is running. - param.result = false - logRewrite(text, "k0 true -> false") + // The generated binding may apply its TextView text asynchronously. + // e.c is the native wordText already consumed by z.a0, so use it as + // the primary script signal and only fall back to the visible View. + val hostWordText = findWordText(holder, wordId, background = false) + val cjkViews = textViews.filter { textView -> + runCatching { textView.text }.getOrNull()?.let(::containsCjkKaraokeScript) == true + } + if (hostWordText?.let(::containsCjkKaraokeScript) == true || cjkViews.isNotEmpty()) { + // The host may have created e.o during this invocation. Cancel it + // again after the original method and leave View properties to + // the AM++ animator below. + cancelNativeWordAnimator(entry) + } + if (hostWordText?.let(::containsCjkKaraokeScript) != true && cjkViews.isEmpty()) { + // A recycled word View can be rebound to Latin text before the + // next CJK callback; restore it immediately in that case. + textViews.forEach(valueAnimator::clear) + return + } + + // a0 is already called once per native word event. The duration is + // the word's own LyricsTiming duration, so long CJK tail words get a + // correspondingly longer AM++ animation without guessing line timing. + val targets = cjkViews.ifEmpty { textViews } + targets.forEach { view -> + // Native z$f/z$h may leave a cancelled lift at a non-zero + // translation/scale. Normalize only those properties before AM++ + // captures its baseline; alpha is left untouched for host row + // dimming semantics. + runCatching { + view.translationY = 0f + view.scaleX = 1f + view.scaleY = 1f } - isJ0Set(languageSet) && containsHangul(text) && param.result != true -> { - // i0 receives the j0 hit as its split/rush eligibility bit. - // Hangul belongs to k0 but not j0, so opt it into the same - // Apple animation only inside a0; g0 remains untouched. - param.result = true - logRewrite(text, "j0 false -> true") + } + valueAnimator.animateViews( + // The generated binding's root is the first word View. The + // explicit target list also covers z.m0's e.k fallback when + // Apple split one native word into several character bindings. + root = targets.first(), + targets = targets, + durationMs = duration, + wordIndex = 0, + wordCount = targets.size, + ) + } + + private fun cancelNativeWordAnimator(entry: Any) { + val field = allFields(entry.javaClass) + .firstOrNull { candidate -> candidate.name == "o" } + ?: return + val animator = readField(field, entry) + if (animator is Animator) runCatching { animator.cancel() } + runCatching { + field.isAccessible = true + field.set(entry, null) + } + // The host also keeps child word-lift animators in e.p. Its verified + // e.a() cleanup only cancels entries tagged KARAOKE_WORD_LIFT_TAG, + // so it cannot touch the AM++ ValueAnimator (which is not in e.p). + runCatching { + entry.javaClass.methods.firstOrNull { method -> + method.name == "a" && method.parameterCount == 0 + }?.invoke(entry) + } + } + + private fun installWordLayoutRecycleHook(owner: Class<*>, failures: MutableList) { + val g0 = findWordLayoutMethod(owner) ?: return + try { + ModernXposedRuntime.hookMethod(g0, object : ModernMethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + // g0 rebuilds one FullWidthAlphaGradientFlexboxLayout at + // a time. Clear only that layout's current children so a + // neighboring visible line keeps its own animation. + (param.args.getOrNull(2) as? View)?.let(::clearViewTree) + } + }) + } catch (error: Throwable) { + // This is an optional safety hook. z.a0 remains useful even if + // an OEM build changes the layout method's return type. + failures += "z.g0 recycle hook failed: ${error.cjkShortMessage()}" + Log.w(TAG, "CJK z.g0 recycle hook failed open", error) + } + } + + private fun clearViewTree(view: View) { + valueAnimator.clear(view) + if (view is ViewGroup) { + for (index in 0 until view.childCount) { + runCatching { view.getChildAt(index) }.getOrNull()?.let(::clearViewTree) } - else -> return } } - private fun logRewrite(text: CharSequence, change: String) { - if (rewriteLogCount.getAndIncrement() < MAX_REWRITE_LOGS) { - ModernXposedRuntime.log( - "CJK karaoke a0 scope: I0\$a.a(${text.length} chars, $change); g0 unchanged", + private fun installFragmentDestroyHook(failures: MutableList) { + val fragment = symbols.resolve(AppleMusicSymbols.LyricsFragment).valueOrNull() ?: return + try { + val declaringClass = generateSequence(fragment) { it.superclass } + .firstOrNull { type -> + type.declaredMethods.any { method -> + method.name == "onDestroyView" && method.parameterCount == 0 + } + } ?: return + ModernXposedRuntime.hookAllMethods( + declaringClass, + "onDestroyView", + object : ModernMethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + valueAnimator.clearAll() + } + }, ) + } catch (error: Throwable) { + failures += "lyrics onDestroyView cleanup failed: ${error.cjkShortMessage()}" + Log.w(TAG, "CJK lyrics lifecycle cleanup failed open", error) } } - private fun isK0Set(languageSet: Set<*>): Boolean = runCatching { - // z.k0 always contains HANGUL_SYLLABLES. This marker distinguishes - // it from the host's j0/l0 sets without touching either set globally. - languageSet.contains(Character.UnicodeBlock.HANGUL_SYLLABLES) - }.getOrElse { error -> - ModernXposedRuntime.log("CJK karaoke k0 marker check failed open", error) - false + /** + * z.p(RecyclerView$D,int) is the RecyclerView rebind seam in the verified + * build; its runtime holder is cast to z$a. It can recycle an attached + * word View without calling g0 first, so only that holder is cleared here. + */ + private fun installHolderRecycleHook( + owner: Class<*>, + holderClass: Class<*>?, + failures: MutableList, + ) { + if (holderClass == null) return + val recycle = owner.declaredMethods.firstOrNull { method -> + val holderParameter = method.parameterTypes.firstOrNull() + method.name == "p" && + method.parameterTypes.size == 2 && + holderParameter != null && + ( + holderParameter == holderClass || + holderParameter.name.contains("RecyclerView\$D") || + holderParameter.isAssignableFrom(holderClass) + ) && + method.parameterTypes[1] == Int::class.javaPrimitiveType + } ?: return + try { + ModernXposedRuntime.hookMethod(recycle, object : ModernMethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + param.args.getOrNull(0)?.let(::clearHolderAnimations) + } + }) + } catch (error: Throwable) { + failures += "z.p recycle hook failed: ${error.cjkShortMessage()}" + Log.w(TAG, "CJK z.p recycle hook failed open", error) + } } - private fun isJ0Set(languageSet: Set<*>): Boolean = runCatching { - languageSet.contains(Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS) && - languageSet.contains(Character.UnicodeBlock.HIRAGANA) && - languageSet.contains(Character.UnicodeBlock.KATAKANA) && - !languageSet.contains(Character.UnicodeBlock.HANGUL_SYLLABLES) && - !languageSet.contains(Character.UnicodeBlock.THAI) - }.getOrElse { error -> - ModernXposedRuntime.log("CJK karaoke j0 marker check failed open", error) - false + private fun clearHolderAnimations(holder: Any) { + allFields(holder.javaClass) + .mapNotNull { field -> readField(field, holder) } + .filterIsInstance>() + .flatMap { map -> map.values().asSequence() } + .filterNotNull() + .flatMap { entry -> + listOf(false, true).asSequence() + .flatMap { background -> findEntryBindings(entry, background).asSequence() } + } + .flatMap(::findBindingTextViews) + .forEach(valueAnimator::clear) } - private fun enterA0Scope() { - runCatching { a0Depth.set((a0Depth.get() ?: 0) + 1) } - .onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth enter failed open", error) } + private fun findWordLayoutMethod(owner: Class<*>): Method? = owner.declaredMethods + .firstOrNull { method -> + method.name == "g0" && + method.parameterTypes.size == 7 && + method.parameterTypes[0].name.endsWith("LyricsWordVector") && + method.parameterTypes[2].name.contains("FullWidthAlphaGradientFlexboxLayout") + } + ?.apply { isAccessible = true } + + private fun findWordBindings(holder: Any, wordId: Int, background: Boolean): List { + val entry = findWordEntry(holder, wordId, background) ?: return emptyList() + return findEntryBindings(entry, background) } - private fun leaveA0Scope() { - runCatching { - val depth = a0Depth.get() ?: 0 - if (depth <= 1) { - a0Depth.remove() - } else { - a0Depth.set(depth - 1) + private fun findWordText(holder: Any, wordId: Int, background: Boolean): CharSequence? = + findWordEntry(holder, wordId, background) + ?.let { entry -> + allFields(entry.javaClass) + .firstOrNull { field -> field.name == "c" } + ?.let { field -> readField(field, entry) as? CharSequence } } - }.onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth cleanup failed open", error) } + + private fun findWordEntry(holder: Any, wordId: Int, background: Boolean): Any? { + val preferredMapName = if (background) "H" else "G" + // z$a also owns I/J pronunciation maps. Falling back to an arbitrary + // Map can therefore animate the wrong TextView when the same wordId + // exists in more than one lyric track; G/H are the exact foreground + // and background maps selected by z.a0. + val map = allFields(holder.javaClass) + .firstOrNull { field -> field.name == preferredMapName } + ?.let { field -> readField(field, holder) as? Map<*, *> } + ?: return null + val entry = runCatching { map[Integer.valueOf(wordId)] }.getOrNull() + ?: runCatching { map[wordId] }.getOrNull() + return entry } - private fun isInA0Scope(): Boolean = runCatching { (a0Depth.get() ?: 0) > 0 } - .getOrElse { error -> - ModernXposedRuntime.log("CJK karaoke a0 depth read failed open", error) - false - } + private fun findEntryBindings(entry: Any, background: Boolean): List { + val preferredBindingName = if (background) "j" else "i" + val directBinding = allFields(entry.javaClass) + .firstOrNull { field -> field.name == preferredBindingName } + ?.let { field -> readField(field, entry) } + ?.takeIf(::looksLikeViewBinding) + if (directBinding != null) return listOf(directBinding) - private companion object { - const val MAX_REWRITE_LOGS = 3 + // z.m0 falls back to e.k when the host split one native word into a + // character/whitespace binding list. This is the common CJK path. + val splitBindings = ( + allFields(entry.javaClass) + .firstOrNull { field -> field.name == "k" } + ?.let { field -> readField(field, entry) as? Iterable<*> } + ?: emptyList() + ) + .filterNotNull() + .filter(::looksLikeViewBinding) + return splitBindings } -} -/** Returns true for the CJK blocks used by the host's karaoke classifier. */ -internal fun containsCjkKaraokeScript(text: CharSequence): Boolean { - for (index in text.indices) { - when (Character.UnicodeBlock.of(text[index])) { - Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS, - Character.UnicodeBlock.HIRAGANA, - Character.UnicodeBlock.KATAKANA, - Character.UnicodeBlock.HANGUL_SYLLABLES, - Character.UnicodeBlock.HANGUL_JAMO, - Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO, - -> return true + private fun findBindingTextViews(binding: Any): List { + if (binding is TextView) return listOf(binding) + + // Generated lyric bindings in 6.5.2 expose their word CustomTextView + // as field U. We intentionally use the type, not only the obfuscated + // name, so a generated binding rename remains fail-open. + allFields(binding.javaClass).forEach { field -> + val value = readField(field, binding) + if (value is TextView) return listOf(value) } + + // A future generated binding may only expose ViewDataBinding#getRoot. + val root = runCatching { + binding.javaClass.methods.firstOrNull { method -> + method.name == "getRoot" && method.parameterCount == 0 + }?.invoke(binding) + }.getOrNull() + return (root as? TextView)?.let(::listOf).orEmpty() } - return false -} -private fun containsHangul(text: CharSequence): Boolean { - for (index in text.indices) { - when (Character.UnicodeBlock.of(text[index])) { - Character.UnicodeBlock.HANGUL_SYLLABLES, - Character.UnicodeBlock.HANGUL_JAMO, - Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO, - -> return true - else -> Unit - } + private fun looksLikeViewBinding(value: Any): Boolean = + value.javaClass.name.contains("ViewDataBinding") || + allFields(value.javaClass).any { field -> + TextView::class.java.isAssignableFrom(field.type) + } + + private fun allFields(type: Class<*>): Sequence = + generateSequence(type) { it.superclass } + .flatMap { current -> current.declaredFields.asSequence() } + .filterNot { field -> Modifier.isStatic(field.modifiers) } + + private fun readField(field: Field, receiver: Any): Any? = runCatching { + field.isAccessible = true + field.get(receiver) + }.getOrNull() + + private companion object { + const val TAG = "AMPP-CJK-KARAOKE" } - return false } +/** Returns true for the CJK blocks used by the host's karaoke classifier. */ +internal fun containsCjkKaraokeScript(text: CharSequence): Boolean = + defaultCjkLyricScriptPredicate(text) + private fun Throwable.cjkShortMessage(): String = buildString { append(javaClass.simpleName.ifBlank { javaClass.name }) message?.takeIf(String::isNotBlank)?.let { append(": ").append(it.take(180)) } diff --git a/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt b/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt index 3486833..b391fd2 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt @@ -3,11 +3,12 @@ package dev.amenhancer.module.hook import dev.amenhancer.module.ModuleConstants /** - * Installs the narrow Apple Music 6.5.2 karaoke animation adaptation. + * Installs the narrow Apple Music 6.5.2 per-word ValueAnimator adaptation. * * The target adapter owns all version and symbol checks; unsupported hosts * therefore report a degraded health result without touching existing lyric - * layout or blur paths. + * layout or blur paths. Apple’s native Unicode/rush-gradient classifier is + * intentionally not rewritten by this feature. */ internal class CjkKaraokeAnimationFeature : FeatureHook { override val key: String = ModuleConstants.FEATURE_CJK_KARAOKE_ANIMATION diff --git a/app/src/main/java/dev/amenhancer/module/hook/CjkLyricValueAnimator.kt b/app/src/main/java/dev/amenhancer/module/hook/CjkLyricValueAnimator.kt new file mode 100644 index 0000000..e224f70 --- /dev/null +++ b/app/src/main/java/dev/amenhancer/module/hook/CjkLyricValueAnimator.kt @@ -0,0 +1,516 @@ +package dev.amenhancer.module.hook + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.view.View +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import android.widget.TextView +import java.lang.ref.WeakReference +import java.util.IdentityHashMap +import java.util.WeakHashMap + +/** + * Small, host-independent timing policy for the CJK lyric animator. + * + * Keeping this policy separate from [CjkLyricValueAnimator] makes the safety + * rules testable on the local JVM, where Android's animation clock is not + * available. A caller may still provide an explicit stagger when it has the + * host's word timing available. + */ +object CjkLyricAnimationPolicy { + const val DEFAULT_DURATION_MS = 420L + const val MIN_DURATION_MS = 120L + const val MAX_DURATION_MS = 1_600L + const val MAX_STAGGER_MS = 180L + const val MAX_START_DELAY_MS = 8_000L + + /** Return a finite duration suitable for ValueAnimator. */ + fun clampDurationMs(durationMs: Long): Long = durationMs.coerceIn( + MIN_DURATION_MS, + MAX_DURATION_MS, + ) + + /** Alias with the shorter name used by callers that do not need the unit suffix. */ + fun clampDuration(durationMs: Long): Long = clampDurationMs(durationMs) + + /** + * Derive a modest word stagger. More words make each offset smaller so a + * complete line does not spend several seconds catching up. + */ + fun staggerMs(durationMs: Long, targetCount: Int): Long { + val count = targetCount.coerceAtLeast(1) + if (count <= 1) return 0L + val duration = clampDurationMs(durationMs) + return (duration / (count.toLong() * 2L)).coerceIn(0L, MAX_STAGGER_MS) + } + + /** Alias useful in tests and integrations that prefer a verb-style name. */ + fun calculateStaggerMs(durationMs: Long, targetCount: Int): Long = + staggerMs(durationMs, targetCount) + + /** + * Compute the delay for a word in a line. Negative offsets and indices are + * treated as zero; arithmetic is saturated and capped to keep malformed + * host callbacks fail-open. + */ + fun startDelayMs( + wordIndex: Int, + staggerMs: Long, + startOffsetMs: Long = 0L, + ): Long { + val index = wordIndex.coerceAtLeast(0).toLong() + val stagger = staggerMs.coerceIn(0L, MAX_STAGGER_MS) + val offset = startOffsetMs.coerceAtLeast(0L) + val indexed = if (stagger == 0L || index == 0L) { + 0L + } else if (index > Long.MAX_VALUE / stagger) { + Long.MAX_VALUE + } else { + index * stagger + } + val sum = if (Long.MAX_VALUE - offset < indexed) { + Long.MAX_VALUE + } else { + offset + indexed + } + return sum.coerceAtMost(MAX_START_DELAY_MS) + } + + /** Alias useful when the call site describes the value as a delay. */ + fun delayFor( + wordIndex: Int, + staggerMs: Long, + startOffsetMs: Long = 0L, + ): Long = startDelayMs(wordIndex, staggerMs, startOffsetMs) +} + +/** + * Default script matcher for lyric text. It deliberately lives in this + * host-independent file: callers can pass [containsCjkKaraokeScript] (the + * existing host adapter predicate) or any other matcher instead. + */ +fun defaultCjkLyricScriptPredicate(text: CharSequence): Boolean { + var index = 0 + while (index < text.length) { + val codePoint = Character.codePointAt(text, index) + // Numeric ranges avoid newer UnicodeBlock fields, which keeps this + // helper safe on the module's minSdk 26 devices as well. + if (isCjkLyricCodePoint(codePoint)) { + return true + } + index += Character.charCount(codePoint) + } + return false +} + +private fun isCjkLyricCodePoint(codePoint: Int): Boolean = + codePoint in 0x3400..0x4DBF || // CJK Unified Ideographs Extension A + codePoint in 0x4E00..0x9FFF || // Unified Ideographs + codePoint in 0x20000..0x323AF || // supplementary CJK extensions + codePoint in 0x3040..0x309F || // Hiragana + codePoint in 0x30A0..0x30FF || // Katakana + codePoint in 0x31F0..0x31FF || // Katakana phonetic extensions + codePoint in 0xAC00..0xD7AF || // Hangul syllables + codePoint in 0x1100..0x11FF || // Hangul Jamo + codePoint in 0x3130..0x318F // Hangul compatibility Jamo + +/** + * Reusable, fail-open animator for lyric lines. + * + * A line is normally a ViewGroup whose word views are TextViews. TextViews + * matching [scriptPredicate] are preferred; when no matching TextView exists, + * leaf views with a matching contentDescription are accepted as a safe generic + * fallback. For custom host adapters, [animateViews] can supply the exact + * target views directly. + * + * The controller owns no host references. It only keeps weak root keys and + * restores every captured alpha/translation/scale value when cancelled, + * cleared, detached, or recycled. All Android calls are guarded so an + * unexpected host view cannot break the original lyric path. + */ +class CjkLyricValueAnimator( + private val startAlphaFraction: Float = DEFAULT_START_ALPHA_FRACTION, + private val startTranslationY: Float = DEFAULT_START_TRANSLATION_Y_PX, + private val startScaleFraction: Float = DEFAULT_START_SCALE_FRACTION, +) { + private val lock = Any() + private val sessions = WeakHashMap() + + /** + * Animate matching TextView words in depth-first child order. + * + * [wordIndex], [wordCount], and [startOffsetMs] are optional host timing + * metadata. They let an adapter map a callback's word id into the same + * stagger timeline; if omitted, the current line's child order is used. + * Returns the number of views for which an animator was started. + */ + fun animateLine( + root: View?, + durationMs: Long = CjkLyricAnimationPolicy.DEFAULT_DURATION_MS, + scriptPredicate: (CharSequence) -> Boolean = ::defaultCjkLyricScriptPredicate, + wordIndex: Int = 0, + wordCount: Int? = null, + startOffsetMs: Long = 0L, + staggerMs: Long? = null, + ): Int { + if (root == null) return 0 + return runCatching { + val targets = collectTargets(root, scriptPredicate) + animateViews( + root = root, + targets = targets, + durationMs = durationMs, + wordIndex = wordIndex, + wordCount = wordCount, + startOffsetMs = startOffsetMs, + staggerMs = staggerMs, + ) + }.getOrElse { 0 } + } + + /** + * Animate explicitly selected views. This is useful when a host exposes + * word views that are not TextViews or when its callback already resolves + * the exact word order. + */ + fun animateViews( + root: View?, + targets: Iterable, + durationMs: Long = CjkLyricAnimationPolicy.DEFAULT_DURATION_MS, + wordIndex: Int = 0, + wordCount: Int? = null, + startOffsetMs: Long = 0L, + staggerMs: Long? = null, + ): Int { + if (root == null) return 0 + return runCatching { + clear(root) + val seenTargets = IdentityHashMap() + val safeTargets = targets.mapNotNull { view -> + view?.takeIf(::isAnimatableTarget)?.takeIf { seenTargets.put(it, true) == null } + } + if (safeTargets.isEmpty()) return@runCatching 0 + + val duration = CjkLyricAnimationPolicy.clampDurationMs(durationMs) + val count = (wordCount ?: safeTargets.size).coerceAtLeast(1) + val lineStagger = CjkLyricAnimationPolicy.staggerMs(duration, count) + val explicitStagger = staggerMs?.coerceIn(0L, CjkLyricAnimationPolicy.MAX_STAGGER_MS) + val effectiveStagger = explicitStagger ?: lineStagger + val session = LineSession(WeakReference(root)) + synchronized(lock) { sessions[root] = session } + installDetachListener(root, session) + + var started = 0 + safeTargets.forEachIndexed { childIndex, target -> + val state = OriginalViewState.capture(target) ?: return@forEachIndexed + session.states[target] = state + installTargetDetachListener(root, session, target) + val effectiveIndex = safeAdd(wordIndex.coerceAtLeast(0), childIndex) + val delay = CjkLyricAnimationPolicy.startDelayMs( + wordIndex = effectiveIndex, + staggerMs = effectiveStagger, + startOffsetMs = startOffsetMs, + ) + if (startAnimator(root, session, target, state, duration, delay)) { + started += 1 + } else { + session.states.remove(target) + } + } + + if (started == 0) { + finishSession(root, session) + } + started + }.getOrElse { error -> + // If collecting or starting one target unexpectedly fails, restore + // anything that was already touched and leave the host untouched. + runCatching { clear(root) } + 0 + } + } + + /** Cancel the line and restore all captured properties. */ + fun clear(root: View?) { + if (root == null) return + val session = synchronized(lock) { sessions.remove(root) } ?: return + restoreAndCancel(root, session) + } + + /** Alias for recycler/lifecycle callbacks. */ + fun onViewRecycled(root: View?) = clear(root) + + /** Explicit cancellation spelling for callers that do not use lifecycle terms. */ + fun cancel(root: View?) = clear(root) + + /** + * Restore a line when its root or one of its word views is no longer + * visible/attached. This is a cheap hook for adapters that receive a + * visibility or recycler callback instead of a View detach callback. + */ + fun clearInactive(root: View?) { + if (root == null) return + val session = synchronized(lock) { sessions[root] } ?: return + val inactive = runCatching { + !root.isShown || session.states.keys.any { !it.isShown } + }.getOrDefault(true) + if (inactive) clear(root) + } + + /** Clear every currently live root tracked by this controller. */ + fun clearAll() { + val roots = synchronized(lock) { sessions.keys.toList() } + roots.forEach(::clear) + } + + /** Cancel every currently live line. */ + fun cancelAll() = clearAll() + + /** Number of active line sessions; useful for diagnostics and tests. */ + fun activeLineCount(): Int = synchronized(lock) { sessions.size } + + private fun collectTargets( + root: View, + scriptPredicate: (CharSequence) -> Boolean, + ): List { + val textTargets = ArrayList() + val descriptionTargets = ArrayList() + + fun visit(view: View) { + if (!isAnimatableTarget(view)) return + if (view is TextView) { + val text = runCatching { view.text }.getOrNull() + if (text != null && runCatching { scriptPredicate(text) }.getOrDefault(false)) { + textTargets += view + } + // A TextView normally has no children; visiting them anyway + // keeps the traversal safe for custom compound widgets. + } else { + val description = runCatching { view.contentDescription }.getOrNull() + if ( + description != null && + runCatching { scriptPredicate(description) }.getOrDefault(false) + ) { + descriptionTargets += view + } + } + if (view is ViewGroup) { + for (index in 0 until view.childCount) { + runCatching { view.getChildAt(index) }.getOrNull()?.let(::visit) + } + } + } + + visit(root) + return if (textTargets.isNotEmpty()) textTargets else descriptionTargets + } + + private fun startAnimator( + root: View, + session: LineSession, + target: View, + state: OriginalViewState, + duration: Long, + startDelay: Long, + ): Boolean { + var animator: ValueAnimator? = null + return runCatching { + val initial = state.initial(startAlphaFraction, startTranslationY, startScaleFraction) + apply(target, initial, 0f) + animator = ValueAnimator.ofFloat(0f, 1f).apply { + this.duration = duration + this.startDelay = startDelay + interpolator = DecelerateInterpolator(1.4f) + addUpdateListener { valueAnimator -> + if (!isCurrent(root, session) || session.states[target] !== state) return@addUpdateListener + val fraction = (valueAnimator.animatedValue as? Float)?.coerceIn(0f, 1f) + ?: return@addUpdateListener + runCatching { apply(target, initial, state, fraction) } + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + if (!isCurrent(root, session)) return + runCatching { state.restore(target) } + session.animators.remove(target) + if (session.animators.isEmpty()) { + finishSession(root, session) + } + } + }) + } + session.animators[target] = checkNotNull(animator) + checkNotNull(animator).start() + true + }.getOrElse { + session.animators.remove(target) + runCatching { state.restore(target) } + false + } + } + + private fun installDetachListener(root: View, session: LineSession) { + runCatching { + val controller = WeakReference(this) + val listener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(view: View) = Unit + + override fun onViewDetachedFromWindow(view: View) { + if (view === session.root.get()) controller.get()?.clear(view) + } + } + session.detachListener = listener + root.addOnAttachStateChangeListener(listener) + } + } + + private fun installTargetDetachListener(root: View, session: LineSession, target: View) { + runCatching { + val controller = WeakReference(this) + val rootReference = WeakReference(root) + val listener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(view: View) = Unit + + override fun onViewDetachedFromWindow(view: View) { + if (view === target && session.active) { + controller.get()?.clear(rootReference.get()) + } + } + } + session.targetDetachListeners[target] = listener + target.addOnAttachStateChangeListener(listener) + } + } + + private fun finishSession(root: View, session: LineSession) { + val removed = synchronized(lock) { + if (sessions[root] === session) { + sessions.remove(root) + true + } else { + false + } + } + if (!removed) return + session.active = false + runCatching { + session.detachListener?.let(root::removeOnAttachStateChangeListener) + } + session.detachListener = null + removeTargetDetachListeners(session) + session.animators.clear() + session.states.clear() + } + + private fun restoreAndCancel(root: View, session: LineSession) { + session.active = false + runCatching { + session.detachListener?.let(root::removeOnAttachStateChangeListener) + } + session.detachListener = null + removeTargetDetachListeners(session) + session.animators.values.toList().forEach { animator -> + runCatching { animator.cancel() } + } + session.animators.clear() + session.states.forEach { (view, state) -> + runCatching { state.restore(view) } + } + session.states.clear() + } + + private fun removeTargetDetachListeners(session: LineSession) { + session.targetDetachListeners.forEach { (view, listener) -> + runCatching { view.removeOnAttachStateChangeListener(listener) } + } + session.targetDetachListeners.clear() + } + + private fun isCurrent(root: View, session: LineSession): Boolean = + session.active && synchronized(lock) { sessions[root] === session } + + private fun isAnimatableTarget(view: View): Boolean = runCatching { + view.visibility == View.VISIBLE + }.getOrDefault(false) + + private fun apply( + view: View, + initial: OriginalViewState, + fraction: Float, + ) { + apply(view, initial, initial, fraction) + } + + private fun apply( + view: View, + initial: OriginalViewState, + state: OriginalViewState, + fraction: Float, + ) { + view.alpha = lerp(initial.alpha, state.alpha, fraction) + view.translationX = lerp(initial.translationX, state.translationX, fraction) + view.translationY = lerp(initial.translationY, state.translationY, fraction) + view.scaleX = lerp(initial.scaleX, state.scaleX, fraction) + view.scaleY = lerp(initial.scaleY, state.scaleY, fraction) + } + + private fun lerp(start: Float, end: Float, fraction: Float): Float = + start + (end - start) * fraction + + private fun safeAdd(first: Int, second: Int): Int = + if (Int.MAX_VALUE - first < second) Int.MAX_VALUE else first + second + + private data class OriginalViewState( + val alpha: Float, + val translationX: Float, + val translationY: Float, + val scaleX: Float, + val scaleY: Float, + ) { + fun initial(alphaFraction: Float, translationOffset: Float, scaleFraction: Float): OriginalViewState { + val safeAlphaFraction = alphaFraction.coerceIn(0f, 1f) + val safeScaleFraction = scaleFraction.coerceIn(0f, 1f) + return copy( + alpha = (alpha * safeAlphaFraction).coerceIn(0f, 1f), + translationY = translationY + translationOffset, + scaleX = scaleX * safeScaleFraction, + scaleY = scaleY * safeScaleFraction, + ) + } + + fun restore(view: View) { + view.alpha = alpha + view.translationX = translationX + view.translationY = translationY + view.scaleX = scaleX + view.scaleY = scaleY + } + + companion object { + fun capture(view: View): OriginalViewState? = runCatching { + OriginalViewState( + alpha = view.alpha, + translationX = view.translationX, + translationY = view.translationY, + scaleX = view.scaleX, + scaleY = view.scaleY, + ) + }.getOrNull() + } + } + + private class LineSession(val root: WeakReference) { + var active: Boolean = true + var detachListener: View.OnAttachStateChangeListener? = null + val states = IdentityHashMap() + val animators = IdentityHashMap() + val targetDetachListeners = IdentityHashMap() + } + + private companion object { + const val DEFAULT_START_ALPHA_FRACTION = 0.58f + const val DEFAULT_START_TRANSLATION_Y_PX = 4f + const val DEFAULT_START_SCALE_FRACTION = 0.985f + } +} diff --git a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt index 63e0a8c..83e0aff 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt @@ -411,10 +411,10 @@ private object AppleMusicProfiles { internal object AppleMusicSymbols { /** - * Apple Music 6.5.2/1586's karaoke transition entry point + * Apple Music 6.5.2/1586's per-word binding/timing entry point * (`com.apple.android.music.player.z.a0(z$a, int, int, int, boolean)`). - * This is intentionally profile-only: a structural match on another - * obfuscated build could alter the host's animation state machine. + * AM++ uses it only as an exact wordId/duration seam for its own + * ValueAnimator; it does not rewrite Apple's Unicode classifier. */ val CjkKaraokeAnimationMethod = methodSymbol( id = "cjk-karaoke-animation-method", @@ -426,10 +426,9 @@ internal object AppleMusicSymbols { ) /** - * Apple Music 6.5.2/1586's UnicodeBlock-set predicate - * (`com.apple.android.music.utils.I0$a.a(CharSequence, Set): boolean`). - * Like the animation entry point, this must never fall back to a guessed - * helper on 6.5.0/6.5.1 or an unknown host build. + * Retained reverse-engineering symbol for Apple Music 6.5.2/1586's + * UnicodeBlock-set predicate. The AM++ ValueAnimator target deliberately + * does not resolve or hook this helper. */ val CjkUnicodeBlockPredicateMethod = methodSymbol( id = "cjk-unicode-block-predicate-method", diff --git a/app/src/test/java/dev/amenhancer/module/hook/CjkLyricValueAnimatorTest.kt b/app/src/test/java/dev/amenhancer/module/hook/CjkLyricValueAnimatorTest.kt new file mode 100644 index 0000000..cdad689 --- /dev/null +++ b/app/src/test/java/dev/amenhancer/module/hook/CjkLyricValueAnimatorTest.kt @@ -0,0 +1,47 @@ +package dev.amenhancer.module.hook + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CjkLyricValueAnimatorTest { + @Test + fun `duration is clamped to safe animator bounds`() { + assertEquals(CjkLyricAnimationPolicy.MIN_DURATION_MS, CjkLyricAnimationPolicy.clampDurationMs(-1L)) + assertEquals(CjkLyricAnimationPolicy.MIN_DURATION_MS, CjkLyricAnimationPolicy.clampDuration(0L)) + assertEquals(420L, CjkLyricAnimationPolicy.clampDurationMs(420L)) + assertEquals(CjkLyricAnimationPolicy.MAX_DURATION_MS, CjkLyricAnimationPolicy.clampDurationMs(Long.MAX_VALUE)) + } + + @Test + fun `stagger is zero for one target and shrinks as target count grows`() { + val one = CjkLyricAnimationPolicy.staggerMs(600L, 1) + val two = CjkLyricAnimationPolicy.staggerMs(600L, 2) + val many = CjkLyricAnimationPolicy.calculateStaggerMs(600L, 12) + + assertEquals(0L, one) + assertTrue(two > many) + assertTrue(many >= 0L) + assertTrue(many <= CjkLyricAnimationPolicy.MAX_STAGGER_MS) + } + + @Test + fun `delay clamps malformed metadata and preserves start offset`() { + assertEquals(0L, CjkLyricAnimationPolicy.startDelayMs(-4, -10L, -5L)) + assertEquals(125L, CjkLyricAnimationPolicy.delayFor(2, 40L, 45L)) + assertEquals( + CjkLyricAnimationPolicy.MAX_START_DELAY_MS, + CjkLyricAnimationPolicy.startDelayMs(Int.MAX_VALUE, Long.MAX_VALUE, Long.MAX_VALUE), + ) + } + + @Test + fun `default matcher accepts mixed CJK and supplementary ideographs`() { + assertTrue(defaultCjkLyricScriptPredicate("hello 漢字 world")) + assertTrue(defaultCjkLyricScriptPredicate("かな")) + assertTrue(defaultCjkLyricScriptPredicate("한글")) + assertTrue(defaultCjkLyricScriptPredicate("\uD840\uDC00")) + assertFalse(defaultCjkLyricScriptPredicate("plain latin lyrics")) + } +} diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index e521749..3a0bb78 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -2,44 +2,43 @@ ## 目的 -本分支保存 Apple Music 6.5.2/1586 的 CJK 长尾歌词动画实验。它不属于 `main` 稳定线,后续维护、设备验证或替换为 AM++ 自绘动画都从本分支继续。 +本分支保存 Apple Music 6.5.2/1586 的 CJK 逐词动画实验。它不属于 `main` 稳定线,后续维护、设备验证或替换动画策略都从本分支继续。 ## 当前分支与远端 - 分支:`codex/cjk-karaoke-animation` -- 当前 HEAD:`38f923c docs: add CJK karaoke animation handoff` -- 功能提交:`39e9b08 feat: isolate CJK karaoke animation feature` - 远端:[Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation](https://github.com/Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation) - 主分支基线:`main`(当前不包含本实验功能) -- 当前分支已推送到 `origin`;不要把本分支代码直接合并到 `main`,除非完成新的设备验收。 +- 当前工作树中的 XAPK、`androguard.db*`、`.work/` 和 `github-lyrics-export-readable/` 均为未跟踪逆向输入,不应提交。 -## 已实现内容 +## 当前实现(AM++ 自绘 ValueAnimator) -1. 仅对 Apple Music `6.5.2/1586` 解析并 Hook: - - `com.apple.android.music.player.z.a0` - - `com.apple.android.music.utils.I0$a.a(CharSequence, Set)` -2. 在 `z.a0` 的线程局部调用范围内,临时放开 CJK 的 `k0/j0` 分类结果,以复用 Apple 原生 rush-gradient;不会全局修改 Apple 的静态字符集合,也不会改变 `g0` 的原始 CJK 排版路径。 -3. 设置页和嵌入式设置页都有独立开关: - - key:`cjk_karaoke_animation_enabled` - - 字段:`ModuleSettings.cjkKaraokeAnimationEnabled` - - 默认值:`true` - - 关闭后需要重启 Apple Music,feature 才不会注册 Hook。 +旧方案曾在 `z.a0` 调用期间 Hook `I0$a.a(CharSequence, Set)`,临时改写 Apple 的 `j0/k0` 分类结果来复用原生 rush-gradient。该方案已从运行时移除,当前不再 Hook `I0$a.a`,也不修改 Apple 的字符集合或原生动画判定。 + +当前方案只把精确的 `z.a0(z$a, lineId, wordId, duration, isBackground)` 当作数据 seam: + +1. `z$a.G/H` 是前景/背景 karaoke 的 `ArrayMap`;只按 `wordId` 从对应的 G/H 读取,避免误取 pronunciation 的 I/J map。 +2. `e.i/e.j` 是单个词 binding;CJK 拆分时回退到 `e.k` binding 列表。 +3. generated binding 的 `CustomTextView`(6.5.2 中为字段 `U`)是实际逐词 View。AM++ 在 `z.a0` after-hook 中对这些 View 启动自己的 `ValueAnimator`,duration 直接来自该词的 `LyricsTiming`。 +4. 对 CJK 前景词,before/after 都会取消并清空宿主 `e.o` 及其 `e.p` 中带 `KARAOKE_WORD_LIFT_TAG` 的子动画;随后归一化被取消动画遗留的 translation/scale。`z.g0` 重建 flexbox、`z.p` RecyclerView rebind、`onDestroyView` 和 View detach 都会取消并恢复 AM++ 动画,避免 recycled View 残留。 +5. 背景人声不接管;混排词按实际 binding/TextView 的 CJK 内容过滤。宿主 binding 文本尚未异步完成时,使用 `e.c` 的 native wordText 判断脚本。 ## 关键代码位置 -- Hook 实现:`app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt` +- 宿主 seam 与 binding 解析:`app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt` +- AM++ 动画控制器/时序策略:`app/src/main/java/dev/amenhancer/module/hook/CjkLyricValueAnimator.kt` +- 动画策略 JVM 测试:`app/src/test/java/dev/amenhancer/module/hook/CjkLyricValueAnimatorTest.kt` - Feature gate:`app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt` - 版本符号:`app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt` - Feature 注册:`app/src/main/java/dev/amenhancer/module/hook/FeatureInstallation.kt` -- 设置模型/schema:`app/src/main/java/dev/amenhancer/module/model/ModuleModels.kt`、`app/src/main/java/dev/amenhancer/module/config/ModuleSettingsSchema.kt` -- 设置 UI:`app/src/main/java/dev/amenhancer/module/ui/SettingsActivity.kt`、`app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt` +- 独立设置 key:`cjk_karaoke_animation_enabled`,字段 `ModuleSettings.cjkKaraokeAnimationEnabled`,默认值 `true`。 -## 已知边界 +## 已知边界与真机验证重点 -- 当前方案是“复用 Apple 原生动画分支”,不是 AM++ 自己绘制的稳定实现;用户已观察到 CJK 仍可能出现动画错位、跳动或宽度问题。 -- 后续更稳妥的路线是:在 Apple adapter 完成逐词 View 绑定后,由 AM++ 自己创建 `ValueAnimator`/渐变遮罩,并处理 View 回收、行切换、混排字符和取消旧动画。 -- 该自绘路线尚未实现。不要只在整行 RecyclerView 上加动画;必须拿到实际词 View 和可靠的逐词时序。 -- Apple 版本、混淆类名和方法签名是私有契约;新增版本必须重新做 exact profile 和设备验证。 +- 目前 exact profile 仍只针对 Apple Music `6.5.2/1586`;其它版本会 degraded,不会猜测混淆方法。 +- 这是第一版安全自绘实现:动画改动 TextView 的 alpha/translation/scale,尚未模拟 Apple 的渐变 mask 绘制;CJK 前景词会先取消宿主 word-lift/rush animator,以减少属性竞争。 +- `z.a0` 可能因宿主已有 `e.o` animator 提前 return;after-hook 仍使用 holder map 读取 binding,但若 binding 尚未生成会 fail-open。应重点测试:中文、日文、韩文、CJK+Latin 混排、长 duration 词、快速切歌、滚动回收和关闭开关后的重启行为。 +- 不要把动画降级为整行 RecyclerView 动画;逐词 binding seam 是本实验的核心。 ## 继续维护步骤 @@ -67,7 +66,7 @@ git switch main - 逆向结论、DEX/smali/native 符号和行为矩阵:仓库根目录 `findings.md` - 时间线、测试、构建和分支操作记录:仓库根目录 `progress.md` - 当前阶段计划:仓库根目录 `task_plan.md` -- Apple Music 6.5.2 输入包:工作区未跟踪的 `Apple+Music_6.5.2_APKPure.xapk`;不要把它或 `androguard.db*` 误加入提交。 +- Apple Music 6.5.2 输入包:未跟踪的 `Apple+Music_6.5.2_APKPure.xapk`;不要把它或 `androguard.db*` 加入提交。 ## 建议后续调用的 skills From 48613b75fd9cf3c11a70eb9616f93457c6827e20 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:53:10 +0800 Subject: [PATCH 05/33] perf: cache CJK lyric binding reflection --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 68 ++++++++++++------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 3dbcbf2..79533f8 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -8,8 +8,10 @@ import android.widget.TextView import java.lang.reflect.Field import java.lang.reflect.Method import java.lang.reflect.Modifier +import java.util.Collections import java.util.IdentityHashMap -import java.util.Map +import java.util.Map as JavaMap +import java.util.WeakHashMap /** * Apple Music 6.5.2 adapter for AM++'s own per-word karaoke animation. @@ -28,6 +30,12 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private val symbols: TargetSymbolResolver, ) : CjkKaraokeAnimationTarget { private val valueAnimator = CjkLyricValueAnimator() + private val fieldCache = Collections.synchronizedMap( + WeakHashMap, Map>(), + ) + private val rootMethodCache = Collections.synchronizedMap( + WeakHashMap, Method?>(), + ) override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) @@ -155,9 +163,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } private fun cancelNativeWordAnimator(entry: Any) { - val field = allFields(entry.javaClass) - .firstOrNull { candidate -> candidate.name == "o" } - ?: return + val field = cachedFields(entry.javaClass)["o"] ?: return val animator = readField(field, entry) if (animator is Animator) runCatching { animator.cancel() } runCatching { @@ -262,9 +268,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } private fun clearHolderAnimations(holder: Any) { - allFields(holder.javaClass) + cachedFields(holder.javaClass).values .mapNotNull { field -> readField(field, holder) } - .filterIsInstance>() + .filterIsInstance>() .flatMap { map -> map.values().asSequence() } .filterNotNull() .flatMap { entry -> @@ -292,8 +298,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun findWordText(holder: Any, wordId: Int, background: Boolean): CharSequence? = findWordEntry(holder, wordId, background) ?.let { entry -> - allFields(entry.javaClass) - .firstOrNull { field -> field.name == "c" } + cachedFields(entry.javaClass)["c"] ?.let { field -> readField(field, entry) as? CharSequence } } @@ -303,9 +308,8 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // Map can therefore animate the wrong TextView when the same wordId // exists in more than one lyric track; G/H are the exact foreground // and background maps selected by z.a0. - val map = allFields(holder.javaClass) - .firstOrNull { field -> field.name == preferredMapName } - ?.let { field -> readField(field, holder) as? Map<*, *> } + val map = cachedFields(holder.javaClass)[preferredMapName] + ?.let { field -> readField(field, holder) as? JavaMap<*, *> } ?: return null val entry = runCatching { map[Integer.valueOf(wordId)] }.getOrNull() ?: runCatching { map[wordId] }.getOrNull() @@ -314,8 +318,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun findEntryBindings(entry: Any, background: Boolean): List { val preferredBindingName = if (background) "j" else "i" - val directBinding = allFields(entry.javaClass) - .firstOrNull { field -> field.name == preferredBindingName } + val directBinding = cachedFields(entry.javaClass)[preferredBindingName] ?.let { field -> readField(field, entry) } ?.takeIf(::looksLikeViewBinding) if (directBinding != null) return listOf(directBinding) @@ -323,8 +326,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // z.m0 falls back to e.k when the host split one native word into a // character/whitespace binding list. This is the common CJK path. val splitBindings = ( - allFields(entry.javaClass) - .firstOrNull { field -> field.name == "k" } + cachedFields(entry.javaClass)["k"] ?.let { field -> readField(field, entry) as? Iterable<*> } ?: emptyList() ) @@ -339,33 +341,49 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // Generated lyric bindings in 6.5.2 expose their word CustomTextView // as field U. We intentionally use the type, not only the obfuscated // name, so a generated binding rename remains fail-open. - allFields(binding.javaClass).forEach { field -> + cachedFields(binding.javaClass).values.forEach { field -> val value = readField(field, binding) if (value is TextView) return listOf(value) } // A future generated binding may only expose ViewDataBinding#getRoot. val root = runCatching { - binding.javaClass.methods.firstOrNull { method -> - method.name == "getRoot" && method.parameterCount == 0 - }?.invoke(binding) + val rootMethod = synchronized(rootMethodCache) { + if (rootMethodCache.containsKey(binding.javaClass)) { + rootMethodCache[binding.javaClass] + } else { + binding.javaClass.methods.firstOrNull { method -> + method.name == "getRoot" && method.parameterCount == 0 + }?.apply { isAccessible = true } + .also { method -> rootMethodCache[binding.javaClass] = method } + } + } + rootMethod?.invoke(binding) }.getOrNull() return (root as? TextView)?.let(::listOf).orEmpty() } private fun looksLikeViewBinding(value: Any): Boolean = value.javaClass.name.contains("ViewDataBinding") || - allFields(value.javaClass).any { field -> + cachedFields(value.javaClass).values.any { field -> TextView::class.java.isAssignableFrom(field.type) } - private fun allFields(type: Class<*>): Sequence = - generateSequence(type) { it.superclass } - .flatMap { current -> current.declaredFields.asSequence() } - .filterNot { field -> Modifier.isStatic(field.modifiers) } + private fun cachedFields(type: Class<*>): Map = synchronized(fieldCache) { + fieldCache[type] ?: buildMap { + generateSequence(type) { it.superclass } + .flatMap { current -> current.declaredFields.asSequence() } + .filterNot { field -> Modifier.isStatic(field.modifiers) } + .forEach { field -> + if (!containsKey(field.name)) { + runCatching { field.isAccessible = true } + put(field.name, field) + } + } + }.also { fields -> fieldCache[type] = fields } + } private fun readField(field: Field, receiver: Any): Any? = runCatching { - field.isAccessible = true field.get(receiver) }.getOrNull() From 781ef34943befb801b5a192ce7ff2b8d57e6a7eb Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:54:17 +0800 Subject: [PATCH 06/33] fix: honor native word text during CJK rebinding --- .../hook/AppleMusicCjkKaraokeAnimationTarget.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 79533f8..fc45028 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -119,16 +119,18 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // e.c is the native wordText already consumed by z.a0, so use it as // the primary script signal and only fall back to the visible View. val hostWordText = findWordText(holder, wordId, background = false) + val hostIsCjk = hostWordText?.let(::containsCjkKaraokeScript) val cjkViews = textViews.filter { textView -> runCatching { textView.text }.getOrNull()?.let(::containsCjkKaraokeScript) == true } - if (hostWordText?.let(::containsCjkKaraokeScript) == true || cjkViews.isNotEmpty()) { + val currentWordIsCjk = hostIsCjk ?: cjkViews.isNotEmpty() + if (currentWordIsCjk) { // The host may have created e.o during this invocation. Cancel it // again after the original method and leave View properties to // the AM++ animator below. cancelNativeWordAnimator(entry) } - if (hostWordText?.let(::containsCjkKaraokeScript) != true && cjkViews.isEmpty()) { + if (!currentWordIsCjk) { // A recycled word View can be rebound to Latin text before the // next CJK callback; restore it immediately in that case. textViews.forEach(valueAnimator::clear) @@ -268,9 +270,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } private fun clearHolderAnimations(holder: Any) { - cachedFields(holder.javaClass).values - .mapNotNull { field -> readField(field, holder) } - .filterIsInstance>() + listOf("G", "H") + .mapNotNull { mapName -> cachedFields(holder.javaClass)[mapName] } + .mapNotNull { field -> readField(field, holder) as? JavaMap<*, *> } .flatMap { map -> map.values().asSequence() } .filterNotNull() .flatMap { entry -> From 5347db28b8f21f280aabf38cacfdda1ed61722fc Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:55:36 +0800 Subject: [PATCH 07/33] fix: report lyric cleanup hook diagnostics --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 51 ++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index fc45028..8f00e96 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -82,8 +82,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ) } + val cleanupNote = failures.joinToString("; ").takeIf(String::isNotBlank) + ?.let { "; cleanup diagnostics: $it" } + .orEmpty() return TargetCapabilityInstall.Active( - "Installed AM++ per-word ValueAnimator on exact 6.5.2/1586 z.a0 binding View", + "Installed AM++ per-word ValueAnimator on exact 6.5.2/1586 z.a0 binding View$cleanupNote", ) } @@ -182,10 +185,13 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } } - private fun installWordLayoutRecycleHook(owner: Class<*>, failures: MutableList) { - val g0 = findWordLayoutMethod(owner) ?: return + private fun installWordLayoutRecycleHook(owner: Class<*>, failures: MutableList): Boolean { + val g0 = findWordLayoutMethod(owner) ?: run { + failures += "z.g0 recycle method unavailable" + return false + } try { - ModernXposedRuntime.hookMethod(g0, object : ModernMethodHook() { + val installed = ModernXposedRuntime.hookMethod(g0, object : ModernMethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { // g0 rebuilds one FullWidthAlphaGradientFlexboxLayout at // a time. Clear only that layout's current children so a @@ -193,11 +199,14 @@ internal class AppleMusicCjkKaraokeAnimationTarget( (param.args.getOrNull(2) as? View)?.let(::clearViewTree) } }) + if (!installed) failures += "z.g0 recycle hook was rejected" + return installed } catch (error: Throwable) { // This is an optional safety hook. z.a0 remains useful even if // an OEM build changes the layout method's return type. failures += "z.g0 recycle hook failed: ${error.cjkShortMessage()}" Log.w(TAG, "CJK z.g0 recycle hook failed open", error) + return false } } @@ -210,16 +219,22 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } } - private fun installFragmentDestroyHook(failures: MutableList) { - val fragment = symbols.resolve(AppleMusicSymbols.LyricsFragment).valueOrNull() ?: return + private fun installFragmentDestroyHook(failures: MutableList): Boolean { + val fragment = symbols.resolve(AppleMusicSymbols.LyricsFragment).valueOrNull() ?: run { + failures += "lyrics fragment symbol unavailable for cleanup" + return false + } try { val declaringClass = generateSequence(fragment) { it.superclass } .firstOrNull { type -> type.declaredMethods.any { method -> method.name == "onDestroyView" && method.parameterCount == 0 } - } ?: return - ModernXposedRuntime.hookAllMethods( + } ?: run { + failures += "lyrics onDestroyView method unavailable" + return false + } + val installed = ModernXposedRuntime.hookAllMethods( declaringClass, "onDestroyView", object : ModernMethodHook() { @@ -228,9 +243,12 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } }, ) + if (installed.isEmpty()) failures += "lyrics onDestroyView hook was rejected" + return installed.isNotEmpty() } catch (error: Throwable) { failures += "lyrics onDestroyView cleanup failed: ${error.cjkShortMessage()}" Log.w(TAG, "CJK lyrics lifecycle cleanup failed open", error) + return false } } @@ -243,8 +261,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( owner: Class<*>, holderClass: Class<*>?, failures: MutableList, - ) { - if (holderClass == null) return + ): Boolean { + if (holderClass == null) { + failures += "z.p holder type unavailable for cleanup" + return false + } val recycle = owner.declaredMethods.firstOrNull { method -> val holderParameter = method.parameterTypes.firstOrNull() method.name == "p" && @@ -256,16 +277,22 @@ internal class AppleMusicCjkKaraokeAnimationTarget( holderParameter.isAssignableFrom(holderClass) ) && method.parameterTypes[1] == Int::class.javaPrimitiveType - } ?: return + } ?: run { + failures += "z.p recycle method unavailable" + return false + } try { - ModernXposedRuntime.hookMethod(recycle, object : ModernMethodHook() { + val installed = ModernXposedRuntime.hookMethod(recycle, object : ModernMethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { param.args.getOrNull(0)?.let(::clearHolderAnimations) } }) + if (!installed) failures += "z.p recycle hook was rejected" + return installed } catch (error: Throwable) { failures += "z.p recycle hook failed: ${error.cjkShortMessage()}" Log.w(TAG, "CJK z.p recycle hook failed open", error) + return false } } From eaa8eb3cb54cf8ac28d470b03e92305d7f360d86 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:59:50 +0800 Subject: [PATCH 08/33] Revert "fix: report lyric cleanup hook diagnostics" This reverts commit 5347db28b8f21f280aabf38cacfdda1ed61722fc. --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 51 +++++-------------- 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 8f00e96..fc45028 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -82,11 +82,8 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ) } - val cleanupNote = failures.joinToString("; ").takeIf(String::isNotBlank) - ?.let { "; cleanup diagnostics: $it" } - .orEmpty() return TargetCapabilityInstall.Active( - "Installed AM++ per-word ValueAnimator on exact 6.5.2/1586 z.a0 binding View$cleanupNote", + "Installed AM++ per-word ValueAnimator on exact 6.5.2/1586 z.a0 binding View", ) } @@ -185,13 +182,10 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } } - private fun installWordLayoutRecycleHook(owner: Class<*>, failures: MutableList): Boolean { - val g0 = findWordLayoutMethod(owner) ?: run { - failures += "z.g0 recycle method unavailable" - return false - } + private fun installWordLayoutRecycleHook(owner: Class<*>, failures: MutableList) { + val g0 = findWordLayoutMethod(owner) ?: return try { - val installed = ModernXposedRuntime.hookMethod(g0, object : ModernMethodHook() { + ModernXposedRuntime.hookMethod(g0, object : ModernMethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { // g0 rebuilds one FullWidthAlphaGradientFlexboxLayout at // a time. Clear only that layout's current children so a @@ -199,14 +193,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( (param.args.getOrNull(2) as? View)?.let(::clearViewTree) } }) - if (!installed) failures += "z.g0 recycle hook was rejected" - return installed } catch (error: Throwable) { // This is an optional safety hook. z.a0 remains useful even if // an OEM build changes the layout method's return type. failures += "z.g0 recycle hook failed: ${error.cjkShortMessage()}" Log.w(TAG, "CJK z.g0 recycle hook failed open", error) - return false } } @@ -219,22 +210,16 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } } - private fun installFragmentDestroyHook(failures: MutableList): Boolean { - val fragment = symbols.resolve(AppleMusicSymbols.LyricsFragment).valueOrNull() ?: run { - failures += "lyrics fragment symbol unavailable for cleanup" - return false - } + private fun installFragmentDestroyHook(failures: MutableList) { + val fragment = symbols.resolve(AppleMusicSymbols.LyricsFragment).valueOrNull() ?: return try { val declaringClass = generateSequence(fragment) { it.superclass } .firstOrNull { type -> type.declaredMethods.any { method -> method.name == "onDestroyView" && method.parameterCount == 0 } - } ?: run { - failures += "lyrics onDestroyView method unavailable" - return false - } - val installed = ModernXposedRuntime.hookAllMethods( + } ?: return + ModernXposedRuntime.hookAllMethods( declaringClass, "onDestroyView", object : ModernMethodHook() { @@ -243,12 +228,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } }, ) - if (installed.isEmpty()) failures += "lyrics onDestroyView hook was rejected" - return installed.isNotEmpty() } catch (error: Throwable) { failures += "lyrics onDestroyView cleanup failed: ${error.cjkShortMessage()}" Log.w(TAG, "CJK lyrics lifecycle cleanup failed open", error) - return false } } @@ -261,11 +243,8 @@ internal class AppleMusicCjkKaraokeAnimationTarget( owner: Class<*>, holderClass: Class<*>?, failures: MutableList, - ): Boolean { - if (holderClass == null) { - failures += "z.p holder type unavailable for cleanup" - return false - } + ) { + if (holderClass == null) return val recycle = owner.declaredMethods.firstOrNull { method -> val holderParameter = method.parameterTypes.firstOrNull() method.name == "p" && @@ -277,22 +256,16 @@ internal class AppleMusicCjkKaraokeAnimationTarget( holderParameter.isAssignableFrom(holderClass) ) && method.parameterTypes[1] == Int::class.javaPrimitiveType - } ?: run { - failures += "z.p recycle method unavailable" - return false - } + } ?: return try { - val installed = ModernXposedRuntime.hookMethod(recycle, object : ModernMethodHook() { + ModernXposedRuntime.hookMethod(recycle, object : ModernMethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { param.args.getOrNull(0)?.let(::clearHolderAnimations) } }) - if (!installed) failures += "z.p recycle hook was rejected" - return installed } catch (error: Throwable) { failures += "z.p recycle hook failed: ${error.cjkShortMessage()}" Log.w(TAG, "CJK z.p recycle hook failed open", error) - return false } } From 3bfb8641e5ef690ad75c9828290917a4027fade8 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:59:50 +0800 Subject: [PATCH 09/33] Revert "fix: honor native word text during CJK rebinding" This reverts commit 781ef34943befb801b5a192ce7ff2b8d57e6a7eb. --- .../hook/AppleMusicCjkKaraokeAnimationTarget.kt | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index fc45028..79533f8 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -119,18 +119,16 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // e.c is the native wordText already consumed by z.a0, so use it as // the primary script signal and only fall back to the visible View. val hostWordText = findWordText(holder, wordId, background = false) - val hostIsCjk = hostWordText?.let(::containsCjkKaraokeScript) val cjkViews = textViews.filter { textView -> runCatching { textView.text }.getOrNull()?.let(::containsCjkKaraokeScript) == true } - val currentWordIsCjk = hostIsCjk ?: cjkViews.isNotEmpty() - if (currentWordIsCjk) { + if (hostWordText?.let(::containsCjkKaraokeScript) == true || cjkViews.isNotEmpty()) { // The host may have created e.o during this invocation. Cancel it // again after the original method and leave View properties to // the AM++ animator below. cancelNativeWordAnimator(entry) } - if (!currentWordIsCjk) { + if (hostWordText?.let(::containsCjkKaraokeScript) != true && cjkViews.isEmpty()) { // A recycled word View can be rebound to Latin text before the // next CJK callback; restore it immediately in that case. textViews.forEach(valueAnimator::clear) @@ -270,9 +268,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } private fun clearHolderAnimations(holder: Any) { - listOf("G", "H") - .mapNotNull { mapName -> cachedFields(holder.javaClass)[mapName] } - .mapNotNull { field -> readField(field, holder) as? JavaMap<*, *> } + cachedFields(holder.javaClass).values + .mapNotNull { field -> readField(field, holder) } + .filterIsInstance>() .flatMap { map -> map.values().asSequence() } .filterNotNull() .flatMap { entry -> From dfba54a4f0a01fcdcfe7faed22a316b3931f382c Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:59:50 +0800 Subject: [PATCH 10/33] Revert "perf: cache CJK lyric binding reflection" This reverts commit 48613b75fd9cf3c11a70eb9616f93457c6827e20. --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 68 +++++++------------ 1 file changed, 25 insertions(+), 43 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 79533f8..3dbcbf2 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -8,10 +8,8 @@ import android.widget.TextView import java.lang.reflect.Field import java.lang.reflect.Method import java.lang.reflect.Modifier -import java.util.Collections import java.util.IdentityHashMap -import java.util.Map as JavaMap -import java.util.WeakHashMap +import java.util.Map /** * Apple Music 6.5.2 adapter for AM++'s own per-word karaoke animation. @@ -30,12 +28,6 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private val symbols: TargetSymbolResolver, ) : CjkKaraokeAnimationTarget { private val valueAnimator = CjkLyricValueAnimator() - private val fieldCache = Collections.synchronizedMap( - WeakHashMap, Map>(), - ) - private val rootMethodCache = Collections.synchronizedMap( - WeakHashMap, Method?>(), - ) override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) @@ -163,7 +155,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } private fun cancelNativeWordAnimator(entry: Any) { - val field = cachedFields(entry.javaClass)["o"] ?: return + val field = allFields(entry.javaClass) + .firstOrNull { candidate -> candidate.name == "o" } + ?: return val animator = readField(field, entry) if (animator is Animator) runCatching { animator.cancel() } runCatching { @@ -268,9 +262,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } private fun clearHolderAnimations(holder: Any) { - cachedFields(holder.javaClass).values + allFields(holder.javaClass) .mapNotNull { field -> readField(field, holder) } - .filterIsInstance>() + .filterIsInstance>() .flatMap { map -> map.values().asSequence() } .filterNotNull() .flatMap { entry -> @@ -298,7 +292,8 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun findWordText(holder: Any, wordId: Int, background: Boolean): CharSequence? = findWordEntry(holder, wordId, background) ?.let { entry -> - cachedFields(entry.javaClass)["c"] + allFields(entry.javaClass) + .firstOrNull { field -> field.name == "c" } ?.let { field -> readField(field, entry) as? CharSequence } } @@ -308,8 +303,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // Map can therefore animate the wrong TextView when the same wordId // exists in more than one lyric track; G/H are the exact foreground // and background maps selected by z.a0. - val map = cachedFields(holder.javaClass)[preferredMapName] - ?.let { field -> readField(field, holder) as? JavaMap<*, *> } + val map = allFields(holder.javaClass) + .firstOrNull { field -> field.name == preferredMapName } + ?.let { field -> readField(field, holder) as? Map<*, *> } ?: return null val entry = runCatching { map[Integer.valueOf(wordId)] }.getOrNull() ?: runCatching { map[wordId] }.getOrNull() @@ -318,7 +314,8 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun findEntryBindings(entry: Any, background: Boolean): List { val preferredBindingName = if (background) "j" else "i" - val directBinding = cachedFields(entry.javaClass)[preferredBindingName] + val directBinding = allFields(entry.javaClass) + .firstOrNull { field -> field.name == preferredBindingName } ?.let { field -> readField(field, entry) } ?.takeIf(::looksLikeViewBinding) if (directBinding != null) return listOf(directBinding) @@ -326,7 +323,8 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // z.m0 falls back to e.k when the host split one native word into a // character/whitespace binding list. This is the common CJK path. val splitBindings = ( - cachedFields(entry.javaClass)["k"] + allFields(entry.javaClass) + .firstOrNull { field -> field.name == "k" } ?.let { field -> readField(field, entry) as? Iterable<*> } ?: emptyList() ) @@ -341,49 +339,33 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // Generated lyric bindings in 6.5.2 expose their word CustomTextView // as field U. We intentionally use the type, not only the obfuscated // name, so a generated binding rename remains fail-open. - cachedFields(binding.javaClass).values.forEach { field -> + allFields(binding.javaClass).forEach { field -> val value = readField(field, binding) if (value is TextView) return listOf(value) } // A future generated binding may only expose ViewDataBinding#getRoot. val root = runCatching { - val rootMethod = synchronized(rootMethodCache) { - if (rootMethodCache.containsKey(binding.javaClass)) { - rootMethodCache[binding.javaClass] - } else { - binding.javaClass.methods.firstOrNull { method -> - method.name == "getRoot" && method.parameterCount == 0 - }?.apply { isAccessible = true } - .also { method -> rootMethodCache[binding.javaClass] = method } - } - } - rootMethod?.invoke(binding) + binding.javaClass.methods.firstOrNull { method -> + method.name == "getRoot" && method.parameterCount == 0 + }?.invoke(binding) }.getOrNull() return (root as? TextView)?.let(::listOf).orEmpty() } private fun looksLikeViewBinding(value: Any): Boolean = value.javaClass.name.contains("ViewDataBinding") || - cachedFields(value.javaClass).values.any { field -> + allFields(value.javaClass).any { field -> TextView::class.java.isAssignableFrom(field.type) } - private fun cachedFields(type: Class<*>): Map = synchronized(fieldCache) { - fieldCache[type] ?: buildMap { - generateSequence(type) { it.superclass } - .flatMap { current -> current.declaredFields.asSequence() } - .filterNot { field -> Modifier.isStatic(field.modifiers) } - .forEach { field -> - if (!containsKey(field.name)) { - runCatching { field.isAccessible = true } - put(field.name, field) - } - } - }.also { fields -> fieldCache[type] = fields } - } + private fun allFields(type: Class<*>): Sequence = + generateSequence(type) { it.superclass } + .flatMap { current -> current.declaredFields.asSequence() } + .filterNot { field -> Modifier.isStatic(field.modifiers) } private fun readField(field: Field, receiver: Any): Any? = runCatching { + field.isAccessible = true field.get(receiver) }.getOrNull() From 38b0062a5becb6dcc19b03ce6714c75b762d2239 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:59:50 +0800 Subject: [PATCH 11/33] Revert "feat: drive CJK lyric words with AM++ ValueAnimator" This reverts commit b9cdf945ea82307fd026e64a2aef3016941568bc. --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 438 +++++---------- .../module/hook/CjkKaraokeAnimationFeature.kt | 5 +- .../module/hook/CjkLyricValueAnimator.kt | 516 ------------------ .../amenhancer/module/hook/TargetSymbols.kt | 13 +- .../module/hook/CjkLyricValueAnimatorTest.kt | 47 -- docs/cjk-karaoke-animation-handoff.md | 45 +- 6 files changed, 157 insertions(+), 907 deletions(-) delete mode 100644 app/src/main/java/dev/amenhancer/module/hook/CjkLyricValueAnimator.kt delete mode 100644 app/src/test/java/dev/amenhancer/module/hook/CjkLyricValueAnimatorTest.kt diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 3dbcbf2..e31dc4e 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -1,383 +1,195 @@ package dev.amenhancer.module.hook -import android.animation.Animator -import android.util.Log -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import java.lang.reflect.Field -import java.lang.reflect.Method -import java.lang.reflect.Modifier -import java.util.IdentityHashMap -import java.util.Map +import java.util.concurrent.atomic.AtomicInteger /** - * Apple Music 6.5.2 adapter for AM++'s own per-word karaoke animation. - * - * The old experiment changed Apple's Unicode-block predicate while `z.a0` - * was running. That made the host's private animation state machine decide - * whether CJK should be animated and was consequently hard to cancel when a - * lyric row was recycled. This target still uses the verified `z.a0` entry - * point, but only as a data seam: its arguments identify the current - * `wordId`, duration and background flag. The holder's exact word map then - * leads to the generated binding's `CustomTextView`, where AM++ starts and - * owns a `ValueAnimator`. For CJK foreground words the host's `e.o` animator - * is cancelled before/after the call so it cannot fight the AM++ properties. + * Narrow Apple Music 6.5.2/1586 adapter for the native karaoke rush-gradient + * path. The host's `z.a0` call is the only scope in which the UnicodeBlock + * helper result is adjusted; normal `z.g0` layout calls therefore retain their + * original CJK behavior. */ internal class AppleMusicCjkKaraokeAnimationTarget( private val symbols: TargetSymbolResolver, ) : CjkKaraokeAnimationTarget { - private val valueAnimator = CjkLyricValueAnimator() + private val a0Depth: ThreadLocal = ThreadLocal.withInitial { 0 } + private val rewriteLogCount = AtomicInteger() override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) + val helperResolution = symbols.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod) val a0 = a0Resolution.valueOrNull() - ?: return TargetCapabilityInstall.Degraded(a0Resolution.summary) + val helper = helperResolution.valueOrNull() + if (a0 == null || helper == null) { + return TargetCapabilityInstall.Degraded( + listOf(a0Resolution, helperResolution) + .filterNot { it is TargetResolution.Found<*> } + .joinToString { it.summary }, + ) + } val failures = mutableListOf() val a0Installed = try { ModernXposedRuntime.hookMethod(a0, object : ModernMethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { - runCatching { suppressNativeWordAnimator(param) } - .onFailure { error -> - Log.w(TAG, "CJK native animator suppression failed open", error) - } + enterA0Scope() } override fun afterHookedMethod(param: MethodHookParam) { - runCatching { animateCurrentWord(param) } - .onFailure { error -> - // A host binding change must never break Apple's - // original callback or lyric layout. - Log.w(TAG, "CJK word View animation failed open", error) - } + // The host callback runs even when z.a0 throws; always + // release this thread's scope so a later g0 call cannot + // inherit a stale override. + leaveA0Scope() } }).also { installed -> if (!installed) failures += "z.a0 hook was rejected" } } catch (error: Throwable) { failures += "z.a0 hook failed: ${error.cjkShortMessage()}" - Log.w(TAG, "CJK word View animation hook failed", error) + ModernXposedRuntime.log("CJK karaoke z.a0 hook failed", error) false } - // g0 removes and rebuilds the flexbox children. Clearing here avoids - // a detached/recycled CustomTextView retaining an in-flight animator - // if the platform keeps the object in its pool for a moment. - installWordLayoutRecycleHook(a0.declaringClass, failures) - installHolderRecycleHook(a0.declaringClass, a0.parameterTypes.firstOrNull(), failures) - installFragmentDestroyHook(failures) + val helperInstalled = try { + ModernXposedRuntime.hookMethod(helper, object : ModernMethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + try { + rewriteCjkK0Result(param) + } catch (error: Throwable) { + // A malformed host call must never break its original + // helper result. This is deliberately fail-open. + ModernXposedRuntime.log("CJK karaoke UnicodeBlock helper failed open", error) + } + } + }).also { installed -> + if (!installed) failures += "I0\$a.a hook was rejected" + } + } catch (error: Throwable) { + failures += "I0\$a.a hook failed: ${error.cjkShortMessage()}" + ModernXposedRuntime.log("CJK karaoke I0\$a.a hook failed", error) + false + } - if (!a0Installed) { + if (!a0Installed || !helperInstalled) { return TargetCapabilityInstall.Degraded( - failures.joinToString("; ").ifBlank { "CJK ValueAnimator hook was not installed" }, + failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, ) } return TargetCapabilityInstall.Active( - "Installed AM++ per-word ValueAnimator on exact 6.5.2/1586 z.a0 binding View", + "Installed exact Apple Music 6.5.2/1586 z.a0 + I0\$a.a CJK j0/k0 rush-gradient scope", ) } - private fun suppressNativeWordAnimator(param: ModernMethodHook.MethodHookParam) { - val background = param.args.getOrNull(4) as? Boolean ?: return - if (background) return - val holder = param.args.getOrNull(0) ?: return - val wordId = (param.args.getOrNull(2) as? Number)?.toInt() ?: return - val entry = findWordEntry(holder, wordId, background = false) ?: return - val wordText = findWordText(holder, wordId, background = false) - if (wordText?.let(::containsCjkKaraokeScript) != true) return - cancelNativeWordAnimator(entry) - } - - private fun animateCurrentWord(param: ModernMethodHook.MethodHookParam) { - // z.a0(z$a holder, int lineId, int wordId, int duration, boolean bg) - val background = param.args.getOrNull(4) as? Boolean ?: return - val holder = param.args.getOrNull(0) ?: return - val wordId = (param.args.getOrNull(2) as? Number)?.toInt() ?: return - val duration = (param.args.getOrNull(3) as? Number)?.toLong() ?: return + private fun rewriteCjkK0Result(param: ModernMethodHook.MethodHookParam) { + if (!isInA0Scope()) return - // Background vocals have a separate binding map and are intentionally - // left to the host. The foreground map is z$a.G in this exact build. - if (background) return - val entry = findWordEntry(holder, wordId, background = false) ?: return - val seenTextViews = IdentityHashMap() - val textViews = findWordBindings(holder, wordId, background = false) - .flatMap(::findBindingTextViews) - .filter { view -> seenTextViews.put(view, true) == null } - if (textViews.isEmpty()) return + val text = param.args.getOrNull(0) as? CharSequence ?: return + if (!containsCjkKaraokeScript(text)) return - // The generated binding may apply its TextView text asynchronously. - // e.c is the native wordText already consumed by z.a0, so use it as - // the primary script signal and only fall back to the visible View. - val hostWordText = findWordText(holder, wordId, background = false) - val cjkViews = textViews.filter { textView -> - runCatching { textView.text }.getOrNull()?.let(::containsCjkKaraokeScript) == true - } - if (hostWordText?.let(::containsCjkKaraokeScript) == true || cjkViews.isNotEmpty()) { - // The host may have created e.o during this invocation. Cancel it - // again after the original method and leave View properties to - // the AM++ animator below. - cancelNativeWordAnimator(entry) - } - if (hostWordText?.let(::containsCjkKaraokeScript) != true && cjkViews.isEmpty()) { - // A recycled word View can be rebound to Latin text before the - // next CJK callback; restore it immediately in that case. - textViews.forEach(valueAnimator::clear) - return - } - - // a0 is already called once per native word event. The duration is - // the word's own LyricsTiming duration, so long CJK tail words get a - // correspondingly longer AM++ animation without guessing line timing. - val targets = cjkViews.ifEmpty { textViews } - targets.forEach { view -> - // Native z$f/z$h may leave a cancelled lift at a non-zero - // translation/scale. Normalize only those properties before AM++ - // captures its baseline; alpha is left untouched for host row - // dimming semantics. - runCatching { - view.translationY = 0f - view.scaleX = 1f - view.scaleY = 1f + val languageSet = param.args.getOrNull(1) as? Set<*> ?: return + when { + isK0Set(languageSet) && param.result == true -> { + // CJK is normally classified into k0, which blocks the + // rush branch. Make that one result look like a default + // script only while a0 is running. + param.result = false + logRewrite(text, "k0 true -> false") } - } - valueAnimator.animateViews( - // The generated binding's root is the first word View. The - // explicit target list also covers z.m0's e.k fallback when - // Apple split one native word into several character bindings. - root = targets.first(), - targets = targets, - durationMs = duration, - wordIndex = 0, - wordCount = targets.size, - ) - } - - private fun cancelNativeWordAnimator(entry: Any) { - val field = allFields(entry.javaClass) - .firstOrNull { candidate -> candidate.name == "o" } - ?: return - val animator = readField(field, entry) - if (animator is Animator) runCatching { animator.cancel() } - runCatching { - field.isAccessible = true - field.set(entry, null) - } - // The host also keeps child word-lift animators in e.p. Its verified - // e.a() cleanup only cancels entries tagged KARAOKE_WORD_LIFT_TAG, - // so it cannot touch the AM++ ValueAnimator (which is not in e.p). - runCatching { - entry.javaClass.methods.firstOrNull { method -> - method.name == "a" && method.parameterCount == 0 - }?.invoke(entry) - } - } - - private fun installWordLayoutRecycleHook(owner: Class<*>, failures: MutableList) { - val g0 = findWordLayoutMethod(owner) ?: return - try { - ModernXposedRuntime.hookMethod(g0, object : ModernMethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - // g0 rebuilds one FullWidthAlphaGradientFlexboxLayout at - // a time. Clear only that layout's current children so a - // neighboring visible line keeps its own animation. - (param.args.getOrNull(2) as? View)?.let(::clearViewTree) - } - }) - } catch (error: Throwable) { - // This is an optional safety hook. z.a0 remains useful even if - // an OEM build changes the layout method's return type. - failures += "z.g0 recycle hook failed: ${error.cjkShortMessage()}" - Log.w(TAG, "CJK z.g0 recycle hook failed open", error) - } - } - - private fun clearViewTree(view: View) { - valueAnimator.clear(view) - if (view is ViewGroup) { - for (index in 0 until view.childCount) { - runCatching { view.getChildAt(index) }.getOrNull()?.let(::clearViewTree) + isJ0Set(languageSet) && containsHangul(text) && param.result != true -> { + // i0 receives the j0 hit as its split/rush eligibility bit. + // Hangul belongs to k0 but not j0, so opt it into the same + // Apple animation only inside a0; g0 remains untouched. + param.result = true + logRewrite(text, "j0 false -> true") } + else -> return } } - private fun installFragmentDestroyHook(failures: MutableList) { - val fragment = symbols.resolve(AppleMusicSymbols.LyricsFragment).valueOrNull() ?: return - try { - val declaringClass = generateSequence(fragment) { it.superclass } - .firstOrNull { type -> - type.declaredMethods.any { method -> - method.name == "onDestroyView" && method.parameterCount == 0 - } - } ?: return - ModernXposedRuntime.hookAllMethods( - declaringClass, - "onDestroyView", - object : ModernMethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - valueAnimator.clearAll() - } - }, + private fun logRewrite(text: CharSequence, change: String) { + if (rewriteLogCount.getAndIncrement() < MAX_REWRITE_LOGS) { + ModernXposedRuntime.log( + "CJK karaoke a0 scope: I0\$a.a(${text.length} chars, $change); g0 unchanged", ) - } catch (error: Throwable) { - failures += "lyrics onDestroyView cleanup failed: ${error.cjkShortMessage()}" - Log.w(TAG, "CJK lyrics lifecycle cleanup failed open", error) } } - /** - * z.p(RecyclerView$D,int) is the RecyclerView rebind seam in the verified - * build; its runtime holder is cast to z$a. It can recycle an attached - * word View without calling g0 first, so only that holder is cleared here. - */ - private fun installHolderRecycleHook( - owner: Class<*>, - holderClass: Class<*>?, - failures: MutableList, - ) { - if (holderClass == null) return - val recycle = owner.declaredMethods.firstOrNull { method -> - val holderParameter = method.parameterTypes.firstOrNull() - method.name == "p" && - method.parameterTypes.size == 2 && - holderParameter != null && - ( - holderParameter == holderClass || - holderParameter.name.contains("RecyclerView\$D") || - holderParameter.isAssignableFrom(holderClass) - ) && - method.parameterTypes[1] == Int::class.javaPrimitiveType - } ?: return - try { - ModernXposedRuntime.hookMethod(recycle, object : ModernMethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - param.args.getOrNull(0)?.let(::clearHolderAnimations) - } - }) - } catch (error: Throwable) { - failures += "z.p recycle hook failed: ${error.cjkShortMessage()}" - Log.w(TAG, "CJK z.p recycle hook failed open", error) - } + private fun isK0Set(languageSet: Set<*>): Boolean = runCatching { + // z.k0 always contains HANGUL_SYLLABLES. This marker distinguishes + // it from the host's j0/l0 sets without touching either set globally. + languageSet.contains(Character.UnicodeBlock.HANGUL_SYLLABLES) + }.getOrElse { error -> + ModernXposedRuntime.log("CJK karaoke k0 marker check failed open", error) + false } - private fun clearHolderAnimations(holder: Any) { - allFields(holder.javaClass) - .mapNotNull { field -> readField(field, holder) } - .filterIsInstance>() - .flatMap { map -> map.values().asSequence() } - .filterNotNull() - .flatMap { entry -> - listOf(false, true).asSequence() - .flatMap { background -> findEntryBindings(entry, background).asSequence() } - } - .flatMap(::findBindingTextViews) - .forEach(valueAnimator::clear) + private fun isJ0Set(languageSet: Set<*>): Boolean = runCatching { + languageSet.contains(Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS) && + languageSet.contains(Character.UnicodeBlock.HIRAGANA) && + languageSet.contains(Character.UnicodeBlock.KATAKANA) && + !languageSet.contains(Character.UnicodeBlock.HANGUL_SYLLABLES) && + !languageSet.contains(Character.UnicodeBlock.THAI) + }.getOrElse { error -> + ModernXposedRuntime.log("CJK karaoke j0 marker check failed open", error) + false } - private fun findWordLayoutMethod(owner: Class<*>): Method? = owner.declaredMethods - .firstOrNull { method -> - method.name == "g0" && - method.parameterTypes.size == 7 && - method.parameterTypes[0].name.endsWith("LyricsWordVector") && - method.parameterTypes[2].name.contains("FullWidthAlphaGradientFlexboxLayout") - } - ?.apply { isAccessible = true } - - private fun findWordBindings(holder: Any, wordId: Int, background: Boolean): List { - val entry = findWordEntry(holder, wordId, background) ?: return emptyList() - return findEntryBindings(entry, background) + private fun enterA0Scope() { + runCatching { a0Depth.set((a0Depth.get() ?: 0) + 1) } + .onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth enter failed open", error) } } - private fun findWordText(holder: Any, wordId: Int, background: Boolean): CharSequence? = - findWordEntry(holder, wordId, background) - ?.let { entry -> - allFields(entry.javaClass) - .firstOrNull { field -> field.name == "c" } - ?.let { field -> readField(field, entry) as? CharSequence } + private fun leaveA0Scope() { + runCatching { + val depth = a0Depth.get() ?: 0 + if (depth <= 1) { + a0Depth.remove() + } else { + a0Depth.set(depth - 1) } - - private fun findWordEntry(holder: Any, wordId: Int, background: Boolean): Any? { - val preferredMapName = if (background) "H" else "G" - // z$a also owns I/J pronunciation maps. Falling back to an arbitrary - // Map can therefore animate the wrong TextView when the same wordId - // exists in more than one lyric track; G/H are the exact foreground - // and background maps selected by z.a0. - val map = allFields(holder.javaClass) - .firstOrNull { field -> field.name == preferredMapName } - ?.let { field -> readField(field, holder) as? Map<*, *> } - ?: return null - val entry = runCatching { map[Integer.valueOf(wordId)] }.getOrNull() - ?: runCatching { map[wordId] }.getOrNull() - return entry + }.onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth cleanup failed open", error) } } - private fun findEntryBindings(entry: Any, background: Boolean): List { - val preferredBindingName = if (background) "j" else "i" - val directBinding = allFields(entry.javaClass) - .firstOrNull { field -> field.name == preferredBindingName } - ?.let { field -> readField(field, entry) } - ?.takeIf(::looksLikeViewBinding) - if (directBinding != null) return listOf(directBinding) + private fun isInA0Scope(): Boolean = runCatching { (a0Depth.get() ?: 0) > 0 } + .getOrElse { error -> + ModernXposedRuntime.log("CJK karaoke a0 depth read failed open", error) + false + } - // z.m0 falls back to e.k when the host split one native word into a - // character/whitespace binding list. This is the common CJK path. - val splitBindings = ( - allFields(entry.javaClass) - .firstOrNull { field -> field.name == "k" } - ?.let { field -> readField(field, entry) as? Iterable<*> } - ?: emptyList() - ) - .filterNotNull() - .filter(::looksLikeViewBinding) - return splitBindings + private companion object { + const val MAX_REWRITE_LOGS = 3 } +} - private fun findBindingTextViews(binding: Any): List { - if (binding is TextView) return listOf(binding) - - // Generated lyric bindings in 6.5.2 expose their word CustomTextView - // as field U. We intentionally use the type, not only the obfuscated - // name, so a generated binding rename remains fail-open. - allFields(binding.javaClass).forEach { field -> - val value = readField(field, binding) - if (value is TextView) return listOf(value) +/** Returns true for the CJK blocks used by the host's karaoke classifier. */ +internal fun containsCjkKaraokeScript(text: CharSequence): Boolean { + for (index in text.indices) { + when (Character.UnicodeBlock.of(text[index])) { + Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS, + Character.UnicodeBlock.HIRAGANA, + Character.UnicodeBlock.KATAKANA, + Character.UnicodeBlock.HANGUL_SYLLABLES, + Character.UnicodeBlock.HANGUL_JAMO, + Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO, + -> return true } - - // A future generated binding may only expose ViewDataBinding#getRoot. - val root = runCatching { - binding.javaClass.methods.firstOrNull { method -> - method.name == "getRoot" && method.parameterCount == 0 - }?.invoke(binding) - }.getOrNull() - return (root as? TextView)?.let(::listOf).orEmpty() } + return false +} - private fun looksLikeViewBinding(value: Any): Boolean = - value.javaClass.name.contains("ViewDataBinding") || - allFields(value.javaClass).any { field -> - TextView::class.java.isAssignableFrom(field.type) - } - - private fun allFields(type: Class<*>): Sequence = - generateSequence(type) { it.superclass } - .flatMap { current -> current.declaredFields.asSequence() } - .filterNot { field -> Modifier.isStatic(field.modifiers) } - - private fun readField(field: Field, receiver: Any): Any? = runCatching { - field.isAccessible = true - field.get(receiver) - }.getOrNull() - - private companion object { - const val TAG = "AMPP-CJK-KARAOKE" +private fun containsHangul(text: CharSequence): Boolean { + for (index in text.indices) { + when (Character.UnicodeBlock.of(text[index])) { + Character.UnicodeBlock.HANGUL_SYLLABLES, + Character.UnicodeBlock.HANGUL_JAMO, + Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO, + -> return true + else -> Unit + } } + return false } -/** Returns true for the CJK blocks used by the host's karaoke classifier. */ -internal fun containsCjkKaraokeScript(text: CharSequence): Boolean = - defaultCjkLyricScriptPredicate(text) - private fun Throwable.cjkShortMessage(): String = buildString { append(javaClass.simpleName.ifBlank { javaClass.name }) message?.takeIf(String::isNotBlank)?.let { append(": ").append(it.take(180)) } diff --git a/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt b/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt index b391fd2..3486833 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt @@ -3,12 +3,11 @@ package dev.amenhancer.module.hook import dev.amenhancer.module.ModuleConstants /** - * Installs the narrow Apple Music 6.5.2 per-word ValueAnimator adaptation. + * Installs the narrow Apple Music 6.5.2 karaoke animation adaptation. * * The target adapter owns all version and symbol checks; unsupported hosts * therefore report a degraded health result without touching existing lyric - * layout or blur paths. Apple’s native Unicode/rush-gradient classifier is - * intentionally not rewritten by this feature. + * layout or blur paths. */ internal class CjkKaraokeAnimationFeature : FeatureHook { override val key: String = ModuleConstants.FEATURE_CJK_KARAOKE_ANIMATION diff --git a/app/src/main/java/dev/amenhancer/module/hook/CjkLyricValueAnimator.kt b/app/src/main/java/dev/amenhancer/module/hook/CjkLyricValueAnimator.kt deleted file mode 100644 index e224f70..0000000 --- a/app/src/main/java/dev/amenhancer/module/hook/CjkLyricValueAnimator.kt +++ /dev/null @@ -1,516 +0,0 @@ -package dev.amenhancer.module.hook - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.ValueAnimator -import android.view.View -import android.view.ViewGroup -import android.view.animation.DecelerateInterpolator -import android.widget.TextView -import java.lang.ref.WeakReference -import java.util.IdentityHashMap -import java.util.WeakHashMap - -/** - * Small, host-independent timing policy for the CJK lyric animator. - * - * Keeping this policy separate from [CjkLyricValueAnimator] makes the safety - * rules testable on the local JVM, where Android's animation clock is not - * available. A caller may still provide an explicit stagger when it has the - * host's word timing available. - */ -object CjkLyricAnimationPolicy { - const val DEFAULT_DURATION_MS = 420L - const val MIN_DURATION_MS = 120L - const val MAX_DURATION_MS = 1_600L - const val MAX_STAGGER_MS = 180L - const val MAX_START_DELAY_MS = 8_000L - - /** Return a finite duration suitable for ValueAnimator. */ - fun clampDurationMs(durationMs: Long): Long = durationMs.coerceIn( - MIN_DURATION_MS, - MAX_DURATION_MS, - ) - - /** Alias with the shorter name used by callers that do not need the unit suffix. */ - fun clampDuration(durationMs: Long): Long = clampDurationMs(durationMs) - - /** - * Derive a modest word stagger. More words make each offset smaller so a - * complete line does not spend several seconds catching up. - */ - fun staggerMs(durationMs: Long, targetCount: Int): Long { - val count = targetCount.coerceAtLeast(1) - if (count <= 1) return 0L - val duration = clampDurationMs(durationMs) - return (duration / (count.toLong() * 2L)).coerceIn(0L, MAX_STAGGER_MS) - } - - /** Alias useful in tests and integrations that prefer a verb-style name. */ - fun calculateStaggerMs(durationMs: Long, targetCount: Int): Long = - staggerMs(durationMs, targetCount) - - /** - * Compute the delay for a word in a line. Negative offsets and indices are - * treated as zero; arithmetic is saturated and capped to keep malformed - * host callbacks fail-open. - */ - fun startDelayMs( - wordIndex: Int, - staggerMs: Long, - startOffsetMs: Long = 0L, - ): Long { - val index = wordIndex.coerceAtLeast(0).toLong() - val stagger = staggerMs.coerceIn(0L, MAX_STAGGER_MS) - val offset = startOffsetMs.coerceAtLeast(0L) - val indexed = if (stagger == 0L || index == 0L) { - 0L - } else if (index > Long.MAX_VALUE / stagger) { - Long.MAX_VALUE - } else { - index * stagger - } - val sum = if (Long.MAX_VALUE - offset < indexed) { - Long.MAX_VALUE - } else { - offset + indexed - } - return sum.coerceAtMost(MAX_START_DELAY_MS) - } - - /** Alias useful when the call site describes the value as a delay. */ - fun delayFor( - wordIndex: Int, - staggerMs: Long, - startOffsetMs: Long = 0L, - ): Long = startDelayMs(wordIndex, staggerMs, startOffsetMs) -} - -/** - * Default script matcher for lyric text. It deliberately lives in this - * host-independent file: callers can pass [containsCjkKaraokeScript] (the - * existing host adapter predicate) or any other matcher instead. - */ -fun defaultCjkLyricScriptPredicate(text: CharSequence): Boolean { - var index = 0 - while (index < text.length) { - val codePoint = Character.codePointAt(text, index) - // Numeric ranges avoid newer UnicodeBlock fields, which keeps this - // helper safe on the module's minSdk 26 devices as well. - if (isCjkLyricCodePoint(codePoint)) { - return true - } - index += Character.charCount(codePoint) - } - return false -} - -private fun isCjkLyricCodePoint(codePoint: Int): Boolean = - codePoint in 0x3400..0x4DBF || // CJK Unified Ideographs Extension A - codePoint in 0x4E00..0x9FFF || // Unified Ideographs - codePoint in 0x20000..0x323AF || // supplementary CJK extensions - codePoint in 0x3040..0x309F || // Hiragana - codePoint in 0x30A0..0x30FF || // Katakana - codePoint in 0x31F0..0x31FF || // Katakana phonetic extensions - codePoint in 0xAC00..0xD7AF || // Hangul syllables - codePoint in 0x1100..0x11FF || // Hangul Jamo - codePoint in 0x3130..0x318F // Hangul compatibility Jamo - -/** - * Reusable, fail-open animator for lyric lines. - * - * A line is normally a ViewGroup whose word views are TextViews. TextViews - * matching [scriptPredicate] are preferred; when no matching TextView exists, - * leaf views with a matching contentDescription are accepted as a safe generic - * fallback. For custom host adapters, [animateViews] can supply the exact - * target views directly. - * - * The controller owns no host references. It only keeps weak root keys and - * restores every captured alpha/translation/scale value when cancelled, - * cleared, detached, or recycled. All Android calls are guarded so an - * unexpected host view cannot break the original lyric path. - */ -class CjkLyricValueAnimator( - private val startAlphaFraction: Float = DEFAULT_START_ALPHA_FRACTION, - private val startTranslationY: Float = DEFAULT_START_TRANSLATION_Y_PX, - private val startScaleFraction: Float = DEFAULT_START_SCALE_FRACTION, -) { - private val lock = Any() - private val sessions = WeakHashMap() - - /** - * Animate matching TextView words in depth-first child order. - * - * [wordIndex], [wordCount], and [startOffsetMs] are optional host timing - * metadata. They let an adapter map a callback's word id into the same - * stagger timeline; if omitted, the current line's child order is used. - * Returns the number of views for which an animator was started. - */ - fun animateLine( - root: View?, - durationMs: Long = CjkLyricAnimationPolicy.DEFAULT_DURATION_MS, - scriptPredicate: (CharSequence) -> Boolean = ::defaultCjkLyricScriptPredicate, - wordIndex: Int = 0, - wordCount: Int? = null, - startOffsetMs: Long = 0L, - staggerMs: Long? = null, - ): Int { - if (root == null) return 0 - return runCatching { - val targets = collectTargets(root, scriptPredicate) - animateViews( - root = root, - targets = targets, - durationMs = durationMs, - wordIndex = wordIndex, - wordCount = wordCount, - startOffsetMs = startOffsetMs, - staggerMs = staggerMs, - ) - }.getOrElse { 0 } - } - - /** - * Animate explicitly selected views. This is useful when a host exposes - * word views that are not TextViews or when its callback already resolves - * the exact word order. - */ - fun animateViews( - root: View?, - targets: Iterable, - durationMs: Long = CjkLyricAnimationPolicy.DEFAULT_DURATION_MS, - wordIndex: Int = 0, - wordCount: Int? = null, - startOffsetMs: Long = 0L, - staggerMs: Long? = null, - ): Int { - if (root == null) return 0 - return runCatching { - clear(root) - val seenTargets = IdentityHashMap() - val safeTargets = targets.mapNotNull { view -> - view?.takeIf(::isAnimatableTarget)?.takeIf { seenTargets.put(it, true) == null } - } - if (safeTargets.isEmpty()) return@runCatching 0 - - val duration = CjkLyricAnimationPolicy.clampDurationMs(durationMs) - val count = (wordCount ?: safeTargets.size).coerceAtLeast(1) - val lineStagger = CjkLyricAnimationPolicy.staggerMs(duration, count) - val explicitStagger = staggerMs?.coerceIn(0L, CjkLyricAnimationPolicy.MAX_STAGGER_MS) - val effectiveStagger = explicitStagger ?: lineStagger - val session = LineSession(WeakReference(root)) - synchronized(lock) { sessions[root] = session } - installDetachListener(root, session) - - var started = 0 - safeTargets.forEachIndexed { childIndex, target -> - val state = OriginalViewState.capture(target) ?: return@forEachIndexed - session.states[target] = state - installTargetDetachListener(root, session, target) - val effectiveIndex = safeAdd(wordIndex.coerceAtLeast(0), childIndex) - val delay = CjkLyricAnimationPolicy.startDelayMs( - wordIndex = effectiveIndex, - staggerMs = effectiveStagger, - startOffsetMs = startOffsetMs, - ) - if (startAnimator(root, session, target, state, duration, delay)) { - started += 1 - } else { - session.states.remove(target) - } - } - - if (started == 0) { - finishSession(root, session) - } - started - }.getOrElse { error -> - // If collecting or starting one target unexpectedly fails, restore - // anything that was already touched and leave the host untouched. - runCatching { clear(root) } - 0 - } - } - - /** Cancel the line and restore all captured properties. */ - fun clear(root: View?) { - if (root == null) return - val session = synchronized(lock) { sessions.remove(root) } ?: return - restoreAndCancel(root, session) - } - - /** Alias for recycler/lifecycle callbacks. */ - fun onViewRecycled(root: View?) = clear(root) - - /** Explicit cancellation spelling for callers that do not use lifecycle terms. */ - fun cancel(root: View?) = clear(root) - - /** - * Restore a line when its root or one of its word views is no longer - * visible/attached. This is a cheap hook for adapters that receive a - * visibility or recycler callback instead of a View detach callback. - */ - fun clearInactive(root: View?) { - if (root == null) return - val session = synchronized(lock) { sessions[root] } ?: return - val inactive = runCatching { - !root.isShown || session.states.keys.any { !it.isShown } - }.getOrDefault(true) - if (inactive) clear(root) - } - - /** Clear every currently live root tracked by this controller. */ - fun clearAll() { - val roots = synchronized(lock) { sessions.keys.toList() } - roots.forEach(::clear) - } - - /** Cancel every currently live line. */ - fun cancelAll() = clearAll() - - /** Number of active line sessions; useful for diagnostics and tests. */ - fun activeLineCount(): Int = synchronized(lock) { sessions.size } - - private fun collectTargets( - root: View, - scriptPredicate: (CharSequence) -> Boolean, - ): List { - val textTargets = ArrayList() - val descriptionTargets = ArrayList() - - fun visit(view: View) { - if (!isAnimatableTarget(view)) return - if (view is TextView) { - val text = runCatching { view.text }.getOrNull() - if (text != null && runCatching { scriptPredicate(text) }.getOrDefault(false)) { - textTargets += view - } - // A TextView normally has no children; visiting them anyway - // keeps the traversal safe for custom compound widgets. - } else { - val description = runCatching { view.contentDescription }.getOrNull() - if ( - description != null && - runCatching { scriptPredicate(description) }.getOrDefault(false) - ) { - descriptionTargets += view - } - } - if (view is ViewGroup) { - for (index in 0 until view.childCount) { - runCatching { view.getChildAt(index) }.getOrNull()?.let(::visit) - } - } - } - - visit(root) - return if (textTargets.isNotEmpty()) textTargets else descriptionTargets - } - - private fun startAnimator( - root: View, - session: LineSession, - target: View, - state: OriginalViewState, - duration: Long, - startDelay: Long, - ): Boolean { - var animator: ValueAnimator? = null - return runCatching { - val initial = state.initial(startAlphaFraction, startTranslationY, startScaleFraction) - apply(target, initial, 0f) - animator = ValueAnimator.ofFloat(0f, 1f).apply { - this.duration = duration - this.startDelay = startDelay - interpolator = DecelerateInterpolator(1.4f) - addUpdateListener { valueAnimator -> - if (!isCurrent(root, session) || session.states[target] !== state) return@addUpdateListener - val fraction = (valueAnimator.animatedValue as? Float)?.coerceIn(0f, 1f) - ?: return@addUpdateListener - runCatching { apply(target, initial, state, fraction) } - } - addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - if (!isCurrent(root, session)) return - runCatching { state.restore(target) } - session.animators.remove(target) - if (session.animators.isEmpty()) { - finishSession(root, session) - } - } - }) - } - session.animators[target] = checkNotNull(animator) - checkNotNull(animator).start() - true - }.getOrElse { - session.animators.remove(target) - runCatching { state.restore(target) } - false - } - } - - private fun installDetachListener(root: View, session: LineSession) { - runCatching { - val controller = WeakReference(this) - val listener = object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(view: View) = Unit - - override fun onViewDetachedFromWindow(view: View) { - if (view === session.root.get()) controller.get()?.clear(view) - } - } - session.detachListener = listener - root.addOnAttachStateChangeListener(listener) - } - } - - private fun installTargetDetachListener(root: View, session: LineSession, target: View) { - runCatching { - val controller = WeakReference(this) - val rootReference = WeakReference(root) - val listener = object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(view: View) = Unit - - override fun onViewDetachedFromWindow(view: View) { - if (view === target && session.active) { - controller.get()?.clear(rootReference.get()) - } - } - } - session.targetDetachListeners[target] = listener - target.addOnAttachStateChangeListener(listener) - } - } - - private fun finishSession(root: View, session: LineSession) { - val removed = synchronized(lock) { - if (sessions[root] === session) { - sessions.remove(root) - true - } else { - false - } - } - if (!removed) return - session.active = false - runCatching { - session.detachListener?.let(root::removeOnAttachStateChangeListener) - } - session.detachListener = null - removeTargetDetachListeners(session) - session.animators.clear() - session.states.clear() - } - - private fun restoreAndCancel(root: View, session: LineSession) { - session.active = false - runCatching { - session.detachListener?.let(root::removeOnAttachStateChangeListener) - } - session.detachListener = null - removeTargetDetachListeners(session) - session.animators.values.toList().forEach { animator -> - runCatching { animator.cancel() } - } - session.animators.clear() - session.states.forEach { (view, state) -> - runCatching { state.restore(view) } - } - session.states.clear() - } - - private fun removeTargetDetachListeners(session: LineSession) { - session.targetDetachListeners.forEach { (view, listener) -> - runCatching { view.removeOnAttachStateChangeListener(listener) } - } - session.targetDetachListeners.clear() - } - - private fun isCurrent(root: View, session: LineSession): Boolean = - session.active && synchronized(lock) { sessions[root] === session } - - private fun isAnimatableTarget(view: View): Boolean = runCatching { - view.visibility == View.VISIBLE - }.getOrDefault(false) - - private fun apply( - view: View, - initial: OriginalViewState, - fraction: Float, - ) { - apply(view, initial, initial, fraction) - } - - private fun apply( - view: View, - initial: OriginalViewState, - state: OriginalViewState, - fraction: Float, - ) { - view.alpha = lerp(initial.alpha, state.alpha, fraction) - view.translationX = lerp(initial.translationX, state.translationX, fraction) - view.translationY = lerp(initial.translationY, state.translationY, fraction) - view.scaleX = lerp(initial.scaleX, state.scaleX, fraction) - view.scaleY = lerp(initial.scaleY, state.scaleY, fraction) - } - - private fun lerp(start: Float, end: Float, fraction: Float): Float = - start + (end - start) * fraction - - private fun safeAdd(first: Int, second: Int): Int = - if (Int.MAX_VALUE - first < second) Int.MAX_VALUE else first + second - - private data class OriginalViewState( - val alpha: Float, - val translationX: Float, - val translationY: Float, - val scaleX: Float, - val scaleY: Float, - ) { - fun initial(alphaFraction: Float, translationOffset: Float, scaleFraction: Float): OriginalViewState { - val safeAlphaFraction = alphaFraction.coerceIn(0f, 1f) - val safeScaleFraction = scaleFraction.coerceIn(0f, 1f) - return copy( - alpha = (alpha * safeAlphaFraction).coerceIn(0f, 1f), - translationY = translationY + translationOffset, - scaleX = scaleX * safeScaleFraction, - scaleY = scaleY * safeScaleFraction, - ) - } - - fun restore(view: View) { - view.alpha = alpha - view.translationX = translationX - view.translationY = translationY - view.scaleX = scaleX - view.scaleY = scaleY - } - - companion object { - fun capture(view: View): OriginalViewState? = runCatching { - OriginalViewState( - alpha = view.alpha, - translationX = view.translationX, - translationY = view.translationY, - scaleX = view.scaleX, - scaleY = view.scaleY, - ) - }.getOrNull() - } - } - - private class LineSession(val root: WeakReference) { - var active: Boolean = true - var detachListener: View.OnAttachStateChangeListener? = null - val states = IdentityHashMap() - val animators = IdentityHashMap() - val targetDetachListeners = IdentityHashMap() - } - - private companion object { - const val DEFAULT_START_ALPHA_FRACTION = 0.58f - const val DEFAULT_START_TRANSLATION_Y_PX = 4f - const val DEFAULT_START_SCALE_FRACTION = 0.985f - } -} diff --git a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt index 83e0aff..63e0a8c 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt @@ -411,10 +411,10 @@ private object AppleMusicProfiles { internal object AppleMusicSymbols { /** - * Apple Music 6.5.2/1586's per-word binding/timing entry point + * Apple Music 6.5.2/1586's karaoke transition entry point * (`com.apple.android.music.player.z.a0(z$a, int, int, int, boolean)`). - * AM++ uses it only as an exact wordId/duration seam for its own - * ValueAnimator; it does not rewrite Apple's Unicode classifier. + * This is intentionally profile-only: a structural match on another + * obfuscated build could alter the host's animation state machine. */ val CjkKaraokeAnimationMethod = methodSymbol( id = "cjk-karaoke-animation-method", @@ -426,9 +426,10 @@ internal object AppleMusicSymbols { ) /** - * Retained reverse-engineering symbol for Apple Music 6.5.2/1586's - * UnicodeBlock-set predicate. The AM++ ValueAnimator target deliberately - * does not resolve or hook this helper. + * Apple Music 6.5.2/1586's UnicodeBlock-set predicate + * (`com.apple.android.music.utils.I0$a.a(CharSequence, Set): boolean`). + * Like the animation entry point, this must never fall back to a guessed + * helper on 6.5.0/6.5.1 or an unknown host build. */ val CjkUnicodeBlockPredicateMethod = methodSymbol( id = "cjk-unicode-block-predicate-method", diff --git a/app/src/test/java/dev/amenhancer/module/hook/CjkLyricValueAnimatorTest.kt b/app/src/test/java/dev/amenhancer/module/hook/CjkLyricValueAnimatorTest.kt deleted file mode 100644 index cdad689..0000000 --- a/app/src/test/java/dev/amenhancer/module/hook/CjkLyricValueAnimatorTest.kt +++ /dev/null @@ -1,47 +0,0 @@ -package dev.amenhancer.module.hook - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class CjkLyricValueAnimatorTest { - @Test - fun `duration is clamped to safe animator bounds`() { - assertEquals(CjkLyricAnimationPolicy.MIN_DURATION_MS, CjkLyricAnimationPolicy.clampDurationMs(-1L)) - assertEquals(CjkLyricAnimationPolicy.MIN_DURATION_MS, CjkLyricAnimationPolicy.clampDuration(0L)) - assertEquals(420L, CjkLyricAnimationPolicy.clampDurationMs(420L)) - assertEquals(CjkLyricAnimationPolicy.MAX_DURATION_MS, CjkLyricAnimationPolicy.clampDurationMs(Long.MAX_VALUE)) - } - - @Test - fun `stagger is zero for one target and shrinks as target count grows`() { - val one = CjkLyricAnimationPolicy.staggerMs(600L, 1) - val two = CjkLyricAnimationPolicy.staggerMs(600L, 2) - val many = CjkLyricAnimationPolicy.calculateStaggerMs(600L, 12) - - assertEquals(0L, one) - assertTrue(two > many) - assertTrue(many >= 0L) - assertTrue(many <= CjkLyricAnimationPolicy.MAX_STAGGER_MS) - } - - @Test - fun `delay clamps malformed metadata and preserves start offset`() { - assertEquals(0L, CjkLyricAnimationPolicy.startDelayMs(-4, -10L, -5L)) - assertEquals(125L, CjkLyricAnimationPolicy.delayFor(2, 40L, 45L)) - assertEquals( - CjkLyricAnimationPolicy.MAX_START_DELAY_MS, - CjkLyricAnimationPolicy.startDelayMs(Int.MAX_VALUE, Long.MAX_VALUE, Long.MAX_VALUE), - ) - } - - @Test - fun `default matcher accepts mixed CJK and supplementary ideographs`() { - assertTrue(defaultCjkLyricScriptPredicate("hello 漢字 world")) - assertTrue(defaultCjkLyricScriptPredicate("かな")) - assertTrue(defaultCjkLyricScriptPredicate("한글")) - assertTrue(defaultCjkLyricScriptPredicate("\uD840\uDC00")) - assertFalse(defaultCjkLyricScriptPredicate("plain latin lyrics")) - } -} diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index 3a0bb78..e521749 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -2,43 +2,44 @@ ## 目的 -本分支保存 Apple Music 6.5.2/1586 的 CJK 逐词动画实验。它不属于 `main` 稳定线,后续维护、设备验证或替换动画策略都从本分支继续。 +本分支保存 Apple Music 6.5.2/1586 的 CJK 长尾歌词动画实验。它不属于 `main` 稳定线,后续维护、设备验证或替换为 AM++ 自绘动画都从本分支继续。 ## 当前分支与远端 - 分支:`codex/cjk-karaoke-animation` +- 当前 HEAD:`38f923c docs: add CJK karaoke animation handoff` +- 功能提交:`39e9b08 feat: isolate CJK karaoke animation feature` - 远端:[Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation](https://github.com/Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation) - 主分支基线:`main`(当前不包含本实验功能) -- 当前工作树中的 XAPK、`androguard.db*`、`.work/` 和 `github-lyrics-export-readable/` 均为未跟踪逆向输入,不应提交。 +- 当前分支已推送到 `origin`;不要把本分支代码直接合并到 `main`,除非完成新的设备验收。 -## 当前实现(AM++ 自绘 ValueAnimator) +## 已实现内容 -旧方案曾在 `z.a0` 调用期间 Hook `I0$a.a(CharSequence, Set)`,临时改写 Apple 的 `j0/k0` 分类结果来复用原生 rush-gradient。该方案已从运行时移除,当前不再 Hook `I0$a.a`,也不修改 Apple 的字符集合或原生动画判定。 - -当前方案只把精确的 `z.a0(z$a, lineId, wordId, duration, isBackground)` 当作数据 seam: - -1. `z$a.G/H` 是前景/背景 karaoke 的 `ArrayMap`;只按 `wordId` 从对应的 G/H 读取,避免误取 pronunciation 的 I/J map。 -2. `e.i/e.j` 是单个词 binding;CJK 拆分时回退到 `e.k` binding 列表。 -3. generated binding 的 `CustomTextView`(6.5.2 中为字段 `U`)是实际逐词 View。AM++ 在 `z.a0` after-hook 中对这些 View 启动自己的 `ValueAnimator`,duration 直接来自该词的 `LyricsTiming`。 -4. 对 CJK 前景词,before/after 都会取消并清空宿主 `e.o` 及其 `e.p` 中带 `KARAOKE_WORD_LIFT_TAG` 的子动画;随后归一化被取消动画遗留的 translation/scale。`z.g0` 重建 flexbox、`z.p` RecyclerView rebind、`onDestroyView` 和 View detach 都会取消并恢复 AM++ 动画,避免 recycled View 残留。 -5. 背景人声不接管;混排词按实际 binding/TextView 的 CJK 内容过滤。宿主 binding 文本尚未异步完成时,使用 `e.c` 的 native wordText 判断脚本。 +1. 仅对 Apple Music `6.5.2/1586` 解析并 Hook: + - `com.apple.android.music.player.z.a0` + - `com.apple.android.music.utils.I0$a.a(CharSequence, Set)` +2. 在 `z.a0` 的线程局部调用范围内,临时放开 CJK 的 `k0/j0` 分类结果,以复用 Apple 原生 rush-gradient;不会全局修改 Apple 的静态字符集合,也不会改变 `g0` 的原始 CJK 排版路径。 +3. 设置页和嵌入式设置页都有独立开关: + - key:`cjk_karaoke_animation_enabled` + - 字段:`ModuleSettings.cjkKaraokeAnimationEnabled` + - 默认值:`true` + - 关闭后需要重启 Apple Music,feature 才不会注册 Hook。 ## 关键代码位置 -- 宿主 seam 与 binding 解析:`app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt` -- AM++ 动画控制器/时序策略:`app/src/main/java/dev/amenhancer/module/hook/CjkLyricValueAnimator.kt` -- 动画策略 JVM 测试:`app/src/test/java/dev/amenhancer/module/hook/CjkLyricValueAnimatorTest.kt` +- Hook 实现:`app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt` - Feature gate:`app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeAnimationFeature.kt` - 版本符号:`app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt` - Feature 注册:`app/src/main/java/dev/amenhancer/module/hook/FeatureInstallation.kt` -- 独立设置 key:`cjk_karaoke_animation_enabled`,字段 `ModuleSettings.cjkKaraokeAnimationEnabled`,默认值 `true`。 +- 设置模型/schema:`app/src/main/java/dev/amenhancer/module/model/ModuleModels.kt`、`app/src/main/java/dev/amenhancer/module/config/ModuleSettingsSchema.kt` +- 设置 UI:`app/src/main/java/dev/amenhancer/module/ui/SettingsActivity.kt`、`app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt` -## 已知边界与真机验证重点 +## 已知边界 -- 目前 exact profile 仍只针对 Apple Music `6.5.2/1586`;其它版本会 degraded,不会猜测混淆方法。 -- 这是第一版安全自绘实现:动画改动 TextView 的 alpha/translation/scale,尚未模拟 Apple 的渐变 mask 绘制;CJK 前景词会先取消宿主 word-lift/rush animator,以减少属性竞争。 -- `z.a0` 可能因宿主已有 `e.o` animator 提前 return;after-hook 仍使用 holder map 读取 binding,但若 binding 尚未生成会 fail-open。应重点测试:中文、日文、韩文、CJK+Latin 混排、长 duration 词、快速切歌、滚动回收和关闭开关后的重启行为。 -- 不要把动画降级为整行 RecyclerView 动画;逐词 binding seam 是本实验的核心。 +- 当前方案是“复用 Apple 原生动画分支”,不是 AM++ 自己绘制的稳定实现;用户已观察到 CJK 仍可能出现动画错位、跳动或宽度问题。 +- 后续更稳妥的路线是:在 Apple adapter 完成逐词 View 绑定后,由 AM++ 自己创建 `ValueAnimator`/渐变遮罩,并处理 View 回收、行切换、混排字符和取消旧动画。 +- 该自绘路线尚未实现。不要只在整行 RecyclerView 上加动画;必须拿到实际词 View 和可靠的逐词时序。 +- Apple 版本、混淆类名和方法签名是私有契约;新增版本必须重新做 exact profile 和设备验证。 ## 继续维护步骤 @@ -66,7 +67,7 @@ git switch main - 逆向结论、DEX/smali/native 符号和行为矩阵:仓库根目录 `findings.md` - 时间线、测试、构建和分支操作记录:仓库根目录 `progress.md` - 当前阶段计划:仓库根目录 `task_plan.md` -- Apple Music 6.5.2 输入包:未跟踪的 `Apple+Music_6.5.2_APKPure.xapk`;不要把它或 `androguard.db*` 加入提交。 +- Apple Music 6.5.2 输入包:工作区未跟踪的 `Apple+Music_6.5.2_APKPure.xapk`;不要把它或 `androguard.db*` 误加入提交。 ## 建议后续调用的 skills From 47de18a3e283167d2c269a29d7fdfa0453808306 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:29:17 +0800 Subject: [PATCH 12/33] fix: block merged CJK chunks from native glow --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 107 ++++++++++++++++-- .../module/hook/CjkKaraokeWordGate.kt | 36 ++++++ .../hook/CjkKaraokeAnimationTargetTest.kt | 57 ++++++++++ docs/cjk-karaoke-animation-handoff.md | 16 +-- 4 files changed, 198 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeWordGate.kt diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index e31dc4e..91412ed 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -1,17 +1,26 @@ package dev.amenhancer.module.hook import java.util.concurrent.atomic.AtomicInteger +import java.lang.reflect.Field +import java.lang.reflect.Modifier +import java.util.Collections +import java.util.Map as JavaMap +import java.util.WeakHashMap /** * Narrow Apple Music 6.5.2/1586 adapter for the native karaoke rush-gradient - * path. The host's `z.a0` call is the only scope in which the UnicodeBlock - * helper result is adjusted; normal `z.g0` layout calls therefore retain their - * original CJK behavior. + * path. The host owns all duration/length trigger conditions; AM++ only + * allows its CJK classifier override for one unmerged native CJK word. */ internal class AppleMusicCjkKaraokeAnimationTarget( private val symbols: TargetSymbolResolver, ) : CjkKaraokeAnimationTarget { private val a0Depth: ThreadLocal = ThreadLocal.withInitial { 0 } + private val a0SingleWordStack: ThreadLocal> = + ThreadLocal.withInitial { mutableListOf() } + private val fieldCache = Collections.synchronizedMap( + WeakHashMap, kotlin.collections.Map>(), + ) private val rewriteLogCount = AtomicInteger() override fun install(): TargetCapabilityInstall { @@ -31,7 +40,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( val a0Installed = try { ModernXposedRuntime.hookMethod(a0, object : ModernMethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { - enterA0Scope() + enterA0Scope(param) } override fun afterHookedMethod(param: MethodHookParam) { @@ -76,12 +85,12 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } return TargetCapabilityInstall.Active( - "Installed exact Apple Music 6.5.2/1586 z.a0 + I0\$a.a CJK j0/k0 rush-gradient scope", + "Installed exact 6.5.2/1586 single-unmerged-CJK guard around z.a0 + I0\$a.a", ) } private fun rewriteCjkK0Result(param: ModernMethodHook.MethodHookParam) { - if (!isInA0Scope()) return + if (!isSingleWordScope()) return val text = param.args.getOrNull(0) as? CharSequence ?: return if (!containsCjkKaraokeScript(text)) return @@ -134,8 +143,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( false } - private fun enterA0Scope() { - runCatching { a0Depth.set((a0Depth.get() ?: 0) + 1) } + private fun enterA0Scope(param: ModernMethodHook.MethodHookParam) { + runCatching { + a0Depth.set((a0Depth.get() ?: 0) + 1) + a0Stack().add(readSingleWordGate(param)) + } .onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth enter failed open", error) } } @@ -147,15 +159,90 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } else { a0Depth.set(depth - 1) } + val stack = a0Stack() + if (stack.isNotEmpty()) stack.removeAt(stack.lastIndex) + if (stack.isEmpty()) a0SingleWordStack.remove() }.onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth cleanup failed open", error) } } - private fun isInA0Scope(): Boolean = runCatching { (a0Depth.get() ?: 0) > 0 } + private fun isSingleWordScope(): Boolean = runCatching { + (a0Depth.get() ?: 0) > 0 && a0Stack().lastOrNull() == true + } .getOrElse { error -> - ModernXposedRuntime.log("CJK karaoke a0 depth read failed open", error) + ModernXposedRuntime.log("CJK karaoke single-word gate read failed open", error) false } + private fun a0Stack(): MutableList = + a0SingleWordStack.get() ?: mutableListOf().also(a0SingleWordStack::set) + + /** Reads only the host's grouping metadata; Apple retains all trigger gates. */ + private fun readSingleWordGate(param: ModernMethodHook.MethodHookParam): Boolean = + runCatching { + val holder = param.args.getOrNull(0) ?: return@runCatching false + val wordId = (param.args.getOrNull(2) as? Number)?.toInt() + ?: return@runCatching false + val nativeDuration = (param.args.getOrNull(3) as? Number)?.toInt() + ?: return@runCatching false + val background = param.args.getOrNull(4) as? Boolean + ?: return@runCatching false + val mapName = if (background) "H" else "G" + val map = cachedFields(holder.javaClass)[mapName] + ?.let { field -> readField(field, holder) as? JavaMap<*, *> } + ?: return@runCatching false + val entry = map.get(Integer.valueOf(wordId)) ?: map.get(wordId) + ?: return@runCatching false + val text = cachedFields(entry.javaClass)["c"] + ?.let { field -> readField(field, entry) as? CharSequence } + ?: return@runCatching false + val cumulativeDuration = cachedFields(entry.javaClass)["f"] + ?.let { field -> (readField(field, entry) as? Number)?.toInt() } + ?: return@runCatching false + val cumulativeLength = cachedFields(entry.javaClass)["g"] + ?.let { field -> (readField(field, entry) as? Number)?.toInt() } + ?: return@runCatching false + val splitValue = cachedFields(entry.javaClass)["k"] + ?.let { field -> readField(field, entry) } + val splitCount = when (splitValue) { + null -> 0 + is Collection<*> -> splitValue.size + else -> -1 + } + isSingleUnmergedCjkWord( + CjkKaraokeWordTiming( + text = text, + nativeDurationMs = nativeDuration, + cumulativeDurationMs = cumulativeDuration, + cumulativeTextLength = cumulativeLength, + splitBindingCount = splitCount, + isBackground = background, + ), + ) + }.getOrElse { error -> + ModernXposedRuntime.log("CJK single-word gate failed closed: ${error.cjkShortMessage()}") + false + } + + private fun cachedFields(type: Class<*>): kotlin.collections.Map = + synchronized(fieldCache) { + fieldCache.get(type) ?: HashMap().also { fields -> + generateSequence(type) { it.superclass } + .flatMap { current -> current.declaredFields.asSequence() } + .filterNot { field -> Modifier.isStatic(field.modifiers) } + .forEach { field -> + if (!fields.containsKey(field.name)) { + runCatching { field.isAccessible = true } + fields[field.name] = field + } + } + fieldCache[type] = fields + } + } + + private fun readField(field: Field, receiver: Any): Any? = runCatching { + field.get(receiver) + }.getOrNull() + private companion object { const val MAX_REWRITE_LOGS = 3 } diff --git a/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeWordGate.kt b/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeWordGate.kt new file mode 100644 index 0000000..0c42a5e --- /dev/null +++ b/app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeWordGate.kt @@ -0,0 +1,36 @@ +package dev.amenhancer.module.hook + +/** + * The host still owns the actual long-glow thresholds. AM++ only answers the + * safety question: is this a single, unmerged CJK native word? Returning + * false is deliberately fail-closed, so a malformed binding keeps Apple's + * original (non-overridden) classifier result. + */ +internal data class CjkKaraokeWordTiming( + val text: CharSequence, + val nativeDurationMs: Int, + val cumulativeDurationMs: Int, + val cumulativeTextLength: Int, + val splitBindingCount: Int, + val isBackground: Boolean, +) + +/** + * Does not impose Apple's duration/length trigger. Those conditions remain in + * z.a0; this gate only blocks merged or multi-character CJK chunks. + */ +internal fun isSingleUnmergedCjkWord(timing: CjkKaraokeWordTiming): Boolean { + if (timing.isBackground) return false + if (timing.splitBindingCount < 0 || timing.splitBindingCount > 1) return false + + val normalized = timing.text.toString().trim() + if (normalized.isEmpty() || normalized.codePointCount(0, normalized.length) != 1) { + return false + } + if (!containsCjkKaraokeScript(normalized)) return false + + // g0 writes f/g as the visual chunk's accumulated duration/text length. + // Equality with the current native word's values is the non-merged case. + return timing.cumulativeDurationMs == timing.nativeDurationMs && + timing.cumulativeTextLength == 1 +} diff --git a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt index c01c415..a32ae53 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt @@ -2,6 +2,7 @@ package dev.amenhancer.module.hook import dev.amenhancer.module.ModuleConstants import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -15,6 +16,62 @@ class CjkKaraokeAnimationTargetTest { assertTrue(!containsCjkKaraokeScript("lyrics")) } + @Test + fun `single unmerged CJK word is allowed without duplicating AM trigger gates`() { + assertTrue( + isSingleUnmergedCjkWord( + CjkKaraokeWordTiming( + text = "漢", + nativeDurationMs = 200, + cumulativeDurationMs = 200, + cumulativeTextLength = 1, + splitBindingCount = 0, + isBackground = false, + ), + ), + ) + } + + @Test + fun `merged or split CJK chunks stay on Apple's original classifier`() { + val merged = CjkKaraokeWordTiming( + text = "漢字", + nativeDurationMs = 600, + cumulativeDurationMs = 1_200, + cumulativeTextLength = 2, + splitBindingCount = 0, + isBackground = false, + ) + val split = merged.copy( + text = "漢", + cumulativeDurationMs = 600, + cumulativeTextLength = 1, + splitBindingCount = 2, + ) + + assertFalse(isSingleUnmergedCjkWord(merged)) + assertFalse(isSingleUnmergedCjkWord(split)) + } + + @Test + fun `background and multi-code-point text are fail closed`() { + val background = CjkKaraokeWordTiming( + text = "한", + nativeDurationMs = 1_200, + cumulativeDurationMs = 1_200, + cumulativeTextLength = 1, + splitBindingCount = 0, + isBackground = true, + ) + val combining = background.copy( + text = "が", + isBackground = false, + ) + + assertFalse(isSingleUnmergedCjkWord(background)) + assertFalse(isSingleUnmergedCjkWord(combining)) + } + @Test fun `karaoke symbols are exact profile only and reject 650 and unknown builds`() { val source = FixtureTargetClassSource( diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index e521749..f72e0dd 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -2,13 +2,12 @@ ## 目的 -本分支保存 Apple Music 6.5.2/1586 的 CJK 长尾歌词动画实验。它不属于 `main` 稳定线,后续维护、设备验证或替换为 AM++ 自绘动画都从本分支继续。 +本分支保存 Apple Music 6.5.2/1586 的 CJK 长尾歌词动画实验。它不属于 `main` 稳定线,后续维护和设备验证都从本分支继续。 ## 当前分支与远端 - 分支:`codex/cjk-karaoke-animation` -- 当前 HEAD:`38f923c docs: add CJK karaoke animation handoff` -- 功能提交:`39e9b08 feat: isolate CJK karaoke animation feature` +- 功能基线:`39e9b08 feat: isolate CJK karaoke animation feature` - 远端:[Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation](https://github.com/Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation) - 主分支基线:`main`(当前不包含本实验功能) - 当前分支已推送到 `origin`;不要把本分支代码直接合并到 `main`,除非完成新的设备验收。 @@ -18,8 +17,9 @@ 1. 仅对 Apple Music `6.5.2/1586` 解析并 Hook: - `com.apple.android.music.player.z.a0` - `com.apple.android.music.utils.I0$a.a(CharSequence, Set)` -2. 在 `z.a0` 的线程局部调用范围内,临时放开 CJK 的 `k0/j0` 分类结果,以复用 Apple 原生 rush-gradient;不会全局修改 Apple 的静态字符集合,也不会改变 `g0` 的原始 CJK 排版路径。 -3. 设置页和嵌入式设置页都有独立开关: +2. AM++ 不接管 Apple 的 duration/length 触发条件。`z.a0` 前只读取当前 `z$a.G/H -> e` 的 grouping metadata:`e.f`(累计 duration)、`e.g`(累计字符数)、`e.c`(词文本)和 `e.k`(拆分 binding)。只有规范化后恰好一个 CJK Unicode 字符、`e.f` 等于当前 native duration、`e.g == 1` 且没有多 binding 时,才临时放开 `k0/j0`;合并词保留 Apple 原始分类,不会进入长辉光分支。 +3. 放行仍限定在 `z.a0` 的线程局部调用范围内,不会全局修改 Apple 的静态字符集合,也不会改变 `g0` 的原始 CJK 排版路径。 +4. 设置页和嵌入式设置页都有独立开关: - key:`cjk_karaoke_animation_enabled` - 字段:`ModuleSettings.cjkKaraokeAnimationEnabled` - 默认值:`true` @@ -36,9 +36,9 @@ ## 已知边界 -- 当前方案是“复用 Apple 原生动画分支”,不是 AM++ 自己绘制的稳定实现;用户已观察到 CJK 仍可能出现动画错位、跳动或宽度问题。 -- 后续更稳妥的路线是:在 Apple adapter 完成逐词 View 绑定后,由 AM++ 自己创建 `ValueAnimator`/渐变遮罩,并处理 View 回收、行切换、混排字符和取消旧动画。 -- 该自绘路线尚未实现。不要只在整行 RecyclerView 上加动画;必须拿到实际词 View 和可靠的逐词时序。 +- 当前方案是“复用 Apple 原生动画分支”,不是 AM++ 自己绘制;AM++ 只阻止合并 CJK 词块进入原生长辉光分类。 +- 判断失败时默认不放行,保持 Apple 原始行为;带组合音标、补充字符或多 binding 的文字会被保守跳过。 +- Apple 版本、混淆类名和方法签名是私有契约;新增版本必须重新做 exact profile 和设备验证。 - Apple 版本、混淆类名和方法签名是私有契约;新增版本必须重新做 exact profile 和设备验证。 ## 继续维护步骤 From 114ed093b78af761ae42d0eb6569063cddf311a6 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 20:52:33 +0800 Subject: [PATCH 13/33] debug: add CJK native animation lifecycle probe --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 377 ++++++++++++++++-- docs/cjk-karaoke-animation-handoff.md | 6 +- 2 files changed, 342 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 91412ed..8c43e14 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -1,8 +1,11 @@ package dev.amenhancer.module.hook +import android.animation.Animator +import android.animation.AnimatorListenerAdapter import java.util.concurrent.atomic.AtomicInteger import java.lang.reflect.Field import java.lang.reflect.Modifier +import java.lang.reflect.Array as ReflectArray import java.util.Collections import java.util.Map as JavaMap import java.util.WeakHashMap @@ -22,6 +25,10 @@ internal class AppleMusicCjkKaraokeAnimationTarget( WeakHashMap, kotlin.collections.Map>(), ) private val rewriteLogCount = AtomicInteger() + private val traceEventCount = AtomicInteger() + private val observedAnimators = Collections.synchronizedMap( + WeakHashMap(), + ) override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) @@ -47,7 +54,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // The host callback runs even when z.a0 throws; always // release this thread's scope so a later g0 call cannot // inherit a stale override. - leaveA0Scope() + try { + traceA0Boundary("after", param, isSingleWordScope()) + } finally { + leaveA0Scope() + } } }).also { installed -> if (!installed) failures += "z.a0 hook was rejected" @@ -96,23 +107,30 @@ internal class AppleMusicCjkKaraokeAnimationTarget( if (!containsCjkKaraokeScript(text)) return val languageSet = param.args.getOrNull(1) as? Set<*> ?: return + val originalResult = param.result + val setName = when { + isK0Set(languageSet) -> "k0" + isJ0Set(languageSet) -> "j0" + else -> null + } when { - isK0Set(languageSet) && param.result == true -> { + setName == "k0" && param.result == true -> { // CJK is normally classified into k0, which blocks the // rush branch. Make that one result look like a default // script only while a0 is running. param.result = false logRewrite(text, "k0 true -> false") } - isJ0Set(languageSet) && containsHangul(text) && param.result != true -> { + setName == "j0" && containsHangul(text) && param.result != true -> { // i0 receives the j0 hit as its split/rush eligibility bit. // Hangul belongs to k0 but not j0, so opt it into the same // Apple animation only inside a0; g0 remains untouched. param.result = true logRewrite(text, "j0 false -> true") } - else -> return + else -> Unit } + traceHelperResult(text, setName, originalResult, param.result) } private fun logRewrite(text: CharSequence, change: String) { @@ -146,7 +164,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun enterA0Scope(param: ModernMethodHook.MethodHookParam) { runCatching { a0Depth.set((a0Depth.get() ?: 0) + 1) - a0Stack().add(readSingleWordGate(param)) + val gate = readSingleWordGate(param) + a0Stack().add(gate) + traceA0Boundary("before", param, gate) } .onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth enter failed open", error) } } @@ -179,43 +199,15 @@ internal class AppleMusicCjkKaraokeAnimationTarget( /** Reads only the host's grouping metadata; Apple retains all trigger gates. */ private fun readSingleWordGate(param: ModernMethodHook.MethodHookParam): Boolean = runCatching { - val holder = param.args.getOrNull(0) ?: return@runCatching false - val wordId = (param.args.getOrNull(2) as? Number)?.toInt() - ?: return@runCatching false - val nativeDuration = (param.args.getOrNull(3) as? Number)?.toInt() - ?: return@runCatching false - val background = param.args.getOrNull(4) as? Boolean - ?: return@runCatching false - val mapName = if (background) "H" else "G" - val map = cachedFields(holder.javaClass)[mapName] - ?.let { field -> readField(field, holder) as? JavaMap<*, *> } - ?: return@runCatching false - val entry = map.get(Integer.valueOf(wordId)) ?: map.get(wordId) - ?: return@runCatching false - val text = cachedFields(entry.javaClass)["c"] - ?.let { field -> readField(field, entry) as? CharSequence } - ?: return@runCatching false - val cumulativeDuration = cachedFields(entry.javaClass)["f"] - ?.let { field -> (readField(field, entry) as? Number)?.toInt() } - ?: return@runCatching false - val cumulativeLength = cachedFields(entry.javaClass)["g"] - ?.let { field -> (readField(field, entry) as? Number)?.toInt() } - ?: return@runCatching false - val splitValue = cachedFields(entry.javaClass)["k"] - ?.let { field -> readField(field, entry) } - val splitCount = when (splitValue) { - null -> 0 - is Collection<*> -> splitValue.size - else -> -1 - } + val metadata = readWordMetadata(param) ?: return@runCatching false isSingleUnmergedCjkWord( CjkKaraokeWordTiming( - text = text, - nativeDurationMs = nativeDuration, - cumulativeDurationMs = cumulativeDuration, - cumulativeTextLength = cumulativeLength, - splitBindingCount = splitCount, - isBackground = background, + text = metadata.text, + nativeDurationMs = metadata.nativeDurationMs, + cumulativeDurationMs = metadata.cumulativeDurationMs, + cumulativeTextLength = metadata.cumulativeTextLength, + splitBindingCount = metadata.splitBindingCount, + isBackground = metadata.background, ), ) }.getOrElse { error -> @@ -223,6 +215,306 @@ internal class AppleMusicCjkKaraokeAnimationTarget( false } + private fun readWordMetadata(param: ModernMethodHook.MethodHookParam): WordMetadata? { + val holder = param.args.getOrNull(0) ?: return null + val wordId = (param.args.getOrNull(2) as? Number)?.toInt() ?: return null + val nativeDuration = (param.args.getOrNull(3) as? Number)?.toInt() ?: return null + val background = param.args.getOrNull(4) as? Boolean ?: return null + val mapName = if (background) "H" else "G" + val map = cachedFields(holder.javaClass)[mapName] + ?.let { field -> readField(field, holder) as? JavaMap<*, *> } + ?: return null + val entry = map.get(Integer.valueOf(wordId)) ?: map.get(wordId) ?: return null + val fields = cachedFields(entry.javaClass) + val text = fields["c"]?.let { field -> readField(field, entry) as? CharSequence } + ?: return null + val cumulativeDuration = fields["f"] + ?.let { field -> (readField(field, entry) as? Number)?.toInt() } + ?: return null + val cumulativeLength = fields["g"] + ?.let { field -> (readField(field, entry) as? Number)?.toInt() } + ?: return null + val splitValue = fields["k"]?.let { field -> readField(field, entry) } + val splitCount = when (splitValue) { + null -> 0 + is Collection<*> -> splitValue.size + else -> -1 + } + return WordMetadata( + wordId = wordId, + nativeDurationMs = nativeDuration, + background = background, + entry = entry, + text = text, + cumulativeDurationMs = cumulativeDuration, + cumulativeTextLength = cumulativeLength, + splitBindingCount = splitCount, + nativeAnimator = fields["o"]?.let { field -> readField(field, entry) }, + animatorList = fields["p"]?.let { field -> readField(field, entry) }, + foregroundBinding = fields["i"]?.let { field -> readField(field, entry) }, + backgroundBinding = fields["j"]?.let { field -> readField(field, entry) }, + splitBindings = splitValue, + ) + } + + private fun traceA0Boundary( + phase: String, + param: ModernMethodHook.MethodHookParam, + gate: Boolean, + ) { + runCatching { + val metadata = readWordMetadata(param) + if (metadata == null) { + val wordId = (param.args.getOrNull(2) as? Number)?.toInt() + if (wordId != null) { + trace( + "a0-$phase metadata-unavailable word=$wordId " + + "holder=${param.args.getOrNull(0)?.javaClass?.simpleName}", + ) + } + return@runCatching + } + val isCjk = containsCjkKaraokeScript(metadata.text) + val isNativeCandidate = !metadata.background && + metadata.cumulativeDurationMs >= NATIVE_CANDIDATE_DURATION_MS && + metadata.cumulativeTextLength <= NATIVE_CANDIDATE_LENGTH_MAX + if (!isCjk && !isNativeCandidate) return@runCatching + trace( + "a0-$phase word=${metadata.wordId} bg=${metadata.background} " + + "gate=$gate text=${quoteTraceText(metadata.text)} " + + "native=${metadata.nativeDurationMs} f=${metadata.cumulativeDurationMs} " + + "g=${metadata.cumulativeTextLength} k=${metadata.splitBindingCount} " + + "entry=${metadata.entry.javaClass.simpleName} " + + "o=${describeAnimator(metadata.nativeAnimator)} " + + "p=${describeAnimatorContainer(metadata.animatorList)} " + + "bindings=${describeBindings(metadata)}", + ) + if (phase == "after") observeAnimatorLifecycle(metadata) + }.onFailure { error -> + trace("a0-$phase trace failed: ${error.cjkShortMessage()}") + } + } + + private fun traceHelperResult( + text: CharSequence, + setName: String?, + originalResult: Any?, + finalResult: Any?, + ) { + if (!isSingleWordScope() || setName == null) return + trace( + "helper set=$setName text=${quoteTraceText(text)} " + + "result=$originalResult->$finalResult depth=${a0Depth.get() ?: 0} " + + "gate=${a0Stack().lastOrNull() == true}", + ) + } + + private fun trace(message: String) { + if (traceEventCount.getAndIncrement() < MAX_TRACE_EVENTS) { + ModernXposedRuntime.log("[DEBUG-cjk-r1] $message") + } + } + + private fun describeAnimator(value: Any?): String { + if (value == null) return "null" + val type = value.javaClass.simpleName.ifBlank { value.javaClass.name } + val started = invokeNoArg(value, "isStarted") + val running = invokeNoArg(value, "isRunning") + val duration = invokeNoArg(value, "getDuration") + val playTime = invokeNoArg(value, "getCurrentPlayTime") + val tag = readAnimatorTag(value) + val listeners = describeAnimatorListeners(value) + return "$type{started=$started,running=$running,duration=$duration,play=$playTime," + + "tag=$tag,listeners=$listeners}" + } + + private fun describeAnimatorListeners(value: Any): String = runCatching { + val listeners = invokeNoArg(value, "getListeners") as? Collection<*> ?: return@runCatching "?" + listeners.take(MAX_TRACE_ITEMS).joinToString( + prefix = "[", + postfix = "]", + ) { listener -> listener?.javaClass?.simpleName ?: "null" } + }.getOrDefault("?") + + private fun describeAnimatorContainer(value: Any?): String { + if (value == null) return "null" + return runCatching { + val items: List = when (value) { + is Collection<*> -> value.take(MAX_TRACE_ITEMS) + else -> { + val backing = readNamedField(value, "b") + when { + backing is Collection<*> -> backing.take(MAX_TRACE_ITEMS) + backing?.javaClass?.isArray == true -> (0 until minOf( + ReflectArray.getLength(backing), + MAX_TRACE_ITEMS, + )).map { index -> ReflectArray.get(backing, index) } + else -> emptyList() + } + } + } + val count = readNamedField(value, "c") ?: items.size + val details = items.joinToString(",") { item -> describeAnimator(item) } + "${value.javaClass.simpleName}{count=$count,items=[$details]}" + }.getOrElse { error -> "${value.javaClass.simpleName}{error=${error.cjkShortMessage()}}" } + } + + private fun observeAnimatorLifecycle(metadata: WordMetadata) { + val candidates = buildList { + (metadata.nativeAnimator as? Animator)?.let(::add) + animatorItems(metadata.animatorList).forEach { item -> + (item as? Animator)?.let(::add) + } + } + candidates.forEach { animator -> + val shouldObserve = synchronized(observedAnimators) { + observedAnimators.put(animator, true) == null + } + if (!shouldObserve) return@forEach + runCatching { + animator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + traceAnimatorEnd(metadata, animation, "end") + } + + override fun onAnimationCancel(animation: Animator) { + traceAnimatorEnd(metadata, animation, "cancel") + } + }) + }.onFailure { error -> + trace( + "anim-observe-failed word=${metadata.wordId} " + + "anim=${animationType(animator)} error=${error.cjkShortMessage()}", + ) + } + } + } + + private fun animatorItems(value: Any?): List = runCatching { + when (value) { + is Collection<*> -> value.take(MAX_TRACE_ITEMS) + null -> emptyList() + else -> { + val backing = readNamedField(value, "b") + when { + backing is Collection<*> -> backing.take(MAX_TRACE_ITEMS) + backing?.javaClass?.isArray == true -> (0 until minOf( + ReflectArray.getLength(backing), + MAX_TRACE_ITEMS, + )).map { index -> ReflectArray.get(backing, index) } + else -> emptyList() + } + } + } + }.getOrDefault(emptyList()) + + private fun traceAnimatorEnd(metadata: WordMetadata, animation: Animator, phase: String) { + runCatching { + val live = readLiveMetadata(metadata) + trace( + "anim-$phase word=${metadata.wordId} text=${quoteTraceText(live.text)} " + + "anim=${describeAnimator(animation)} " + + "o=${describeAnimator(live.nativeAnimator)} " + + "p=${describeAnimatorContainer(live.animatorList)} " + + "bindings=${describeBindings(live)}", + ) + }.onFailure { error -> + trace( + "anim-$phase word=${metadata.wordId} " + + "anim=${animationType(animation)} live-state-failed=${error.cjkShortMessage()}", + ) + } + } + + private fun readLiveMetadata(original: WordMetadata): WordMetadata { + val fields = cachedFields(original.entry.javaClass) + return original.copy( + text = fields["c"]?.let { field -> readField(field, original.entry) as? CharSequence } + ?: original.text, + cumulativeDurationMs = fields["f"] + ?.let { field -> (readField(field, original.entry) as? Number)?.toInt() } + ?: original.cumulativeDurationMs, + cumulativeTextLength = fields["g"] + ?.let { field -> (readField(field, original.entry) as? Number)?.toInt() } + ?: original.cumulativeTextLength, + splitBindingCount = fields["k"]?.let { field -> splitCount(readField(field, original.entry)) } + ?: original.splitBindingCount, + nativeAnimator = fields["o"]?.let { field -> readField(field, original.entry) }, + animatorList = fields["p"]?.let { field -> readField(field, original.entry) }, + foregroundBinding = fields["i"]?.let { field -> readField(field, original.entry) }, + backgroundBinding = fields["j"]?.let { field -> readField(field, original.entry) }, + splitBindings = fields["k"]?.let { field -> readField(field, original.entry) }, + ) + } + + private fun splitCount(value: Any?): Int = when (value) { + null -> 0 + is Collection<*> -> value.size + else -> -1 + } + + private fun animationType(value: Any): String = + value.javaClass.simpleName.ifBlank { value.javaClass.name } + + private fun describeBindings(metadata: WordMetadata): String { + val bindings = mutableListOf().apply { + metadata.foregroundBinding?.let(::add) + metadata.backgroundBinding?.let(::add) + when (val split = metadata.splitBindings) { + is Collection<*> -> split.take(MAX_TRACE_ITEMS).forEach { add(it) } + else -> Unit + } + } + if (bindings.isEmpty()) return "[]" + return bindings.joinToString(prefix = "[", postfix = "]") { binding -> + val view = binding?.let { readNamedField(it, "U") } + "${binding?.javaClass?.simpleName ?: "null"}/${describeView(view)}" + } + } + + private fun describeView(value: Any?): String { + if (value == null) return "view=null" + val type = value.javaClass.simpleName.ifBlank { value.javaClass.name } + return "$type{alpha=${invokeNoArg(value, "getAlpha")},ty=${invokeNoArg(value, "getTranslationY")}," + + "sx=${invokeNoArg(value, "getScaleX")},sy=${invokeNoArg(value, "getScaleY")}}" + } + + private fun readAnimatorTag(value: Any?): Any? = value?.let { invokeNoArg(it, "getTag") } + + private fun invokeNoArg(receiver: Any, methodName: String): Any? = runCatching { + receiver.javaClass.methods + .firstOrNull { it.name == methodName && it.parameterTypes.isEmpty() } + ?.invoke(receiver) + }.getOrNull() + + private fun readNamedField(receiver: Any, name: String): Any? = + cachedFields(receiver.javaClass)[name]?.let { field -> readField(field, receiver) } + + private fun quoteTraceText(text: CharSequence): String = + "\"${text.toString() + .replace("\\", "\\\\") + .replace("\r", "\\r") + .replace("\n", "\\n") + .replace("\t", "\\t") + .replace("\"", "\\\"") + .take(MAX_TRACE_TEXT)}\"" + + private data class WordMetadata( + val wordId: Int, + val nativeDurationMs: Int, + val background: Boolean, + val entry: Any, + val text: CharSequence, + val cumulativeDurationMs: Int, + val cumulativeTextLength: Int, + val splitBindingCount: Int, + val nativeAnimator: Any?, + val animatorList: Any?, + val foregroundBinding: Any?, + val backgroundBinding: Any?, + val splitBindings: Any?, + ) + private fun cachedFields(type: Class<*>): kotlin.collections.Map = synchronized(fieldCache) { fieldCache.get(type) ?: HashMap().also { fields -> @@ -245,6 +537,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private companion object { const val MAX_REWRITE_LOGS = 3 + const val MAX_TRACE_EVENTS = 180 + const val MAX_TRACE_ITEMS = 6 + const val MAX_TRACE_TEXT = 32 + const val NATIVE_CANDIDATE_DURATION_MS = 1_000 + const val NATIVE_CANDIDATE_LENGTH_MAX = 7 } } diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index f72e0dd..cca35fc 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -7,7 +7,7 @@ ## 当前分支与远端 - 分支:`codex/cjk-karaoke-animation` -- 功能基线:`39e9b08 feat: isolate CJK karaoke animation feature` +- 功能基线:`47de18a fix: block merged CJK chunks from native glow` - 远端:[Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation](https://github.com/Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation) - 主分支基线:`main`(当前不包含本实验功能) - 当前分支已推送到 `origin`;不要把本分支代码直接合并到 `main`,除非完成新的设备验收。 @@ -25,6 +25,10 @@ - 默认值:`true` - 关闭后需要重启 Apple Music,feature 才不会注册 Hook。 +## 临时生命周期探针 + +当前诊断提交会在 `AppleMusicCjkKaraokeAnimationTarget` 输出带 `[DEBUG-cjk-r1]` 前缀的日志,记录 `e.o/e.p`、Animator listener、词 View 的 alpha/translation/scale,以及动画结束或取消时的同一组状态。它还会给已发现的原生 Animator 加一个只读 `onAnimationEnd/onAnimationCancel` 观察 listener;这是临时诊断包,不应当作为长期发布包使用,拿到设备日志后应移除探针。 + ## 关键代码位置 - Hook 实现:`app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt` From 28d4d3c404d7a597cff2c6cb0ac3158bbc714680 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:04:32 +0800 Subject: [PATCH 14/33] debug: trace CJK shadow cleanup --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 32 +++++++++++++++++-- docs/cjk-karaoke-animation-handoff.md | 2 +- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 8c43e14..5185012 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -311,7 +311,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun trace(message: String) { if (traceEventCount.getAndIncrement() < MAX_TRACE_EVENTS) { - ModernXposedRuntime.log("[DEBUG-cjk-r1] $message") + ModernXposedRuntime.log("[DEBUG-cjk-r2] $message") } } @@ -475,8 +475,34 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun describeView(value: Any?): String { if (value == null) return "view=null" val type = value.javaClass.simpleName.ifBlank { value.javaClass.name } - return "$type{alpha=${invokeNoArg(value, "getAlpha")},ty=${invokeNoArg(value, "getTranslationY")}," + - "sx=${invokeNoArg(value, "getScaleX")},sy=${invokeNoArg(value, "getScaleY")}}" + val background = invokeNoArg(value, "getBackground") + return "$type{alpha=${invokeNoArg(value, "getAlpha")},tx=${invokeNoArg(value, "getTranslationX")}," + + "ty=${invokeNoArg(value, "getTranslationY")},sx=${invokeNoArg(value, "getScaleX")}," + + "sy=${invokeNoArg(value, "getScaleY")},shadow=${describeShadow(value)}," + + "paintShadow=${describePaintShadow(value)},bg=${describeDrawable(background)}}" + } + + private fun describeShadow(value: Any): String = + listOf( + "r" to invokeNoArg(value, "getShadowRadius"), + "dx" to invokeNoArg(value, "getShadowDx"), + "dy" to invokeNoArg(value, "getShadowDy"), + "c" to invokeNoArg(value, "getShadowColor"), + ).joinToString(prefix = "{", postfix = "}") { (name, result) -> "$name=$result" } + + private fun describePaintShadow(value: Any): String { + val paint = invokeNoArg(value, "getPaint") ?: return "null" + return listOf( + "r" to invokeNoArg(paint, "getShadowLayerRadius"), + "dx" to invokeNoArg(paint, "getShadowLayerDx"), + "dy" to invokeNoArg(paint, "getShadowLayerDy"), + "c" to invokeNoArg(paint, "getShadowLayerColor"), + ).joinToString(prefix = "{", postfix = "}") { (name, result) -> "$name=$result" } + } + + private fun describeDrawable(value: Any?): String { + if (value == null) return "null" + return "${value.javaClass.simpleName}{alpha=${invokeNoArg(value, "getAlpha")}}" } private fun readAnimatorTag(value: Any?): Any? = value?.let { invokeNoArg(it, "getTag") } diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index cca35fc..668dded 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -27,7 +27,7 @@ ## 临时生命周期探针 -当前诊断提交会在 `AppleMusicCjkKaraokeAnimationTarget` 输出带 `[DEBUG-cjk-r1]` 前缀的日志,记录 `e.o/e.p`、Animator listener、词 View 的 alpha/translation/scale,以及动画结束或取消时的同一组状态。它还会给已发现的原生 Animator 加一个只读 `onAnimationEnd/onAnimationCancel` 观察 listener;这是临时诊断包,不应当作为长期发布包使用,拿到设备日志后应移除探针。 +当前诊断提交会在 `AppleMusicCjkKaraokeAnimationTarget` 输出带 `[DEBUG-cjk-r2]` 前缀的日志,记录 `e.o/e.p`、Animator listener、词 View 的 alpha/translation/scale、TextView/Paint shadow 和背景 Drawable,以及动画结束或取消时的同一组状态。它还会给已发现的原生 Animator 加一个只读 `onAnimationEnd/onAnimationCancel` 观察 listener;这是临时诊断包,不应当作为长期发布包使用,拿到设备日志后应移除探针。 ## 关键代码位置 From 27f6942ba4d689b9e29c3e30aec2b761aab14be0 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:22:03 +0800 Subject: [PATCH 15/33] fix: restore CJK native karaoke view state --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 566 +++++++++--------- docs/cjk-karaoke-animation-handoff.md | 4 +- 2 files changed, 274 insertions(+), 296 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 5185012..d1c3c37 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -2,13 +2,14 @@ package dev.amenhancer.module.hook import android.animation.Animator import android.animation.AnimatorListenerAdapter -import java.util.concurrent.atomic.AtomicInteger +import android.animation.AnimatorSet +import java.lang.reflect.Array as ReflectArray import java.lang.reflect.Field import java.lang.reflect.Modifier -import java.lang.reflect.Array as ReflectArray import java.util.Collections import java.util.Map as JavaMap import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicInteger /** * Narrow Apple Music 6.5.2/1586 adapter for the native karaoke rush-gradient @@ -21,12 +22,13 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private val a0Depth: ThreadLocal = ThreadLocal.withInitial { 0 } private val a0SingleWordStack: ThreadLocal> = ThreadLocal.withInitial { mutableListOf() } + private val a0MetadataStack: ThreadLocal> = + ThreadLocal.withInitial { mutableListOf() } private val fieldCache = Collections.synchronizedMap( WeakHashMap, kotlin.collections.Map>(), ) private val rewriteLogCount = AtomicInteger() - private val traceEventCount = AtomicInteger() - private val observedAnimators = Collections.synchronizedMap( + private val cleanupAnimators = Collections.synchronizedMap( WeakHashMap(), ) @@ -51,11 +53,17 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } override fun afterHookedMethod(param: MethodHookParam) { - // The host callback runs even when z.a0 throws; always - // release this thread's scope so a later g0 call cannot - // inherit a stale override. + // Attach cleanup only to the exact CJK words admitted by + // the gate. English and merged CJK retain Apple's path. try { - traceA0Boundary("after", param, isSingleWordScope()) + runCatching { + if (isSingleWordScope()) { + a0MetadataStack().lastOrNull() + ?.let { metadata -> installCjkCleanup(readLiveAnimators(metadata)) } + } + }.onFailure { error -> + ModernXposedRuntime.log("CJK karaoke cleanup install failed open", error) + } } finally { leaveA0Scope() } @@ -76,7 +84,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( rewriteCjkK0Result(param) } catch (error: Throwable) { // A malformed host call must never break its original - // helper result. This is deliberately fail-open. + // helper result. This is deliberately fail-open. ModernXposedRuntime.log("CJK karaoke UnicodeBlock helper failed open", error) } } @@ -107,30 +115,21 @@ internal class AppleMusicCjkKaraokeAnimationTarget( if (!containsCjkKaraokeScript(text)) return val languageSet = param.args.getOrNull(1) as? Set<*> ?: return - val originalResult = param.result - val setName = when { - isK0Set(languageSet) -> "k0" - isJ0Set(languageSet) -> "j0" - else -> null - } when { - setName == "k0" && param.result == true -> { + isK0Set(languageSet) && param.result == true -> { // CJK is normally classified into k0, which blocks the - // rush branch. Make that one result look like a default + // rush branch. Make that one result look like a default // script only while a0 is running. param.result = false logRewrite(text, "k0 true -> false") } - setName == "j0" && containsHangul(text) && param.result != true -> { - // i0 receives the j0 hit as its split/rush eligibility bit. + isJ0Set(languageSet) && containsHangul(text) && param.result != true -> { // Hangul belongs to k0 but not j0, so opt it into the same // Apple animation only inside a0; g0 remains untouched. param.result = true logRewrite(text, "j0 false -> true") } - else -> Unit } - traceHelperResult(text, setName, originalResult, param.result) } private fun logRewrite(text: CharSequence, change: String) { @@ -142,7 +141,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } private fun isK0Set(languageSet: Set<*>): Boolean = runCatching { - // z.k0 always contains HANGUL_SYLLABLES. This marker distinguishes + // z.k0 always contains HANGUL_SYLLABLES. This marker distinguishes // it from the host's j0/l0 sets without touching either set globally. languageSet.contains(Character.UnicodeBlock.HANGUL_SYLLABLES) }.getOrElse { error -> @@ -164,11 +163,19 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun enterA0Scope(param: ModernMethodHook.MethodHookParam) { runCatching { a0Depth.set((a0Depth.get() ?: 0) + 1) - val gate = readSingleWordGate(param) + val metadata = readWordMetadata(param) + val gate = metadata != null && isSingleUnmergedWord(metadata) a0Stack().add(gate) - traceA0Boundary("before", param, gate) + a0MetadataStack().add( + if (gate) metadata?.captureViewBaselines() else null, + ) + }.onFailure { error -> + // A reflection mismatch must never break the host callback. Keep + // this nested scope fail-closed and balanced for the helper hook. + a0Stack().add(false) + a0MetadataStack().add(null) + ModernXposedRuntime.log("CJK karaoke a0 scope enter failed closed", error) } - .onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth enter failed open", error) } } private fun leaveA0Scope() { @@ -181,40 +188,31 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } val stack = a0Stack() if (stack.isNotEmpty()) stack.removeAt(stack.lastIndex) - if (stack.isEmpty()) a0SingleWordStack.remove() - }.onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth cleanup failed open", error) } + val metadataStack = a0MetadataStack() + if (metadataStack.isNotEmpty()) metadataStack.removeAt(metadataStack.lastIndex) + if (stack.isEmpty()) { + a0SingleWordStack.remove() + a0MetadataStack.remove() + } + }.onFailure { error -> + ModernXposedRuntime.log("CJK karaoke a0 depth cleanup failed open", error) + } } private fun isSingleWordScope(): Boolean = runCatching { (a0Depth.get() ?: 0) > 0 && a0Stack().lastOrNull() == true + }.getOrElse { error -> + ModernXposedRuntime.log("CJK karaoke single-word gate read failed open", error) + false } - .getOrElse { error -> - ModernXposedRuntime.log("CJK karaoke single-word gate read failed open", error) - false - } private fun a0Stack(): MutableList = a0SingleWordStack.get() ?: mutableListOf().also(a0SingleWordStack::set) - /** Reads only the host's grouping metadata; Apple retains all trigger gates. */ - private fun readSingleWordGate(param: ModernMethodHook.MethodHookParam): Boolean = - runCatching { - val metadata = readWordMetadata(param) ?: return@runCatching false - isSingleUnmergedCjkWord( - CjkKaraokeWordTiming( - text = metadata.text, - nativeDurationMs = metadata.nativeDurationMs, - cumulativeDurationMs = metadata.cumulativeDurationMs, - cumulativeTextLength = metadata.cumulativeTextLength, - splitBindingCount = metadata.splitBindingCount, - isBackground = metadata.background, - ), - ) - }.getOrElse { error -> - ModernXposedRuntime.log("CJK single-word gate failed closed: ${error.cjkShortMessage()}") - false - } + private fun a0MetadataStack(): MutableList = + a0MetadataStack.get() ?: mutableListOf().also(a0MetadataStack::set) + /** Reads only the host's grouping metadata; Apple retains all trigger gates. */ private fun readWordMetadata(param: ModernMethodHook.MethodHookParam): WordMetadata? { val holder = param.args.getOrNull(0) ?: return null val wordId = (param.args.getOrNull(2) as? Number)?.toInt() ?: return null @@ -235,20 +233,16 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ?.let { field -> (readField(field, entry) as? Number)?.toInt() } ?: return null val splitValue = fields["k"]?.let { field -> readField(field, entry) } - val splitCount = when (splitValue) { - null -> 0 - is Collection<*> -> splitValue.size - else -> -1 - } return WordMetadata( wordId = wordId, nativeDurationMs = nativeDuration, background = background, + holder = holder, entry = entry, text = text, cumulativeDurationMs = cumulativeDuration, cumulativeTextLength = cumulativeLength, - splitBindingCount = splitCount, + splitBindingCount = splitCount(splitValue), nativeAnimator = fields["o"]?.let { field -> readField(field, entry) }, animatorList = fields["p"]?.let { field -> readField(field, entry) }, foregroundBinding = fields["i"]?.let { field -> readField(field, entry) }, @@ -257,293 +251,248 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ) } - private fun traceA0Boundary( - phase: String, - param: ModernMethodHook.MethodHookParam, - gate: Boolean, - ) { - runCatching { - val metadata = readWordMetadata(param) - if (metadata == null) { - val wordId = (param.args.getOrNull(2) as? Number)?.toInt() - if (wordId != null) { - trace( - "a0-$phase metadata-unavailable word=$wordId " + - "holder=${param.args.getOrNull(0)?.javaClass?.simpleName}", - ) - } - return@runCatching - } - val isCjk = containsCjkKaraokeScript(metadata.text) - val isNativeCandidate = !metadata.background && - metadata.cumulativeDurationMs >= NATIVE_CANDIDATE_DURATION_MS && - metadata.cumulativeTextLength <= NATIVE_CANDIDATE_LENGTH_MAX - if (!isCjk && !isNativeCandidate) return@runCatching - trace( - "a0-$phase word=${metadata.wordId} bg=${metadata.background} " + - "gate=$gate text=${quoteTraceText(metadata.text)} " + - "native=${metadata.nativeDurationMs} f=${metadata.cumulativeDurationMs} " + - "g=${metadata.cumulativeTextLength} k=${metadata.splitBindingCount} " + - "entry=${metadata.entry.javaClass.simpleName} " + - "o=${describeAnimator(metadata.nativeAnimator)} " + - "p=${describeAnimatorContainer(metadata.animatorList)} " + - "bindings=${describeBindings(metadata)}", - ) - if (phase == "after") observeAnimatorLifecycle(metadata) - }.onFailure { error -> - trace("a0-$phase trace failed: ${error.cjkShortMessage()}") - } - } - - private fun traceHelperResult( - text: CharSequence, - setName: String?, - originalResult: Any?, - finalResult: Any?, - ) { - if (!isSingleWordScope() || setName == null) return - trace( - "helper set=$setName text=${quoteTraceText(text)} " + - "result=$originalResult->$finalResult depth=${a0Depth.get() ?: 0} " + - "gate=${a0Stack().lastOrNull() == true}", + private fun readLiveAnimators(metadata: WordMetadata): WordMetadata { + val fields = cachedFields(metadata.entry.javaClass) + return metadata.copy( + nativeAnimator = readNamedField(metadata.entry, "o"), + animatorList = readNamedField(metadata.entry, "p"), + foregroundBinding = fields["i"]?.let { field -> readField(field, metadata.entry) }, + backgroundBinding = fields["j"]?.let { field -> readField(field, metadata.entry) }, + splitBindings = fields["k"]?.let { field -> readField(field, metadata.entry) }, ) } - private fun trace(message: String) { - if (traceEventCount.getAndIncrement() < MAX_TRACE_EVENTS) { - ModernXposedRuntime.log("[DEBUG-cjk-r2] $message") - } - } - - private fun describeAnimator(value: Any?): String { - if (value == null) return "null" - val type = value.javaClass.simpleName.ifBlank { value.javaClass.name } - val started = invokeNoArg(value, "isStarted") - val running = invokeNoArg(value, "isRunning") - val duration = invokeNoArg(value, "getDuration") - val playTime = invokeNoArg(value, "getCurrentPlayTime") - val tag = readAnimatorTag(value) - val listeners = describeAnimatorListeners(value) - return "$type{started=$started,running=$running,duration=$duration,play=$playTime," + - "tag=$tag,listeners=$listeners}" - } - - private fun describeAnimatorListeners(value: Any): String = runCatching { - val listeners = invokeNoArg(value, "getListeners") as? Collection<*> ?: return@runCatching "?" - listeners.take(MAX_TRACE_ITEMS).joinToString( - prefix = "[", - postfix = "]", - ) { listener -> listener?.javaClass?.simpleName ?: "null" } - }.getOrDefault("?") - - private fun describeAnimatorContainer(value: Any?): String { - if (value == null) return "null" - return runCatching { - val items: List = when (value) { - is Collection<*> -> value.take(MAX_TRACE_ITEMS) - else -> { - val backing = readNamedField(value, "b") - when { - backing is Collection<*> -> backing.take(MAX_TRACE_ITEMS) - backing?.javaClass?.isArray == true -> (0 until minOf( - ReflectArray.getLength(backing), - MAX_TRACE_ITEMS, - )).map { index -> ReflectArray.get(backing, index) } - else -> emptyList() - } - } - } - val count = readNamedField(value, "c") ?: items.size - val details = items.joinToString(",") { item -> describeAnimator(item) } - "${value.javaClass.simpleName}{count=$count,items=[$details]}" - }.getOrElse { error -> "${value.javaClass.simpleName}{error=${error.cjkShortMessage()}}" } - } + private fun isSingleUnmergedWord(metadata: WordMetadata): Boolean = + isSingleUnmergedCjkWord( + CjkKaraokeWordTiming( + text = metadata.text, + nativeDurationMs = metadata.nativeDurationMs, + cumulativeDurationMs = metadata.cumulativeDurationMs, + cumulativeTextLength = metadata.cumulativeTextLength, + splitBindingCount = metadata.splitBindingCount, + isBackground = metadata.background, + ), + ) - private fun observeAnimatorLifecycle(metadata: WordMetadata) { - val candidates = buildList { - (metadata.nativeAnimator as? Animator)?.let(::add) - animatorItems(metadata.animatorList).forEach { item -> - (item as? Animator)?.let(::add) - } + private fun installCjkCleanup(metadata: WordMetadata) { + val candidates = mutableListOf() + (metadata.nativeAnimator as? Animator)?.let { candidates += it } + animatorItems(metadata.animatorList).forEach { item -> + val animator = item as? Animator ?: return@forEach + if (candidates.none { it === animator }) candidates += animator } candidates.forEach { animator -> - val shouldObserve = synchronized(observedAnimators) { - observedAnimators.put(animator, true) == null + val shouldInstall = synchronized(cleanupAnimators) { + cleanupAnimators.put(animator, true) == null } - if (!shouldObserve) return@forEach + if (!shouldInstall) return@forEach runCatching { animator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - traceAnimatorEnd(metadata, animation, "end") + if (cleanupAfterCjkAnimator(metadata, animation)) { + animation.removeListener(this) + synchronized(cleanupAnimators) { cleanupAnimators.remove(animation) } + } } override fun onAnimationCancel(animation: Animator) { - traceAnimatorEnd(metadata, animation, "cancel") + if (cleanupAfterCjkAnimator(metadata, animation)) { + animation.removeListener(this) + synchronized(cleanupAnimators) { cleanupAnimators.remove(animation) } + } } }) }.onFailure { error -> - trace( - "anim-observe-failed word=${metadata.wordId} " + - "anim=${animationType(animator)} error=${error.cjkShortMessage()}", - ) + ModernXposedRuntime.log("CJK karaoke cleanup listener failed", error) } } } - private fun animatorItems(value: Any?): List = runCatching { - when (value) { - is Collection<*> -> value.take(MAX_TRACE_ITEMS) - null -> emptyList() - else -> { - val backing = readNamedField(value, "b") - when { - backing is Collection<*> -> backing.take(MAX_TRACE_ITEMS) - backing?.javaClass?.isArray == true -> (0 until minOf( - ReflectArray.getLength(backing), - MAX_TRACE_ITEMS, - )).map { index -> ReflectArray.get(backing, index) } - else -> emptyList() - } + private fun cleanupAfterCjkAnimator(metadata: WordMetadata, trigger: Animator): Boolean = + runCatching { + val liveList = readNamedField(metadata.entry, "p") + val liveNative = readNamedField(metadata.entry, "o") as? Animator + val active = mutableListOf() + liveNative?.let { active += it } + animatorItems(liveList).forEach { item -> + val animator = item as? Animator ?: return@forEach + if (active.none { it === animator }) active += animator + } + // A child callback can arrive before Apple's outer AnimatorSet; + // wait for that outer set while it is actually running. A stale + // custom spring may report isStarted=true forever after cancel, + // so it must not block the final cleanup pass. + // An AnimatorSet is Apple's outer word animation. Its end is the + // authoritative boundary even if a custom spring remains in e.p + // without dispatching onAnimationEnd. + if (trigger !is AnimatorSet && active.any { it !== trigger && isAnimatorRunning(it) }) { + return@runCatching false } - } - }.getOrDefault(emptyList()) - private fun traceAnimatorEnd(metadata: WordMetadata, animation: Animator, phase: String) { - runCatching { - val live = readLiveMetadata(metadata) - trace( - "anim-$phase word=${metadata.wordId} text=${quoteTraceText(live.text)} " + - "anim=${describeAnimator(animation)} " + - "o=${describeAnimator(live.nativeAnimator)} " + - "p=${describeAnimatorContainer(live.animatorList)} " + - "bindings=${describeBindings(live)}", - ) + active.filter { it !== trigger && isAnimatorStarted(it) }.forEach { animator -> + runCatching { animator.cancel() } + } + removeAnimators(liveList, active) + restoreViewBaselines(metadata, readLiveAnimators(metadata)) + true }.onFailure { error -> - trace( - "anim-$phase word=${metadata.wordId} " + - "anim=${animationType(animation)} live-state-failed=${error.cjkShortMessage()}", - ) + ModernXposedRuntime.log("CJK karaoke view cleanup failed", error) + }.getOrDefault(false) + + private fun isAnimatorRunning(animator: Animator): Boolean = runCatching { + animator.isRunning + }.getOrDefault(false) + + private fun isAnimatorStarted(animator: Animator): Boolean = runCatching { + animator.isRunning || animator.isStarted + }.getOrDefault(false) + + private fun removeAnimators(container: Any?, animators: List) { + if (container == null) return + if (container is MutableCollection<*>) { + @Suppress("UNCHECKED_CAST") + val mutable = container as MutableCollection + animators.forEach { animator -> runCatching { mutable.remove(animator) } } + } else { + // Keep the cleanup compatible with Apple's obfuscated F/b + // collection even if a future runtime exposes it as read-only. + animators.forEach { animator -> invokeMethod(container, "remove", animator) } } } - private fun readLiveMetadata(original: WordMetadata): WordMetadata { - val fields = cachedFields(original.entry.javaClass) - return original.copy( - text = fields["c"]?.let { field -> readField(field, original.entry) as? CharSequence } - ?: original.text, - cumulativeDurationMs = fields["f"] - ?.let { field -> (readField(field, original.entry) as? Number)?.toInt() } - ?: original.cumulativeDurationMs, - cumulativeTextLength = fields["g"] - ?.let { field -> (readField(field, original.entry) as? Number)?.toInt() } - ?: original.cumulativeTextLength, - splitBindingCount = fields["k"]?.let { field -> splitCount(readField(field, original.entry)) } - ?: original.splitBindingCount, - nativeAnimator = fields["o"]?.let { field -> readField(field, original.entry) }, - animatorList = fields["p"]?.let { field -> readField(field, original.entry) }, - foregroundBinding = fields["i"]?.let { field -> readField(field, original.entry) }, - backgroundBinding = fields["j"]?.let { field -> readField(field, original.entry) }, - splitBindings = fields["k"]?.let { field -> readField(field, original.entry) }, - ) + private fun restoreViewBaselines(metadata: WordMetadata, liveMetadata: WordMetadata) { + if (!isCurrentWordEntry(metadata)) return + val liveViews = bindingViews(liveMetadata) + metadata.viewBaselines.forEach { baseline -> + if (liveViews.none { it === baseline.view }) return@forEach + val currentText = invokeNoArg(baseline.view, "getText") as? CharSequence + if (currentText == null || !containsCjkKaraokeScript(currentText)) return@forEach + invokeMethod(baseline.view, "setTranslationX", baseline.translationX) + invokeMethod(baseline.view, "setTranslationY", baseline.translationY) + invokeMethod(baseline.view, "setScaleX", baseline.scaleX) + invokeMethod(baseline.view, "setScaleY", baseline.scaleY) + invokeNoArg(baseline.view, "resetPivot") + restoreShadow(baseline.view, baseline.shadow) + invokeNoArg(baseline.view, "invalidate") + } } - private fun splitCount(value: Any?): Int = when (value) { - null -> 0 - is Collection<*> -> value.size - else -> -1 + private fun isCurrentWordEntry(metadata: WordMetadata): Boolean = runCatching { + val mapName = if (metadata.background) "H" else "G" + val map = cachedFields(metadata.holder.javaClass)[mapName] + ?.let { field -> readField(field, metadata.holder) as? JavaMap<*, *> } + ?: return@runCatching false + val current = map.get(Integer.valueOf(metadata.wordId)) ?: map.get(metadata.wordId) + current === metadata.entry + }.getOrDefault(false) + + private fun restoreShadow(view: Any, shadow: ShadowState) { + if (shadow.radius == null || shadow.dx == null || shadow.dy == null || shadow.color == null) return + invokeMethod( + view, + "setShadowLayer", + shadow.radius, + shadow.dx, + shadow.dy, + shadow.color, + ) } - private fun animationType(value: Any): String = - value.javaClass.simpleName.ifBlank { value.javaClass.name } + private fun WordMetadata.captureViewBaselines(): WordMetadata = copy( + viewBaselines = bindingViews(this).mapNotNull { view -> captureViewBaseline(view) }, + ) - private fun describeBindings(metadata: WordMetadata): String { + private fun bindingViews(metadata: WordMetadata): List { val bindings = mutableListOf().apply { - metadata.foregroundBinding?.let(::add) - metadata.backgroundBinding?.let(::add) + val primaryBinding = if (metadata.background) { + metadata.backgroundBinding + } else { + metadata.foregroundBinding + } + primaryBinding?.let(::add) when (val split = metadata.splitBindings) { - is Collection<*> -> split.take(MAX_TRACE_ITEMS).forEach { add(it) } + is Collection<*> -> split.forEach { add(it) } else -> Unit } } - if (bindings.isEmpty()) return "[]" - return bindings.joinToString(prefix = "[", postfix = "]") { binding -> - val view = binding?.let { readNamedField(it, "U") } - "${binding?.javaClass?.simpleName ?: "null"}/${describeView(view)}" + val views = mutableListOf() + bindings.forEach { binding -> + val view = binding?.let { readNamedField(it, "U") } ?: return@forEach + if (views.none { it === view }) views += view } + return views } - private fun describeView(value: Any?): String { - if (value == null) return "view=null" - val type = value.javaClass.simpleName.ifBlank { value.javaClass.name } - val background = invokeNoArg(value, "getBackground") - return "$type{alpha=${invokeNoArg(value, "getAlpha")},tx=${invokeNoArg(value, "getTranslationX")}," + - "ty=${invokeNoArg(value, "getTranslationY")},sx=${invokeNoArg(value, "getScaleX")}," + - "sy=${invokeNoArg(value, "getScaleY")},shadow=${describeShadow(value)}," + - "paintShadow=${describePaintShadow(value)},bg=${describeDrawable(background)}}" - } + private fun captureViewBaseline(view: Any): ViewBaseline? = runCatching { + ViewBaseline( + view = view, + translationX = readFloat(invokeNoArg(view, "getTranslationX")) ?: 0f, + translationY = readFloat(invokeNoArg(view, "getTranslationY")) ?: 0f, + scaleX = readFloat(invokeNoArg(view, "getScaleX")) ?: 1f, + scaleY = readFloat(invokeNoArg(view, "getScaleY")) ?: 1f, + shadow = readShadow(view), + ) + }.getOrNull() - private fun describeShadow(value: Any): String = - listOf( - "r" to invokeNoArg(value, "getShadowRadius"), - "dx" to invokeNoArg(value, "getShadowDx"), - "dy" to invokeNoArg(value, "getShadowDy"), - "c" to invokeNoArg(value, "getShadowColor"), - ).joinToString(prefix = "{", postfix = "}") { (name, result) -> "$name=$result" } - - private fun describePaintShadow(value: Any): String { - val paint = invokeNoArg(value, "getPaint") ?: return "null" - return listOf( - "r" to invokeNoArg(paint, "getShadowLayerRadius"), - "dx" to invokeNoArg(paint, "getShadowLayerDx"), - "dy" to invokeNoArg(paint, "getShadowLayerDy"), - "c" to invokeNoArg(paint, "getShadowLayerColor"), - ).joinToString(prefix = "{", postfix = "}") { (name, result) -> "$name=$result" } + private fun readShadow(view: Any): ShadowState { + val paint = invokeNoArg(view, "getPaint") + return ShadowState( + radius = readFloat(invokeNoArg(view, "getShadowRadius")) + ?: readFloat(paint?.let { invokeNoArg(it, "getShadowLayerRadius") }), + dx = readFloat(invokeNoArg(view, "getShadowDx")) + ?: readFloat(paint?.let { invokeNoArg(it, "getShadowLayerDx") }), + dy = readFloat(invokeNoArg(view, "getShadowDy")) + ?: readFloat(paint?.let { invokeNoArg(it, "getShadowLayerDy") }), + color = readInt(invokeNoArg(view, "getShadowColor")) + ?: readInt(paint?.let { invokeNoArg(it, "getShadowLayerColor") }), + ) } - private fun describeDrawable(value: Any?): String { - if (value == null) return "null" - return "${value.javaClass.simpleName}{alpha=${invokeNoArg(value, "getAlpha")}}" + private fun animatorItems(value: Any?): List = runCatching { + when (value) { + is Collection<*> -> value.toList() + null -> emptyList() + else -> { + val backing = readNamedField(value, "b") + when { + backing is Collection<*> -> backing.toList() + backing?.javaClass?.isArray == true -> + (0 until ReflectArray.getLength(backing)).map { index -> + ReflectArray.get(backing, index) + } + else -> emptyList() + } + } + } + }.getOrDefault(emptyList()) + + private fun splitCount(value: Any?): Int = when (value) { + null -> 0 + is Collection<*> -> value.size + else -> -1 } - private fun readAnimatorTag(value: Any?): Any? = value?.let { invokeNoArg(it, "getTag") } + private fun readFloat(value: Any?): Float? = (value as? Number)?.toFloat() + + private fun readInt(value: Any?): Int? = (value as? Number)?.toInt() - private fun invokeNoArg(receiver: Any, methodName: String): Any? = runCatching { + private fun invokeNoArg(receiver: Any, methodName: String): Any? = + invokeMethod(receiver, methodName) + + private fun invokeMethod(receiver: Any, methodName: String, vararg args: Any?): Any? = runCatching { receiver.javaClass.methods - .firstOrNull { it.name == methodName && it.parameterTypes.isEmpty() } - ?.invoke(receiver) + .firstOrNull { method -> + method.name == methodName && method.parameterTypes.size == args.size + } + ?.invoke(receiver, *args) }.getOrNull() private fun readNamedField(receiver: Any, name: String): Any? = cachedFields(receiver.javaClass)[name]?.let { field -> readField(field, receiver) } - private fun quoteTraceText(text: CharSequence): String = - "\"${text.toString() - .replace("\\", "\\\\") - .replace("\r", "\\r") - .replace("\n", "\\n") - .replace("\t", "\\t") - .replace("\"", "\\\"") - .take(MAX_TRACE_TEXT)}\"" - - private data class WordMetadata( - val wordId: Int, - val nativeDurationMs: Int, - val background: Boolean, - val entry: Any, - val text: CharSequence, - val cumulativeDurationMs: Int, - val cumulativeTextLength: Int, - val splitBindingCount: Int, - val nativeAnimator: Any?, - val animatorList: Any?, - val foregroundBinding: Any?, - val backgroundBinding: Any?, - val splitBindings: Any?, - ) - private fun cachedFields(type: Class<*>): kotlin.collections.Map = synchronized(fieldCache) { - fieldCache.get(type) ?: HashMap().also { fields -> + fieldCache[type] ?: HashMap().also { fields -> generateSequence(type) { it.superclass } .flatMap { current -> current.declaredFields.asSequence() } .filterNot { field -> Modifier.isStatic(field.modifiers) } @@ -561,13 +510,42 @@ internal class AppleMusicCjkKaraokeAnimationTarget( field.get(receiver) }.getOrNull() + private data class ShadowState( + val radius: Float?, + val dx: Float?, + val dy: Float?, + val color: Int?, + ) + + private data class ViewBaseline( + val view: Any, + val translationX: Float, + val translationY: Float, + val scaleX: Float, + val scaleY: Float, + val shadow: ShadowState, + ) + + private data class WordMetadata( + val wordId: Int, + val nativeDurationMs: Int, + val background: Boolean, + val holder: Any, + val entry: Any, + val text: CharSequence, + val cumulativeDurationMs: Int, + val cumulativeTextLength: Int, + val splitBindingCount: Int, + val nativeAnimator: Any?, + val animatorList: Any?, + val foregroundBinding: Any?, + val backgroundBinding: Any?, + val splitBindings: Any?, + val viewBaselines: List = emptyList(), + ) + private companion object { const val MAX_REWRITE_LOGS = 3 - const val MAX_TRACE_EVENTS = 180 - const val MAX_TRACE_ITEMS = 6 - const val MAX_TRACE_TEXT = 32 - const val NATIVE_CANDIDATE_DURATION_MS = 1_000 - const val NATIVE_CANDIDATE_LENGTH_MAX = 7 } } diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index 668dded..5b00363 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -25,9 +25,9 @@ - 默认值:`true` - 关闭后需要重启 Apple Music,feature 才不会注册 Hook。 -## 临时生命周期探针 +## CJK 动画结束清理 -当前诊断提交会在 `AppleMusicCjkKaraokeAnimationTarget` 输出带 `[DEBUG-cjk-r2]` 前缀的日志,记录 `e.o/e.p`、Animator listener、词 View 的 alpha/translation/scale、TextView/Paint shadow 和背景 Drawable,以及动画结束或取消时的同一组状态。它还会给已发现的原生 Animator 加一个只读 `onAnimationEnd/onAnimationCancel` 观察 listener;这是临时诊断包,不应当作为长期发布包使用,拿到设备日志后应移除探针。 +当前实现只对 gate 放行的单个前景 CJK 词安装原生 Animator 的结束/取消 listener:外层 `AnimatorSet` 结束后,取消并移除该词 `e.p` 中残留的 Animator,再在同一个 `G[wordId]` 和 binding View 仍有效时恢复 translation、scale、pivot 和 TextView/Paint shadow。不会恢复 View alpha,也不会触碰英语或合并 CJK 词的动画。 ## 关键代码位置 From e3500ef794cab8918c6137ec8712e34755e0b334 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:27:56 +0800 Subject: [PATCH 16/33] Revert "fix: restore CJK native karaoke view state" This reverts commit 27f6942ba4d689b9e29c3e30aec2b761aab14be0. --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 566 +++++++++--------- docs/cjk-karaoke-animation-handoff.md | 4 +- 2 files changed, 296 insertions(+), 274 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index d1c3c37..5185012 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -2,14 +2,13 @@ package dev.amenhancer.module.hook import android.animation.Animator import android.animation.AnimatorListenerAdapter -import android.animation.AnimatorSet -import java.lang.reflect.Array as ReflectArray +import java.util.concurrent.atomic.AtomicInteger import java.lang.reflect.Field import java.lang.reflect.Modifier +import java.lang.reflect.Array as ReflectArray import java.util.Collections import java.util.Map as JavaMap import java.util.WeakHashMap -import java.util.concurrent.atomic.AtomicInteger /** * Narrow Apple Music 6.5.2/1586 adapter for the native karaoke rush-gradient @@ -22,13 +21,12 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private val a0Depth: ThreadLocal = ThreadLocal.withInitial { 0 } private val a0SingleWordStack: ThreadLocal> = ThreadLocal.withInitial { mutableListOf() } - private val a0MetadataStack: ThreadLocal> = - ThreadLocal.withInitial { mutableListOf() } private val fieldCache = Collections.synchronizedMap( WeakHashMap, kotlin.collections.Map>(), ) private val rewriteLogCount = AtomicInteger() - private val cleanupAnimators = Collections.synchronizedMap( + private val traceEventCount = AtomicInteger() + private val observedAnimators = Collections.synchronizedMap( WeakHashMap(), ) @@ -53,17 +51,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } override fun afterHookedMethod(param: MethodHookParam) { - // Attach cleanup only to the exact CJK words admitted by - // the gate. English and merged CJK retain Apple's path. + // The host callback runs even when z.a0 throws; always + // release this thread's scope so a later g0 call cannot + // inherit a stale override. try { - runCatching { - if (isSingleWordScope()) { - a0MetadataStack().lastOrNull() - ?.let { metadata -> installCjkCleanup(readLiveAnimators(metadata)) } - } - }.onFailure { error -> - ModernXposedRuntime.log("CJK karaoke cleanup install failed open", error) - } + traceA0Boundary("after", param, isSingleWordScope()) } finally { leaveA0Scope() } @@ -84,7 +76,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( rewriteCjkK0Result(param) } catch (error: Throwable) { // A malformed host call must never break its original - // helper result. This is deliberately fail-open. + // helper result. This is deliberately fail-open. ModernXposedRuntime.log("CJK karaoke UnicodeBlock helper failed open", error) } } @@ -115,21 +107,30 @@ internal class AppleMusicCjkKaraokeAnimationTarget( if (!containsCjkKaraokeScript(text)) return val languageSet = param.args.getOrNull(1) as? Set<*> ?: return + val originalResult = param.result + val setName = when { + isK0Set(languageSet) -> "k0" + isJ0Set(languageSet) -> "j0" + else -> null + } when { - isK0Set(languageSet) && param.result == true -> { + setName == "k0" && param.result == true -> { // CJK is normally classified into k0, which blocks the - // rush branch. Make that one result look like a default + // rush branch. Make that one result look like a default // script only while a0 is running. param.result = false logRewrite(text, "k0 true -> false") } - isJ0Set(languageSet) && containsHangul(text) && param.result != true -> { + setName == "j0" && containsHangul(text) && param.result != true -> { + // i0 receives the j0 hit as its split/rush eligibility bit. // Hangul belongs to k0 but not j0, so opt it into the same // Apple animation only inside a0; g0 remains untouched. param.result = true logRewrite(text, "j0 false -> true") } + else -> Unit } + traceHelperResult(text, setName, originalResult, param.result) } private fun logRewrite(text: CharSequence, change: String) { @@ -141,7 +142,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } private fun isK0Set(languageSet: Set<*>): Boolean = runCatching { - // z.k0 always contains HANGUL_SYLLABLES. This marker distinguishes + // z.k0 always contains HANGUL_SYLLABLES. This marker distinguishes // it from the host's j0/l0 sets without touching either set globally. languageSet.contains(Character.UnicodeBlock.HANGUL_SYLLABLES) }.getOrElse { error -> @@ -163,19 +164,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun enterA0Scope(param: ModernMethodHook.MethodHookParam) { runCatching { a0Depth.set((a0Depth.get() ?: 0) + 1) - val metadata = readWordMetadata(param) - val gate = metadata != null && isSingleUnmergedWord(metadata) + val gate = readSingleWordGate(param) a0Stack().add(gate) - a0MetadataStack().add( - if (gate) metadata?.captureViewBaselines() else null, - ) - }.onFailure { error -> - // A reflection mismatch must never break the host callback. Keep - // this nested scope fail-closed and balanced for the helper hook. - a0Stack().add(false) - a0MetadataStack().add(null) - ModernXposedRuntime.log("CJK karaoke a0 scope enter failed closed", error) + traceA0Boundary("before", param, gate) } + .onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth enter failed open", error) } } private fun leaveA0Scope() { @@ -188,31 +181,40 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } val stack = a0Stack() if (stack.isNotEmpty()) stack.removeAt(stack.lastIndex) - val metadataStack = a0MetadataStack() - if (metadataStack.isNotEmpty()) metadataStack.removeAt(metadataStack.lastIndex) - if (stack.isEmpty()) { - a0SingleWordStack.remove() - a0MetadataStack.remove() - } - }.onFailure { error -> - ModernXposedRuntime.log("CJK karaoke a0 depth cleanup failed open", error) - } + if (stack.isEmpty()) a0SingleWordStack.remove() + }.onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth cleanup failed open", error) } } private fun isSingleWordScope(): Boolean = runCatching { (a0Depth.get() ?: 0) > 0 && a0Stack().lastOrNull() == true - }.getOrElse { error -> - ModernXposedRuntime.log("CJK karaoke single-word gate read failed open", error) - false } + .getOrElse { error -> + ModernXposedRuntime.log("CJK karaoke single-word gate read failed open", error) + false + } private fun a0Stack(): MutableList = a0SingleWordStack.get() ?: mutableListOf().also(a0SingleWordStack::set) - private fun a0MetadataStack(): MutableList = - a0MetadataStack.get() ?: mutableListOf().also(a0MetadataStack::set) - /** Reads only the host's grouping metadata; Apple retains all trigger gates. */ + private fun readSingleWordGate(param: ModernMethodHook.MethodHookParam): Boolean = + runCatching { + val metadata = readWordMetadata(param) ?: return@runCatching false + isSingleUnmergedCjkWord( + CjkKaraokeWordTiming( + text = metadata.text, + nativeDurationMs = metadata.nativeDurationMs, + cumulativeDurationMs = metadata.cumulativeDurationMs, + cumulativeTextLength = metadata.cumulativeTextLength, + splitBindingCount = metadata.splitBindingCount, + isBackground = metadata.background, + ), + ) + }.getOrElse { error -> + ModernXposedRuntime.log("CJK single-word gate failed closed: ${error.cjkShortMessage()}") + false + } + private fun readWordMetadata(param: ModernMethodHook.MethodHookParam): WordMetadata? { val holder = param.args.getOrNull(0) ?: return null val wordId = (param.args.getOrNull(2) as? Number)?.toInt() ?: return null @@ -233,16 +235,20 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ?.let { field -> (readField(field, entry) as? Number)?.toInt() } ?: return null val splitValue = fields["k"]?.let { field -> readField(field, entry) } + val splitCount = when (splitValue) { + null -> 0 + is Collection<*> -> splitValue.size + else -> -1 + } return WordMetadata( wordId = wordId, nativeDurationMs = nativeDuration, background = background, - holder = holder, entry = entry, text = text, cumulativeDurationMs = cumulativeDuration, cumulativeTextLength = cumulativeLength, - splitBindingCount = splitCount(splitValue), + splitBindingCount = splitCount, nativeAnimator = fields["o"]?.let { field -> readField(field, entry) }, animatorList = fields["p"]?.let { field -> readField(field, entry) }, foregroundBinding = fields["i"]?.let { field -> readField(field, entry) }, @@ -251,248 +257,293 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ) } - private fun readLiveAnimators(metadata: WordMetadata): WordMetadata { - val fields = cachedFields(metadata.entry.javaClass) - return metadata.copy( - nativeAnimator = readNamedField(metadata.entry, "o"), - animatorList = readNamedField(metadata.entry, "p"), - foregroundBinding = fields["i"]?.let { field -> readField(field, metadata.entry) }, - backgroundBinding = fields["j"]?.let { field -> readField(field, metadata.entry) }, - splitBindings = fields["k"]?.let { field -> readField(field, metadata.entry) }, - ) + private fun traceA0Boundary( + phase: String, + param: ModernMethodHook.MethodHookParam, + gate: Boolean, + ) { + runCatching { + val metadata = readWordMetadata(param) + if (metadata == null) { + val wordId = (param.args.getOrNull(2) as? Number)?.toInt() + if (wordId != null) { + trace( + "a0-$phase metadata-unavailable word=$wordId " + + "holder=${param.args.getOrNull(0)?.javaClass?.simpleName}", + ) + } + return@runCatching + } + val isCjk = containsCjkKaraokeScript(metadata.text) + val isNativeCandidate = !metadata.background && + metadata.cumulativeDurationMs >= NATIVE_CANDIDATE_DURATION_MS && + metadata.cumulativeTextLength <= NATIVE_CANDIDATE_LENGTH_MAX + if (!isCjk && !isNativeCandidate) return@runCatching + trace( + "a0-$phase word=${metadata.wordId} bg=${metadata.background} " + + "gate=$gate text=${quoteTraceText(metadata.text)} " + + "native=${metadata.nativeDurationMs} f=${metadata.cumulativeDurationMs} " + + "g=${metadata.cumulativeTextLength} k=${metadata.splitBindingCount} " + + "entry=${metadata.entry.javaClass.simpleName} " + + "o=${describeAnimator(metadata.nativeAnimator)} " + + "p=${describeAnimatorContainer(metadata.animatorList)} " + + "bindings=${describeBindings(metadata)}", + ) + if (phase == "after") observeAnimatorLifecycle(metadata) + }.onFailure { error -> + trace("a0-$phase trace failed: ${error.cjkShortMessage()}") + } } - private fun isSingleUnmergedWord(metadata: WordMetadata): Boolean = - isSingleUnmergedCjkWord( - CjkKaraokeWordTiming( - text = metadata.text, - nativeDurationMs = metadata.nativeDurationMs, - cumulativeDurationMs = metadata.cumulativeDurationMs, - cumulativeTextLength = metadata.cumulativeTextLength, - splitBindingCount = metadata.splitBindingCount, - isBackground = metadata.background, - ), + private fun traceHelperResult( + text: CharSequence, + setName: String?, + originalResult: Any?, + finalResult: Any?, + ) { + if (!isSingleWordScope() || setName == null) return + trace( + "helper set=$setName text=${quoteTraceText(text)} " + + "result=$originalResult->$finalResult depth=${a0Depth.get() ?: 0} " + + "gate=${a0Stack().lastOrNull() == true}", ) + } - private fun installCjkCleanup(metadata: WordMetadata) { - val candidates = mutableListOf() - (metadata.nativeAnimator as? Animator)?.let { candidates += it } - animatorItems(metadata.animatorList).forEach { item -> - val animator = item as? Animator ?: return@forEach - if (candidates.none { it === animator }) candidates += animator + private fun trace(message: String) { + if (traceEventCount.getAndIncrement() < MAX_TRACE_EVENTS) { + ModernXposedRuntime.log("[DEBUG-cjk-r2] $message") + } + } + + private fun describeAnimator(value: Any?): String { + if (value == null) return "null" + val type = value.javaClass.simpleName.ifBlank { value.javaClass.name } + val started = invokeNoArg(value, "isStarted") + val running = invokeNoArg(value, "isRunning") + val duration = invokeNoArg(value, "getDuration") + val playTime = invokeNoArg(value, "getCurrentPlayTime") + val tag = readAnimatorTag(value) + val listeners = describeAnimatorListeners(value) + return "$type{started=$started,running=$running,duration=$duration,play=$playTime," + + "tag=$tag,listeners=$listeners}" + } + + private fun describeAnimatorListeners(value: Any): String = runCatching { + val listeners = invokeNoArg(value, "getListeners") as? Collection<*> ?: return@runCatching "?" + listeners.take(MAX_TRACE_ITEMS).joinToString( + prefix = "[", + postfix = "]", + ) { listener -> listener?.javaClass?.simpleName ?: "null" } + }.getOrDefault("?") + + private fun describeAnimatorContainer(value: Any?): String { + if (value == null) return "null" + return runCatching { + val items: List = when (value) { + is Collection<*> -> value.take(MAX_TRACE_ITEMS) + else -> { + val backing = readNamedField(value, "b") + when { + backing is Collection<*> -> backing.take(MAX_TRACE_ITEMS) + backing?.javaClass?.isArray == true -> (0 until minOf( + ReflectArray.getLength(backing), + MAX_TRACE_ITEMS, + )).map { index -> ReflectArray.get(backing, index) } + else -> emptyList() + } + } + } + val count = readNamedField(value, "c") ?: items.size + val details = items.joinToString(",") { item -> describeAnimator(item) } + "${value.javaClass.simpleName}{count=$count,items=[$details]}" + }.getOrElse { error -> "${value.javaClass.simpleName}{error=${error.cjkShortMessage()}}" } + } + + private fun observeAnimatorLifecycle(metadata: WordMetadata) { + val candidates = buildList { + (metadata.nativeAnimator as? Animator)?.let(::add) + animatorItems(metadata.animatorList).forEach { item -> + (item as? Animator)?.let(::add) + } } candidates.forEach { animator -> - val shouldInstall = synchronized(cleanupAnimators) { - cleanupAnimators.put(animator, true) == null + val shouldObserve = synchronized(observedAnimators) { + observedAnimators.put(animator, true) == null } - if (!shouldInstall) return@forEach + if (!shouldObserve) return@forEach runCatching { animator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - if (cleanupAfterCjkAnimator(metadata, animation)) { - animation.removeListener(this) - synchronized(cleanupAnimators) { cleanupAnimators.remove(animation) } - } + traceAnimatorEnd(metadata, animation, "end") } override fun onAnimationCancel(animation: Animator) { - if (cleanupAfterCjkAnimator(metadata, animation)) { - animation.removeListener(this) - synchronized(cleanupAnimators) { cleanupAnimators.remove(animation) } - } + traceAnimatorEnd(metadata, animation, "cancel") } }) }.onFailure { error -> - ModernXposedRuntime.log("CJK karaoke cleanup listener failed", error) + trace( + "anim-observe-failed word=${metadata.wordId} " + + "anim=${animationType(animator)} error=${error.cjkShortMessage()}", + ) } } } - private fun cleanupAfterCjkAnimator(metadata: WordMetadata, trigger: Animator): Boolean = - runCatching { - val liveList = readNamedField(metadata.entry, "p") - val liveNative = readNamedField(metadata.entry, "o") as? Animator - val active = mutableListOf() - liveNative?.let { active += it } - animatorItems(liveList).forEach { item -> - val animator = item as? Animator ?: return@forEach - if (active.none { it === animator }) active += animator - } - // A child callback can arrive before Apple's outer AnimatorSet; - // wait for that outer set while it is actually running. A stale - // custom spring may report isStarted=true forever after cancel, - // so it must not block the final cleanup pass. - // An AnimatorSet is Apple's outer word animation. Its end is the - // authoritative boundary even if a custom spring remains in e.p - // without dispatching onAnimationEnd. - if (trigger !is AnimatorSet && active.any { it !== trigger && isAnimatorRunning(it) }) { - return@runCatching false + private fun animatorItems(value: Any?): List = runCatching { + when (value) { + is Collection<*> -> value.take(MAX_TRACE_ITEMS) + null -> emptyList() + else -> { + val backing = readNamedField(value, "b") + when { + backing is Collection<*> -> backing.take(MAX_TRACE_ITEMS) + backing?.javaClass?.isArray == true -> (0 until minOf( + ReflectArray.getLength(backing), + MAX_TRACE_ITEMS, + )).map { index -> ReflectArray.get(backing, index) } + else -> emptyList() + } } + } + }.getOrDefault(emptyList()) - active.filter { it !== trigger && isAnimatorStarted(it) }.forEach { animator -> - runCatching { animator.cancel() } - } - removeAnimators(liveList, active) - restoreViewBaselines(metadata, readLiveAnimators(metadata)) - true + private fun traceAnimatorEnd(metadata: WordMetadata, animation: Animator, phase: String) { + runCatching { + val live = readLiveMetadata(metadata) + trace( + "anim-$phase word=${metadata.wordId} text=${quoteTraceText(live.text)} " + + "anim=${describeAnimator(animation)} " + + "o=${describeAnimator(live.nativeAnimator)} " + + "p=${describeAnimatorContainer(live.animatorList)} " + + "bindings=${describeBindings(live)}", + ) }.onFailure { error -> - ModernXposedRuntime.log("CJK karaoke view cleanup failed", error) - }.getOrDefault(false) - - private fun isAnimatorRunning(animator: Animator): Boolean = runCatching { - animator.isRunning - }.getOrDefault(false) - - private fun isAnimatorStarted(animator: Animator): Boolean = runCatching { - animator.isRunning || animator.isStarted - }.getOrDefault(false) - - private fun removeAnimators(container: Any?, animators: List) { - if (container == null) return - if (container is MutableCollection<*>) { - @Suppress("UNCHECKED_CAST") - val mutable = container as MutableCollection - animators.forEach { animator -> runCatching { mutable.remove(animator) } } - } else { - // Keep the cleanup compatible with Apple's obfuscated F/b - // collection even if a future runtime exposes it as read-only. - animators.forEach { animator -> invokeMethod(container, "remove", animator) } + trace( + "anim-$phase word=${metadata.wordId} " + + "anim=${animationType(animation)} live-state-failed=${error.cjkShortMessage()}", + ) } } - private fun restoreViewBaselines(metadata: WordMetadata, liveMetadata: WordMetadata) { - if (!isCurrentWordEntry(metadata)) return - val liveViews = bindingViews(liveMetadata) - metadata.viewBaselines.forEach { baseline -> - if (liveViews.none { it === baseline.view }) return@forEach - val currentText = invokeNoArg(baseline.view, "getText") as? CharSequence - if (currentText == null || !containsCjkKaraokeScript(currentText)) return@forEach - invokeMethod(baseline.view, "setTranslationX", baseline.translationX) - invokeMethod(baseline.view, "setTranslationY", baseline.translationY) - invokeMethod(baseline.view, "setScaleX", baseline.scaleX) - invokeMethod(baseline.view, "setScaleY", baseline.scaleY) - invokeNoArg(baseline.view, "resetPivot") - restoreShadow(baseline.view, baseline.shadow) - invokeNoArg(baseline.view, "invalidate") - } + private fun readLiveMetadata(original: WordMetadata): WordMetadata { + val fields = cachedFields(original.entry.javaClass) + return original.copy( + text = fields["c"]?.let { field -> readField(field, original.entry) as? CharSequence } + ?: original.text, + cumulativeDurationMs = fields["f"] + ?.let { field -> (readField(field, original.entry) as? Number)?.toInt() } + ?: original.cumulativeDurationMs, + cumulativeTextLength = fields["g"] + ?.let { field -> (readField(field, original.entry) as? Number)?.toInt() } + ?: original.cumulativeTextLength, + splitBindingCount = fields["k"]?.let { field -> splitCount(readField(field, original.entry)) } + ?: original.splitBindingCount, + nativeAnimator = fields["o"]?.let { field -> readField(field, original.entry) }, + animatorList = fields["p"]?.let { field -> readField(field, original.entry) }, + foregroundBinding = fields["i"]?.let { field -> readField(field, original.entry) }, + backgroundBinding = fields["j"]?.let { field -> readField(field, original.entry) }, + splitBindings = fields["k"]?.let { field -> readField(field, original.entry) }, + ) } - private fun isCurrentWordEntry(metadata: WordMetadata): Boolean = runCatching { - val mapName = if (metadata.background) "H" else "G" - val map = cachedFields(metadata.holder.javaClass)[mapName] - ?.let { field -> readField(field, metadata.holder) as? JavaMap<*, *> } - ?: return@runCatching false - val current = map.get(Integer.valueOf(metadata.wordId)) ?: map.get(metadata.wordId) - current === metadata.entry - }.getOrDefault(false) - - private fun restoreShadow(view: Any, shadow: ShadowState) { - if (shadow.radius == null || shadow.dx == null || shadow.dy == null || shadow.color == null) return - invokeMethod( - view, - "setShadowLayer", - shadow.radius, - shadow.dx, - shadow.dy, - shadow.color, - ) + private fun splitCount(value: Any?): Int = when (value) { + null -> 0 + is Collection<*> -> value.size + else -> -1 } - private fun WordMetadata.captureViewBaselines(): WordMetadata = copy( - viewBaselines = bindingViews(this).mapNotNull { view -> captureViewBaseline(view) }, - ) + private fun animationType(value: Any): String = + value.javaClass.simpleName.ifBlank { value.javaClass.name } - private fun bindingViews(metadata: WordMetadata): List { + private fun describeBindings(metadata: WordMetadata): String { val bindings = mutableListOf().apply { - val primaryBinding = if (metadata.background) { - metadata.backgroundBinding - } else { - metadata.foregroundBinding - } - primaryBinding?.let(::add) + metadata.foregroundBinding?.let(::add) + metadata.backgroundBinding?.let(::add) when (val split = metadata.splitBindings) { - is Collection<*> -> split.forEach { add(it) } + is Collection<*> -> split.take(MAX_TRACE_ITEMS).forEach { add(it) } else -> Unit } } - val views = mutableListOf() - bindings.forEach { binding -> - val view = binding?.let { readNamedField(it, "U") } ?: return@forEach - if (views.none { it === view }) views += view + if (bindings.isEmpty()) return "[]" + return bindings.joinToString(prefix = "[", postfix = "]") { binding -> + val view = binding?.let { readNamedField(it, "U") } + "${binding?.javaClass?.simpleName ?: "null"}/${describeView(view)}" } - return views } - private fun captureViewBaseline(view: Any): ViewBaseline? = runCatching { - ViewBaseline( - view = view, - translationX = readFloat(invokeNoArg(view, "getTranslationX")) ?: 0f, - translationY = readFloat(invokeNoArg(view, "getTranslationY")) ?: 0f, - scaleX = readFloat(invokeNoArg(view, "getScaleX")) ?: 1f, - scaleY = readFloat(invokeNoArg(view, "getScaleY")) ?: 1f, - shadow = readShadow(view), - ) - }.getOrNull() - - private fun readShadow(view: Any): ShadowState { - val paint = invokeNoArg(view, "getPaint") - return ShadowState( - radius = readFloat(invokeNoArg(view, "getShadowRadius")) - ?: readFloat(paint?.let { invokeNoArg(it, "getShadowLayerRadius") }), - dx = readFloat(invokeNoArg(view, "getShadowDx")) - ?: readFloat(paint?.let { invokeNoArg(it, "getShadowLayerDx") }), - dy = readFloat(invokeNoArg(view, "getShadowDy")) - ?: readFloat(paint?.let { invokeNoArg(it, "getShadowLayerDy") }), - color = readInt(invokeNoArg(view, "getShadowColor")) - ?: readInt(paint?.let { invokeNoArg(it, "getShadowLayerColor") }), - ) + private fun describeView(value: Any?): String { + if (value == null) return "view=null" + val type = value.javaClass.simpleName.ifBlank { value.javaClass.name } + val background = invokeNoArg(value, "getBackground") + return "$type{alpha=${invokeNoArg(value, "getAlpha")},tx=${invokeNoArg(value, "getTranslationX")}," + + "ty=${invokeNoArg(value, "getTranslationY")},sx=${invokeNoArg(value, "getScaleX")}," + + "sy=${invokeNoArg(value, "getScaleY")},shadow=${describeShadow(value)}," + + "paintShadow=${describePaintShadow(value)},bg=${describeDrawable(background)}}" } - private fun animatorItems(value: Any?): List = runCatching { - when (value) { - is Collection<*> -> value.toList() - null -> emptyList() - else -> { - val backing = readNamedField(value, "b") - when { - backing is Collection<*> -> backing.toList() - backing?.javaClass?.isArray == true -> - (0 until ReflectArray.getLength(backing)).map { index -> - ReflectArray.get(backing, index) - } - else -> emptyList() - } - } - } - }.getOrDefault(emptyList()) - - private fun splitCount(value: Any?): Int = when (value) { - null -> 0 - is Collection<*> -> value.size - else -> -1 + private fun describeShadow(value: Any): String = + listOf( + "r" to invokeNoArg(value, "getShadowRadius"), + "dx" to invokeNoArg(value, "getShadowDx"), + "dy" to invokeNoArg(value, "getShadowDy"), + "c" to invokeNoArg(value, "getShadowColor"), + ).joinToString(prefix = "{", postfix = "}") { (name, result) -> "$name=$result" } + + private fun describePaintShadow(value: Any): String { + val paint = invokeNoArg(value, "getPaint") ?: return "null" + return listOf( + "r" to invokeNoArg(paint, "getShadowLayerRadius"), + "dx" to invokeNoArg(paint, "getShadowLayerDx"), + "dy" to invokeNoArg(paint, "getShadowLayerDy"), + "c" to invokeNoArg(paint, "getShadowLayerColor"), + ).joinToString(prefix = "{", postfix = "}") { (name, result) -> "$name=$result" } } - private fun readFloat(value: Any?): Float? = (value as? Number)?.toFloat() - - private fun readInt(value: Any?): Int? = (value as? Number)?.toInt() + private fun describeDrawable(value: Any?): String { + if (value == null) return "null" + return "${value.javaClass.simpleName}{alpha=${invokeNoArg(value, "getAlpha")}}" + } - private fun invokeNoArg(receiver: Any, methodName: String): Any? = - invokeMethod(receiver, methodName) + private fun readAnimatorTag(value: Any?): Any? = value?.let { invokeNoArg(it, "getTag") } - private fun invokeMethod(receiver: Any, methodName: String, vararg args: Any?): Any? = runCatching { + private fun invokeNoArg(receiver: Any, methodName: String): Any? = runCatching { receiver.javaClass.methods - .firstOrNull { method -> - method.name == methodName && method.parameterTypes.size == args.size - } - ?.invoke(receiver, *args) + .firstOrNull { it.name == methodName && it.parameterTypes.isEmpty() } + ?.invoke(receiver) }.getOrNull() private fun readNamedField(receiver: Any, name: String): Any? = cachedFields(receiver.javaClass)[name]?.let { field -> readField(field, receiver) } + private fun quoteTraceText(text: CharSequence): String = + "\"${text.toString() + .replace("\\", "\\\\") + .replace("\r", "\\r") + .replace("\n", "\\n") + .replace("\t", "\\t") + .replace("\"", "\\\"") + .take(MAX_TRACE_TEXT)}\"" + + private data class WordMetadata( + val wordId: Int, + val nativeDurationMs: Int, + val background: Boolean, + val entry: Any, + val text: CharSequence, + val cumulativeDurationMs: Int, + val cumulativeTextLength: Int, + val splitBindingCount: Int, + val nativeAnimator: Any?, + val animatorList: Any?, + val foregroundBinding: Any?, + val backgroundBinding: Any?, + val splitBindings: Any?, + ) + private fun cachedFields(type: Class<*>): kotlin.collections.Map = synchronized(fieldCache) { - fieldCache[type] ?: HashMap().also { fields -> + fieldCache.get(type) ?: HashMap().also { fields -> generateSequence(type) { it.superclass } .flatMap { current -> current.declaredFields.asSequence() } .filterNot { field -> Modifier.isStatic(field.modifiers) } @@ -510,42 +561,13 @@ internal class AppleMusicCjkKaraokeAnimationTarget( field.get(receiver) }.getOrNull() - private data class ShadowState( - val radius: Float?, - val dx: Float?, - val dy: Float?, - val color: Int?, - ) - - private data class ViewBaseline( - val view: Any, - val translationX: Float, - val translationY: Float, - val scaleX: Float, - val scaleY: Float, - val shadow: ShadowState, - ) - - private data class WordMetadata( - val wordId: Int, - val nativeDurationMs: Int, - val background: Boolean, - val holder: Any, - val entry: Any, - val text: CharSequence, - val cumulativeDurationMs: Int, - val cumulativeTextLength: Int, - val splitBindingCount: Int, - val nativeAnimator: Any?, - val animatorList: Any?, - val foregroundBinding: Any?, - val backgroundBinding: Any?, - val splitBindings: Any?, - val viewBaselines: List = emptyList(), - ) - private companion object { const val MAX_REWRITE_LOGS = 3 + const val MAX_TRACE_EVENTS = 180 + const val MAX_TRACE_ITEMS = 6 + const val MAX_TRACE_TEXT = 32 + const val NATIVE_CANDIDATE_DURATION_MS = 1_000 + const val NATIVE_CANDIDATE_LENGTH_MAX = 7 } } diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index 5b00363..668dded 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -25,9 +25,9 @@ - 默认值:`true` - 关闭后需要重启 Apple Music,feature 才不会注册 Hook。 -## CJK 动画结束清理 +## 临时生命周期探针 -当前实现只对 gate 放行的单个前景 CJK 词安装原生 Animator 的结束/取消 listener:外层 `AnimatorSet` 结束后,取消并移除该词 `e.p` 中残留的 Animator,再在同一个 `G[wordId]` 和 binding View 仍有效时恢复 translation、scale、pivot 和 TextView/Paint shadow。不会恢复 View alpha,也不会触碰英语或合并 CJK 词的动画。 +当前诊断提交会在 `AppleMusicCjkKaraokeAnimationTarget` 输出带 `[DEBUG-cjk-r2]` 前缀的日志,记录 `e.o/e.p`、Animator listener、词 View 的 alpha/translation/scale、TextView/Paint shadow 和背景 Drawable,以及动画结束或取消时的同一组状态。它还会给已发现的原生 Animator 加一个只读 `onAnimationEnd/onAnimationCancel` 观察 listener;这是临时诊断包,不应当作为长期发布包使用,拿到设备日志后应移除探针。 ## 关键代码位置 From 379ac419d6ca0e84ba3add40152e47d69ddde3ee Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:27:56 +0800 Subject: [PATCH 17/33] Revert "debug: trace CJK shadow cleanup" This reverts commit 28d4d3c404d7a597cff2c6cb0ac3158bbc714680. --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 32 ++----------------- docs/cjk-karaoke-animation-handoff.md | 2 +- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 5185012..8c43e14 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -311,7 +311,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun trace(message: String) { if (traceEventCount.getAndIncrement() < MAX_TRACE_EVENTS) { - ModernXposedRuntime.log("[DEBUG-cjk-r2] $message") + ModernXposedRuntime.log("[DEBUG-cjk-r1] $message") } } @@ -475,34 +475,8 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun describeView(value: Any?): String { if (value == null) return "view=null" val type = value.javaClass.simpleName.ifBlank { value.javaClass.name } - val background = invokeNoArg(value, "getBackground") - return "$type{alpha=${invokeNoArg(value, "getAlpha")},tx=${invokeNoArg(value, "getTranslationX")}," + - "ty=${invokeNoArg(value, "getTranslationY")},sx=${invokeNoArg(value, "getScaleX")}," + - "sy=${invokeNoArg(value, "getScaleY")},shadow=${describeShadow(value)}," + - "paintShadow=${describePaintShadow(value)},bg=${describeDrawable(background)}}" - } - - private fun describeShadow(value: Any): String = - listOf( - "r" to invokeNoArg(value, "getShadowRadius"), - "dx" to invokeNoArg(value, "getShadowDx"), - "dy" to invokeNoArg(value, "getShadowDy"), - "c" to invokeNoArg(value, "getShadowColor"), - ).joinToString(prefix = "{", postfix = "}") { (name, result) -> "$name=$result" } - - private fun describePaintShadow(value: Any): String { - val paint = invokeNoArg(value, "getPaint") ?: return "null" - return listOf( - "r" to invokeNoArg(paint, "getShadowLayerRadius"), - "dx" to invokeNoArg(paint, "getShadowLayerDx"), - "dy" to invokeNoArg(paint, "getShadowLayerDy"), - "c" to invokeNoArg(paint, "getShadowLayerColor"), - ).joinToString(prefix = "{", postfix = "}") { (name, result) -> "$name=$result" } - } - - private fun describeDrawable(value: Any?): String { - if (value == null) return "null" - return "${value.javaClass.simpleName}{alpha=${invokeNoArg(value, "getAlpha")}}" + return "$type{alpha=${invokeNoArg(value, "getAlpha")},ty=${invokeNoArg(value, "getTranslationY")}," + + "sx=${invokeNoArg(value, "getScaleX")},sy=${invokeNoArg(value, "getScaleY")}}" } private fun readAnimatorTag(value: Any?): Any? = value?.let { invokeNoArg(it, "getTag") } diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index 668dded..cca35fc 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -27,7 +27,7 @@ ## 临时生命周期探针 -当前诊断提交会在 `AppleMusicCjkKaraokeAnimationTarget` 输出带 `[DEBUG-cjk-r2]` 前缀的日志,记录 `e.o/e.p`、Animator listener、词 View 的 alpha/translation/scale、TextView/Paint shadow 和背景 Drawable,以及动画结束或取消时的同一组状态。它还会给已发现的原生 Animator 加一个只读 `onAnimationEnd/onAnimationCancel` 观察 listener;这是临时诊断包,不应当作为长期发布包使用,拿到设备日志后应移除探针。 +当前诊断提交会在 `AppleMusicCjkKaraokeAnimationTarget` 输出带 `[DEBUG-cjk-r1]` 前缀的日志,记录 `e.o/e.p`、Animator listener、词 View 的 alpha/translation/scale,以及动画结束或取消时的同一组状态。它还会给已发现的原生 Animator 加一个只读 `onAnimationEnd/onAnimationCancel` 观察 listener;这是临时诊断包,不应当作为长期发布包使用,拿到设备日志后应移除探针。 ## 关键代码位置 From 4155bc1b6976f1189c82e58aac0c86b74b711d9e Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:27:56 +0800 Subject: [PATCH 18/33] Revert "debug: add CJK native animation lifecycle probe" This reverts commit 114ed093b78af761ae42d0eb6569063cddf311a6. --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 377 ++---------------- docs/cjk-karaoke-animation-handoff.md | 6 +- 2 files changed, 41 insertions(+), 342 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 8c43e14..91412ed 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -1,11 +1,8 @@ package dev.amenhancer.module.hook -import android.animation.Animator -import android.animation.AnimatorListenerAdapter import java.util.concurrent.atomic.AtomicInteger import java.lang.reflect.Field import java.lang.reflect.Modifier -import java.lang.reflect.Array as ReflectArray import java.util.Collections import java.util.Map as JavaMap import java.util.WeakHashMap @@ -25,10 +22,6 @@ internal class AppleMusicCjkKaraokeAnimationTarget( WeakHashMap, kotlin.collections.Map>(), ) private val rewriteLogCount = AtomicInteger() - private val traceEventCount = AtomicInteger() - private val observedAnimators = Collections.synchronizedMap( - WeakHashMap(), - ) override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) @@ -54,11 +47,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // The host callback runs even when z.a0 throws; always // release this thread's scope so a later g0 call cannot // inherit a stale override. - try { - traceA0Boundary("after", param, isSingleWordScope()) - } finally { - leaveA0Scope() - } + leaveA0Scope() } }).also { installed -> if (!installed) failures += "z.a0 hook was rejected" @@ -107,30 +96,23 @@ internal class AppleMusicCjkKaraokeAnimationTarget( if (!containsCjkKaraokeScript(text)) return val languageSet = param.args.getOrNull(1) as? Set<*> ?: return - val originalResult = param.result - val setName = when { - isK0Set(languageSet) -> "k0" - isJ0Set(languageSet) -> "j0" - else -> null - } when { - setName == "k0" && param.result == true -> { + isK0Set(languageSet) && param.result == true -> { // CJK is normally classified into k0, which blocks the // rush branch. Make that one result look like a default // script only while a0 is running. param.result = false logRewrite(text, "k0 true -> false") } - setName == "j0" && containsHangul(text) && param.result != true -> { + isJ0Set(languageSet) && containsHangul(text) && param.result != true -> { // i0 receives the j0 hit as its split/rush eligibility bit. // Hangul belongs to k0 but not j0, so opt it into the same // Apple animation only inside a0; g0 remains untouched. param.result = true logRewrite(text, "j0 false -> true") } - else -> Unit + else -> return } - traceHelperResult(text, setName, originalResult, param.result) } private fun logRewrite(text: CharSequence, change: String) { @@ -164,9 +146,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun enterA0Scope(param: ModernMethodHook.MethodHookParam) { runCatching { a0Depth.set((a0Depth.get() ?: 0) + 1) - val gate = readSingleWordGate(param) - a0Stack().add(gate) - traceA0Boundary("before", param, gate) + a0Stack().add(readSingleWordGate(param)) } .onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth enter failed open", error) } } @@ -199,15 +179,43 @@ internal class AppleMusicCjkKaraokeAnimationTarget( /** Reads only the host's grouping metadata; Apple retains all trigger gates. */ private fun readSingleWordGate(param: ModernMethodHook.MethodHookParam): Boolean = runCatching { - val metadata = readWordMetadata(param) ?: return@runCatching false + val holder = param.args.getOrNull(0) ?: return@runCatching false + val wordId = (param.args.getOrNull(2) as? Number)?.toInt() + ?: return@runCatching false + val nativeDuration = (param.args.getOrNull(3) as? Number)?.toInt() + ?: return@runCatching false + val background = param.args.getOrNull(4) as? Boolean + ?: return@runCatching false + val mapName = if (background) "H" else "G" + val map = cachedFields(holder.javaClass)[mapName] + ?.let { field -> readField(field, holder) as? JavaMap<*, *> } + ?: return@runCatching false + val entry = map.get(Integer.valueOf(wordId)) ?: map.get(wordId) + ?: return@runCatching false + val text = cachedFields(entry.javaClass)["c"] + ?.let { field -> readField(field, entry) as? CharSequence } + ?: return@runCatching false + val cumulativeDuration = cachedFields(entry.javaClass)["f"] + ?.let { field -> (readField(field, entry) as? Number)?.toInt() } + ?: return@runCatching false + val cumulativeLength = cachedFields(entry.javaClass)["g"] + ?.let { field -> (readField(field, entry) as? Number)?.toInt() } + ?: return@runCatching false + val splitValue = cachedFields(entry.javaClass)["k"] + ?.let { field -> readField(field, entry) } + val splitCount = when (splitValue) { + null -> 0 + is Collection<*> -> splitValue.size + else -> -1 + } isSingleUnmergedCjkWord( CjkKaraokeWordTiming( - text = metadata.text, - nativeDurationMs = metadata.nativeDurationMs, - cumulativeDurationMs = metadata.cumulativeDurationMs, - cumulativeTextLength = metadata.cumulativeTextLength, - splitBindingCount = metadata.splitBindingCount, - isBackground = metadata.background, + text = text, + nativeDurationMs = nativeDuration, + cumulativeDurationMs = cumulativeDuration, + cumulativeTextLength = cumulativeLength, + splitBindingCount = splitCount, + isBackground = background, ), ) }.getOrElse { error -> @@ -215,306 +223,6 @@ internal class AppleMusicCjkKaraokeAnimationTarget( false } - private fun readWordMetadata(param: ModernMethodHook.MethodHookParam): WordMetadata? { - val holder = param.args.getOrNull(0) ?: return null - val wordId = (param.args.getOrNull(2) as? Number)?.toInt() ?: return null - val nativeDuration = (param.args.getOrNull(3) as? Number)?.toInt() ?: return null - val background = param.args.getOrNull(4) as? Boolean ?: return null - val mapName = if (background) "H" else "G" - val map = cachedFields(holder.javaClass)[mapName] - ?.let { field -> readField(field, holder) as? JavaMap<*, *> } - ?: return null - val entry = map.get(Integer.valueOf(wordId)) ?: map.get(wordId) ?: return null - val fields = cachedFields(entry.javaClass) - val text = fields["c"]?.let { field -> readField(field, entry) as? CharSequence } - ?: return null - val cumulativeDuration = fields["f"] - ?.let { field -> (readField(field, entry) as? Number)?.toInt() } - ?: return null - val cumulativeLength = fields["g"] - ?.let { field -> (readField(field, entry) as? Number)?.toInt() } - ?: return null - val splitValue = fields["k"]?.let { field -> readField(field, entry) } - val splitCount = when (splitValue) { - null -> 0 - is Collection<*> -> splitValue.size - else -> -1 - } - return WordMetadata( - wordId = wordId, - nativeDurationMs = nativeDuration, - background = background, - entry = entry, - text = text, - cumulativeDurationMs = cumulativeDuration, - cumulativeTextLength = cumulativeLength, - splitBindingCount = splitCount, - nativeAnimator = fields["o"]?.let { field -> readField(field, entry) }, - animatorList = fields["p"]?.let { field -> readField(field, entry) }, - foregroundBinding = fields["i"]?.let { field -> readField(field, entry) }, - backgroundBinding = fields["j"]?.let { field -> readField(field, entry) }, - splitBindings = splitValue, - ) - } - - private fun traceA0Boundary( - phase: String, - param: ModernMethodHook.MethodHookParam, - gate: Boolean, - ) { - runCatching { - val metadata = readWordMetadata(param) - if (metadata == null) { - val wordId = (param.args.getOrNull(2) as? Number)?.toInt() - if (wordId != null) { - trace( - "a0-$phase metadata-unavailable word=$wordId " + - "holder=${param.args.getOrNull(0)?.javaClass?.simpleName}", - ) - } - return@runCatching - } - val isCjk = containsCjkKaraokeScript(metadata.text) - val isNativeCandidate = !metadata.background && - metadata.cumulativeDurationMs >= NATIVE_CANDIDATE_DURATION_MS && - metadata.cumulativeTextLength <= NATIVE_CANDIDATE_LENGTH_MAX - if (!isCjk && !isNativeCandidate) return@runCatching - trace( - "a0-$phase word=${metadata.wordId} bg=${metadata.background} " + - "gate=$gate text=${quoteTraceText(metadata.text)} " + - "native=${metadata.nativeDurationMs} f=${metadata.cumulativeDurationMs} " + - "g=${metadata.cumulativeTextLength} k=${metadata.splitBindingCount} " + - "entry=${metadata.entry.javaClass.simpleName} " + - "o=${describeAnimator(metadata.nativeAnimator)} " + - "p=${describeAnimatorContainer(metadata.animatorList)} " + - "bindings=${describeBindings(metadata)}", - ) - if (phase == "after") observeAnimatorLifecycle(metadata) - }.onFailure { error -> - trace("a0-$phase trace failed: ${error.cjkShortMessage()}") - } - } - - private fun traceHelperResult( - text: CharSequence, - setName: String?, - originalResult: Any?, - finalResult: Any?, - ) { - if (!isSingleWordScope() || setName == null) return - trace( - "helper set=$setName text=${quoteTraceText(text)} " + - "result=$originalResult->$finalResult depth=${a0Depth.get() ?: 0} " + - "gate=${a0Stack().lastOrNull() == true}", - ) - } - - private fun trace(message: String) { - if (traceEventCount.getAndIncrement() < MAX_TRACE_EVENTS) { - ModernXposedRuntime.log("[DEBUG-cjk-r1] $message") - } - } - - private fun describeAnimator(value: Any?): String { - if (value == null) return "null" - val type = value.javaClass.simpleName.ifBlank { value.javaClass.name } - val started = invokeNoArg(value, "isStarted") - val running = invokeNoArg(value, "isRunning") - val duration = invokeNoArg(value, "getDuration") - val playTime = invokeNoArg(value, "getCurrentPlayTime") - val tag = readAnimatorTag(value) - val listeners = describeAnimatorListeners(value) - return "$type{started=$started,running=$running,duration=$duration,play=$playTime," + - "tag=$tag,listeners=$listeners}" - } - - private fun describeAnimatorListeners(value: Any): String = runCatching { - val listeners = invokeNoArg(value, "getListeners") as? Collection<*> ?: return@runCatching "?" - listeners.take(MAX_TRACE_ITEMS).joinToString( - prefix = "[", - postfix = "]", - ) { listener -> listener?.javaClass?.simpleName ?: "null" } - }.getOrDefault("?") - - private fun describeAnimatorContainer(value: Any?): String { - if (value == null) return "null" - return runCatching { - val items: List = when (value) { - is Collection<*> -> value.take(MAX_TRACE_ITEMS) - else -> { - val backing = readNamedField(value, "b") - when { - backing is Collection<*> -> backing.take(MAX_TRACE_ITEMS) - backing?.javaClass?.isArray == true -> (0 until minOf( - ReflectArray.getLength(backing), - MAX_TRACE_ITEMS, - )).map { index -> ReflectArray.get(backing, index) } - else -> emptyList() - } - } - } - val count = readNamedField(value, "c") ?: items.size - val details = items.joinToString(",") { item -> describeAnimator(item) } - "${value.javaClass.simpleName}{count=$count,items=[$details]}" - }.getOrElse { error -> "${value.javaClass.simpleName}{error=${error.cjkShortMessage()}}" } - } - - private fun observeAnimatorLifecycle(metadata: WordMetadata) { - val candidates = buildList { - (metadata.nativeAnimator as? Animator)?.let(::add) - animatorItems(metadata.animatorList).forEach { item -> - (item as? Animator)?.let(::add) - } - } - candidates.forEach { animator -> - val shouldObserve = synchronized(observedAnimators) { - observedAnimators.put(animator, true) == null - } - if (!shouldObserve) return@forEach - runCatching { - animator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - traceAnimatorEnd(metadata, animation, "end") - } - - override fun onAnimationCancel(animation: Animator) { - traceAnimatorEnd(metadata, animation, "cancel") - } - }) - }.onFailure { error -> - trace( - "anim-observe-failed word=${metadata.wordId} " + - "anim=${animationType(animator)} error=${error.cjkShortMessage()}", - ) - } - } - } - - private fun animatorItems(value: Any?): List = runCatching { - when (value) { - is Collection<*> -> value.take(MAX_TRACE_ITEMS) - null -> emptyList() - else -> { - val backing = readNamedField(value, "b") - when { - backing is Collection<*> -> backing.take(MAX_TRACE_ITEMS) - backing?.javaClass?.isArray == true -> (0 until minOf( - ReflectArray.getLength(backing), - MAX_TRACE_ITEMS, - )).map { index -> ReflectArray.get(backing, index) } - else -> emptyList() - } - } - } - }.getOrDefault(emptyList()) - - private fun traceAnimatorEnd(metadata: WordMetadata, animation: Animator, phase: String) { - runCatching { - val live = readLiveMetadata(metadata) - trace( - "anim-$phase word=${metadata.wordId} text=${quoteTraceText(live.text)} " + - "anim=${describeAnimator(animation)} " + - "o=${describeAnimator(live.nativeAnimator)} " + - "p=${describeAnimatorContainer(live.animatorList)} " + - "bindings=${describeBindings(live)}", - ) - }.onFailure { error -> - trace( - "anim-$phase word=${metadata.wordId} " + - "anim=${animationType(animation)} live-state-failed=${error.cjkShortMessage()}", - ) - } - } - - private fun readLiveMetadata(original: WordMetadata): WordMetadata { - val fields = cachedFields(original.entry.javaClass) - return original.copy( - text = fields["c"]?.let { field -> readField(field, original.entry) as? CharSequence } - ?: original.text, - cumulativeDurationMs = fields["f"] - ?.let { field -> (readField(field, original.entry) as? Number)?.toInt() } - ?: original.cumulativeDurationMs, - cumulativeTextLength = fields["g"] - ?.let { field -> (readField(field, original.entry) as? Number)?.toInt() } - ?: original.cumulativeTextLength, - splitBindingCount = fields["k"]?.let { field -> splitCount(readField(field, original.entry)) } - ?: original.splitBindingCount, - nativeAnimator = fields["o"]?.let { field -> readField(field, original.entry) }, - animatorList = fields["p"]?.let { field -> readField(field, original.entry) }, - foregroundBinding = fields["i"]?.let { field -> readField(field, original.entry) }, - backgroundBinding = fields["j"]?.let { field -> readField(field, original.entry) }, - splitBindings = fields["k"]?.let { field -> readField(field, original.entry) }, - ) - } - - private fun splitCount(value: Any?): Int = when (value) { - null -> 0 - is Collection<*> -> value.size - else -> -1 - } - - private fun animationType(value: Any): String = - value.javaClass.simpleName.ifBlank { value.javaClass.name } - - private fun describeBindings(metadata: WordMetadata): String { - val bindings = mutableListOf().apply { - metadata.foregroundBinding?.let(::add) - metadata.backgroundBinding?.let(::add) - when (val split = metadata.splitBindings) { - is Collection<*> -> split.take(MAX_TRACE_ITEMS).forEach { add(it) } - else -> Unit - } - } - if (bindings.isEmpty()) return "[]" - return bindings.joinToString(prefix = "[", postfix = "]") { binding -> - val view = binding?.let { readNamedField(it, "U") } - "${binding?.javaClass?.simpleName ?: "null"}/${describeView(view)}" - } - } - - private fun describeView(value: Any?): String { - if (value == null) return "view=null" - val type = value.javaClass.simpleName.ifBlank { value.javaClass.name } - return "$type{alpha=${invokeNoArg(value, "getAlpha")},ty=${invokeNoArg(value, "getTranslationY")}," + - "sx=${invokeNoArg(value, "getScaleX")},sy=${invokeNoArg(value, "getScaleY")}}" - } - - private fun readAnimatorTag(value: Any?): Any? = value?.let { invokeNoArg(it, "getTag") } - - private fun invokeNoArg(receiver: Any, methodName: String): Any? = runCatching { - receiver.javaClass.methods - .firstOrNull { it.name == methodName && it.parameterTypes.isEmpty() } - ?.invoke(receiver) - }.getOrNull() - - private fun readNamedField(receiver: Any, name: String): Any? = - cachedFields(receiver.javaClass)[name]?.let { field -> readField(field, receiver) } - - private fun quoteTraceText(text: CharSequence): String = - "\"${text.toString() - .replace("\\", "\\\\") - .replace("\r", "\\r") - .replace("\n", "\\n") - .replace("\t", "\\t") - .replace("\"", "\\\"") - .take(MAX_TRACE_TEXT)}\"" - - private data class WordMetadata( - val wordId: Int, - val nativeDurationMs: Int, - val background: Boolean, - val entry: Any, - val text: CharSequence, - val cumulativeDurationMs: Int, - val cumulativeTextLength: Int, - val splitBindingCount: Int, - val nativeAnimator: Any?, - val animatorList: Any?, - val foregroundBinding: Any?, - val backgroundBinding: Any?, - val splitBindings: Any?, - ) - private fun cachedFields(type: Class<*>): kotlin.collections.Map = synchronized(fieldCache) { fieldCache.get(type) ?: HashMap().also { fields -> @@ -537,11 +245,6 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private companion object { const val MAX_REWRITE_LOGS = 3 - const val MAX_TRACE_EVENTS = 180 - const val MAX_TRACE_ITEMS = 6 - const val MAX_TRACE_TEXT = 32 - const val NATIVE_CANDIDATE_DURATION_MS = 1_000 - const val NATIVE_CANDIDATE_LENGTH_MAX = 7 } } diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index cca35fc..f72e0dd 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -7,7 +7,7 @@ ## 当前分支与远端 - 分支:`codex/cjk-karaoke-animation` -- 功能基线:`47de18a fix: block merged CJK chunks from native glow` +- 功能基线:`39e9b08 feat: isolate CJK karaoke animation feature` - 远端:[Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation](https://github.com/Zennmn/AM-plus-plus/tree/codex/cjk-karaoke-animation) - 主分支基线:`main`(当前不包含本实验功能) - 当前分支已推送到 `origin`;不要把本分支代码直接合并到 `main`,除非完成新的设备验收。 @@ -25,10 +25,6 @@ - 默认值:`true` - 关闭后需要重启 Apple Music,feature 才不会注册 Hook。 -## 临时生命周期探针 - -当前诊断提交会在 `AppleMusicCjkKaraokeAnimationTarget` 输出带 `[DEBUG-cjk-r1]` 前缀的日志,记录 `e.o/e.p`、Animator listener、词 View 的 alpha/translation/scale,以及动画结束或取消时的同一组状态。它还会给已发现的原生 Animator 加一个只读 `onAnimationEnd/onAnimationCancel` 观察 listener;这是临时诊断包,不应当作为长期发布包使用,拿到设备日志后应移除探针。 - ## 关键代码位置 - Hook 实现:`app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt` From c346d723373922cdf89e93c283fd72e9af225573 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:36:12 +0800 Subject: [PATCH 19/33] fix: narrow CJK cleanup to host special end --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 113 +++++++++++++++++- 1 file changed, 112 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 91412ed..4990e9d 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -1,5 +1,7 @@ package dev.amenhancer.module.hook +import android.animation.Animator +import java.lang.reflect.Executable import java.util.concurrent.atomic.AtomicInteger import java.lang.reflect.Field import java.lang.reflect.Modifier @@ -22,6 +24,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( WeakHashMap, kotlin.collections.Map>(), ) private val rewriteLogCount = AtomicInteger() + private var specialEndHookInstalled = false override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) @@ -78,6 +81,12 @@ internal class AppleMusicCjkKaraokeAnimationTarget( false } + // The special path owns its own z$c end callback. Observe that + // callback instead of adding listeners to every Animator in e.p; the + // latter changes Apple's ordering and was shown to disturb all lyric + // animations on device. + specialEndHookInstalled = installSpecialEndHook(a0) + if (!a0Installed || !helperInstalled) { return TargetCapabilityInstall.Degraded( failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, @@ -85,10 +94,98 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } return TargetCapabilityInstall.Active( - "Installed exact 6.5.2/1586 single-unmerged-CJK guard around z.a0 + I0\$a.a", + "Installed exact 6.5.2/1586 single-unmerged-CJK guard with z\$c end cleanup", ) } + private fun installSpecialEndHook(a0: Executable): Boolean = runCatching { + val owner = a0.declaringClass + val cleanupType = Class.forName( + "${owner.name}\$c", + false, + owner.classLoader, + ) + val method = cleanupType.declaredMethods + .filter { candidate -> + candidate.name == "onAnimationEnd" && + candidate.parameterTypes.size == 1 && + Animator::class.java.isAssignableFrom(candidate.parameterTypes[0]) + } + .singleOrNull() + ?: return@runCatching false + specialEndHookInstalled = ModernXposedRuntime.hookMethod( + method, + object : ModernMethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + cleanupSpecialCjkEnd(param.thisObject) + } + }, + ) + specialEndHookInstalled + }.onFailure { error -> + ModernXposedRuntime.log("CJK karaoke z\$c end hook unavailable", error) + }.getOrDefault(false) + + private fun cleanupSpecialCjkEnd(listener: Any?) { + if (!specialEndHookInstalled || listener == null) return + runCatching { + val entry = readNamedField(listener, "a") ?: return@runCatching + val text = readNamedField(entry, "c") as? CharSequence ?: return@runCatching + val background = readNamedField(entry, "h") as? Boolean ?: return@runCatching + val nativeDuration = (readNamedField(entry, "e") as? Number)?.toInt() + ?: return@runCatching + val timing = CjkKaraokeWordTiming( + text = text, + nativeDurationMs = nativeDuration, + cumulativeDurationMs = (readNamedField(entry, "f") as? Number)?.toInt() + ?: return@runCatching, + cumulativeTextLength = (readNamedField(entry, "g") as? Number)?.toInt() + ?: return@runCatching, + splitBindingCount = splitCount(readNamedField(entry, "k")), + isBackground = background, + ) + if (!isSingleUnmergedCjkWord(timing)) return@runCatching + + val expectedText = text.toString().trim() + foregroundEntryViews(entry).forEach { view -> + val currentText = invokeNoArg(view, "getText") as? CharSequence + if (currentText == null || currentText.toString().trim() != expectedText) { + return@forEach + } + // z$c has already removed its outer AnimatorSet from e.p; + // only clear the properties written by z$f/z$g. + invokeMethod(view, "setScaleX", 1f) + invokeMethod(view, "setScaleY", 1f) + invokeNoArg(view, "resetPivot") + invokeMethod(view, "setShadowLayer", 0f, 0f, 0f, 0) + invokeNoArg(view, "invalidate") + } + }.onFailure { error -> + ModernXposedRuntime.log("CJK karaoke z\$c end cleanup failed", error) + } + } + + private fun foregroundEntryViews(entry: Any): List { + val bindings = mutableListOf() + readNamedField(entry, "i")?.let(bindings::add) + when (val split = readNamedField(entry, "k")) { + is Collection<*> -> split.forEach(bindings::add) + else -> Unit + } + val views = mutableListOf() + bindings.forEach { binding -> + val view = binding?.let { readNamedField(it, "U") } ?: return@forEach + if (views.none { it === view }) views += view + } + return views + } + + private fun splitCount(value: Any?): Int = when (value) { + null -> 0 + is Collection<*> -> value.size + else -> -1 + } + private fun rewriteCjkK0Result(param: ModernMethodHook.MethodHookParam) { if (!isSingleWordScope()) return @@ -243,6 +340,20 @@ internal class AppleMusicCjkKaraokeAnimationTarget( field.get(receiver) }.getOrNull() + private fun readNamedField(receiver: Any, name: String): Any? = + cachedFields(receiver.javaClass)[name]?.let { field -> readField(field, receiver) } + + private fun invokeNoArg(receiver: Any, methodName: String): Any? = + invokeMethod(receiver, methodName) + + private fun invokeMethod(receiver: Any, methodName: String, vararg args: Any?): Any? = runCatching { + receiver.javaClass.methods + .firstOrNull { method -> + method.name == methodName && method.parameterTypes.size == args.size + } + ?.invoke(receiver, *args) + }.getOrNull() + private companion object { const val MAX_REWRITE_LOGS = 3 } From c22e670e7bd0b80c72018ed8afef5a14193d5265 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:41:44 +0800 Subject: [PATCH 20/33] fix: bind CJK end cleanup to current lyric entry --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 76 +++++++++++++------ docs/cjk-karaoke-animation-handoff.md | 1 + 2 files changed, 55 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 4990e9d..23d01e2 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -23,6 +23,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private val fieldCache = Collections.synchronizedMap( WeakHashMap, kotlin.collections.Map>(), ) + private val cjkEntryStates = Collections.synchronizedMap( + WeakHashMap(), + ) private val rewriteLogCount = AtomicInteger() private var specialEndHookInstalled = false @@ -130,6 +133,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( if (!specialEndHookInstalled || listener == null) return runCatching { val entry = readNamedField(listener, "a") ?: return@runCatching + val state = synchronized(cjkEntryStates) { cjkEntryStates.remove(entry) } + ?: return@runCatching + if (!isCurrentCjkEntry(state)) return@runCatching val text = readNamedField(entry, "c") as? CharSequence ?: return@runCatching val background = readNamedField(entry, "h") as? Boolean ?: return@runCatching val nativeDuration = (readNamedField(entry, "e") as? Number)?.toInt() @@ -147,7 +153,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( if (!isSingleUnmergedCjkWord(timing)) return@runCatching val expectedText = text.toString().trim() - foregroundEntryViews(entry).forEach { view -> + foregroundEntryViews(entry) + .filter { view -> state.views.any { recorded -> recorded === view } } + .forEach { view -> val currentText = invokeNoArg(view, "getText") as? CharSequence if (currentText == null || currentText.toString().trim() != expectedText) { return@forEach @@ -165,6 +173,14 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } } + private fun isCurrentCjkEntry(state: CjkEntryState): Boolean = runCatching { + val map = cachedFields(state.holder.javaClass)["G"] + ?.let { field -> readField(field, state.holder) as? JavaMap<*, *> } + ?: return@runCatching false + val current = map.get(Integer.valueOf(state.wordId)) ?: map.get(state.wordId) + current === state.entry + }.getOrDefault(false) + private fun foregroundEntryViews(entry: Any): List { val bindings = mutableListOf() readNamedField(entry, "i")?.let(bindings::add) @@ -243,7 +259,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun enterA0Scope(param: ModernMethodHook.MethodHookParam) { runCatching { a0Depth.set((a0Depth.get() ?: 0) + 1) - a0Stack().add(readSingleWordGate(param)) + val candidate = readSingleWordGateEntry(param) + a0Stack().add(candidate != null) + candidate?.let { state -> + synchronized(cjkEntryStates) { cjkEntryStates[state.entry] = state } + } } .onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth enter failed open", error) } } @@ -274,30 +294,30 @@ internal class AppleMusicCjkKaraokeAnimationTarget( a0SingleWordStack.get() ?: mutableListOf().also(a0SingleWordStack::set) /** Reads only the host's grouping metadata; Apple retains all trigger gates. */ - private fun readSingleWordGate(param: ModernMethodHook.MethodHookParam): Boolean = + private fun readSingleWordGateEntry(param: ModernMethodHook.MethodHookParam): CjkEntryState? = runCatching { - val holder = param.args.getOrNull(0) ?: return@runCatching false + val holder = param.args.getOrNull(0) ?: return@runCatching null val wordId = (param.args.getOrNull(2) as? Number)?.toInt() - ?: return@runCatching false + ?: return@runCatching null val nativeDuration = (param.args.getOrNull(3) as? Number)?.toInt() - ?: return@runCatching false + ?: return@runCatching null val background = param.args.getOrNull(4) as? Boolean - ?: return@runCatching false + ?: return@runCatching null val mapName = if (background) "H" else "G" val map = cachedFields(holder.javaClass)[mapName] ?.let { field -> readField(field, holder) as? JavaMap<*, *> } - ?: return@runCatching false + ?: return@runCatching null val entry = map.get(Integer.valueOf(wordId)) ?: map.get(wordId) - ?: return@runCatching false + ?: return@runCatching null val text = cachedFields(entry.javaClass)["c"] ?.let { field -> readField(field, entry) as? CharSequence } - ?: return@runCatching false + ?: return@runCatching null val cumulativeDuration = cachedFields(entry.javaClass)["f"] ?.let { field -> (readField(field, entry) as? Number)?.toInt() } - ?: return@runCatching false + ?: return@runCatching null val cumulativeLength = cachedFields(entry.javaClass)["g"] ?.let { field -> (readField(field, entry) as? Number)?.toInt() } - ?: return@runCatching false + ?: return@runCatching null val splitValue = cachedFields(entry.javaClass)["k"] ?.let { field -> readField(field, entry) } val splitCount = when (splitValue) { @@ -305,19 +325,24 @@ internal class AppleMusicCjkKaraokeAnimationTarget( is Collection<*> -> splitValue.size else -> -1 } - isSingleUnmergedCjkWord( - CjkKaraokeWordTiming( - text = text, - nativeDurationMs = nativeDuration, - cumulativeDurationMs = cumulativeDuration, - cumulativeTextLength = cumulativeLength, - splitBindingCount = splitCount, - isBackground = background, - ), + val timing = CjkKaraokeWordTiming( + text = text, + nativeDurationMs = nativeDuration, + cumulativeDurationMs = cumulativeDuration, + cumulativeTextLength = cumulativeLength, + splitBindingCount = splitCount, + isBackground = background, + ) + if (!isSingleUnmergedCjkWord(timing)) return@runCatching null + CjkEntryState( + holder = holder, + wordId = wordId, + entry = entry, + views = foregroundEntryViews(entry), ) }.getOrElse { error -> ModernXposedRuntime.log("CJK single-word gate failed closed: ${error.cjkShortMessage()}") - false + null } private fun cachedFields(type: Class<*>): kotlin.collections.Map = @@ -354,6 +379,13 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ?.invoke(receiver, *args) }.getOrNull() + private data class CjkEntryState( + val holder: Any, + val wordId: Int, + val entry: Any, + val views: List, + ) + private companion object { const val MAX_REWRITE_LOGS = 3 } diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index f72e0dd..e098c9a 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -24,6 +24,7 @@ - 字段:`ModuleSettings.cjkKaraokeAnimationEnabled` - 默认值:`true` - 关闭后需要重启 Apple Music,feature 才不会注册 Hook。 +5. CJK 特殊路径的清理只挂在宿主自己的 `z$c.onAnimationEnd` 上;它要求回调中的 `e` 仍是 `G[wordId]` 当前 entry,并且 binding `U` 仍是 `a0` 时记录的同一 View,才清除该词的 scale/pivot/shadow。不会遍历或删除 `e.p`,也不会恢复 alpha、translation 或英语动画状态。 ## 关键代码位置 From 1ddce4ab52eb884a0286506391890a40278af64f Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:29:30 +0800 Subject: [PATCH 21/33] experiment: build CJK words with ordinary karaoke layout --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 116 ++++++++++++++---- .../amenhancer/module/hook/TargetSymbols.kt | 30 +++++ .../hook/CjkKaraokeAnimationTargetTest.kt | 12 ++ docs/cjk-karaoke-animation-handoff.md | 5 +- 4 files changed, 140 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 23d01e2..777db74 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -10,14 +10,16 @@ import java.util.Map as JavaMap import java.util.WeakHashMap /** - * Narrow Apple Music 6.5.2/1586 adapter for the native karaoke rush-gradient - * path. The host owns all duration/length trigger conditions; AM++ only - * allows its CJK classifier override for one unmerged native CJK word. + * Narrow Apple Music 6.5.2/1586 adapter for the native karaoke path. The host + * owns all duration/length trigger conditions; this experiment only makes a + * single unmerged CJK word use the ordinary (Latin-shaped) binding inputs + * before Apple constructs its own animator. */ internal class AppleMusicCjkKaraokeAnimationTarget( private val symbols: TargetSymbolResolver, ) : CjkKaraokeAnimationTarget { private val a0Depth: ThreadLocal = ThreadLocal.withInitial { 0 } + private val g0Depth: ThreadLocal = ThreadLocal.withInitial { 0 } private val a0SingleWordStack: ThreadLocal> = ThreadLocal.withInitial { mutableListOf() } private val fieldCache = Collections.synchronizedMap( @@ -31,18 +33,39 @@ internal class AppleMusicCjkKaraokeAnimationTarget( override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) + val layoutResolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeLayoutMethod) val helperResolution = symbols.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod) val a0 = a0Resolution.valueOrNull() + val layout = layoutResolution.valueOrNull() val helper = helperResolution.valueOrNull() - if (a0 == null || helper == null) { + if (a0 == null || layout == null || helper == null) { return TargetCapabilityInstall.Degraded( - listOf(a0Resolution, helperResolution) + listOf(a0Resolution, layoutResolution, helperResolution) .filterNot { it is TargetResolution.Found<*> } .joinToString { it.summary }, ) } val failures = mutableListOf() + val layoutInstalled = try { + ModernXposedRuntime.hookMethod(layout, object : ModernMethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + enterG0Scope() + } + + override fun afterHookedMethod(param: MethodHookParam) { + // g0 may throw while inflating a line; never leak the + // classifier override into a later line or a0 callback. + leaveG0Scope() + } + }).also { installed -> + if (!installed) failures += "z.g0 hook was rejected" + } + } catch (error: Throwable) { + failures += "z.g0 hook failed: ${error.cjkShortMessage()}" + ModernXposedRuntime.log("CJK karaoke z.g0 hook failed", error) + false + } val a0Installed = try { ModernXposedRuntime.hookMethod(a0, object : ModernMethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { @@ -68,7 +91,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ModernXposedRuntime.hookMethod(helper, object : ModernMethodHook() { override fun afterHookedMethod(param: MethodHookParam) { try { - rewriteCjkK0Result(param) + rewriteCjkClassifierResult(param) } catch (error: Throwable) { // A malformed host call must never break its original // helper result. This is deliberately fail-open. @@ -90,14 +113,14 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // animations on device. specialEndHookInstalled = installSpecialEndHook(a0) - if (!a0Installed || !helperInstalled) { + if (!layoutInstalled || !a0Installed || !helperInstalled || !specialEndHookInstalled) { return TargetCapabilityInstall.Degraded( failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, ) } return TargetCapabilityInstall.Active( - "Installed exact 6.5.2/1586 single-unmerged-CJK guard with z\$c end cleanup", + "Installed exact 6.5.2/1586 single-unmerged-CJK ordinary-layout experiment", ) } @@ -202,27 +225,52 @@ internal class AppleMusicCjkKaraokeAnimationTarget( else -> -1 } - private fun rewriteCjkK0Result(param: ModernMethodHook.MethodHookParam) { - if (!isSingleWordScope()) return - + private fun rewriteCjkClassifierResult(param: ModernMethodHook.MethodHookParam) { val text = param.args.getOrNull(0) as? CharSequence ?: return - if (!containsCjkKaraokeScript(text)) return - val languageSet = param.args.getOrNull(1) as? Set<*> ?: return + if (isG0Scope()) { + rewriteOrdinaryLayoutResult(param, text, languageSet) + } else if (isSingleWordScope()) { + rewriteCjkAnimationResult(param, text, languageSet) + } + } + + /** + * g0 has already grouped/merged multi-character CJK chunks by the time + * a0 runs. Only a single trimmed Unicode code point is eligible for this + * experiment; merged text stays on Apple's original layout path. + */ + private fun rewriteOrdinaryLayoutResult( + param: ModernMethodHook.MethodHookParam, + text: CharSequence, + languageSet: Set<*>, + ) { + if (!isSingleCjkCodePoint(text)) return + if (!isK0Set(languageSet) && !isJ0Set(languageSet)) return + if (param.result == true) { + param.result = false + logRewrite(text, "g0 CJK classifier true -> false") + } + } + + private fun rewriteCjkAnimationResult( + param: ModernMethodHook.MethodHookParam, + text: CharSequence, + languageSet: Set<*>, + ) { + if (!containsCjkKaraokeScript(text)) return when { isK0Set(languageSet) && param.result == true -> { - // CJK is normally classified into k0, which blocks the - // rush branch. Make that one result look like a default - // script only while a0 is running. + // Keep Apple's duration/length gates, but opt this one CJK + // word into the same outer branch used by Latin candidates. param.result = false - logRewrite(text, "k0 true -> false") + logRewrite(text, "a0 k0 true -> false") } isJ0Set(languageSet) && containsHangul(text) && param.result != true -> { - // i0 receives the j0 hit as its split/rush eligibility bit. // Hangul belongs to k0 but not j0, so opt it into the same - // Apple animation only inside a0; g0 remains untouched. + // Apple animation only inside a0. param.result = true - logRewrite(text, "j0 false -> true") + logRewrite(text, "a0 j0 false -> true") } else -> return } @@ -231,7 +279,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun logRewrite(text: CharSequence, change: String) { if (rewriteLogCount.getAndIncrement() < MAX_REWRITE_LOGS) { ModernXposedRuntime.log( - "CJK karaoke a0 scope: I0\$a.a(${text.length} chars, $change); g0 unchanged", + "CJK karaoke classifier: I0\$a.a(${text.length} chars, $change)", ) } } @@ -256,6 +304,32 @@ internal class AppleMusicCjkKaraokeAnimationTarget( false } + private fun isSingleCjkCodePoint(text: CharSequence): Boolean { + val normalized = text.toString().trim() + if (normalized.isEmpty() || normalized.codePointCount(0, normalized.length) != 1) { + return false + } + return containsCjkKaraokeScript(normalized) + } + + private fun enterG0Scope() { + runCatching { g0Depth.set((g0Depth.get() ?: 0) + 1) } + .onFailure { error -> ModernXposedRuntime.log("CJK karaoke g0 depth enter failed open", error) } + } + + private fun leaveG0Scope() { + runCatching { + val depth = g0Depth.get() ?: 0 + if (depth <= 1) g0Depth.remove() else g0Depth.set(depth - 1) + }.onFailure { error -> ModernXposedRuntime.log("CJK karaoke g0 depth cleanup failed open", error) } + } + + private fun isG0Scope(): Boolean = runCatching { (g0Depth.get() ?: 0) > 0 } + .getOrElse { error -> + ModernXposedRuntime.log("CJK karaoke g0 scope read failed open", error) + false + } + private fun enterA0Scope(param: ModernMethodHook.MethodHookParam) { runCatching { a0Depth.set((a0Depth.get() ?: 0) + 1) diff --git a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt index 63e0a8c..057e13e 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt @@ -263,6 +263,7 @@ internal enum class TargetSymbolId { STORE_FRONT_LANGUAGE_ARRAY_METHOD, CJK_KARAOKE_ANIMATION_OWNER, CJK_KARAOKE_ANIMATION_METHOD, + CJK_KARAOKE_LAYOUT_METHOD, CJK_UNICODE_BLOCK_HELPER_OWNER, CJK_UNICODE_BLOCK_HELPER_METHOD, } @@ -391,6 +392,7 @@ private object AppleMusicProfiles { TargetSymbolId.LYRICS_ITEM_UPDATE_METHOD to "o2", TargetSymbolId.STORE_FRONT_LANGUAGE_ARRAY_METHOD to "b", TargetSymbolId.CJK_KARAOKE_ANIMATION_METHOD to "a0", + TargetSymbolId.CJK_KARAOKE_LAYOUT_METHOD to "g0", TargetSymbolId.CJK_UNICODE_BLOCK_HELPER_METHOD to "a", ), exactFields = mapOf( @@ -440,6 +442,23 @@ internal object AppleMusicSymbols { contract = ::isCjkUnicodeBlockPredicateMethod, ) + /** + * Apple Music 6.5.2/1586's lyric line binding/layout builder + * (`com.apple.android.music.player.z.g0(...)`). The experiment hooks + * this before the Unicode predicate is consulted so a single CJK word + * can use the same ordinary binding shape as a Latin word. It is kept + * exact-profile only because this method owns the host's RecyclerView + * binding/recycling state. + */ + val CjkKaraokeLayoutMethod = methodSymbol( + id = "cjk-karaoke-layout-method", + profileOwner = TargetSymbolId.CJK_KARAOKE_ANIMATION_OWNER, + profilePolicy = ProfilePolicy.EXACT_REQUIRED, + exactMethodId = TargetSymbolId.CJK_KARAOKE_LAYOUT_METHOD, + fallbackOwner = { false }, + contract = ::isCjkKaraokeLayoutMethod, + ) + val PlayerController = classSymbol( id = "player-controller", profileId = TargetSymbolId.PLAYER_CONTROLLER, @@ -1798,6 +1817,17 @@ private fun isCjkUnicodeBlockPredicateMethod(method: Method): Boolean = ) && method.returnType == Boolean::class.javaPrimitiveType +private fun isCjkKaraokeLayoutMethod(method: Method): Boolean = + !Modifier.isStatic(method.modifiers) && + method.name == "g0" && + method.parameterTypes.size == 7 && + method.parameterTypes[1].name == "android.util.ArrayMap" && + method.parameterTypes[3] == Int::class.javaPrimitiveType && + method.parameterTypes[4] == Int::class.javaPrimitiveType && + method.parameterTypes[5] == Boolean::class.javaPrimitiveType && + method.parameterTypes[6] == Boolean::class.javaPrimitiveType && + method.returnType.name == "android.util.ArrayMap" + private fun classSymbol( id: String, profileId: TargetSymbolId? = null, diff --git a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt index a32ae53..a597153 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt @@ -87,6 +87,7 @@ class CjkKaraokeAnimationTargetTest { ) val exactMethods = listOf( exact652.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod), + exact652.resolve(AppleMusicSymbols.CjkKaraokeLayoutMethod), exact652.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod), ) exactMethods.forEach { resolution -> @@ -122,6 +123,17 @@ private class CjkAnimationFixture { fun a0(holder: a, first: Int, second: Int, third: Int, background: Boolean) { // Signature-only fixture for the profile resolver. } + + @Suppress("UNUSED_PARAMETER") + fun g0( + first: Any, + map: android.util.ArrayMap<*, *>, + third: Any, + line: Int, + width: Int, + background: Boolean, + rtl: Boolean, + ): android.util.ArrayMap = android.util.ArrayMap() } private class CjkHelperFixture { diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index e098c9a..77422c1 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -15,10 +15,11 @@ ## 已实现内容 1. 仅对 Apple Music `6.5.2/1586` 解析并 Hook: + - `com.apple.android.music.player.z.g0` - `com.apple.android.music.player.z.a0` - `com.apple.android.music.utils.I0$a.a(CharSequence, Set)` 2. AM++ 不接管 Apple 的 duration/length 触发条件。`z.a0` 前只读取当前 `z$a.G/H -> e` 的 grouping metadata:`e.f`(累计 duration)、`e.g`(累计字符数)、`e.c`(词文本)和 `e.k`(拆分 binding)。只有规范化后恰好一个 CJK Unicode 字符、`e.f` 等于当前 native duration、`e.g == 1` 且没有多 binding 时,才临时放开 `k0/j0`;合并词保留 Apple 原始分类,不会进入长辉光分支。 -3. 放行仍限定在 `z.a0` 的线程局部调用范围内,不会全局修改 Apple 的静态字符集合,也不会改变 `g0` 的原始 CJK 排版路径。 +3. 当前实验在 `z.g0` 的线程局部调用范围内,只把规范化后恰好一个 CJK Unicode 字符的 `j0/k0` 分类结果临时改为普通文字结果;多字符/合并文本保留 Apple 原始分类。随后 `z.a0` 仍只对单字未合并 entry 放行原生动画。不会全局修改 Apple 的静态字符集合。 4. 设置页和嵌入式设置页都有独立开关: - key:`cjk_karaoke_animation_enabled` - 字段:`ModuleSettings.cjkKaraokeAnimationEnabled` @@ -37,7 +38,7 @@ ## 已知边界 -- 当前方案是“复用 Apple 原生动画分支”,不是 AM++ 自己绘制;AM++ 只阻止合并 CJK 词块进入原生长辉光分类。 +- 当前方案是“尝试复用 Apple 英语形状的 binding + Apple 原生动画分支”,不是 AM++ 自己绘制;`g0` 改写可能影响 CJK 行布局和 RecyclerView 回收,必须用设备验收,失败时直接回退本实验提交。 - 判断失败时默认不放行,保持 Apple 原始行为;带组合音标、补充字符或多 binding 的文字会被保守跳过。 - Apple 版本、混淆类名和方法签名是私有契约;新增版本必须重新做 exact profile 和设备验证。 - Apple 版本、混淆类名和方法签名是私有契约;新增版本必须重新做 exact profile 和设备验证。 From 2ba45079085b71ee15b3c2eadf1328e302076727 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:34:41 +0800 Subject: [PATCH 22/33] Revert "experiment: build CJK words with ordinary karaoke layout" This reverts commit 1ddce4ab52eb884a0286506391890a40278af64f. --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 116 ++++-------------- .../amenhancer/module/hook/TargetSymbols.kt | 30 ----- .../hook/CjkKaraokeAnimationTargetTest.kt | 12 -- docs/cjk-karaoke-animation-handoff.md | 5 +- 4 files changed, 23 insertions(+), 140 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 777db74..23d01e2 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -10,16 +10,14 @@ import java.util.Map as JavaMap import java.util.WeakHashMap /** - * Narrow Apple Music 6.5.2/1586 adapter for the native karaoke path. The host - * owns all duration/length trigger conditions; this experiment only makes a - * single unmerged CJK word use the ordinary (Latin-shaped) binding inputs - * before Apple constructs its own animator. + * Narrow Apple Music 6.5.2/1586 adapter for the native karaoke rush-gradient + * path. The host owns all duration/length trigger conditions; AM++ only + * allows its CJK classifier override for one unmerged native CJK word. */ internal class AppleMusicCjkKaraokeAnimationTarget( private val symbols: TargetSymbolResolver, ) : CjkKaraokeAnimationTarget { private val a0Depth: ThreadLocal = ThreadLocal.withInitial { 0 } - private val g0Depth: ThreadLocal = ThreadLocal.withInitial { 0 } private val a0SingleWordStack: ThreadLocal> = ThreadLocal.withInitial { mutableListOf() } private val fieldCache = Collections.synchronizedMap( @@ -33,39 +31,18 @@ internal class AppleMusicCjkKaraokeAnimationTarget( override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) - val layoutResolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeLayoutMethod) val helperResolution = symbols.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod) val a0 = a0Resolution.valueOrNull() - val layout = layoutResolution.valueOrNull() val helper = helperResolution.valueOrNull() - if (a0 == null || layout == null || helper == null) { + if (a0 == null || helper == null) { return TargetCapabilityInstall.Degraded( - listOf(a0Resolution, layoutResolution, helperResolution) + listOf(a0Resolution, helperResolution) .filterNot { it is TargetResolution.Found<*> } .joinToString { it.summary }, ) } val failures = mutableListOf() - val layoutInstalled = try { - ModernXposedRuntime.hookMethod(layout, object : ModernMethodHook() { - override fun beforeHookedMethod(param: MethodHookParam) { - enterG0Scope() - } - - override fun afterHookedMethod(param: MethodHookParam) { - // g0 may throw while inflating a line; never leak the - // classifier override into a later line or a0 callback. - leaveG0Scope() - } - }).also { installed -> - if (!installed) failures += "z.g0 hook was rejected" - } - } catch (error: Throwable) { - failures += "z.g0 hook failed: ${error.cjkShortMessage()}" - ModernXposedRuntime.log("CJK karaoke z.g0 hook failed", error) - false - } val a0Installed = try { ModernXposedRuntime.hookMethod(a0, object : ModernMethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { @@ -91,7 +68,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ModernXposedRuntime.hookMethod(helper, object : ModernMethodHook() { override fun afterHookedMethod(param: MethodHookParam) { try { - rewriteCjkClassifierResult(param) + rewriteCjkK0Result(param) } catch (error: Throwable) { // A malformed host call must never break its original // helper result. This is deliberately fail-open. @@ -113,14 +90,14 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // animations on device. specialEndHookInstalled = installSpecialEndHook(a0) - if (!layoutInstalled || !a0Installed || !helperInstalled || !specialEndHookInstalled) { + if (!a0Installed || !helperInstalled) { return TargetCapabilityInstall.Degraded( failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, ) } return TargetCapabilityInstall.Active( - "Installed exact 6.5.2/1586 single-unmerged-CJK ordinary-layout experiment", + "Installed exact 6.5.2/1586 single-unmerged-CJK guard with z\$c end cleanup", ) } @@ -225,52 +202,27 @@ internal class AppleMusicCjkKaraokeAnimationTarget( else -> -1 } - private fun rewriteCjkClassifierResult(param: ModernMethodHook.MethodHookParam) { - val text = param.args.getOrNull(0) as? CharSequence ?: return - val languageSet = param.args.getOrNull(1) as? Set<*> ?: return - if (isG0Scope()) { - rewriteOrdinaryLayoutResult(param, text, languageSet) - } else if (isSingleWordScope()) { - rewriteCjkAnimationResult(param, text, languageSet) - } - } - - /** - * g0 has already grouped/merged multi-character CJK chunks by the time - * a0 runs. Only a single trimmed Unicode code point is eligible for this - * experiment; merged text stays on Apple's original layout path. - */ - private fun rewriteOrdinaryLayoutResult( - param: ModernMethodHook.MethodHookParam, - text: CharSequence, - languageSet: Set<*>, - ) { - if (!isSingleCjkCodePoint(text)) return - if (!isK0Set(languageSet) && !isJ0Set(languageSet)) return - if (param.result == true) { - param.result = false - logRewrite(text, "g0 CJK classifier true -> false") - } - } + private fun rewriteCjkK0Result(param: ModernMethodHook.MethodHookParam) { + if (!isSingleWordScope()) return - private fun rewriteCjkAnimationResult( - param: ModernMethodHook.MethodHookParam, - text: CharSequence, - languageSet: Set<*>, - ) { + val text = param.args.getOrNull(0) as? CharSequence ?: return if (!containsCjkKaraokeScript(text)) return + + val languageSet = param.args.getOrNull(1) as? Set<*> ?: return when { isK0Set(languageSet) && param.result == true -> { - // Keep Apple's duration/length gates, but opt this one CJK - // word into the same outer branch used by Latin candidates. + // CJK is normally classified into k0, which blocks the + // rush branch. Make that one result look like a default + // script only while a0 is running. param.result = false - logRewrite(text, "a0 k0 true -> false") + logRewrite(text, "k0 true -> false") } isJ0Set(languageSet) && containsHangul(text) && param.result != true -> { + // i0 receives the j0 hit as its split/rush eligibility bit. // Hangul belongs to k0 but not j0, so opt it into the same - // Apple animation only inside a0. + // Apple animation only inside a0; g0 remains untouched. param.result = true - logRewrite(text, "a0 j0 false -> true") + logRewrite(text, "j0 false -> true") } else -> return } @@ -279,7 +231,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun logRewrite(text: CharSequence, change: String) { if (rewriteLogCount.getAndIncrement() < MAX_REWRITE_LOGS) { ModernXposedRuntime.log( - "CJK karaoke classifier: I0\$a.a(${text.length} chars, $change)", + "CJK karaoke a0 scope: I0\$a.a(${text.length} chars, $change); g0 unchanged", ) } } @@ -304,32 +256,6 @@ internal class AppleMusicCjkKaraokeAnimationTarget( false } - private fun isSingleCjkCodePoint(text: CharSequence): Boolean { - val normalized = text.toString().trim() - if (normalized.isEmpty() || normalized.codePointCount(0, normalized.length) != 1) { - return false - } - return containsCjkKaraokeScript(normalized) - } - - private fun enterG0Scope() { - runCatching { g0Depth.set((g0Depth.get() ?: 0) + 1) } - .onFailure { error -> ModernXposedRuntime.log("CJK karaoke g0 depth enter failed open", error) } - } - - private fun leaveG0Scope() { - runCatching { - val depth = g0Depth.get() ?: 0 - if (depth <= 1) g0Depth.remove() else g0Depth.set(depth - 1) - }.onFailure { error -> ModernXposedRuntime.log("CJK karaoke g0 depth cleanup failed open", error) } - } - - private fun isG0Scope(): Boolean = runCatching { (g0Depth.get() ?: 0) > 0 } - .getOrElse { error -> - ModernXposedRuntime.log("CJK karaoke g0 scope read failed open", error) - false - } - private fun enterA0Scope(param: ModernMethodHook.MethodHookParam) { runCatching { a0Depth.set((a0Depth.get() ?: 0) + 1) diff --git a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt index 057e13e..63e0a8c 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt @@ -263,7 +263,6 @@ internal enum class TargetSymbolId { STORE_FRONT_LANGUAGE_ARRAY_METHOD, CJK_KARAOKE_ANIMATION_OWNER, CJK_KARAOKE_ANIMATION_METHOD, - CJK_KARAOKE_LAYOUT_METHOD, CJK_UNICODE_BLOCK_HELPER_OWNER, CJK_UNICODE_BLOCK_HELPER_METHOD, } @@ -392,7 +391,6 @@ private object AppleMusicProfiles { TargetSymbolId.LYRICS_ITEM_UPDATE_METHOD to "o2", TargetSymbolId.STORE_FRONT_LANGUAGE_ARRAY_METHOD to "b", TargetSymbolId.CJK_KARAOKE_ANIMATION_METHOD to "a0", - TargetSymbolId.CJK_KARAOKE_LAYOUT_METHOD to "g0", TargetSymbolId.CJK_UNICODE_BLOCK_HELPER_METHOD to "a", ), exactFields = mapOf( @@ -442,23 +440,6 @@ internal object AppleMusicSymbols { contract = ::isCjkUnicodeBlockPredicateMethod, ) - /** - * Apple Music 6.5.2/1586's lyric line binding/layout builder - * (`com.apple.android.music.player.z.g0(...)`). The experiment hooks - * this before the Unicode predicate is consulted so a single CJK word - * can use the same ordinary binding shape as a Latin word. It is kept - * exact-profile only because this method owns the host's RecyclerView - * binding/recycling state. - */ - val CjkKaraokeLayoutMethod = methodSymbol( - id = "cjk-karaoke-layout-method", - profileOwner = TargetSymbolId.CJK_KARAOKE_ANIMATION_OWNER, - profilePolicy = ProfilePolicy.EXACT_REQUIRED, - exactMethodId = TargetSymbolId.CJK_KARAOKE_LAYOUT_METHOD, - fallbackOwner = { false }, - contract = ::isCjkKaraokeLayoutMethod, - ) - val PlayerController = classSymbol( id = "player-controller", profileId = TargetSymbolId.PLAYER_CONTROLLER, @@ -1817,17 +1798,6 @@ private fun isCjkUnicodeBlockPredicateMethod(method: Method): Boolean = ) && method.returnType == Boolean::class.javaPrimitiveType -private fun isCjkKaraokeLayoutMethod(method: Method): Boolean = - !Modifier.isStatic(method.modifiers) && - method.name == "g0" && - method.parameterTypes.size == 7 && - method.parameterTypes[1].name == "android.util.ArrayMap" && - method.parameterTypes[3] == Int::class.javaPrimitiveType && - method.parameterTypes[4] == Int::class.javaPrimitiveType && - method.parameterTypes[5] == Boolean::class.javaPrimitiveType && - method.parameterTypes[6] == Boolean::class.javaPrimitiveType && - method.returnType.name == "android.util.ArrayMap" - private fun classSymbol( id: String, profileId: TargetSymbolId? = null, diff --git a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt index a597153..a32ae53 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt @@ -87,7 +87,6 @@ class CjkKaraokeAnimationTargetTest { ) val exactMethods = listOf( exact652.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod), - exact652.resolve(AppleMusicSymbols.CjkKaraokeLayoutMethod), exact652.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod), ) exactMethods.forEach { resolution -> @@ -123,17 +122,6 @@ private class CjkAnimationFixture { fun a0(holder: a, first: Int, second: Int, third: Int, background: Boolean) { // Signature-only fixture for the profile resolver. } - - @Suppress("UNUSED_PARAMETER") - fun g0( - first: Any, - map: android.util.ArrayMap<*, *>, - third: Any, - line: Int, - width: Int, - background: Boolean, - rtl: Boolean, - ): android.util.ArrayMap = android.util.ArrayMap() } private class CjkHelperFixture { diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index 77422c1..e098c9a 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -15,11 +15,10 @@ ## 已实现内容 1. 仅对 Apple Music `6.5.2/1586` 解析并 Hook: - - `com.apple.android.music.player.z.g0` - `com.apple.android.music.player.z.a0` - `com.apple.android.music.utils.I0$a.a(CharSequence, Set)` 2. AM++ 不接管 Apple 的 duration/length 触发条件。`z.a0` 前只读取当前 `z$a.G/H -> e` 的 grouping metadata:`e.f`(累计 duration)、`e.g`(累计字符数)、`e.c`(词文本)和 `e.k`(拆分 binding)。只有规范化后恰好一个 CJK Unicode 字符、`e.f` 等于当前 native duration、`e.g == 1` 且没有多 binding 时,才临时放开 `k0/j0`;合并词保留 Apple 原始分类,不会进入长辉光分支。 -3. 当前实验在 `z.g0` 的线程局部调用范围内,只把规范化后恰好一个 CJK Unicode 字符的 `j0/k0` 分类结果临时改为普通文字结果;多字符/合并文本保留 Apple 原始分类。随后 `z.a0` 仍只对单字未合并 entry 放行原生动画。不会全局修改 Apple 的静态字符集合。 +3. 放行仍限定在 `z.a0` 的线程局部调用范围内,不会全局修改 Apple 的静态字符集合,也不会改变 `g0` 的原始 CJK 排版路径。 4. 设置页和嵌入式设置页都有独立开关: - key:`cjk_karaoke_animation_enabled` - 字段:`ModuleSettings.cjkKaraokeAnimationEnabled` @@ -38,7 +37,7 @@ ## 已知边界 -- 当前方案是“尝试复用 Apple 英语形状的 binding + Apple 原生动画分支”,不是 AM++ 自己绘制;`g0` 改写可能影响 CJK 行布局和 RecyclerView 回收,必须用设备验收,失败时直接回退本实验提交。 +- 当前方案是“复用 Apple 原生动画分支”,不是 AM++ 自己绘制;AM++ 只阻止合并 CJK 词块进入原生长辉光分类。 - 判断失败时默认不放行,保持 Apple 原始行为;带组合音标、补充字符或多 binding 的文字会被保守跳过。 - Apple 版本、混淆类名和方法签名是私有契约;新增版本必须重新做 exact profile 和设备验证。 - Apple 版本、混淆类名和方法签名是私有契约;新增版本必须重新做 exact profile 和设备验证。 From 0af0a409fd552af8d857e49c7dd22cba852a2fa4 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:36:27 +0800 Subject: [PATCH 23/33] experiment: route CJK through ordinary karaoke lifecycle --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 31 ++++--------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 23d01e2..2a70d06 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -210,19 +210,13 @@ internal class AppleMusicCjkKaraokeAnimationTarget( val languageSet = param.args.getOrNull(1) as? Set<*> ?: return when { - isK0Set(languageSet) && param.result == true -> { - // CJK is normally classified into k0, which blocks the - // rush branch. Make that one result look like a default - // script only while a0 is running. + isJ0Set(languageSet) && param.result == true -> { + // Keep k0=true so z.a0 takes Apple's ordinary z$e/e.o + // lifecycle instead of the rush z$c/e.p lifecycle. The + // j0 bit is only used by the ordinary child animator; make + // Han/Kana look like the Latin path there as well. param.result = false - logRewrite(text, "k0 true -> false") - } - isJ0Set(languageSet) && containsHangul(text) && param.result != true -> { - // i0 receives the j0 hit as its split/rush eligibility bit. - // Hangul belongs to k0 but not j0, so opt it into the same - // Apple animation only inside a0; g0 remains untouched. - param.result = true - logRewrite(text, "j0 false -> true") + logRewrite(text, "j0 true -> false; k0 preserved") } else -> return } @@ -407,19 +401,6 @@ internal fun containsCjkKaraokeScript(text: CharSequence): Boolean { return false } -private fun containsHangul(text: CharSequence): Boolean { - for (index in text.indices) { - when (Character.UnicodeBlock.of(text[index])) { - Character.UnicodeBlock.HANGUL_SYLLABLES, - Character.UnicodeBlock.HANGUL_JAMO, - Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO, - -> return true - else -> Unit - } - } - return false -} - private fun Throwable.cjkShortMessage(): String = buildString { append(javaClass.simpleName.ifBlank { javaClass.name }) message?.takeIf(String::isNotBlank)?.let { append(": ").append(it.take(180)) } From 55350079afb1b5d8da43ba1ac95fe8deb5e8b177 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:42:16 +0800 Subject: [PATCH 24/33] Revert "experiment: route CJK through ordinary karaoke lifecycle" This reverts commit 0af0a409fd552af8d857e49c7dd22cba852a2fa4. --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 2a70d06..23d01e2 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -210,13 +210,19 @@ internal class AppleMusicCjkKaraokeAnimationTarget( val languageSet = param.args.getOrNull(1) as? Set<*> ?: return when { - isJ0Set(languageSet) && param.result == true -> { - // Keep k0=true so z.a0 takes Apple's ordinary z$e/e.o - // lifecycle instead of the rush z$c/e.p lifecycle. The - // j0 bit is only used by the ordinary child animator; make - // Han/Kana look like the Latin path there as well. + isK0Set(languageSet) && param.result == true -> { + // CJK is normally classified into k0, which blocks the + // rush branch. Make that one result look like a default + // script only while a0 is running. param.result = false - logRewrite(text, "j0 true -> false; k0 preserved") + logRewrite(text, "k0 true -> false") + } + isJ0Set(languageSet) && containsHangul(text) && param.result != true -> { + // i0 receives the j0 hit as its split/rush eligibility bit. + // Hangul belongs to k0 but not j0, so opt it into the same + // Apple animation only inside a0; g0 remains untouched. + param.result = true + logRewrite(text, "j0 false -> true") } else -> return } @@ -401,6 +407,19 @@ internal fun containsCjkKaraokeScript(text: CharSequence): Boolean { return false } +private fun containsHangul(text: CharSequence): Boolean { + for (index in text.indices) { + when (Character.UnicodeBlock.of(text[index])) { + Character.UnicodeBlock.HANGUL_SYLLABLES, + Character.UnicodeBlock.HANGUL_JAMO, + Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO, + -> return true + else -> Unit + } + } + return false +} + private fun Throwable.cjkShortMessage(): String = buildString { append(javaClass.simpleName.ifBlank { javaClass.name }) message?.takeIf(String::isNotBlank)?.let { append(": ").append(it.take(180)) } From 6ada2ed997a172e5053a1a610d1f204e11713705 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:43:57 +0800 Subject: [PATCH 25/33] fix: reset CJK glow animator at its own end --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 96 ++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 23d01e2..90fcc41 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -1,8 +1,10 @@ package dev.amenhancer.module.hook import android.animation.Animator +import android.animation.AnimatorListenerAdapter import java.lang.reflect.Executable import java.util.concurrent.atomic.AtomicInteger +import java.lang.ref.WeakReference import java.lang.reflect.Field import java.lang.reflect.Modifier import java.util.Collections @@ -26,8 +28,12 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private val cjkEntryStates = Collections.synchronizedMap( WeakHashMap(), ) + private val glowAnimators = Collections.synchronizedMap( + WeakHashMap(), + ) private val rewriteLogCount = AtomicInteger() private var specialEndHookInstalled = false + private var glowEndHookInstalled = false override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) @@ -89,18 +95,104 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // latter changes Apple's ordering and was shown to disturb all lyric // animations on device. specialEndHookInstalled = installSpecialEndHook(a0) + glowEndHookInstalled = installGlowAnimatorEndHook(a0) - if (!a0Installed || !helperInstalled) { + if (!a0Installed || !helperInstalled || !specialEndHookInstalled || !glowEndHookInstalled) { return TargetCapabilityInstall.Degraded( failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, ) } return TargetCapabilityInstall.Active( - "Installed exact 6.5.2/1586 single-unmerged-CJK guard with z\$c end cleanup", + "Installed exact 6.5.2/1586 single-unmerged-CJK glow end cleanup", ) } + /** + * z$f only implements AnimatorUpdateListener, so Apple has no end/cancel + * callback for the ValueAnimator that writes scale and shadow. Hook that + * exact host listener and attach a listener to its own animator. No e.p + * membership or animator cancellation is changed here. + */ + private fun installGlowAnimatorEndHook(a0: Executable): Boolean = runCatching { + val owner = a0.declaringClass + val updateType = Class.forName( + "${owner.name}\$f", + false, + owner.classLoader, + ) + val method = updateType.declaredMethods + .filter { candidate -> + candidate.name == "onAnimationUpdate" && + candidate.parameterTypes.size == 1 && + candidate.parameterTypes[0].name == "android.animation.ValueAnimator" + } + .singleOrNull() + ?: return@runCatching false + ModernXposedRuntime.hookMethod( + method, + object : ModernMethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + attachGlowEndListener(param.thisObject, param.args.getOrNull(0) as? Animator) + } + }, + ) + }.onFailure { error -> + ModernXposedRuntime.log("CJK karaoke z\$f end hook unavailable", error) + }.getOrDefault(false) + + private fun attachGlowEndListener(updateListener: Any?, animator: Animator?) { + if (updateListener == null || animator == null) return + val view = readNamedField(updateListener, "c") ?: return + if (!isSingleCjkGlowView(view)) return + val shouldAttach = synchronized(glowAnimators) { + if (glowAnimators.containsKey(animator)) { + false + } else { + glowAnimators[animator] = true + true + } + } + if (!shouldAttach) return + + val animatorRef = WeakReference(animator) + val viewRef = WeakReference(view) + animator.addListener(object : AnimatorListenerAdapter() { + private var cleaned = false + + private fun cleanup() { + if (cleaned) return + cleaned = true + animatorRef.get()?.let { ended -> + synchronized(glowAnimators) { glowAnimators.remove(ended) } + } + viewRef.get()?.let { target -> + if (isSingleCjkGlowView(target)) resetCjkGlowView(target) + } + } + + override fun onAnimationCancel(animation: Animator) = cleanup() + + override fun onAnimationEnd(animation: Animator) = cleanup() + }) + } + + private fun isSingleCjkGlowView(view: Any): Boolean { + val text = invokeNoArg(view, "getText") as? CharSequence ?: return false + val normalized = text.toString().trim() + return normalized.isNotEmpty() && + normalized.codePointCount(0, normalized.length) == 1 && + containsCjkKaraokeScript(normalized) + } + + private fun resetCjkGlowView(view: Any) { + invokeMethod(view, "setScaleX", 1f) + invokeMethod(view, "setScaleY", 1f) + invokeNoArg(view, "resetPivot") + invokeMethod(view, "setShadowLayer", 0f, 0f, 0f, 0) + invokeNoArg(view, "invalidate") + } + private fun installSpecialEndHook(a0: Executable): Boolean = runCatching { val owner = a0.declaringClass val cleanupType = Class.forName( From d952385e3de9c7553a6a75219ff18d8a4860a103 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:49:39 +0800 Subject: [PATCH 26/33] experiment: reuse English-shaped CJK glow bindings --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 54 +++++++++++++++++-- .../amenhancer/module/hook/TargetSymbols.kt | 20 +++++++ .../hook/CjkKaraokeAnimationTargetTest.kt | 8 +++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 90fcc41..f73281b 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -37,12 +37,14 @@ internal class AppleMusicCjkKaraokeAnimationTarget( override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) + val bindingsResolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeBindingsMethod) val helperResolution = symbols.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod) val a0 = a0Resolution.valueOrNull() + val bindings = bindingsResolution.valueOrNull() val helper = helperResolution.valueOrNull() - if (a0 == null || helper == null) { + if (a0 == null || bindings == null || helper == null) { return TargetCapabilityInstall.Degraded( - listOf(a0Resolution, helperResolution) + listOf(a0Resolution, bindingsResolution, helperResolution) .filterNot { it is TargetResolution.Found<*> } .joinToString { it.summary }, ) @@ -70,6 +72,26 @@ internal class AppleMusicCjkKaraokeAnimationTarget( false } + val bindingsInstalled = try { + ModernXposedRuntime.hookMethod(bindings, object : ModernMethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + try { + collapseSingleCjkBindings(param) + } catch (error: Throwable) { + // A malformed entry must retain Apple's original + // binding list and therefore fail open. + ModernXposedRuntime.log("CJK karaoke binding collapse failed open", error) + } + } + }).also { installed -> + if (!installed) failures += "z.m0 hook was rejected" + } + } catch (error: Throwable) { + failures += "z.m0 hook failed: ${error.cjkShortMessage()}" + ModernXposedRuntime.log("CJK karaoke z.m0 hook failed", error) + false + } + val helperInstalled = try { ModernXposedRuntime.hookMethod(helper, object : ModernMethodHook() { override fun afterHookedMethod(param: MethodHookParam) { @@ -97,14 +119,16 @@ internal class AppleMusicCjkKaraokeAnimationTarget( specialEndHookInstalled = installSpecialEndHook(a0) glowEndHookInstalled = installGlowAnimatorEndHook(a0) - if (!a0Installed || !helperInstalled || !specialEndHookInstalled || !glowEndHookInstalled) { + if (!a0Installed || !bindingsInstalled || !helperInstalled || + !specialEndHookInstalled || !glowEndHookInstalled + ) { return TargetCapabilityInstall.Degraded( failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, ) } return TargetCapabilityInstall.Active( - "Installed exact 6.5.2/1586 single-unmerged-CJK glow end cleanup", + "Installed exact 6.5.2/1586 single-unmerged-CJK English-shaped glow", ) } @@ -294,6 +318,28 @@ internal class AppleMusicCjkKaraokeAnimationTarget( else -> -1 } + /** + * English words normally produce one foreground binding. For the + * single-word CJK entries admitted by the gate, keep only that same + * primary binding in the list consumed by z.a0. The entry's backing + * fields and all host timing gates remain untouched; merged CJK chunks + * and every non-foreground call retain Apple's original list. + */ + private fun collapseSingleCjkBindings(param: ModernMethodHook.MethodHookParam) { + if (!isSingleWordScope()) return + if (param.args.getOrNull(1) as? Boolean == true) return + val entry = param.args.getOrNull(0) ?: return + val state = synchronized(cjkEntryStates) { cjkEntryStates[entry] } ?: return + val primary = readNamedField(entry, "i") ?: return + val original = param.result as? List<*> ?: return + if (original.size <= 1 || original.none { it === primary }) return + val collapsed = ArrayList(1) + collapsed += primary + param.result = collapsed + val text = readNamedField(state.entry, "c") as? CharSequence ?: return + logRewrite(text, "m0 bindings ${original.size} -> 1") + } + private fun rewriteCjkK0Result(param: ModernMethodHook.MethodHookParam) { if (!isSingleWordScope()) return diff --git a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt index 63e0a8c..3c810b8 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt @@ -263,6 +263,7 @@ internal enum class TargetSymbolId { STORE_FRONT_LANGUAGE_ARRAY_METHOD, CJK_KARAOKE_ANIMATION_OWNER, CJK_KARAOKE_ANIMATION_METHOD, + CJK_KARAOKE_BINDINGS_METHOD, CJK_UNICODE_BLOCK_HELPER_OWNER, CJK_UNICODE_BLOCK_HELPER_METHOD, } @@ -391,6 +392,7 @@ private object AppleMusicProfiles { TargetSymbolId.LYRICS_ITEM_UPDATE_METHOD to "o2", TargetSymbolId.STORE_FRONT_LANGUAGE_ARRAY_METHOD to "b", TargetSymbolId.CJK_KARAOKE_ANIMATION_METHOD to "a0", + TargetSymbolId.CJK_KARAOKE_BINDINGS_METHOD to "m0", TargetSymbolId.CJK_UNICODE_BLOCK_HELPER_METHOD to "a", ), exactFields = mapOf( @@ -425,6 +427,16 @@ internal object AppleMusicSymbols { contract = ::isCjkKaraokeAnimationMethod, ) + /** Exact 6.5.2/1586 binding list used by z.a0 before it builds glow animators. */ + val CjkKaraokeBindingsMethod = methodSymbol( + id = "cjk-karaoke-bindings-method", + profileOwner = TargetSymbolId.CJK_KARAOKE_ANIMATION_OWNER, + profilePolicy = ProfilePolicy.EXACT_REQUIRED, + exactMethodId = TargetSymbolId.CJK_KARAOKE_BINDINGS_METHOD, + fallbackOwner = { false }, + contract = ::isCjkKaraokeBindingsMethod, + ) + /** * Apple Music 6.5.2/1586's UnicodeBlock-set predicate * (`com.apple.android.music.utils.I0$a.a(CharSequence, Set): boolean`). @@ -1790,6 +1802,14 @@ private fun isCjkKaraokeAnimationMethod(method: Method): Boolean = method.parameterTypes[4] == Boolean::class.javaPrimitiveType && method.returnType == Void.TYPE +private fun isCjkKaraokeBindingsMethod(method: Method): Boolean = + Modifier.isStatic(method.modifiers) && + method.name == "m0" && + method.parameterTypes.size == 2 && + method.parameterTypes[0].name.endsWith("\$e") && + method.parameterTypes[1] == Boolean::class.javaPrimitiveType && + java.util.List::class.java.isAssignableFrom(method.returnType) + private fun isCjkUnicodeBlockPredicateMethod(method: Method): Boolean = Modifier.isStatic(method.modifiers) && method.name == "a" && diff --git a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt index a32ae53..db841f6 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt @@ -87,6 +87,7 @@ class CjkKaraokeAnimationTargetTest { ) val exactMethods = listOf( exact652.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod), + exact652.resolve(AppleMusicSymbols.CjkKaraokeBindingsMethod), exact652.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod), ) exactMethods.forEach { resolution -> @@ -117,11 +118,18 @@ private class FixtureTargetClassSource( private class CjkAnimationFixture { class a + class e @Suppress("UNUSED_PARAMETER") fun a0(holder: a, first: Int, second: Int, third: Int, background: Boolean) { // Signature-only fixture for the profile resolver. } + + companion object { + @JvmStatic + @Suppress("UNUSED_PARAMETER") + fun m0(entry: e, background: Boolean): List = emptyList() + } } private class CjkHelperFixture { From a3f5ee92df18279fd7588569da70c5615b208e02 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 22:51:40 +0800 Subject: [PATCH 27/33] Revert "experiment: reuse English-shaped CJK glow bindings" This reverts commit d952385e3de9c7553a6a75219ff18d8a4860a103. --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 54 ++----------------- .../amenhancer/module/hook/TargetSymbols.kt | 20 ------- .../hook/CjkKaraokeAnimationTargetTest.kt | 8 --- 3 files changed, 4 insertions(+), 78 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index f73281b..90fcc41 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -37,14 +37,12 @@ internal class AppleMusicCjkKaraokeAnimationTarget( override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) - val bindingsResolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeBindingsMethod) val helperResolution = symbols.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod) val a0 = a0Resolution.valueOrNull() - val bindings = bindingsResolution.valueOrNull() val helper = helperResolution.valueOrNull() - if (a0 == null || bindings == null || helper == null) { + if (a0 == null || helper == null) { return TargetCapabilityInstall.Degraded( - listOf(a0Resolution, bindingsResolution, helperResolution) + listOf(a0Resolution, helperResolution) .filterNot { it is TargetResolution.Found<*> } .joinToString { it.summary }, ) @@ -72,26 +70,6 @@ internal class AppleMusicCjkKaraokeAnimationTarget( false } - val bindingsInstalled = try { - ModernXposedRuntime.hookMethod(bindings, object : ModernMethodHook() { - override fun afterHookedMethod(param: MethodHookParam) { - try { - collapseSingleCjkBindings(param) - } catch (error: Throwable) { - // A malformed entry must retain Apple's original - // binding list and therefore fail open. - ModernXposedRuntime.log("CJK karaoke binding collapse failed open", error) - } - } - }).also { installed -> - if (!installed) failures += "z.m0 hook was rejected" - } - } catch (error: Throwable) { - failures += "z.m0 hook failed: ${error.cjkShortMessage()}" - ModernXposedRuntime.log("CJK karaoke z.m0 hook failed", error) - false - } - val helperInstalled = try { ModernXposedRuntime.hookMethod(helper, object : ModernMethodHook() { override fun afterHookedMethod(param: MethodHookParam) { @@ -119,16 +97,14 @@ internal class AppleMusicCjkKaraokeAnimationTarget( specialEndHookInstalled = installSpecialEndHook(a0) glowEndHookInstalled = installGlowAnimatorEndHook(a0) - if (!a0Installed || !bindingsInstalled || !helperInstalled || - !specialEndHookInstalled || !glowEndHookInstalled - ) { + if (!a0Installed || !helperInstalled || !specialEndHookInstalled || !glowEndHookInstalled) { return TargetCapabilityInstall.Degraded( failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, ) } return TargetCapabilityInstall.Active( - "Installed exact 6.5.2/1586 single-unmerged-CJK English-shaped glow", + "Installed exact 6.5.2/1586 single-unmerged-CJK glow end cleanup", ) } @@ -318,28 +294,6 @@ internal class AppleMusicCjkKaraokeAnimationTarget( else -> -1 } - /** - * English words normally produce one foreground binding. For the - * single-word CJK entries admitted by the gate, keep only that same - * primary binding in the list consumed by z.a0. The entry's backing - * fields and all host timing gates remain untouched; merged CJK chunks - * and every non-foreground call retain Apple's original list. - */ - private fun collapseSingleCjkBindings(param: ModernMethodHook.MethodHookParam) { - if (!isSingleWordScope()) return - if (param.args.getOrNull(1) as? Boolean == true) return - val entry = param.args.getOrNull(0) ?: return - val state = synchronized(cjkEntryStates) { cjkEntryStates[entry] } ?: return - val primary = readNamedField(entry, "i") ?: return - val original = param.result as? List<*> ?: return - if (original.size <= 1 || original.none { it === primary }) return - val collapsed = ArrayList(1) - collapsed += primary - param.result = collapsed - val text = readNamedField(state.entry, "c") as? CharSequence ?: return - logRewrite(text, "m0 bindings ${original.size} -> 1") - } - private fun rewriteCjkK0Result(param: ModernMethodHook.MethodHookParam) { if (!isSingleWordScope()) return diff --git a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt index 3c810b8..63e0a8c 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt @@ -263,7 +263,6 @@ internal enum class TargetSymbolId { STORE_FRONT_LANGUAGE_ARRAY_METHOD, CJK_KARAOKE_ANIMATION_OWNER, CJK_KARAOKE_ANIMATION_METHOD, - CJK_KARAOKE_BINDINGS_METHOD, CJK_UNICODE_BLOCK_HELPER_OWNER, CJK_UNICODE_BLOCK_HELPER_METHOD, } @@ -392,7 +391,6 @@ private object AppleMusicProfiles { TargetSymbolId.LYRICS_ITEM_UPDATE_METHOD to "o2", TargetSymbolId.STORE_FRONT_LANGUAGE_ARRAY_METHOD to "b", TargetSymbolId.CJK_KARAOKE_ANIMATION_METHOD to "a0", - TargetSymbolId.CJK_KARAOKE_BINDINGS_METHOD to "m0", TargetSymbolId.CJK_UNICODE_BLOCK_HELPER_METHOD to "a", ), exactFields = mapOf( @@ -427,16 +425,6 @@ internal object AppleMusicSymbols { contract = ::isCjkKaraokeAnimationMethod, ) - /** Exact 6.5.2/1586 binding list used by z.a0 before it builds glow animators. */ - val CjkKaraokeBindingsMethod = methodSymbol( - id = "cjk-karaoke-bindings-method", - profileOwner = TargetSymbolId.CJK_KARAOKE_ANIMATION_OWNER, - profilePolicy = ProfilePolicy.EXACT_REQUIRED, - exactMethodId = TargetSymbolId.CJK_KARAOKE_BINDINGS_METHOD, - fallbackOwner = { false }, - contract = ::isCjkKaraokeBindingsMethod, - ) - /** * Apple Music 6.5.2/1586's UnicodeBlock-set predicate * (`com.apple.android.music.utils.I0$a.a(CharSequence, Set): boolean`). @@ -1802,14 +1790,6 @@ private fun isCjkKaraokeAnimationMethod(method: Method): Boolean = method.parameterTypes[4] == Boolean::class.javaPrimitiveType && method.returnType == Void.TYPE -private fun isCjkKaraokeBindingsMethod(method: Method): Boolean = - Modifier.isStatic(method.modifiers) && - method.name == "m0" && - method.parameterTypes.size == 2 && - method.parameterTypes[0].name.endsWith("\$e") && - method.parameterTypes[1] == Boolean::class.javaPrimitiveType && - java.util.List::class.java.isAssignableFrom(method.returnType) - private fun isCjkUnicodeBlockPredicateMethod(method: Method): Boolean = Modifier.isStatic(method.modifiers) && method.name == "a" && diff --git a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt index db841f6..a32ae53 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt @@ -87,7 +87,6 @@ class CjkKaraokeAnimationTargetTest { ) val exactMethods = listOf( exact652.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod), - exact652.resolve(AppleMusicSymbols.CjkKaraokeBindingsMethod), exact652.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod), ) exactMethods.forEach { resolution -> @@ -118,18 +117,11 @@ private class FixtureTargetClassSource( private class CjkAnimationFixture { class a - class e @Suppress("UNUSED_PARAMETER") fun a0(holder: a, first: Int, second: Int, third: Int, background: Boolean) { // Signature-only fixture for the profile resolver. } - - companion object { - @JvmStatic - @Suppress("UNUSED_PARAMETER") - fun m0(entry: e, background: Boolean): List = emptyList() - } } private class CjkHelperFixture { From 287e414ccb1fbdad7e95d3a8aa7451b83207e41e Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:01:28 +0800 Subject: [PATCH 28/33] fix: restore CJK glow view at host animator end --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 67 +++++++++++++++++-- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 90fcc41..2afcba5 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -31,9 +31,13 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private val glowAnimators = Collections.synchronizedMap( WeakHashMap(), ) + private val trackedGlowViews = Collections.synchronizedMap( + WeakHashMap(), + ) private val rewriteLogCount = AtomicInteger() private var specialEndHookInstalled = false private var glowEndHookInstalled = false + private var glowViewEndHookInstalled = false override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) @@ -96,8 +100,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // animations on device. specialEndHookInstalled = installSpecialEndHook(a0) glowEndHookInstalled = installGlowAnimatorEndHook(a0) + glowViewEndHookInstalled = installGlowViewEndHook(a0) - if (!a0Installed || !helperInstalled || !specialEndHookInstalled || !glowEndHookInstalled) { + if (!a0Installed || !helperInstalled || !specialEndHookInstalled || + !glowEndHookInstalled || !glowViewEndHookInstalled + ) { return TargetCapabilityInstall.Degraded( failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, ) @@ -144,7 +151,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun attachGlowEndListener(updateListener: Any?, animator: Animator?) { if (updateListener == null || animator == null) return val view = readNamedField(updateListener, "c") ?: return - if (!isSingleCjkGlowView(view)) return + if (!isTrackedCjkGlowView(view) && !isSingleCjkGlowView(view)) return val shouldAttach = synchronized(glowAnimators) { if (glowAnimators.containsKey(animator)) { false @@ -166,9 +173,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( animatorRef.get()?.let { ended -> synchronized(glowAnimators) { glowAnimators.remove(ended) } } - viewRef.get()?.let { target -> - if (isSingleCjkGlowView(target)) resetCjkGlowView(target) - } + cleanupGlowView(viewRef.get()) } override fun onAnimationCancel(animation: Animator) = cleanup() @@ -177,6 +182,52 @@ internal class AppleMusicCjkKaraokeAnimationTarget( }) } + /** + * z$g is the host's own end listener for the ValueAnimator that runs the + * glow's return-to-normal phase. Hooking that exact seam is more reliable + * than waiting for a later outer AnimatorSet callback: CJK may be rendered + * through a binding whose text is not a single code point, while the + * recorded View identity still identifies the glow target precisely. + */ + private fun installGlowViewEndHook(a0: Executable): Boolean = runCatching { + val owner = a0.declaringClass + val endType = Class.forName( + "${owner.name}\$g", + false, + owner.classLoader, + ) + val method = endType.declaredMethods + .filter { candidate -> + candidate.name == "onAnimationEnd" && + candidate.parameterTypes.size == 1 && + Animator::class.java.isAssignableFrom(candidate.parameterTypes[0]) + } + .singleOrNull() + ?: return@runCatching false + ModernXposedRuntime.hookMethod( + method, + object : ModernMethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + param.thisObject + ?.let { readNamedField(it, "b") } + ?.let(::cleanupGlowView) + } + }, + ) + }.onFailure { error -> + ModernXposedRuntime.log("CJK karaoke z\$g view end hook unavailable", error) + }.getOrDefault(false) + + private fun cleanupGlowView(view: Any?) { + if (view == null) return + val tracked = isTrackedCjkGlowView(view) + if (!tracked && !isSingleCjkGlowView(view)) return + resetCjkGlowView(view) + if (tracked) { + synchronized(trackedGlowViews) { trackedGlowViews.remove(view) } + } + } + private fun isSingleCjkGlowView(view: Any): Boolean { val text = invokeNoArg(view, "getText") as? CharSequence ?: return false val normalized = text.toString().trim() @@ -185,6 +236,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( containsCjkKaraokeScript(normalized) } + private fun isTrackedCjkGlowView(view: Any): Boolean = + synchronized(trackedGlowViews) { trackedGlowViews.containsKey(view) } + private fun resetCjkGlowView(view: Any) { invokeMethod(view, "setScaleX", 1f) invokeMethod(view, "setScaleY", 1f) @@ -355,6 +409,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( a0Stack().add(candidate != null) candidate?.let { state -> synchronized(cjkEntryStates) { cjkEntryStates[state.entry] = state } + synchronized(trackedGlowViews) { + state.views.forEach { view -> trackedGlowViews[view] = true } + } } } .onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth enter failed open", error) } From 09ab82a8802be184e0bd199a17ce548cca3a9757 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:07:53 +0800 Subject: [PATCH 29/33] fix: bind CJK glow cleanup to exact animator --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 181 ++++-------------- 1 file changed, 42 insertions(+), 139 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 2afcba5..f19426e 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -25,17 +25,16 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private val fieldCache = Collections.synchronizedMap( WeakHashMap, kotlin.collections.Map>(), ) - private val cjkEntryStates = Collections.synchronizedMap( - WeakHashMap(), - ) private val glowAnimators = Collections.synchronizedMap( - WeakHashMap(), + WeakHashMap>(), + ) + private val activeGlowByView = Collections.synchronizedMap( + WeakHashMap>(), ) private val trackedGlowViews = Collections.synchronizedMap( WeakHashMap(), ) private val rewriteLogCount = AtomicInteger() - private var specialEndHookInstalled = false private var glowEndHookInstalled = false private var glowViewEndHookInstalled = false @@ -94,16 +93,14 @@ internal class AppleMusicCjkKaraokeAnimationTarget( false } - // The special path owns its own z$c end callback. Observe that - // callback instead of adding listeners to every Animator in e.p; the - // latter changes Apple's ordering and was shown to disturb all lyric - // animations on device. - specialEndHookInstalled = installSpecialEndHook(a0) + // Observe only the host's own glow child callback. Do not add + // listeners to every Animator in e.p: that changes Apple's ordering + // and was shown to disturb all lyric animations on device. glowEndHookInstalled = installGlowAnimatorEndHook(a0) glowViewEndHookInstalled = installGlowViewEndHook(a0) - if (!a0Installed || !helperInstalled || !specialEndHookInstalled || - !glowEndHookInstalled || !glowViewEndHookInstalled + if (!a0Installed || !helperInstalled || !glowEndHookInstalled || + !glowViewEndHookInstalled ) { return TargetCapabilityInstall.Degraded( failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, @@ -151,12 +148,15 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun attachGlowEndListener(updateListener: Any?, animator: Animator?) { if (updateListener == null || animator == null) return val view = readNamedField(updateListener, "c") ?: return - if (!isTrackedCjkGlowView(view) && !isSingleCjkGlowView(view)) return + if (!isTrackedCjkGlowView(view)) return val shouldAttach = synchronized(glowAnimators) { if (glowAnimators.containsKey(animator)) { false } else { - glowAnimators[animator] = true + glowAnimators[animator] = WeakReference(view) + synchronized(activeGlowByView) { + activeGlowByView[view] = WeakReference(animator) + } true } } @@ -170,10 +170,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun cleanup() { if (cleaned) return cleaned = true - animatorRef.get()?.let { ended -> - synchronized(glowAnimators) { glowAnimators.remove(ended) } - } - cleanupGlowView(viewRef.get()) + cleanupGlowAnimator(animatorRef.get(), viewRef.get()) } override fun onAnimationCancel(animation: Animator) = cleanup() @@ -208,9 +205,10 @@ internal class AppleMusicCjkKaraokeAnimationTarget( method, object : ModernMethodHook() { override fun afterHookedMethod(param: MethodHookParam) { - param.thisObject + val animator = param.args.getOrNull(0) as? Animator + val fallbackView = param.thisObject ?.let { readNamedField(it, "b") } - ?.let(::cleanupGlowView) + cleanupGlowAnimator(animator, fallbackView) } }, ) @@ -218,22 +216,18 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ModernXposedRuntime.log("CJK karaoke z\$g view end hook unavailable", error) }.getOrDefault(false) - private fun cleanupGlowView(view: Any?) { - if (view == null) return - val tracked = isTrackedCjkGlowView(view) - if (!tracked && !isSingleCjkGlowView(view)) return - resetCjkGlowView(view) - if (tracked) { - synchronized(trackedGlowViews) { trackedGlowViews.remove(view) } + private fun cleanupGlowAnimator(animator: Animator?, fallbackView: Any?) { + if (animator == null) return + val view = synchronized(glowAnimators) { + glowAnimators.remove(animator)?.get() + } ?: fallbackView ?: return + val activeForView = synchronized(activeGlowByView) { + activeGlowByView[view]?.get() } - } - - private fun isSingleCjkGlowView(view: Any): Boolean { - val text = invokeNoArg(view, "getText") as? CharSequence ?: return false - val normalized = text.toString().trim() - return normalized.isNotEmpty() && - normalized.codePointCount(0, normalized.length) == 1 && - containsCjkKaraokeScript(normalized) + if (activeForView != null && activeForView !== animator) return + if (isTrackedCjkGlowView(view)) resetCjkGlowView(view) + synchronized(activeGlowByView) { activeGlowByView.remove(view) } + synchronized(trackedGlowViews) { trackedGlowViews.remove(view) } } private fun isTrackedCjkGlowView(view: Any): Boolean = @@ -247,92 +241,15 @@ internal class AppleMusicCjkKaraokeAnimationTarget( invokeNoArg(view, "invalidate") } - private fun installSpecialEndHook(a0: Executable): Boolean = runCatching { - val owner = a0.declaringClass - val cleanupType = Class.forName( - "${owner.name}\$c", - false, - owner.classLoader, - ) - val method = cleanupType.declaredMethods - .filter { candidate -> - candidate.name == "onAnimationEnd" && - candidate.parameterTypes.size == 1 && - Animator::class.java.isAssignableFrom(candidate.parameterTypes[0]) - } - .singleOrNull() - ?: return@runCatching false - specialEndHookInstalled = ModernXposedRuntime.hookMethod( - method, - object : ModernMethodHook() { - override fun afterHookedMethod(param: MethodHookParam) { - cleanupSpecialCjkEnd(param.thisObject) - } - }, - ) - specialEndHookInstalled - }.onFailure { error -> - ModernXposedRuntime.log("CJK karaoke z\$c end hook unavailable", error) - }.getOrDefault(false) - - private fun cleanupSpecialCjkEnd(listener: Any?) { - if (!specialEndHookInstalled || listener == null) return - runCatching { - val entry = readNamedField(listener, "a") ?: return@runCatching - val state = synchronized(cjkEntryStates) { cjkEntryStates.remove(entry) } - ?: return@runCatching - if (!isCurrentCjkEntry(state)) return@runCatching - val text = readNamedField(entry, "c") as? CharSequence ?: return@runCatching - val background = readNamedField(entry, "h") as? Boolean ?: return@runCatching - val nativeDuration = (readNamedField(entry, "e") as? Number)?.toInt() - ?: return@runCatching - val timing = CjkKaraokeWordTiming( - text = text, - nativeDurationMs = nativeDuration, - cumulativeDurationMs = (readNamedField(entry, "f") as? Number)?.toInt() - ?: return@runCatching, - cumulativeTextLength = (readNamedField(entry, "g") as? Number)?.toInt() - ?: return@runCatching, - splitBindingCount = splitCount(readNamedField(entry, "k")), - isBackground = background, - ) - if (!isSingleUnmergedCjkWord(timing)) return@runCatching - - val expectedText = text.toString().trim() - foregroundEntryViews(entry) - .filter { view -> state.views.any { recorded -> recorded === view } } - .forEach { view -> - val currentText = invokeNoArg(view, "getText") as? CharSequence - if (currentText == null || currentText.toString().trim() != expectedText) { - return@forEach - } - // z$c has already removed its outer AnimatorSet from e.p; - // only clear the properties written by z$f/z$g. - invokeMethod(view, "setScaleX", 1f) - invokeMethod(view, "setScaleY", 1f) - invokeNoArg(view, "resetPivot") - invokeMethod(view, "setShadowLayer", 0f, 0f, 0f, 0) - invokeNoArg(view, "invalidate") - } - }.onFailure { error -> - ModernXposedRuntime.log("CJK karaoke z\$c end cleanup failed", error) - } - } - - private fun isCurrentCjkEntry(state: CjkEntryState): Boolean = runCatching { - val map = cachedFields(state.holder.javaClass)["G"] - ?.let { field -> readField(field, state.holder) as? JavaMap<*, *> } - ?: return@runCatching false - val current = map.get(Integer.valueOf(state.wordId)) ?: map.get(state.wordId) - current === state.entry - }.getOrDefault(false) - private fun foregroundEntryViews(entry: Any): List { - val bindings = mutableListOf() - readNamedField(entry, "i")?.let(bindings::add) - when (val split = readNamedField(entry, "k")) { - is Collection<*> -> split.forEach(bindings::add) - else -> Unit + // z.m0(e, foreground=false) returns the primary e.i binding when it + // exists and only falls back to e.k otherwise. Mirror that choice so + // a recycled split binding cannot be mistaken for this glow target. + val primary = readNamedField(entry, "i") + val bindings: List = if (primary != null) { + listOf(primary) + } else { + (readNamedField(entry, "k") as? Collection<*>)?.toList().orEmpty() } val views = mutableListOf() bindings.forEach { binding -> @@ -342,12 +259,6 @@ internal class AppleMusicCjkKaraokeAnimationTarget( return views } - private fun splitCount(value: Any?): Int = when (value) { - null -> 0 - is Collection<*> -> value.size - else -> -1 - } - private fun rewriteCjkK0Result(param: ModernMethodHook.MethodHookParam) { if (!isSingleWordScope()) return @@ -408,7 +319,6 @@ internal class AppleMusicCjkKaraokeAnimationTarget( val candidate = readSingleWordGateEntry(param) a0Stack().add(candidate != null) candidate?.let { state -> - synchronized(cjkEntryStates) { cjkEntryStates[state.entry] = state } synchronized(trackedGlowViews) { state.views.forEach { view -> trackedGlowViews[view] = true } } @@ -469,24 +379,20 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ?: return@runCatching null val splitValue = cachedFields(entry.javaClass)["k"] ?.let { field -> readField(field, entry) } - val splitCount = when (splitValue) { - null -> 0 - is Collection<*> -> splitValue.size - else -> -1 - } val timing = CjkKaraokeWordTiming( text = text, nativeDurationMs = nativeDuration, cumulativeDurationMs = cumulativeDuration, cumulativeTextLength = cumulativeLength, - splitBindingCount = splitCount, + splitBindingCount = when (splitValue) { + null -> 0 + is Collection<*> -> splitValue.size + else -> -1 + }, isBackground = background, ) if (!isSingleUnmergedCjkWord(timing)) return@runCatching null CjkEntryState( - holder = holder, - wordId = wordId, - entry = entry, views = foregroundEntryViews(entry), ) }.getOrElse { error -> @@ -529,9 +435,6 @@ internal class AppleMusicCjkKaraokeAnimationTarget( }.getOrNull() private data class CjkEntryState( - val holder: Any, - val wordId: Int, - val entry: Any, val views: List, ) From ef341fd69afffeca4c737561d618b4a55e4f14b4 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:11:06 +0800 Subject: [PATCH 30/33] docs: update CJK glow cleanup handoff --- docs/cjk-karaoke-animation-handoff.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index e098c9a..14caa90 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -24,7 +24,7 @@ - 字段:`ModuleSettings.cjkKaraokeAnimationEnabled` - 默认值:`true` - 关闭后需要重启 Apple Music,feature 才不会注册 Hook。 -5. CJK 特殊路径的清理只挂在宿主自己的 `z$c.onAnimationEnd` 上;它要求回调中的 `e` 仍是 `G[wordId]` 当前 entry,并且 binding `U` 仍是 `a0` 时记录的同一 View,才清除该词的 scale/pivot/shadow。不会遍历或删除 `e.p`,也不会恢复 alpha、translation 或英语动画状态。 +5. CJK 特殊路径的清理只挂在宿主自己的 `z$g.onAnimationEnd` 上;`z$g.b` 是该辉光 ValueAnimator 的真实 `CustomTextView`。AM++ 在 `a0` 入口按宿主 `z.m0(false)` 的 `e.i` 优先/e.k 回退规则记录 View,并以 Animator→View 和 View→当前 Animator 的弱引用关联,只有对应的真实子动画结束且 View 没有被新动画接管时,才清除 scale/shadow/pivot。不会遍历或删除 `e.p`,也不会恢复 alpha、translation 或改写英语动画时序。 ## 关键代码位置 From 88a2b49298d4d9b465633ffd5fe3ce6a1a88cc1a Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Sat, 22 Aug 2026 00:39:54 +0800 Subject: [PATCH 31/33] perf: trim CJK karaoke hook overhead --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 130 +++++++++++++++--- 1 file changed, 113 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index f19426e..389247a 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -2,6 +2,7 @@ package dev.amenhancer.module.hook import android.animation.Animator import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator import java.lang.reflect.Executable import java.util.concurrent.atomic.AtomicInteger import java.lang.ref.WeakReference @@ -28,15 +29,21 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private val glowAnimators = Collections.synchronizedMap( WeakHashMap>(), ) + /** z$f is called once per frame; cache its view lookup after the first frame. */ + private val inspectedGlowListeners = Collections.synchronizedMap( + WeakHashMap?>(), + ) private val activeGlowByView = Collections.synchronizedMap( WeakHashMap>(), ) private val trackedGlowViews = Collections.synchronizedMap( - WeakHashMap(), + WeakHashMap(), ) + private val timingRewriteLogCount = AtomicInteger() private val rewriteLogCount = AtomicInteger() private var glowEndHookInstalled = false private var glowViewEndHookInstalled = false + private var glowTimingHookInstalled = false override fun install(): TargetCapabilityInstall { val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) @@ -60,7 +67,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( override fun afterHookedMethod(param: MethodHookParam) { // The host callback runs even when z.a0 throws; always - // release this thread's scope so a later g0 call cannot + // release this thread's scope so a later classifier call cannot // inherit a stale override. leaveA0Scope() } @@ -77,7 +84,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ModernXposedRuntime.hookMethod(helper, object : ModernMethodHook() { override fun afterHookedMethod(param: MethodHookParam) { try { - rewriteCjkK0Result(param) + rewriteCjkClassifierResult(param) } catch (error: Throwable) { // A malformed host call must never break its original // helper result. This is deliberately fail-open. @@ -98,9 +105,10 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // and was shown to disturb all lyric animations on device. glowEndHookInstalled = installGlowAnimatorEndHook(a0) glowViewEndHookInstalled = installGlowViewEndHook(a0) + glowTimingHookInstalled = installGlowTimingHook(a0) if (!a0Installed || !helperInstalled || !glowEndHookInstalled || - !glowViewEndHookInstalled + !glowViewEndHookInstalled || !glowTimingHookInstalled ) { return TargetCapabilityInstall.Degraded( failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, @@ -137,7 +145,8 @@ internal class AppleMusicCjkKaraokeAnimationTarget( method, object : ModernMethodHook() { override fun afterHookedMethod(param: MethodHookParam) { - attachGlowEndListener(param.thisObject, param.args.getOrNull(0) as? Animator) + val animator = param.args.getOrNull(0) as? Animator + attachGlowEndListener(param.thisObject, animator) } }, ) @@ -145,9 +154,52 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ModernXposedRuntime.log("CJK karaoke z\$f end hook unavailable", error) }.getOrDefault(false) + /** + * The host's special glow uses a rise child followed by a return child. + * For a single CJK binding the return child is otherwise delayed by + * 2*duration, leaving the raised/glowing frame held for a long envelope. + * Shorten only that exact z$f child to begin after the rise duration. + */ + private fun installGlowTimingHook(a0: Executable): Boolean = runCatching { + val addUpdateListener = ValueAnimator::class.java.getDeclaredMethod( + "addUpdateListener", + ValueAnimator.AnimatorUpdateListener::class.java, + ) + ModernXposedRuntime.hookMethod( + addUpdateListener, + object : ModernMethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + val animator = param.thisObject as? ValueAnimator ?: return + val listener = param.args.getOrNull(0) ?: return + if (!isSingleWordScope()) return + if (listener.javaClass.name != "${a0.declaringClass.name}\$f") return + runCatching { + val duration = animator.duration + val originalDelay = animator.startDelay + if (duration > 0L && originalDelay > duration) { + animator.startDelay = duration + if (timingRewriteLogCount.getAndIncrement() < MAX_TIMING_REWRITE_LOGS) { + ModernXposedRuntime.log( + "CJK karaoke glow timing: z\$f delay " + + "$originalDelay -> $duration (duration=$duration)", + ) + } + } + }.onFailure { error -> + ModernXposedRuntime.log("CJK karaoke glow timing failed open", error) + } + } + }, + ) + }.onFailure { error -> + ModernXposedRuntime.log("CJK karaoke glow timing hook unavailable", error) + }.getOrDefault(false) + private fun attachGlowEndListener(updateListener: Any?, animator: Animator?) { if (updateListener == null || animator == null) return - val view = readNamedField(updateListener, "c") ?: return + val alreadyTracked = synchronized(glowAnimators) { glowAnimators.containsKey(animator) } + if (alreadyTracked) return + val view = inspectedGlowView(updateListener) ?: return if (!isTrackedCjkGlowView(view)) return val shouldAttach = synchronized(glowAnimators) { if (glowAnimators.containsKey(animator)) { @@ -179,6 +231,15 @@ internal class AppleMusicCjkKaraokeAnimationTarget( }) } + private fun inspectedGlowView(listener: Any): Any? = synchronized(inspectedGlowListeners) { + if (inspectedGlowListeners.containsKey(listener)) { + return@synchronized inspectedGlowListeners[listener]?.get() + } + val view = readNamedField(listener, "c") + inspectedGlowListeners[listener] = view?.let(::WeakReference) + view + } + /** * z$g is the host's own end listener for the ValueAnimator that runs the * glow's return-to-normal phase. Hooking that exact seam is more reliable @@ -225,7 +286,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( activeGlowByView[view]?.get() } if (activeForView != null && activeForView !== animator) return - if (isTrackedCjkGlowView(view)) resetCjkGlowView(view) + synchronized(trackedGlowViews) { + trackedGlowViews[view]?.let { baseline -> + resetCjkGlowView(view, baseline) + } + } synchronized(activeGlowByView) { activeGlowByView.remove(view) } synchronized(trackedGlowViews) { trackedGlowViews.remove(view) } } @@ -233,14 +298,24 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun isTrackedCjkGlowView(view: Any): Boolean = synchronized(trackedGlowViews) { trackedGlowViews.containsKey(view) } - private fun resetCjkGlowView(view: Any) { - invokeMethod(view, "setScaleX", 1f) - invokeMethod(view, "setScaleY", 1f) + private fun resetCjkGlowView(view: Any, baseline: CjkGlowBaseline) { + invokeMethod(view, "setScaleX", baseline.scaleX) + invokeMethod(view, "setScaleY", baseline.scaleY) + invokeMethod(view, "setTranslationY", baseline.translationY) invokeNoArg(view, "resetPivot") invokeMethod(view, "setShadowLayer", 0f, 0f, 0f, 0) invokeNoArg(view, "invalidate") } + private fun captureCjkGlowBaseline(view: Any): CjkGlowBaseline? = runCatching { + CjkGlowBaseline( + scaleX = (invokeNoArg(view, "getScaleX") as? Number)?.toFloat() ?: return@runCatching null, + scaleY = (invokeNoArg(view, "getScaleY") as? Number)?.toFloat() ?: return@runCatching null, + translationY = (invokeNoArg(view, "getTranslationY") as? Number) + ?.toFloat() ?: return@runCatching null, + ) + }.getOrNull() + private fun foregroundEntryViews(entry: Any): List { // z.m0(e, foreground=false) returns the primary e.i binding when it // exists and only falls back to e.k otherwise. Mirror that choice so @@ -259,13 +334,20 @@ internal class AppleMusicCjkKaraokeAnimationTarget( return views } - private fun rewriteCjkK0Result(param: ModernMethodHook.MethodHookParam) { - if (!isSingleWordScope()) return - + private fun rewriteCjkClassifierResult(param: ModernMethodHook.MethodHookParam) { val text = param.args.getOrNull(0) as? CharSequence ?: return - if (!containsCjkKaraokeScript(text)) return - val languageSet = param.args.getOrNull(1) as? Set<*> ?: return + if (isSingleWordScope()) { + rewriteCjkAnimationResult(param, text, languageSet) + } + } + + private fun rewriteCjkAnimationResult( + param: ModernMethodHook.MethodHookParam, + text: CharSequence, + languageSet: Set<*>, + ) { + if (!containsCjkKaraokeScript(text)) return when { isK0Set(languageSet) && param.result == true -> { // CJK is normally classified into k0, which blocks the @@ -288,11 +370,12 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun logRewrite(text: CharSequence, change: String) { if (rewriteLogCount.getAndIncrement() < MAX_REWRITE_LOGS) { ModernXposedRuntime.log( - "CJK karaoke a0 scope: I0\$a.a(${text.length} chars, $change); g0 unchanged", + "CJK karaoke classifier: I0\$a.a(${text.length} chars, $change)", ) } } + private fun isK0Set(languageSet: Set<*>): Boolean = runCatching { // z.k0 always contains HANGUL_SYLLABLES. This marker distinguishes // it from the host's j0/l0 sets without touching either set globally. @@ -320,7 +403,13 @@ internal class AppleMusicCjkKaraokeAnimationTarget( a0Stack().add(candidate != null) candidate?.let { state -> synchronized(trackedGlowViews) { - state.views.forEach { view -> trackedGlowViews[view] = true } + state.views.forEach { view -> + if (!trackedGlowViews.containsKey(view)) { + captureCjkGlowBaseline(view)?.let { baseline -> + trackedGlowViews[view] = baseline + } + } + } } } } @@ -438,8 +527,15 @@ internal class AppleMusicCjkKaraokeAnimationTarget( val views: List, ) + private data class CjkGlowBaseline( + val scaleX: Float, + val scaleY: Float, + val translationY: Float, + ) + private companion object { const val MAX_REWRITE_LOGS = 3 + const val MAX_TIMING_REWRITE_LOGS = 3 } } From d59021007de78e3e7ba4de3576cee79f131cb5cb Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:45:12 +0800 Subject: [PATCH 32/33] fix: keep CJK glow cleanup out of lyric position --- .../hook/AppleMusicCjkKaraokeAnimationTarget.kt | 7 +++---- .../module/hook/CjkKaraokeAnimationTargetTest.kt | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index 389247a..df80a33 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -299,9 +299,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( synchronized(trackedGlowViews) { trackedGlowViews.containsKey(view) } private fun resetCjkGlowView(view: Any, baseline: CjkGlowBaseline) { + // translationY belongs to Apple's lyric layout/rebind state. The glow + // listener never owns it, so restoring a captured value here races a + // later host layout pass and can make a word jump to a lower baseline. invokeMethod(view, "setScaleX", baseline.scaleX) invokeMethod(view, "setScaleY", baseline.scaleY) - invokeMethod(view, "setTranslationY", baseline.translationY) invokeNoArg(view, "resetPivot") invokeMethod(view, "setShadowLayer", 0f, 0f, 0f, 0) invokeNoArg(view, "invalidate") @@ -311,8 +313,6 @@ internal class AppleMusicCjkKaraokeAnimationTarget( CjkGlowBaseline( scaleX = (invokeNoArg(view, "getScaleX") as? Number)?.toFloat() ?: return@runCatching null, scaleY = (invokeNoArg(view, "getScaleY") as? Number)?.toFloat() ?: return@runCatching null, - translationY = (invokeNoArg(view, "getTranslationY") as? Number) - ?.toFloat() ?: return@runCatching null, ) }.getOrNull() @@ -530,7 +530,6 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private data class CjkGlowBaseline( val scaleX: Float, val scaleY: Float, - val translationY: Float, ) private companion object { diff --git a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt index a32ae53..3648bcc 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt @@ -1,12 +1,28 @@ package dev.amenhancer.module.hook import dev.amenhancer.module.ModuleConstants +import java.io.File import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class CjkKaraokeAnimationTargetTest { + @Test + fun `glow cleanup leaves host-owned vertical position untouched`() { + val source = sequenceOf( + File("src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt"), + File("app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt"), + ).firstOrNull(File::isFile)?.readText() + ?: error("AppleMusicCjkKaraokeAnimationTarget.kt was not found") + val cleanup = source.substringAfter("private fun resetCjkGlowView") + .substringBefore("private fun captureCjkGlowBaseline") + + assertFalse(cleanup.contains("setTranslationY")) + assertFalse(cleanup.contains("baseline.translationY")) + assertFalse(source.contains("val translationY: Float")) + } + @Test fun `predicate recognizes host CJK script blocks but not latin text`() { assertTrue(containsCjkKaraokeScript("漢")) From d551b06c473d269d54dcd0257175350ee3a667a9 Mon Sep 17 00:00:00 2001 From: Zennmn <176552149+Zennmn@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:25:24 +0800 Subject: [PATCH 33/33] fix: harden CJK glow hook lifecycle --- .../AppleMusicCjkKaraokeAnimationTarget.kt | 76 ++++++++++++++----- .../hook/CjkKaraokeAnimationTargetTest.kt | 43 +++++++++-- docs/cjk-karaoke-animation-handoff.md | 2 +- 3 files changed, 98 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt index df80a33..5fe7a20 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -21,7 +21,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private val symbols: TargetSymbolResolver, ) : CjkKaraokeAnimationTarget { private val a0Depth: ThreadLocal = ThreadLocal.withInitial { 0 } - private val a0SingleWordStack: ThreadLocal> = + private val a0SingleWordStack: ThreadLocal> = ThreadLocal.withInitial { mutableListOf() } private val fieldCache = Collections.synchronizedMap( WeakHashMap, kotlin.collections.Map>(), @@ -44,8 +44,14 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private var glowEndHookInstalled = false private var glowViewEndHookInstalled = false private var glowTimingHookInstalled = false + @Volatile + private var hooksReady = false override fun install(): TargetCapabilityInstall { + hooksReady = false + glowEndHookInstalled = false + glowViewEndHookInstalled = false + glowTimingHookInstalled = false val a0Resolution = symbols.resolve(AppleMusicSymbols.CjkKaraokeAnimationMethod) val helperResolution = symbols.resolve(AppleMusicSymbols.CjkUnicodeBlockPredicateMethod) val a0 = a0Resolution.valueOrNull() @@ -69,6 +75,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( // The host callback runs even when z.a0 throws; always // release this thread's scope so a later classifier call cannot // inherit a stale override. + completeA0Scope(param) leaveA0Scope() } }).also { installed -> @@ -107,9 +114,12 @@ internal class AppleMusicCjkKaraokeAnimationTarget( glowViewEndHookInstalled = installGlowViewEndHook(a0) glowTimingHookInstalled = installGlowTimingHook(a0) - if (!a0Installed || !helperInstalled || !glowEndHookInstalled || - !glowViewEndHookInstalled || !glowTimingHookInstalled - ) { + val hooksInstalled = a0Installed && helperInstalled && glowEndHookInstalled && + glowViewEndHookInstalled && glowTimingHookInstalled + hooksReady = hooksInstalled + if (!hooksInstalled) { + // Hooks cannot be removed reliably through the modern runtime. Keep + // any partially registered callbacks inert until every seam is ready. return TargetCapabilityInstall.Degraded( failures.joinToString("; ").ifBlank { "CJK karaoke animation hooks were not installed" }, ) @@ -196,7 +206,7 @@ internal class AppleMusicCjkKaraokeAnimationTarget( }.getOrDefault(false) private fun attachGlowEndListener(updateListener: Any?, animator: Animator?) { - if (updateListener == null || animator == null) return + if (!hooksReady || updateListener == null || animator == null) return val alreadyTracked = synchronized(glowAnimators) { glowAnimators.containsKey(animator) } if (alreadyTracked) return val view = inspectedGlowView(updateListener) ?: return @@ -397,23 +407,39 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } private fun enterA0Scope(param: ModernMethodHook.MethodHookParam) { + if (!hooksReady) return runCatching { a0Depth.set((a0Depth.get() ?: 0) + 1) val candidate = readSingleWordGateEntry(param) - a0Stack().add(candidate != null) - candidate?.let { state -> - synchronized(trackedGlowViews) { - state.views.forEach { view -> - if (!trackedGlowViews.containsKey(view)) { - captureCjkGlowBaseline(view)?.let { baseline -> - trackedGlowViews[view] = baseline - } + a0Stack().add(candidate) + } + .onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth enter failed open", error) } + } + + /** + * Commit a baseline only after the host has added a new special-path + * Animator to the entry. A single-word candidate can still be rejected by + * Apple's duration/length gates, in which case its recycled View must not + * remain globally classified as a CJK glow target. + */ + private fun completeA0Scope(param: ModernMethodHook.MethodHookParam) { + if (!hooksReady || param.throwable != null) return + runCatching { + val state = a0Stack().lastOrNull() ?: return@runCatching + val after = specialAnimatorSnapshot(state.entry) + if (!hasNewCjkGlowAnimator(state.specialAnimatorsBefore, after)) return@runCatching + synchronized(trackedGlowViews) { + state.views.forEach { view -> + if (!trackedGlowViews.containsKey(view)) { + captureCjkGlowBaseline(view)?.let { baseline -> + trackedGlowViews[view] = baseline } } } } + }.onFailure { error -> + ModernXposedRuntime.log("CJK karaoke glow baseline commit failed open", error) } - .onFailure { error -> ModernXposedRuntime.log("CJK karaoke a0 depth enter failed open", error) } } private fun leaveA0Scope() { @@ -431,15 +457,15 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } private fun isSingleWordScope(): Boolean = runCatching { - (a0Depth.get() ?: 0) > 0 && a0Stack().lastOrNull() == true + hooksReady && (a0Depth.get() ?: 0) > 0 && a0Stack().lastOrNull() != null } .getOrElse { error -> ModernXposedRuntime.log("CJK karaoke single-word gate read failed open", error) false } - private fun a0Stack(): MutableList = - a0SingleWordStack.get() ?: mutableListOf().also(a0SingleWordStack::set) + private fun a0Stack(): MutableList = + a0SingleWordStack.get() ?: mutableListOf().also(a0SingleWordStack::set) /** Reads only the host's grouping metadata; Apple retains all trigger gates. */ private fun readSingleWordGateEntry(param: ModernMethodHook.MethodHookParam): CjkEntryState? = @@ -482,7 +508,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( ) if (!isSingleUnmergedCjkWord(timing)) return@runCatching null CjkEntryState( + entry = entry, views = foregroundEntryViews(entry), + specialAnimatorsBefore = specialAnimatorSnapshot(entry), ) }.getOrElse { error -> ModernXposedRuntime.log("CJK single-word gate failed closed: ${error.cjkShortMessage()}") @@ -512,6 +540,13 @@ internal class AppleMusicCjkKaraokeAnimationTarget( private fun readNamedField(receiver: Any, name: String): Any? = cachedFields(receiver.javaClass)[name]?.let { field -> readField(field, receiver) } + private fun specialAnimatorSnapshot(entry: Any): List = when ( + val value = readNamedField(entry, "p") + ) { + is Collection<*> -> value.filterIsInstance().map { it as Any } + else -> emptyList() + } + private fun invokeNoArg(receiver: Any, methodName: String): Any? = invokeMethod(receiver, methodName) @@ -524,7 +559,9 @@ internal class AppleMusicCjkKaraokeAnimationTarget( }.getOrNull() private data class CjkEntryState( + val entry: Any, val views: List, + val specialAnimatorsBefore: List, ) private data class CjkGlowBaseline( @@ -538,6 +575,11 @@ internal class AppleMusicCjkKaraokeAnimationTarget( } } +internal fun hasNewCjkGlowAnimator( + before: Collection, + after: Collection, +): Boolean = after.any { candidate -> before.none { previous -> previous === candidate } } + /** Returns true for the CJK blocks used by the host's karaoke classifier. */ internal fun containsCjkKaraokeScript(text: CharSequence): Boolean { for (index in text.indices) { diff --git a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt index 3648bcc..ccc598b 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt @@ -8,13 +8,40 @@ import org.junit.Assert.assertTrue import org.junit.Test class CjkKaraokeAnimationTargetTest { + @Test + fun `only a newly created special animator commits a CJK glow baseline`() { + val existing = Any() + + assertFalse(hasNewCjkGlowAnimator(listOf(existing), listOf(existing))) + assertTrue(hasNewCjkGlowAnimator(listOf(existing), listOf(existing, Any()))) + } + + @Test + fun `CJK classifier scope is gated by complete hook installation`() { + val source = targetSource() + val scope = source.substringAfter("private fun isSingleWordScope") + .substringBefore("private fun a0Stack") + + assertTrue(source.contains("@Volatile\n private var hooksReady = false")) + assertTrue(scope.contains("hooksReady")) + assertTrue(source.contains("val hooksInstalled = a0Installed && helperInstalled")) + assertTrue(source.contains("hooksReady = hooksInstalled")) + } + + @Test + fun `candidate view is committed only after a0 confirms a new special animator`() { + val source = targetSource() + val enter = source.substringAfter("private fun enterA0Scope") + .substringBefore("private fun completeA0Scope") + + assertFalse(enter.contains("trackedGlowViews")) + assertTrue(source.contains("completeA0Scope")) + assertTrue(source.contains("hasNewCjkGlowAnimator")) + } + @Test fun `glow cleanup leaves host-owned vertical position untouched`() { - val source = sequenceOf( - File("src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt"), - File("app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt"), - ).firstOrNull(File::isFile)?.readText() - ?: error("AppleMusicCjkKaraokeAnimationTarget.kt was not found") + val source = targetSource() val cleanup = source.substringAfter("private fun resetCjkGlowView") .substringBefore("private fun captureCjkGlowBaseline") @@ -23,6 +50,12 @@ class CjkKaraokeAnimationTargetTest { assertFalse(source.contains("val translationY: Float")) } + private fun targetSource(): String = sequenceOf( + File("src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt"), + File("app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt"), + ).firstOrNull(File::isFile)?.readText()?.replace("\r\n", "\n") + ?: error("AppleMusicCjkKaraokeAnimationTarget.kt was not found") + @Test fun `predicate recognizes host CJK script blocks but not latin text`() { assertTrue(containsCjkKaraokeScript("漢")) diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md index 14caa90..2b7877a 100644 --- a/docs/cjk-karaoke-animation-handoff.md +++ b/docs/cjk-karaoke-animation-handoff.md @@ -24,7 +24,7 @@ - 字段:`ModuleSettings.cjkKaraokeAnimationEnabled` - 默认值:`true` - 关闭后需要重启 Apple Music,feature 才不会注册 Hook。 -5. CJK 特殊路径的清理只挂在宿主自己的 `z$g.onAnimationEnd` 上;`z$g.b` 是该辉光 ValueAnimator 的真实 `CustomTextView`。AM++ 在 `a0` 入口按宿主 `z.m0(false)` 的 `e.i` 优先/e.k 回退规则记录 View,并以 Animator→View 和 View→当前 Animator 的弱引用关联,只有对应的真实子动画结束且 View 没有被新动画接管时,才清除 scale/shadow/pivot。不会遍历或删除 `e.p`,也不会恢复 alpha、translation 或改写英语动画时序。 +5. CJK 特殊路径的清理只挂在宿主自己的 `z$g.onAnimationEnd` 上;`z$g.b` 是该辉光 ValueAnimator 的真实 `CustomTextView`。AM++ 在 `a0` 返回后确认宿主 `e.p` 新增了特殊 Animator,才按宿主 `z.m0(false)` 的 `e.i` 优先/e.k 回退规则记录 View,并以 Animator→View 和 View→当前 Animator 的弱引用关联;duration/length 门槛未创建辉光时不会留下追踪项。只有对应的真实子动画结束且 View 没有被新动画接管时,才清除 scale/shadow/pivot。不会遍历或删除 `e.p`,也不会恢复 alpha、translation 或改写英语动画时序。 ## 关键代码位置