diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b07fdca..aabf45e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,8 +35,8 @@ android { applicationId = "dev.amenhancer.module" minSdk = 26 targetSdk = 37 - versionCode = 97 - versionName = "1.4.0" + versionCode = 98 + versionName = "1.4.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt index 324749b..12c16e7 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.res.Configuration import android.graphics.Color import android.os.Bundle +import android.os.SystemClock import android.view.Gravity import android.view.View import android.view.ViewGroup @@ -19,6 +20,7 @@ import java.lang.reflect.Method import java.lang.reflect.Modifier import java.lang.ref.WeakReference import java.util.WeakHashMap +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.roundToInt /** @@ -46,6 +48,7 @@ internal object DualPaneResourceHook { DualPaneShell.installImmediately(root) } hookTabletLandscapeLyricsSheet() + TranslationsPopupOffsetHook.install() // The modified package changes `lyrics_line_text_size` only in its // w640dp resource table. Hook the two layouts that actually reference // that dimension so normal and karaoke lyrics receive the same 35sp @@ -180,6 +183,137 @@ private object RightLyricsPaneLayout { } } +/** + * Apple shows the translations popup synchronously from the button onClick as + * PopupWindow.showAsDropDown(anchor, x, y). With the dual-pane landscape + * controls strip hidden the lyrics sheet reaches the bottom edge, so the + * stock popup would open below the visible sheet and clip black. This + * framework hook shifts only the popup's own y offset by the popup's own + * measured height plus the anchor button's height (unless overlapAnchor is + * set, in which case the framework already counts the anchor), so the popup's + * bottom edge lands on the button's top edge; the anchor view, + * ConstraintLayout, lyrics metrics and bottom-bar boundary are never touched. + * Matching is strict (popup content id + lyrics-sheet ancestor + tablet + * predicate), so every other PopupWindow in the target process passes through + * unchanged. The four-argument showAsDropDown is hooked because the one- and + * three-argument overloads both delegate to it, so one hook covers every + * entry point without double-shifting. + */ +private object TranslationsPopupOffsetHook { + private const val TRANSLATIONS_POPUP_MENU = "translations_popup_menu" + private const val CONTROLS = "controls" + private const val RECYCLER_VIEW_GRADIENTS = "recycler_view_gradients" + private const val SPARSE_DEBUG_INTERVAL_MS = 60_000L + + private val installed = AtomicBoolean(false) + @Volatile + private var lastDebugUptime = 0L + + /** + * Idempotent: DualPaneResourceHook.install may run more than once, and a + * framework hook failure must never take dual-pane down with it. + */ + fun install() { + if (!installed.compareAndSet(false, true)) return + runCatching { + val showAsDropDown = android.widget.PopupWindow::class.java.getDeclaredMethod( + "showAsDropDown", + View::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ) + ModernXposedRuntime.hookMethod(showAsDropDown, object : XC_MethodHook() { + override fun beforeHookedMethod(param: XC_MethodHook.MethodHookParam) { + shiftTranslationsPopupOffset(param) + } + }) + debug("translations popup offset hook installed") + }.onFailure { + debug("translations popup offset hook registration failed: $it") + } + } + + private fun shiftTranslationsPopupOffset(param: XC_MethodHook.MethodHookParam) { + val popup = param.thisObject as? android.widget.PopupWindow ?: return + val anchor = param.args.firstOrNull() as? View ?: return + if (!TabletModeQualifier.isEligible(anchor.context)) return + val resources = anchor.resources + val popupMenuId = resources.getIdentifier( + TRANSLATIONS_POPUP_MENU, + "id", + ModuleConstants.TARGET_PACKAGE, + ).takeIf { it != 0 } ?: run { + sparseDebug("translations popup offset skipped: id/translations_popup_menu missing") + return + } + val contentView = runCatching { popup.contentView }.getOrNull() ?: return + if (contentView.id != popupMenuId) return + if (findLyricsSheetRoot(anchor, resources) == null) { + sparseDebug("translations popup offset skipped: lyrics sheet ancestor missing") + return + } + val measureResult = runCatching { + contentView.measure( + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + } + if (measureResult.isFailure) { + sparseDebug("translations popup offset skipped: contentView measure failed") + return + } + val popupHeight = contentView.measuredHeight + if (popupHeight <= 0 || anchor.height <= 0) { + sparseDebug( + "translations popup offset skipped: popupHeight=" + popupHeight + + " anchorHeight=" + anchor.height, + ) + return + } + val originalYOffset = param.args[2] as? Int ?: return + val overlapAnchor = runCatching { popup.overlapAnchor }.getOrDefault(false) + val shiftAmount = popupHeight + if (overlapAnchor) 0 else anchor.height + val shiftedYOffset = originalYOffset - shiftAmount + param.args[2] = shiftedYOffset + sparseDebug( + "translations popup yOffset shifted from " + originalYOffset + " to " + shiftedYOffset + + " popupHeight=" + popupHeight + " anchorHeight=" + anchor.height, + ) + } + + /** + * The first ancestor containing both the hidden controls container and + * the recycler gradients is the landscape lyrics sheet root; the gate + * keeps the shift on popups anchored inside the lyrics sheet only. + */ + private fun findLyricsSheetRoot(anchor: View, resources: android.content.res.Resources): View? { + val controlsId = resources.getIdentifier(CONTROLS, "id", ModuleConstants.TARGET_PACKAGE) + .takeIf { it != 0 } ?: return null + val gradientsId = resources.getIdentifier(RECYCLER_VIEW_GRADIENTS, "id", ModuleConstants.TARGET_PACKAGE) + .takeIf { it != 0 } ?: return null + var candidate = anchor.parent as? View + while (candidate != null) { + if ( + candidate.findViewById(controlsId) != null && + candidate.findViewById(gradientsId) != null + ) { + return candidate + } + candidate = candidate.parent as? View + } + return null + } + + /** The framework hook runs for every popup; keep diagnostics sparse. */ + private fun sparseDebug(message: String) { + val now = SystemClock.uptimeMillis() + if (now - lastDebugUptime < SPARSE_DEBUG_INTERVAL_MS) return + lastDebugUptime = now + debug(message) + } +} + internal data class AlphaGradientEdgeFieldProfile( val vertical: List, val horizontal: List, diff --git a/app/src/test/java/dev/amenhancer/module/hook/RightLyricsPaneStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/RightLyricsPaneStructuralRegressionTest.kt index ffd2f7c..14db22b 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/RightLyricsPaneStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/RightLyricsPaneStructuralRegressionTest.kt @@ -20,6 +20,10 @@ class RightLyricsPaneStructuralRegressionTest { ?: error("AppleMusicDualPaneTarget.kt was not found from the unit-test working directory") } private val compactSource: String by lazy { source.replace(Regex("\\s+"), " ") } + private val paneSource: String by lazy { + source.substringAfter("private object RightLyricsPaneLayout") + .substringBefore("internal data class AlphaGradientEdgeFieldProfile") + } @Test fun `mirrors the modified right lyrics sheet resource at its inflation boundary`() { @@ -77,4 +81,79 @@ class RightLyricsPaneStructuralRegressionTest { assertTrue(source.contains("profile.synchronizedMetrics.first()")) assertTrue(source.contains("RightLyricsPaneLayout.reapplyVerticalGradientEdges(gradients)")) } + + @Test + fun `keeps controls gone through the hide helper without an invisible candidate`() { + assertTrue(paneSource.contains("hide(root, resources, CONTROLS)")) + assertTrue(paneSource.contains("private fun hide(root: View, resources: android.content.res.Resources, name: String)")) + assertTrue(paneSource.contains("visibility = View.GONE")) + assertFalse(paneSource.contains("INVISIBLE")) + assertFalse(paneSource.contains("visibility: Int")) + } + + @Test + fun `offsets the translations popup through the framework showAsDropDown hook`() { + assertTrue(paneSource.contains("TRANSLATIONS_POPUP_MENU = \"translations_popup_menu\"")) + assertTrue(source.contains("TranslationsPopupOffsetHook.install()")) + assertTrue(paneSource.contains("PopupWindow::class.java.getDeclaredMethod(")) + assertTrue(paneSource.contains("\"showAsDropDown\"")) + assertTrue(paneSource.contains("View::class.java")) + assertTrue(paneSource.contains("Int::class.javaPrimitiveType")) + assertTrue( + Regex( + """getDeclaredMethod\(\s*"showAsDropDown",\s*View::class.java,\s*Int::class.javaPrimitiveType,\s*Int::class.javaPrimitiveType,\s*Int::class.javaPrimitiveType""", + ).containsMatchIn(paneSource), + ) + assertTrue(paneSource.contains("ModernXposedRuntime.hookMethod(showAsDropDown")) + assertTrue(paneSource.contains("override fun beforeHookedMethod")) + assertTrue(paneSource.contains("shiftTranslationsPopupOffset(param)")) + assertTrue(paneSource.contains("contentView.id != popupMenuId")) + assertTrue(paneSource.contains("contentView.measure(")) + assertTrue(paneSource.contains("View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)")) + assertTrue(paneSource.contains("measureResult.isFailure")) + assertTrue(paneSource.contains("popup.overlapAnchor")) + assertTrue(paneSource.contains("if (overlapAnchor) 0 else anchor.height")) + assertTrue(paneSource.contains("param.args[2]")) + assertFalse(paneSource.contains("PLAYER_CONTROLS_HEIGHT_PERCENT")) + assertFalse(paneSource.contains("sheetHeight")) + assertFalse(paneSource.contains("TypedValue")) + } + + @Test + fun `matches only a popup anchored inside the landscape lyrics sheet`() { + assertTrue(paneSource.contains("findLyricsSheetRoot(anchor, resources)")) + assertTrue(paneSource.contains("CONTROLS")) + assertTrue(paneSource.contains("RECYCLER_VIEW_GRADIENTS")) + assertTrue(paneSource.contains("candidate.findViewById(controlsId) != null")) + assertTrue(paneSource.contains("candidate.findViewById(gradientsId) != null")) + assertTrue(paneSource.contains("candidate = candidate.parent as? View")) + assertTrue(paneSource.contains("TabletModeQualifier.isEligible(anchor.context)")) + } + + @Test + fun `never translates the translations button to reposition the popup`() { + assertFalse(paneSource.contains("setOnTouchListener")) + assertFalse(paneSource.contains("MotionEvent")) + assertFalse(paneSource.contains("ACTION_UP")) + assertFalse(paneSource.contains("translationY")) + assertFalse(paneSource.contains("installTranslationsButtonOffset")) + assertFalse(paneSource.contains("popupOffset")) + assertFalse(source.contains("import android.view.MotionEvent")) + } + + @Test + fun `registers the popup hook once and never disables dual pane on failure`() { + assertTrue(paneSource.contains("compareAndSet(false, true)")) + assertTrue(paneSource.contains("runCatching")) + assertTrue(paneSource.contains("translations popup offset hook registration failed")) + assertTrue(paneSource.contains("hide(root, resources, CONTROLS)")) + } + + @Test + fun `fails open when the popup or anchor has no measured height`() { + assertTrue(paneSource.contains("popupHeight <= 0")) + assertTrue(paneSource.contains("anchor.height <= 0")) + assertTrue(paneSource.contains("translations popup offset skipped")) + assertFalse(paneSource.contains("0.345f")) + } }