diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de9018c..bc0a32f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,8 @@ dokka = "2.0.0" # https://github.com/kotlin/dokka/releases junit = "4.13.2" composeLintChecks = "1.4.2" # https://github.com/slackhq/compose-lints/releases androidx-test-core = "1.6.1" +androidx-test-ext-junit = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/test +robolectric = "4.14.1" # https://github.com/robolectric/robolectric/releases mavenPublish = "0.35.0" # https://vanniktech.github.io/gradle-maven-publish-plugin/changelog/ [plugins] @@ -42,4 +44,7 @@ telephoto = { module = "me.saket.telephoto:zoomable", version.ref = "telephoto" junit = { module = "junit:junit", version.ref = "junit" } androidx-compose-ui-test-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "androidx-compose-ui" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "androidx-compose-ui" } androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } diff --git a/touchrobot-core/build.gradle.kts b/touchrobot-core/build.gradle.kts index e677d52..6280435 100644 --- a/touchrobot-core/build.gradle.kts +++ b/touchrobot-core/build.gradle.kts @@ -13,6 +13,11 @@ android { compileSdk = libs.versions.compileSdk.get().toInt() lint.abortOnError = true } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { @@ -21,6 +26,21 @@ dependencies { implementation(libs.androidx.ktx) implementation(libs.androidx.lifecycle) implementation(libs.androidx.savedstate) + + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.ext.junit) + testImplementation(libs.androidx.compose.ui.test.junit) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} + +// The Compose UI test manifest (which supplies the launcher activity used by createComposeRule) +// is intentionally debug-only so it never ships in the published release artifact. That leaves the +// release unit test variant without an activity to launch, so skip it — debug covers these tests. +tasks.configureEach { + if (name.contains("Release") && name.contains("UnitTest")) { + enabled = false + } } // Used on CI to prevent publishing of non-snapshot versions. diff --git a/touchrobot-core/src/test/kotlin/me/saket/touchrobot/ShowTapsOverlayPositionTest.kt b/touchrobot-core/src/test/kotlin/me/saket/touchrobot/ShowTapsOverlayPositionTest.kt new file mode 100644 index 0000000..c878d3f --- /dev/null +++ b/touchrobot-core/src/test/kotlin/me/saket/touchrobot/ShowTapsOverlayPositionTest.kt @@ -0,0 +1,117 @@ +package me.saket.touchrobot + +import android.graphics.Rect +import android.view.Gravity +import android.view.View +import android.view.WindowManager +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.time.Duration.Companion.milliseconds + +/** + * Reproducer: the "show taps" overlay window is misplaced when the host view does not fill + * the screen. + * + * rememberTouchRobot() draws taps in a separate window added via WindowManager.addView(). + * MatchParentSizePopup sizes that window to the host view but leaves + * WindowManager.LayoutParams.gravity at its default (0). With gravity == 0, WindowManager centers + * the window inside the screen, so an overlay smaller than the screen is drawn offset from the + * host view (and therefore offset from the touches, which are injected at the host's top-left). + * + * Robolectric does not run WindowManager's window-placement math, so View.getLocationOnScreen() + * reports (0,0) for the overlay and hides the bug. This test instead reconstructs the on-screen + * rect with the exact call WindowManager makes to place a window - Gravity.apply(...) - and asserts + * the overlay lands at the host view's position. + */ +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +// 480x799 hdpi screen; the 300.dp host is 450x450 px, i.e. smaller than the screen. +@Config(sdk = [35], qualifiers = "w320dp-h533dp-port-hdpi") +class ShowTapsOverlayPositionTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun showTapsOverlayIsAnchoredToHostView() { + lateinit var hostView: View + + composeTestRule.setContent { + val view = LocalView.current + SideEffect { hostView = view } + + val touchRobot = rememberTouchRobot(showTaps = true) + LaunchedEffect(Unit) { + touchRobot.onRoot().performGesture { + swipe(start = topLeft, stop = center, duration = 200.milliseconds) + } + } + + // A host view that is smaller than the screen. + Box( + Modifier + .size(300.dp) + .background(Color.LightGray) + ) + } + composeTestRule.waitForIdle() + + val decorView = hostView.rootView + val (overlayView, overlayParams) = topLevelWindows().first { (view, _) -> + view !== decorView && view.javaClass.name.contains("ComposeView") + } + + val screenRect = Rect(0, 0, decorView.width, decorView.height) + val overlayRect = Rect() + Gravity.apply( + overlayParams.gravity, + if (overlayParams.width >= 0) overlayParams.width else overlayView.width, + if (overlayParams.height >= 0) overlayParams.height else overlayView.height, + screenRect, + overlayParams.x, + overlayParams.y, + overlayRect, + ) + + val hostLocation = IntArray(2).also { hostView.getLocationOnScreen(it) } + + assertEquals( + "Show-taps overlay window is not anchored to the host view. " + + "Overlay window at (${overlayRect.left}, ${overlayRect.top}) " + + "size ${overlayRect.width()}x${overlayRect.height()}, " + + "but host view is at (${hostLocation[0]}, ${hostLocation[1]}) " + + "size ${hostView.width}x${hostView.height} on a ${screenRect.width()}x${screenRect.height()} screen. " + + "The overlay LayoutParams gravity is ${overlayParams.gravity} (0 = centered by WindowManager).", + hostLocation[0] to hostLocation[1], + overlayRect.left to overlayRect.top, + ) + } + + /** Reads WindowManagerGlobal's top-level windows via reflection (the API is @hide). */ + private fun topLevelWindows(): List> { + val cls = Class.forName("android.view.WindowManagerGlobal") + val instance = cls.getMethod("getInstance").invoke(null) + @Suppress("UNCHECKED_CAST") + val views = cls.getDeclaredField("mViews") + .apply { isAccessible = true }.get(instance) as List + @Suppress("UNCHECKED_CAST") + val params = cls.getDeclaredField("mParams") + .apply { isAccessible = true }.get(instance) as List + return views.indices.map { views[it] to params[it] } + } +}