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..5fe7a20 --- /dev/null +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicCjkKaraokeAnimationTarget.kt @@ -0,0 +1,615 @@ +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 +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 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 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(), + ) + private val timingRewriteLogCount = AtomicInteger() + private val rewriteLogCount = AtomicInteger() + 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() + 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(param) + } + + override fun afterHookedMethod(param: MethodHookParam) { + // 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 -> + 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 { + rewriteCjkClassifierResult(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 + } + + // 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) + glowTimingHookInstalled = installGlowTimingHook(a0) + + 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" }, + ) + } + + return TargetCapabilityInstall.Active( + "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) { + val animator = param.args.getOrNull(0) as? Animator + attachGlowEndListener(param.thisObject, animator) + } + }, + ) + }.onFailure { error -> + 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 (!hooksReady || updateListener == null || animator == null) 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)) { + false + } else { + glowAnimators[animator] = WeakReference(view) + synchronized(activeGlowByView) { + activeGlowByView[view] = WeakReference(animator) + } + 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 + cleanupGlowAnimator(animatorRef.get(), viewRef.get()) + } + + override fun onAnimationCancel(animation: Animator) = cleanup() + + override fun onAnimationEnd(animation: Animator) = cleanup() + }) + } + + 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 + * 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) { + val animator = param.args.getOrNull(0) as? Animator + val fallbackView = param.thisObject + ?.let { readNamedField(it, "b") } + cleanupGlowAnimator(animator, fallbackView) + } + }, + ) + }.onFailure { error -> + ModernXposedRuntime.log("CJK karaoke z\$g view end hook unavailable", error) + }.getOrDefault(false) + + 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() + } + if (activeForView != null && activeForView !== animator) return + synchronized(trackedGlowViews) { + trackedGlowViews[view]?.let { baseline -> + resetCjkGlowView(view, baseline) + } + } + synchronized(activeGlowByView) { activeGlowByView.remove(view) } + synchronized(trackedGlowViews) { trackedGlowViews.remove(view) } + } + + private fun isTrackedCjkGlowView(view: Any): Boolean = + 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) + 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, + ) + }.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 + // 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 -> + val view = binding?.let { readNamedField(it, "U") } ?: return@forEach + if (views.none { it === view }) views += view + } + return views + } + + 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 (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 + // 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 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. + 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(param: ModernMethodHook.MethodHookParam) { + if (!hooksReady) return + runCatching { + a0Depth.set((a0Depth.get() ?: 0) + 1) + val candidate = readSingleWordGateEntry(param) + 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) + } + } + + private fun leaveA0Scope() { + runCatching { + val depth = a0Depth.get() ?: 0 + if (depth <= 1) { + a0Depth.remove() + } 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 isSingleWordScope(): Boolean = runCatching { + 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) + + /** Reads only the host's grouping metadata; Apple retains all trigger gates. */ + private fun readSingleWordGateEntry(param: ModernMethodHook.MethodHookParam): CjkEntryState? = + runCatching { + val holder = param.args.getOrNull(0) ?: return@runCatching null + val wordId = (param.args.getOrNull(2) as? Number)?.toInt() + ?: return@runCatching null + val nativeDuration = (param.args.getOrNull(3) as? Number)?.toInt() + ?: return@runCatching null + val background = param.args.getOrNull(4) as? Boolean + ?: 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 null + val entry = map.get(Integer.valueOf(wordId)) ?: map.get(wordId) + ?: return@runCatching null + val text = cachedFields(entry.javaClass)["c"] + ?.let { field -> readField(field, entry) as? CharSequence } + ?: return@runCatching null + val cumulativeDuration = cachedFields(entry.javaClass)["f"] + ?.let { field -> (readField(field, entry) as? Number)?.toInt() } + ?: return@runCatching null + val cumulativeLength = cachedFields(entry.javaClass)["g"] + ?.let { field -> (readField(field, entry) as? Number)?.toInt() } + ?: return@runCatching null + val splitValue = cachedFields(entry.javaClass)["k"] + ?.let { field -> readField(field, entry) } + val timing = CjkKaraokeWordTiming( + text = text, + nativeDurationMs = nativeDuration, + cumulativeDurationMs = cumulativeDuration, + cumulativeTextLength = cumulativeLength, + splitBindingCount = when (splitValue) { + null -> 0 + is Collection<*> -> splitValue.size + else -> -1 + }, + isBackground = background, + ) + 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()}") + null + } + + 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 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) + + 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 data class CjkEntryState( + val entry: Any, + val views: List, + val specialAnimatorsBefore: List, + ) + + private data class CjkGlowBaseline( + val scaleX: Float, + val scaleY: Float, + ) + + private companion object { + const val MAX_REWRITE_LOGS = 3 + const val MAX_TIMING_REWRITE_LOGS = 3 + } +} + +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) { + 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/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/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..ccc598b --- /dev/null +++ b/app/src/test/java/dev/amenhancer/module/hook/CjkKaraokeAnimationTargetTest.kt @@ -0,0 +1,182 @@ +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 `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 = targetSource() + 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")) + } + + 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("漢")) + assertTrue(containsCjkKaraokeScript("ひ")) + assertTrue(containsCjkKaraokeScript("カ")) + assertTrue(containsCjkKaraokeScript("한")) + 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( + 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("歌词模糊半径偏移")) diff --git a/docs/cjk-karaoke-animation-handoff.md b/docs/cjk-karaoke-animation-handoff.md new file mode 100644 index 0000000..2b7877a --- /dev/null +++ b/docs/cjk-karaoke-animation-handoff.md @@ -0,0 +1,79 @@ +# CJK Karaoke Animation 交接文档 + +## 目的 + +本分支保存 Apple Music 6.5.2/1586 的 CJK 长尾歌词动画实验。它不属于 `main` 稳定线,后续维护和设备验证都从本分支继续。 + +## 当前分支与远端 + +- 分支:`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. 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` + - 关闭后需要重启 Apple Music,feature 才不会注册 Hook。 +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 或改写英语动画时序。 + +## 关键代码位置 + +- 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++ 自己绘制;AM++ 只阻止合并 CJK 词块进入原生长辉光分类。 +- 判断失败时默认不放行,保持 Apple 原始行为;带组合音标、补充字符或多 binding 的文字会被保守跳过。 +- Apple 版本、混淆类名和方法签名是私有契约;新增版本必须重新做 exact profile 和设备验证。 +- 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 或发布后续提交。