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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<View>(controlsId) != null &&
candidate.findViewById<View>(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<String>,
val horizontal: List<String>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`() {
Expand Down Expand Up @@ -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<View>(controlsId) != null"))
assertTrue(paneSource.contains("candidate.findViewById<View>(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"))
}
}
Loading