diff --git a/.github/workflows/release-deploy-ext-clicks.yml b/.github/workflows/release-deploy-ext-clicks.yml new file mode 100644 index 000000000..6cc2cfd47 --- /dev/null +++ b/.github/workflows/release-deploy-ext-clicks.yml @@ -0,0 +1,30 @@ +name: Deploy Clicks Extension to Sonatype +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + - uses: little-core-labs/get-git-tag@v3.0.2 + - name: deploy-release-ext-clicks + run: ./gradlew :kakao-ext-clicks:publishDefaultPublicationToOSSHRRepository -PreleaseMode=RELEASE --stacktrace + env: + GH_TOKEN: ${{ secrets.GH_TOKEN }} + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + notify: + needs: deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Notify Telegram + uses: appleboy/telegram-action@master + with: + to: ${{ secrets.TELEGRAM_TO }} + token: ${{ secrets.TELEGRAM_TOKEN }} + message: | + New extension version has been released: Clicks diff --git a/README.md b/README.md index f0648f10f..530f93cc9 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,10 @@ class KMyView : KBaseView, MyActions, MyAssertions { } ``` +##### Custom clicks + +See [Kakao-ext-clicks](kakao-ext-clicks/README.md) + ##### Intercepting If you need to add custom logic during the `Kakao -> Espresso` call chain (for example, logging) or diff --git a/buildSrc/src/main/kotlin/Deployment.kt b/buildSrc/src/main/kotlin/KakaoDeployment.kt similarity index 97% rename from buildSrc/src/main/kotlin/Deployment.kt rename to buildSrc/src/main/kotlin/KakaoDeployment.kt index ebf781191..22133a955 100644 --- a/buildSrc/src/main/kotlin/Deployment.kt +++ b/buildSrc/src/main/kotlin/KakaoDeployment.kt @@ -22,7 +22,7 @@ import org.gradle.plugins.signing.SigningExtension import org.jetbrains.dokka.gradle.DokkaTask import java.net.URI -object Deployment { +object KakaoDeployment { val ghToken = System.getenv("GH_TOKEN") val sonatypeUser = System.getenv("SONATYPE_USERNAME") val sonatypePassword = System.getenv("SONATYPE_PASSWORD") @@ -42,8 +42,8 @@ object Deployment { else -> "-SNAPSHOT" } - Deployment.releaseMode = releaseMode - Deployment.versionSuffix = versionSuffix + KakaoDeployment.releaseMode = releaseMode + KakaoDeployment.versionSuffix = versionSuffix deployUrl = when (releaseMode) { "RELEASE" -> releaseDeployUrl else -> snapshotDeployUrl @@ -100,7 +100,7 @@ object Deployment { project.configure { publications { create("default", MavenPublication::class.java) { - Deployment.customizePom(pom) + KakaoDeployment.customizePom(pom) additionalArtifacts.forEach { it -> artifact(it) } diff --git a/buildSrc/src/main/kotlin/KakaoExtClicksDeployment.kt b/buildSrc/src/main/kotlin/KakaoExtClicksDeployment.kt new file mode 100644 index 000000000..97303e315 --- /dev/null +++ b/buildSrc/src/main/kotlin/KakaoExtClicksDeployment.kt @@ -0,0 +1,149 @@ +@file:Suppress("DEPRECATION") + +import com.android.build.gradle.LibraryExtension +import org.gradle.api.Action +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginConvention +import org.gradle.api.publish.PublishingExtension +import org.gradle.api.publish.maven.MavenPom +import org.gradle.api.publish.maven.MavenPomContributorSpec +import org.gradle.api.publish.maven.MavenPomDeveloperSpec +import org.gradle.api.publish.maven.MavenPublication +import org.gradle.api.tasks.bundling.Jar +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.creating +import org.gradle.kotlin.dsl.extra +import org.gradle.kotlin.dsl.findByType +import org.gradle.kotlin.dsl.get +import org.gradle.kotlin.dsl.getValue +import org.gradle.kotlin.dsl.provideDelegate +import org.gradle.kotlin.dsl.the +import org.gradle.plugins.signing.SigningExtension +import org.jetbrains.dokka.gradle.DokkaTask +import java.net.URI + +object KakaoExtClicksDeployment { + val ghToken = System.getenv("GH_TOKEN") + val sonatypeUser = System.getenv("SONATYPE_USERNAME") + val sonatypePassword = System.getenv("SONATYPE_PASSWORD") + var releaseMode: String? = null + var versionSuffix: String? = null + var deployUrl: String? = null + + val snapshotDeployUrl = System.getenv("SONATYPE_SNAPSHOTS_URL") + ?: "https://s01.oss.sonatype.org/content/repositories/snapshots/" + val releaseDeployUrl = System.getenv("SONATYPE_RELEASES_URL") + ?: "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/" + + fun initialize(project: Project) { + val releaseMode: String? by project + val versionSuffix = when (releaseMode) { + "RELEASE" -> "" + else -> "-SNAPSHOT" + } + + KakaoExtClicksDeployment.releaseMode = releaseMode + KakaoExtClicksDeployment.versionSuffix = versionSuffix + deployUrl = when (releaseMode) { + "RELEASE" -> releaseDeployUrl + else -> snapshotDeployUrl + } + + initializePublishing(project) + initializeSigning(project) + } + + private fun initializePublishing(project: Project) { + project.version = Versions.kakaoExtClicksVersion + versionSuffix + + project.plugins.apply("maven-publish") + + val (component, additionalArtifacts) = when { + project.extensions.findByType(LibraryExtension::class) != null -> { + val android = project.extensions.findByType(LibraryExtension::class)!! + val main = android.sourceSets.getByName("main") + val sourcesJar by project.tasks.creating(Jar::class) { + classifier = "sources" + from(main.java.srcDirs) + } + + Pair(project.components["release"], listOf(sourcesJar)) + } + project.the(JavaPluginConvention::class) != null -> { + val javaPlugin = project.the(JavaPluginConvention::class) + + val sourcesJar by project.tasks.creating(Jar::class) { + classifier = "sources" + from(javaPlugin.sourceSets["main"].allSource) + } + Pair(project.components["java"], listOf(sourcesJar)) + } + else -> { + throw RuntimeException("Unknown plugin") + } + } + + project.configure { + publications { + create("default", MavenPublication::class.java) { + KakaoExtClicksDeployment.customizePom(pom) + additionalArtifacts.forEach { it -> + artifact(it) + } + from(component) + } + } + repositories { + maven { + name = "Local" + setUrl("${project.rootDir}/build/repository") + } + maven { + name = "OSSHR" + credentials { + username = sonatypeUser + password = sonatypePassword + } + url = URI.create(deployUrl) + } + } + } + } + + private fun initializeSigning(project: Project) { + val passphrase = System.getenv("GPG_PASSPHRASE") + passphrase?.let { + project.plugins.apply("signing") + + val publishing = project.the(PublishingExtension::class) + project.configure { + sign(publishing.publications.getByName("default")) + } + + project.extra.set("signing.keyId", "0110979F") + project.extra.set("signing.password", passphrase) + project.extra.set("signing.secretKeyRingFile", "${project.rootProject.rootDir}/buildsystem/secring.gpg") + } + } + + fun customizePom(pom: MavenPom?) { + pom?.apply { + name.set("kakao-ext-clicks") + url.set("https://github.com/KakaoCup/Kakao/kakao-ext-clicks") + description.set("Clicks extension for Kakao") + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + + scm { + url.set("https://github.com/KakaoCup/Kakao.git") + connection.set("scm:git:ssh://github.com/KakaoCup/Kakao") + developerConnection.set("scm:git:ssh://github.com/KakaoCup/Kakao") + } + } + } +} diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index fdca84c60..facec6df6 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -2,7 +2,7 @@ import java.io.File object Versions { val kakaoVersion = File("buildsystem/version").readText().trim() - val gmapsVersion = File("buildsystem/gmapsversion").readText().trim() + val kakaoExtClicksVersion = File("buildsystem/extclickversion").readText().trim() val kotlin = "1.7.21" val detekt = "1.17.1" diff --git a/buildsystem/extclickversion b/buildsystem/extclickversion new file mode 100644 index 000000000..afaf360d3 --- /dev/null +++ b/buildsystem/extclickversion @@ -0,0 +1 @@ +1.0.0 \ No newline at end of file diff --git a/buildsystem/gmapsversion b/buildsystem/gmapsversion deleted file mode 100644 index 283020197..000000000 --- a/buildsystem/gmapsversion +++ /dev/null @@ -1 +0,0 @@ -0.0.1-beta \ No newline at end of file diff --git a/kakao-ext-clicks/README.md b/kakao-ext-clicks/README.md new file mode 100644 index 000000000..306f03331 --- /dev/null +++ b/kakao-ext-clicks/README.md @@ -0,0 +1,151 @@ +# Kakao custom clicks + +Espresso clicks implementation sometimes causes additional problems while devices are unstable. +It could be a click that was registered as long click. Click that is not registered at all. And so on. + +From the [androidx.test.espresso.action.GeneralClickAction.perform] comment: + +``` + Native event injection is quite a tricky process. A tap is actually 2 + seperate motion events which need to get injected into the system. Injection + makes an RPC call from our app under test to the Android system server, the + system server decides which window layer to deliver the event to, the system + server makes an RPC to that window layer, that window layer delivers the event + to the correct UI element, activity, or window object. Now we need to repeat + that 2x. for a simple down and up. Oh and the down event triggers timers to + detect whether or not the event is a long vs. short press. The timers are + removed the moment the up event is received (NOTE: the possibility of eventTime + being in the future is totally ignored by most motion event processors). + + Phew. + + The net result of this is sometimes we'll want to do a regular tap, and for + whatever reason the up event (last half) of the tap is delivered after long + press timeout (depending on system load) and the long press behaviour is + displayed (EG: show a context menu). There is no way to avoid or handle this more + gracefully. Also the longpress behavour is app/widget specific. So if you have + a seperate long press behaviour from your short press, you can pass in a + 'RollBack' ViewAction which when executed will undo the effects of long press. +``` + +If you experience unreliable tap/click in UI tests you can try our naive but sometimes more reliable implementation, that dispatches events +directly to View. + +## How to use + +There are multiple ways to apply custom clicks: + +### Apply KakaoClicksTestRule + +If you want to apply it directly to single test class the TestRule is an obvious choice. + +For example: +``` +@Rule +@JvmField +var chain: TestRule = RuleChain.outerRule(ActivityScenarioRule(MyActivity::class.java)) + .around(KakaoClicksTestRule()) +``` + +### Override Kakao clicks behaviour + +If you need to change it globally, you can override static variables of kakao, like that: + +``` +Kakao { + singleClickAction = KakaoSingleClick() + doubleClickAction = KakaoDoubleClick() + longClickAction = KakaoLongClick() +} +``` + +To revert this behavior you can set it back: + +``` +Kakao { + singleClickAction = EspressoSingleClick() + doubleClickAction = EspressoDoubleClick() + longClickAction = EspressoLongClick() +} +``` + +To make a precise change for single click you can implement your own function and execute your test code wrapped in it: + +```kotlin +fun withCustomClicks(block: () -> Unit) { + Kakao { + singleClickAction = KakaoSingleClick() + doubleClickAction = KakaoDoubleClick() + longClickAction = KakaoLongClick() + } + + block.invoke() + + Kakao { + singleClickAction = EspressoSingleClick() + doubleClickAction = EspressoDoubleClick() + longClickAction = EspressoLongClick() + } +} + +button { + withCustomClicks { + click() // clicked with custom mechanism + } + click() // clicked by espresso +} +``` + +## Click visualization + +Click visualization is a useful debug tool. Usually it's enabled with Android option `Developer Options > Show taps`. Or using ADB: + +``` +adb shell settings put system show_touches 1 +``` + +On different setups it can be impossible to set or it can simply not working. + +To enable visual taps programmatically with custom clicks, use custom click constructor: + +```kotlin +KakaoSingleClick(visualClicksConfig = VisualClicksConfig()) // null is the default argument +KakaoDoubleClick(visualClicksConfig = VisualClicksConfig()) +KakaoLongClick(visualClicksConfig = VisualClicksConfig()) +``` + +or if you are using TestRule: + +```kotlin +KakaoClicksTestRule(visualClicksConfig = VisualClicksConfig()) +``` + +to apply config to all types of clicks + +`VisualClicksConfig` data class has some customization options: like color and radius of the tap circle. + +## Global Center coordinates + +There are some cases when standard espresso coordinates not working. +For example clicking on center of the view with applied property animations or transitions with help of `GeneralLocation.VISIBLE_CENTER`. +See explanation on why it happens [here](https://github.com/avito-tech/avito-android/pull/308). + +`VisibleCenterGlobalCoordinatesProvider` to the rescue. + +Pass it as a replacement for your click location like that: + +```kotlin +tranformedView.click(VisibleCenterGlobalCoordinatesProvider()) +``` + +## Customize emulator + +You can also tune emulator a bit that could help with long click registration. + +``` +adb shell "settings put secure long_press_timeout 1500" // default can vary but usually it's 400ms +``` + +## Credits + +Initial idea and implementation by [Avito](https://github.com/avito-tech/avito-android) diff --git a/kakao-ext-clicks/build.gradle.kts b/kakao-ext-clicks/build.gradle.kts new file mode 100644 index 000000000..64c92a487 --- /dev/null +++ b/kakao-ext-clicks/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + id("com.android.library") + id("kotlin-android") + id("com.github.ben-manes.versions") version Versions.gradle_versions +} + +android { + compileSdk = 33 + defaultConfig { + minSdk = 14 + targetSdk = 33 + multiDexEnabled = true + } + + sourceSets { + getByName("main") { + java.srcDir("src/main/kotlin") + res.srcDir("src/main/res") + } + getByName("test") { + java.srcDir("src/test/kotlin") + } + } +} + +dependencies { + implementation(Libraries.kotlin_stdlib) + implementation(Libraries.espresso_core) + implementation(project(":kakao")) +} + +afterEvaluate { + KakaoExtClicksDeployment.initialize(project) +} diff --git a/kakao-ext-clicks/src/main/AndroidManifest.xml b/kakao-ext-clicks/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b4560fe5a --- /dev/null +++ b/kakao-ext-clicks/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/KakaoDoubleClick.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/KakaoDoubleClick.kt new file mode 100644 index 000000000..44628c48f --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/KakaoDoubleClick.kt @@ -0,0 +1,17 @@ +package io.github.kakaocup.kakao.ext.clicks + +import androidx.test.espresso.action.CoordinatesProvider +import androidx.test.espresso.action.Press +import io.github.kakaocup.kakao.common.actions.clicks.ClickAction +import io.github.kakaocup.kakao.ext.clicks.inprocess.DoubleClickEvent +import io.github.kakaocup.kakao.ext.clicks.inprocess.InProcessClickAction +import io.github.kakaocup.kakao.ext.clicks.visualization.VisualClicksConfig + +class KakaoDoubleClick(private val visualClicksConfig: VisualClicksConfig? = null) : ClickAction { + override fun click(location: CoordinatesProvider) = InProcessClickAction( + clickEvent = DoubleClickEvent(visualClicksConfig), + coordinatesProvider = location, + precisionDescriber = Press.FINGER, + visualClicksConfig = visualClicksConfig + ) +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/KakaoLongClick.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/KakaoLongClick.kt new file mode 100644 index 000000000..2c7d30504 --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/KakaoLongClick.kt @@ -0,0 +1,17 @@ +package io.github.kakaocup.kakao.ext.clicks + +import androidx.test.espresso.action.CoordinatesProvider +import androidx.test.espresso.action.Press +import io.github.kakaocup.kakao.common.actions.clicks.ClickAction +import io.github.kakaocup.kakao.ext.clicks.inprocess.InProcessClickAction +import io.github.kakaocup.kakao.ext.clicks.inprocess.LongClickEvent +import io.github.kakaocup.kakao.ext.clicks.visualization.VisualClicksConfig + +class KakaoLongClick(private val visualClicksConfig: VisualClicksConfig? = null) : ClickAction { + override fun click(location: CoordinatesProvider) = InProcessClickAction( + clickEvent = LongClickEvent(visualClicksConfig), + coordinatesProvider = location, + precisionDescriber = Press.FINGER, + visualClicksConfig = visualClicksConfig + ) +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/KakaoSingleClick.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/KakaoSingleClick.kt new file mode 100644 index 000000000..1d762fd66 --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/KakaoSingleClick.kt @@ -0,0 +1,17 @@ +package io.github.kakaocup.kakao.ext.clicks + +import androidx.test.espresso.action.CoordinatesProvider +import androidx.test.espresso.action.Press +import io.github.kakaocup.kakao.common.actions.clicks.ClickAction +import io.github.kakaocup.kakao.ext.clicks.inprocess.InProcessClickAction +import io.github.kakaocup.kakao.ext.clicks.inprocess.SingleClickEvent +import io.github.kakaocup.kakao.ext.clicks.visualization.VisualClicksConfig + +class KakaoSingleClick(private val visualClicksConfig: VisualClicksConfig? = null) : ClickAction { + override fun click(location: CoordinatesProvider) = InProcessClickAction( + clickEvent = SingleClickEvent(visualClicksConfig), + coordinatesProvider = location, + precisionDescriber = Press.FINGER, + visualClicksConfig = visualClicksConfig + ) +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/coordinates/RelativeCoordinatesProvider.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/coordinates/RelativeCoordinatesProvider.kt new file mode 100644 index 000000000..98b9b293f --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/coordinates/RelativeCoordinatesProvider.kt @@ -0,0 +1,14 @@ +package io.github.kakaocup.kakao.ext.clicks.coordinates + +import android.view.View +import androidx.test.espresso.action.CoordinatesProvider + +internal class RelativeCoordinatesProvider( + private val coordinatesProvider: CoordinatesProvider +) : CoordinatesProvider { + override fun calculateCoordinates(view: View): FloatArray { + val (offsetX, offsetY) = IntArray(2).apply(view.rootView::getLocationOnScreen) + val (viewX, viewY) = coordinatesProvider.calculateCoordinates(view) + return floatArrayOf(viewX - offsetX, viewY - offsetY) + } +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/coordinates/VisibleCenterGlobalCoordinatesProvider.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/coordinates/VisibleCenterGlobalCoordinatesProvider.kt new file mode 100644 index 000000000..41d3527a1 --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/coordinates/VisibleCenterGlobalCoordinatesProvider.kt @@ -0,0 +1,21 @@ +package io.github.kakaocup.kakao.ext.clicks.coordinates + +import android.graphics.Rect +import android.view.View +import androidx.test.espresso.action.CoordinatesProvider + +/** + * Returns center of a visible part + * + * It's a replacement of androidx.test.espresso.action.GeneralLocation.VISIBLE_CENTER. + * Espresso implementation can miss on property animated views (e.g. rotated and scaled) + * + * Known issues: + * - It can miss in case of clipping when a view is rotated. + */ +class VisibleCenterGlobalCoordinatesProvider : CoordinatesProvider { + override fun calculateCoordinates(view: View): FloatArray { + val rect = Rect().apply(view::getGlobalVisibleRect) + return floatArrayOf(rect.centerX().toFloat(), rect.centerY().toFloat()) + } +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/ClickEvent.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/ClickEvent.kt new file mode 100644 index 000000000..fac390995 --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/ClickEvent.kt @@ -0,0 +1,57 @@ +package io.github.kakaocup.kakao.ext.clicks.inprocess + +import android.view.View +import android.view.ViewConfiguration +import androidx.test.espresso.UiController +import io.github.kakaocup.kakao.ext.clicks.visualization.ClickVisualization +import io.github.kakaocup.kakao.ext.clicks.visualization.VisualClicksConfig +import org.hamcrest.Matcher + +sealed class ClickEvent { + + abstract val visualClicksConfig: VisualClicksConfig? + + abstract fun perform( + uiController: UiController, + view: View, + rootView: View, + coordinates: FloatArray, + precision: FloatArray, + ) + + abstract fun constraints(): Matcher + + abstract fun description(): String + + protected fun performSingleClick( + uiController: UiController, + rootView: View, + coordinates: FloatArray, + precision: FloatArray, + delayBetweenDownAndUp: Long = ViewConfiguration.getTapTimeout().toLong(), + ) { + val clickVisualization = visualClicksConfig?.let { + ClickVisualization( + x = coordinates[0], + y = coordinates[1], + visualClicksConfig = it, + ) + } + + val downEvent = MotionEvents.downEvent( + coordinates = coordinates, + precision = precision + ) + clickVisualization?.attachTo(rootView) + rootView.dispatchTouchEvent(downEvent) + + uiController.loopMainThreadForAtLeast(delayBetweenDownAndUp) + + val upEvent = MotionEvents.upEvent(downEvent) + rootView.dispatchTouchEvent(upEvent) + clickVisualization?.detach() + + downEvent.recycle() + upEvent.recycle() + } +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/DoubleClickEvent.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/DoubleClickEvent.kt new file mode 100644 index 000000000..fc7ccb4cb --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/DoubleClickEvent.kt @@ -0,0 +1,74 @@ +package io.github.kakaocup.kakao.ext.clicks.inprocess + +import android.annotation.SuppressLint +import android.os.Build +import android.util.Log +import android.view.View +import android.view.ViewConfiguration +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.test.espresso.UiController +import io.github.kakaocup.kakao.ext.clicks.visualization.VisualClicksConfig +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.Matchers +import org.hamcrest.TypeSafeMatcher + +class DoubleClickEvent(override val visualClicksConfig: VisualClicksConfig?) : ClickEvent() { + + private val doubleTapMinTime: Int = getDoubleTapMinTime() + + override fun description(): String = "double click" + + override fun constraints(): Matcher = Matchers.allOf( + MinApiConstraint(), + DoubleTapAvailabilityConstraint(doubleTapMinTime) + ) + + override fun perform( + uiController: UiController, + view: View, + rootView: View, + coordinates: FloatArray, + precision: FloatArray, + ) { + performSingleClick(uiController, rootView, coordinates, precision) + + if (doubleTapMinTime > 0) { + uiController.loopMainThreadForAtLeast(doubleTapMinTime.toLong()) + } + + performSingleClick(uiController, rootView, coordinates, precision) + } + + private class DoubleTapAvailabilityConstraint(private val doubleTapMinTime: Int) : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("doubleTapMinTime is unavailable. Double click will not succeed. See log for clues") + } + + override fun matchesSafely(item: View): Boolean { + return doubleTapMinTime > 0 + } + } + + private class MinApiConstraint : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("getDoubleTapMinTime only available on KITKAT+") + } + + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.KITKAT) + override fun matchesSafely(item: View): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT + } + } + + @SuppressLint("DiscouragedPrivateApi") // that's the only way to get system settings value + private fun getDoubleTapMinTime(): Int { + return try { + val getDoubleTapMinTimeMethod = ViewConfiguration::class.java.getDeclaredMethod("getDoubleTapMinTime") + getDoubleTapMinTimeMethod.invoke(null) as Int + } catch (e: NoSuchMethodException) { + Log.w("Kakao", "Expected to find getDoubleTapMinTime", e) + 0 + } + } +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/InProcessClickAction.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/InProcessClickAction.kt new file mode 100644 index 000000000..a9423756d --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/InProcessClickAction.kt @@ -0,0 +1,73 @@ +package io.github.kakaocup.kakao.ext.clicks.inprocess + +import android.os.Build +import android.view.View +import android.view.ViewConfiguration +import android.webkit.WebView +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.CoordinatesProvider +import androidx.test.espresso.action.GeneralLocation +import androidx.test.espresso.action.PrecisionDescriber +import io.github.kakaocup.kakao.ext.clicks.coordinates.RelativeCoordinatesProvider +import io.github.kakaocup.kakao.ext.clicks.visualization.VisualClicksConfig +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.Matchers +import org.hamcrest.TypeSafeMatcher + +class InProcessClickAction( + private val clickEvent: ClickEvent, + private val coordinatesProvider: CoordinatesProvider, + private val precisionDescriber: PrecisionDescriber, + private val visualClicksConfig: VisualClicksConfig?, +) : ViewAction { + + override fun getDescription(): String = clickEvent.description() + + override fun getConstraints(): Matcher = Matchers.allOf( + VisualClicksAPIConstraint(visualClicksConfig), + clickEvent.constraints() + ) + + override fun perform(uiController: UiController, view: View) { + val rootView = view.rootView + val precision = precisionDescriber.describePrecision() + + // In the case of custom CoordinatesProvider we expect user to handle relativism + // and also opening the room for clicking on non-relative location, see [VisibleCenterGlobalCoordinatesProvider] + // [RelativeCoordinatesProvider] is still available in public API to wrap your custom location + val coordinatesProvider = if (coordinatesProvider is GeneralLocation) { + RelativeCoordinatesProvider(coordinatesProvider) + } else { + coordinatesProvider + } + + clickEvent.perform( + uiController = uiController, + view = view, + rootView = rootView, + coordinates = coordinatesProvider.calculateCoordinates(view), + precision = precision, + ) + + /** + * According to [androidx.test.espresso.action.GeneralClickAction.perform] + * Probably valid for any click types + */ + if (view is WebView) { + uiController.loopMainThreadForAtLeast(ViewConfiguration.getDoubleTapTimeout().toLong()) + } else { + uiController.loopMainThreadForAtLeast(ViewConfiguration.getPressedStateDuration().toLong()) + } + } + + private class VisualClicksAPIConstraint(private val visualClicksConfig: VisualClicksConfig?) : TypeSafeMatcher() { + + override fun describeTo(description: Description) { + description.appendText("Click visualization supported only on Android M+ (23+ API)") + } + + override fun matchesSafely(item: View): Boolean = visualClicksConfig == null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + } +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/LongClickEvent.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/LongClickEvent.kt new file mode 100644 index 000000000..418e2c313 --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/LongClickEvent.kt @@ -0,0 +1,41 @@ +package io.github.kakaocup.kakao.ext.clicks.inprocess + +import android.view.View +import android.view.ViewConfiguration +import androidx.test.espresso.UiController +import io.github.kakaocup.kakao.ext.clicks.visualization.VisualClicksConfig +import org.hamcrest.Matcher +import org.hamcrest.Matchers + +class LongClickEvent(override val visualClicksConfig: VisualClicksConfig?) : ClickEvent() { + + override fun description(): String = "long click" + + override fun constraints(): Matcher = Matchers.allOf() + + override fun perform( + uiController: UiController, + view: View, + rootView: View, + coordinates: FloatArray, + precision: FloatArray, + ) { + + performSingleClick( + uiController, + rootView, + coordinates, + precision, + delayBetweenDownAndUp = (LONG_PRESS_FACTOR * ViewConfiguration.getLongPressTimeout()).toLong() + ) + } + + private companion object { + + /** + * Factor is needed, otherwise a long press is not safely detected. + * According to [androidx.test.espresso.action.Tap.LONG] + */ + const val LONG_PRESS_FACTOR = 1.5f + } +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/MotionEvents.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/MotionEvents.kt new file mode 100644 index 000000000..8b61de92f --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/MotionEvents.kt @@ -0,0 +1,50 @@ +package io.github.kakaocup.kakao.ext.clicks.inprocess + +import android.os.SystemClock +import android.view.InputDevice +import android.view.MotionEvent + +internal object MotionEvents { + + private const val NORMAL_PRESSURE = 1F + private const val NORMAL_SIZE = 1F + private const val WITHOUT_MODIFIERS_META_STATE = 0 + + fun downEvent(coordinates: FloatArray, precision: FloatArray): MotionEvent = obtainEvent( + coordinates = coordinates, + precision = precision, + event = MotionEvent.ACTION_DOWN, + ) + + fun upEvent(down: MotionEvent): MotionEvent = MotionEvent.obtain( + down.downTime, + provideTime(), + MotionEvent.ACTION_UP, + down.x, + down.y, + down.pressure, + down.size, + down.metaState, + down.xPrecision, + down.yPrecision, + down.deviceId, + down.edgeFlags + ) + + private fun provideTime(): Long = SystemClock.uptimeMillis() + + private fun obtainEvent(coordinates: FloatArray, precision: FloatArray, event: Int): MotionEvent = MotionEvent.obtain( + provideTime(), + provideTime(), + event, + coordinates[0], + coordinates[1], + NORMAL_PRESSURE, + NORMAL_SIZE, + WITHOUT_MODIFIERS_META_STATE, + precision[0], + precision[1], + InputDevice.SOURCE_UNKNOWN, + 0 + ) +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/SingleClickEvent.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/SingleClickEvent.kt new file mode 100644 index 000000000..d01a05a1d --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/inprocess/SingleClickEvent.kt @@ -0,0 +1,24 @@ +package io.github.kakaocup.kakao.ext.clicks.inprocess + +import android.view.View +import androidx.test.espresso.UiController +import io.github.kakaocup.kakao.ext.clicks.visualization.VisualClicksConfig +import org.hamcrest.Matcher +import org.hamcrest.Matchers + +class SingleClickEvent(override val visualClicksConfig: VisualClicksConfig?) : ClickEvent() { + + override fun description(): String = "single click" + + override fun constraints(): Matcher = Matchers.allOf() + + override fun perform( + uiController: UiController, + view: View, + rootView: View, + coordinates: FloatArray, + precision: FloatArray, + ) { + performSingleClick(uiController, rootView, coordinates, precision) + } +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/testrule/KakaoClicksTestRule.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/testrule/KakaoClicksTestRule.kt new file mode 100644 index 000000000..54b1a34d6 --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/testrule/KakaoClicksTestRule.kt @@ -0,0 +1,34 @@ +package io.github.kakaocup.kakao.ext.clicks.testrule + +import io.github.kakaocup.kakao.Kakao +import io.github.kakaocup.kakao.ext.clicks.KakaoDoubleClick +import io.github.kakaocup.kakao.ext.clicks.KakaoLongClick +import io.github.kakaocup.kakao.ext.clicks.KakaoSingleClick +import io.github.kakaocup.kakao.ext.clicks.visualization.VisualClicksConfig +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class KakaoClicksTestRule(private val visualClicksConfig: VisualClicksConfig? = null) : TestRule { + override fun apply(base: Statement, description: Description) = object : Statement() { + override fun evaluate() { + + val oldSingleClickAction = Kakao.singleClickAction + val oldDoubleClickAction = Kakao.doubleClickAction + val oldLongClickAction = Kakao.longClickAction + + val visualClicksConfig = visualClicksConfig + ?: description.getAnnotation(WithVisualClicks::class.java)?.let { VisualClicksConfig() } + + Kakao.singleClickAction = KakaoSingleClick(visualClicksConfig) + Kakao.doubleClickAction = KakaoDoubleClick(visualClicksConfig) + Kakao.longClickAction = KakaoLongClick(visualClicksConfig) + + base.evaluate() + + Kakao.singleClickAction = oldSingleClickAction + Kakao.doubleClickAction = oldDoubleClickAction + Kakao.longClickAction = oldLongClickAction + } + } +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/testrule/WithVisualClicks.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/testrule/WithVisualClicks.kt new file mode 100644 index 000000000..9011f8db1 --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/testrule/WithVisualClicks.kt @@ -0,0 +1,5 @@ +package io.github.kakaocup.kakao.ext.clicks.testrule + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION) +annotation class WithVisualClicks diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/visualization/ClickVisualization.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/visualization/ClickVisualization.kt new file mode 100644 index 000000000..8ff142daa --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/visualization/ClickVisualization.kt @@ -0,0 +1,39 @@ +package io.github.kakaocup.kakao.ext.clicks.visualization + +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.os.Build +import android.view.View + +internal class ClickVisualization( + private val x: Float, + private val y: Float, + private val visualClicksConfig: VisualClicksConfig, +) { + + private var attachedView: View? = null + private var originalForeground: Drawable? = null + + fun attachTo(view: View) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + attachedView = view + originalForeground = view.foreground + + view.foreground = VisualizationDrawable( + originalForeground = originalForeground, + x = x, + y = y, + radiusInPixels = visualClicksConfig.radiusInDp.toPx(), + color = visualClicksConfig.color + ) + } + } + + fun detach() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + attachedView?.foreground = originalForeground + } + } + + private fun Int.toPx(): Float = this * Resources.getSystem().displayMetrics.density +} diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/visualization/VisualClicksConfig.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/visualization/VisualClicksConfig.kt new file mode 100644 index 000000000..e70bc2ab6 --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/visualization/VisualClicksConfig.kt @@ -0,0 +1,11 @@ +package io.github.kakaocup.kakao.ext.clicks.visualization + +import androidx.annotation.ColorInt + +data class VisualClicksConfig( + @ColorInt val color: Int = DEFAULT_TOUCH_COLOR, + val radiusInDp: Int = DEFAULT_TOUCH_RADIUS_DP +) + +private const val DEFAULT_TOUCH_RADIUS_DP = 16 +private const val DEFAULT_TOUCH_COLOR = -1610612481 // Color.argb(0xA0, 0x00, 0x00, 0xFF) diff --git a/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/visualization/VisualizationDrawable.kt b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/visualization/VisualizationDrawable.kt new file mode 100644 index 000000000..d4d0bc632 --- /dev/null +++ b/kakao-ext-clicks/src/main/kotlin/io/github/kakaocup/kakao/ext/clicks/visualization/VisualizationDrawable.kt @@ -0,0 +1,39 @@ +package io.github.kakaocup.kakao.ext.clicks.visualization + +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.drawable.Drawable +import androidx.annotation.ColorInt + +internal class VisualizationDrawable( + private val originalForeground: Drawable?, + private val x: Float, + private val y: Float, + private val radiusInPixels: Float, + @ColorInt val color: Int +) : Drawable() { + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = this@VisualizationDrawable.color + } + + override fun draw(canvas: Canvas) { + originalForeground?.draw(canvas) + canvas.drawCircle(x, y, radiusInPixels, paint) + } + + override fun setAlpha(alpha: Int) { + originalForeground?.alpha = alpha + paint.alpha = alpha + } + + @Suppress("OVERRIDE_DEPRECATION") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + override fun setColorFilter(colorFilter: ColorFilter?) { + originalForeground?.colorFilter = colorFilter + paint.colorFilter = colorFilter + } +} diff --git a/kakao/build.gradle.kts b/kakao/build.gradle.kts index 97ea6c405..8ba6618a0 100644 --- a/kakao/build.gradle.kts +++ b/kakao/build.gradle.kts @@ -58,5 +58,5 @@ tasks.dokkaHtml.configure { } afterEvaluate { - Deployment.initialize(project) + KakaoDeployment.initialize(project) } diff --git a/kakao/src/main/kotlin/io/github/kakaocup/kakao/Kakao.kt b/kakao/src/main/kotlin/io/github/kakaocup/kakao/Kakao.kt index 7b67b1b7c..fc744a8b7 100644 --- a/kakao/src/main/kotlin/io/github/kakaocup/kakao/Kakao.kt +++ b/kakao/src/main/kotlin/io/github/kakaocup/kakao/Kakao.kt @@ -7,6 +7,10 @@ import androidx.test.espresso.ViewInteraction import androidx.test.espresso.web.assertion.WebAssertion import androidx.test.espresso.web.model.Atom import androidx.test.espresso.web.sugar.Web +import io.github.kakaocup.kakao.common.actions.clicks.ClickAction +import io.github.kakaocup.kakao.common.actions.clicks.EspressoDoubleClick +import io.github.kakaocup.kakao.common.actions.clicks.EspressoLongClick +import io.github.kakaocup.kakao.common.actions.clicks.EspressoSingleClick import io.github.kakaocup.kakao.intercept.Interceptor object Kakao { @@ -14,6 +18,10 @@ object Kakao { internal var dataInterceptor: Interceptor? = null internal var webInterceptor: Interceptor, WebAssertion<*>, Atom<*>>? = null + var singleClickAction: ClickAction = EspressoSingleClick() + var doubleClickAction: ClickAction = EspressoDoubleClick() + var longClickAction: ClickAction = EspressoLongClick() + /** * Operator that allows usage of DSL style * diff --git a/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/BaseActions.kt b/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/BaseActions.kt index 3869e59f9..cb6a50149 100644 --- a/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/BaseActions.kt +++ b/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/BaseActions.kt @@ -2,16 +2,13 @@ package io.github.kakaocup.kakao.common.actions -import android.view.InputDevice -import android.view.MotionEvent import android.view.View import androidx.test.espresso.FailureHandler import androidx.test.espresso.ViewAction -import androidx.test.espresso.action.GeneralClickAction +import androidx.test.espresso.action.CoordinatesProvider import androidx.test.espresso.action.GeneralLocation -import androidx.test.espresso.action.Press -import androidx.test.espresso.action.Tap import androidx.test.espresso.action.ViewActions +import io.github.kakaocup.kakao.Kakao import io.github.kakaocup.kakao.common.builders.ViewBuilder import io.github.kakaocup.kakao.delegate.ViewInteractionDelegate import org.hamcrest.Matcher @@ -34,13 +31,8 @@ interface BaseActions { * * @param location Location of view where it should be clicked (VISIBLE_CENTER by default) */ - fun click(location: GeneralLocation = GeneralLocation.VISIBLE_CENTER) { - view.perform( - GeneralClickAction( - Tap.SINGLE, location, Press.FINGER, - InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY - ) - ) + fun click(location: CoordinatesProvider = GeneralLocation.VISIBLE_CENTER) { + view.perform(Kakao.singleClickAction.click(location)) } /** @@ -48,13 +40,8 @@ interface BaseActions { * * @param location Location of view where it should be clicked (VISIBLE_CENTER by default) */ - fun doubleClick(location: GeneralLocation = GeneralLocation.VISIBLE_CENTER) { - view.perform( - GeneralClickAction( - Tap.DOUBLE, location, Press.FINGER, - InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY - ) - ) + fun doubleClick(location: CoordinatesProvider = GeneralLocation.VISIBLE_CENTER) { + view.perform(Kakao.doubleClickAction.click(location)) } /** @@ -62,13 +49,8 @@ interface BaseActions { * * @param location Location of view where it should be clicked (VISIBLE_CENTER by default) */ - fun longClick(location: GeneralLocation = GeneralLocation.VISIBLE_CENTER) { - view.perform( - GeneralClickAction( - Tap.LONG, location, Press.FINGER, - InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY - ) - ) + fun longClick(location: CoordinatesProvider = GeneralLocation.VISIBLE_CENTER) { + view.perform(Kakao.longClickAction.click(location)) } /** diff --git a/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/clicks/ClickAction.kt b/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/clicks/ClickAction.kt new file mode 100644 index 000000000..7966bd0b4 --- /dev/null +++ b/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/clicks/ClickAction.kt @@ -0,0 +1,8 @@ +package io.github.kakaocup.kakao.common.actions.clicks + +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.CoordinatesProvider + +interface ClickAction { + fun click(location: CoordinatesProvider): ViewAction +} diff --git a/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/clicks/EspressoDoubleClick.kt b/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/clicks/EspressoDoubleClick.kt new file mode 100644 index 000000000..0d2123147 --- /dev/null +++ b/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/clicks/EspressoDoubleClick.kt @@ -0,0 +1,15 @@ +package io.github.kakaocup.kakao.common.actions.clicks + +import android.view.InputDevice +import android.view.MotionEvent +import androidx.test.espresso.action.CoordinatesProvider +import androidx.test.espresso.action.GeneralClickAction +import androidx.test.espresso.action.Press +import androidx.test.espresso.action.Tap + +class EspressoDoubleClick : ClickAction { + override fun click(location: CoordinatesProvider) = GeneralClickAction( + Tap.DOUBLE, location, Press.FINGER, + InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY + ) +} diff --git a/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/clicks/EspressoLongClick.kt b/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/clicks/EspressoLongClick.kt new file mode 100644 index 000000000..28ac80423 --- /dev/null +++ b/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/clicks/EspressoLongClick.kt @@ -0,0 +1,15 @@ +package io.github.kakaocup.kakao.common.actions.clicks + +import android.view.InputDevice +import android.view.MotionEvent +import androidx.test.espresso.action.CoordinatesProvider +import androidx.test.espresso.action.GeneralClickAction +import androidx.test.espresso.action.Press +import androidx.test.espresso.action.Tap + +class EspressoLongClick : ClickAction { + override fun click(location: CoordinatesProvider) = GeneralClickAction( + Tap.LONG, location, Press.FINGER, + InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY + ) +} diff --git a/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/clicks/EspressoSingleClick.kt b/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/clicks/EspressoSingleClick.kt new file mode 100644 index 000000000..2820a4226 --- /dev/null +++ b/kakao/src/main/kotlin/io/github/kakaocup/kakao/common/actions/clicks/EspressoSingleClick.kt @@ -0,0 +1,16 @@ +package io.github.kakaocup.kakao.common.actions.clicks + +import android.view.InputDevice +import android.view.MotionEvent +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.CoordinatesProvider +import androidx.test.espresso.action.GeneralClickAction +import androidx.test.espresso.action.Press +import androidx.test.espresso.action.Tap + +class EspressoSingleClick : ClickAction { + override fun click(location: CoordinatesProvider): ViewAction = GeneralClickAction( + Tap.SINGLE, location, Press.FINGER, + InputDevice.SOURCE_UNKNOWN, MotionEvent.BUTTON_PRIMARY + ) +} diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 7d3ec227c..8823cd849 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { testImplementation(Libraries.junit) androidTestImplementation(project(":kakao")) + androidTestImplementation(project(":kakao-ext-clicks")) androidTestImplementation(Libraries.annotation) androidTestImplementation(Libraries.espresso_runner) diff --git a/sample/src/androidTest/kotlin/io/github/kakaocup/sample/AnimatedButtonClickTest.kt b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/AnimatedButtonClickTest.kt new file mode 100644 index 000000000..36f66b300 --- /dev/null +++ b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/AnimatedButtonClickTest.kt @@ -0,0 +1,61 @@ +package io.github.kakaocup.sample + +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import io.github.kakaocup.kakao.ext.clicks.coordinates.VisibleCenterGlobalCoordinatesProvider +import io.github.kakaocup.kakao.screen.Screen +import io.github.kakaocup.sample.screen.AnimatedButtonClickScreen +import io.github.kakaocup.sample.tools.applyEspressoClickExtension +import io.github.kakaocup.sample.tools.applyKakaoClickExtension +import org.junit.Assert.assertThrows +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4ClassRunner::class) +class AnimatedButtonClickTest { + @Rule + @JvmField + val rule = ActivityScenarioRule(AnimatedButtonClickActivity::class.java) + + @Ignore("Not failing on CI emulators as expected, passing locally") + @Test + fun testEspressoClickAction() { + Screen.onScreen { + applyEspressoClickExtension() + + animatedView.click() + assertThrows(AssertionError::class.java) { + clickIndicator.isVisible() + } + } + } + + @Ignore("Not failing on CI emulators as expected, passing locally") + @Test + fun testKakaoClickAction() { + Screen.onScreen { + applyKakaoClickExtension() + + animatedView.click() + assertThrows(AssertionError::class.java) { + clickIndicator.isVisible() + } + + applyEspressoClickExtension() + } + } + + @Test + fun testKakaoClickActionOnGlobalCenter() { + Screen.onScreen { + applyKakaoClickExtension() + + animatedView.click(VisibleCenterGlobalCoordinatesProvider()) + clickIndicator.isVisible() + + applyEspressoClickExtension() + } + } +} diff --git a/sample/src/androidTest/kotlin/io/github/kakaocup/sample/ButtonClickRuleTest.kt b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/ButtonClickRuleTest.kt new file mode 100644 index 000000000..ff4d9c9f8 --- /dev/null +++ b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/ButtonClickRuleTest.kt @@ -0,0 +1,42 @@ +package io.github.kakaocup.sample + +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import io.github.kakaocup.kakao.Kakao +import io.github.kakaocup.kakao.ext.clicks.testrule.KakaoClicksTestRule +import io.github.kakaocup.kakao.ext.clicks.KakaoDoubleClick +import io.github.kakaocup.kakao.ext.clicks.KakaoLongClick +import io.github.kakaocup.kakao.ext.clicks.KakaoSingleClick +import io.github.kakaocup.kakao.screen.Screen +import io.github.kakaocup.sample.screen.ButtonClickScreen +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4ClassRunner::class) +class ButtonClickRuleTest { + @Rule + @JvmField + var chain: TestRule = RuleChain.outerRule(ActivityScenarioRule(ButtonClickActivity::class.java)) + .around(KakaoClicksTestRule()) + + @Test + fun testTestRuleKakaoClickAction() { + Screen.onScreen { + Kakao { + assert(singleClickAction is KakaoSingleClick) + assert(doubleClickAction is KakaoDoubleClick) + assert(longClickAction is KakaoLongClick) + } + + button { + click() + hasText("Single Click") + longClick() + hasText("Long Click") + } + } + } +} diff --git a/sample/src/androidTest/kotlin/io/github/kakaocup/sample/ButtonClickTest.kt b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/ButtonClickTest.kt new file mode 100644 index 000000000..fd454afa6 --- /dev/null +++ b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/ButtonClickTest.kt @@ -0,0 +1,64 @@ +package io.github.kakaocup.sample + +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import io.github.kakaocup.kakao.ext.clicks.coordinates.VisibleCenterGlobalCoordinatesProvider +import io.github.kakaocup.kakao.screen.Screen +import io.github.kakaocup.sample.screen.ButtonClickScreen +import io.github.kakaocup.sample.tools.applyEspressoClickExtension +import io.github.kakaocup.sample.tools.applyKakaoClickExtension +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4ClassRunner::class) +class ButtonClickTest { + @Rule + @JvmField + val rule = ActivityScenarioRule(ButtonClickActivity::class.java) + + @Test + fun testEspressoClickAction() { + Screen.onScreen { + applyEspressoClickExtension() + button { + click() + hasText("Single Click") + longClick() + hasText("Long Click") + } + } + } + + @Test + fun testKakaoClickAction() { + Screen.onScreen { + applyKakaoClickExtension() + + button { + click() + hasText("Single Click") + longClick() + hasText("Long Click") + } + + applyEspressoClickExtension() + } + } + + @Test + fun testKakaoClickActionOnGlobalCenter() { + Screen.onScreen { + applyKakaoClickExtension() + + button { + click(VisibleCenterGlobalCoordinatesProvider()) + hasText("Single Click") + longClick(VisibleCenterGlobalCoordinatesProvider()) + hasText("Long Click") + } + + applyEspressoClickExtension() + } + } +} diff --git a/sample/src/androidTest/kotlin/io/github/kakaocup/sample/ButtonDoubleClickTest.kt b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/ButtonDoubleClickTest.kt new file mode 100644 index 000000000..273b2cd89 --- /dev/null +++ b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/ButtonDoubleClickTest.kt @@ -0,0 +1,59 @@ +package io.github.kakaocup.sample + +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import io.github.kakaocup.kakao.ext.clicks.coordinates.VisibleCenterGlobalCoordinatesProvider +import io.github.kakaocup.kakao.screen.Screen +import io.github.kakaocup.sample.screen.ButtonClickScreen +import io.github.kakaocup.sample.tools.applyEspressoClickExtension +import io.github.kakaocup.sample.tools.applyKakaoClickExtension +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4ClassRunner::class) +class ButtonDoubleClickTest { + @Rule + @JvmField + val rule = ActivityScenarioRule(ButtonDoubleClickActivity::class.java) + + @Test + fun testEspressoClickAction() { + Screen.onScreen { + applyEspressoClickExtension() + + button { + doubleClick() + hasText("Double click registered") + } + } + } + + @Test + fun testKakaoClickAction() { + Screen.onScreen { + applyKakaoClickExtension() + + button { + doubleClick() + hasText("Double click registered") + } + + applyEspressoClickExtension() + } + } + + @Test + fun testKakaoClickActionOnGlobalCenter() { + Screen.onScreen { + applyKakaoClickExtension() + + button { + doubleClick(VisibleCenterGlobalCoordinatesProvider()) + hasText("Double click registered") + } + + applyEspressoClickExtension() + } + } +} diff --git a/sample/src/androidTest/kotlin/io/github/kakaocup/sample/PickersTest.kt b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/PickersTest.kt index 9f459d304..d2a9cb6a2 100644 --- a/sample/src/androidTest/kotlin/io/github/kakaocup/sample/PickersTest.kt +++ b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/PickersTest.kt @@ -4,6 +4,8 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner import io.github.kakaocup.kakao.screen.Screen import io.github.kakaocup.sample.screen.PickersActivityScreen +import io.github.kakaocup.sample.tools.applyEspressoClickExtension +import io.github.kakaocup.sample.tools.applyKakaoClickExtension import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -90,4 +92,84 @@ class PickersTest { } } + @Test + fun testDatePickerDialogKakaoClicks() { + applyKakaoClickExtension() + Screen.onScreen { + selectDateButton { + click() + } + + datePickerDialog { + datePicker { + setDate(1955, 11, 12) + hasDate(1955, 11, 12) + } + cancelButton { + click() + } + } + + selectDateButton { + click() + } + + datePickerDialog { + datePicker { + setDate(1955, 11, 12) + } + okButton { + click() + } + } + + dateText { + hasText("12 11 1955") + } + } + applyEspressoClickExtension() + } + + @Test + fun testTimePickerDialogKakaoClicks() { + applyKakaoClickExtension() + Screen.onScreen { + selectTimeButton { + click() + } + + timePickerDialog { + timePicker { + setTime(22, 4) + hasTime(22, 4) + } + cancelButton { + click() + } + } + + selectTimeButton { + click() + } + + timePickerDialog { + timePicker { + setTime(22, 4) + } + okButton { + click() + } + } + + timeText { + hasText("22:4") + } + + selectDateButton { + click() + } + } + applyEspressoClickExtension() + } } + diff --git a/sample/src/androidTest/kotlin/io/github/kakaocup/sample/SpinnerTest.kt b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/SpinnerTest.kt index 1d18b46a6..3b4c2fa37 100644 --- a/sample/src/androidTest/kotlin/io/github/kakaocup/sample/SpinnerTest.kt +++ b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/SpinnerTest.kt @@ -5,6 +5,8 @@ import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner import io.github.kakaocup.kakao.screen.Screen.Companion.onScreen import io.github.kakaocup.kakao.spinner.KSpinnerItem import io.github.kakaocup.sample.screen.SpinnerActivityScreen +import io.github.kakaocup.sample.tools.applyEspressoClickExtension +import io.github.kakaocup.sample.tools.applyKakaoClickExtension import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -54,4 +56,46 @@ class SpinnerTest { } } } + + @Test + fun testContentItemsSpinnerKakaoClicks() { + applyKakaoClickExtension() + onScreen { + spinner { + isVisible() + hasSize(10) + + open() + + emptyFirstChild { + isVisible() + hasText("Title 0") + } + + childAt(1) { + isVisible() + hasText("Title 1") + } + + emptyLastChild { + isVisible() + hasText("Title 9") + } + + emptyChildWith { + isInstanceOf(String::class.java) + equals("Title 5") + } + + emptyChildAt(4) { + isDisplayed() + hasText("Title 4") + click() + } + + hasText("Title 4") + } + } + applyEspressoClickExtension() + } } diff --git a/sample/src/androidTest/kotlin/io/github/kakaocup/sample/screen/AnimatedButtonClickScreen.kt b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/screen/AnimatedButtonClickScreen.kt new file mode 100644 index 000000000..9c438544d --- /dev/null +++ b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/screen/AnimatedButtonClickScreen.kt @@ -0,0 +1,11 @@ +package io.github.kakaocup.sample.screen + +import io.github.kakaocup.kakao.common.views.KView +import io.github.kakaocup.kakao.screen.Screen +import io.github.kakaocup.kakao.text.KTextView +import io.github.kakaocup.sample.R + +class AnimatedButtonClickScreen : Screen() { + val animatedView = KView { withId(R.id.animated_view) } + val clickIndicator = KTextView { withId(R.id.animated_view_click_indicator) } +} diff --git a/sample/src/androidTest/kotlin/io/github/kakaocup/sample/screen/ButtonClickScreen.kt b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/screen/ButtonClickScreen.kt new file mode 100644 index 000000000..ee7686b05 --- /dev/null +++ b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/screen/ButtonClickScreen.kt @@ -0,0 +1,9 @@ +package io.github.kakaocup.sample.screen + +import io.github.kakaocup.kakao.screen.Screen +import io.github.kakaocup.kakao.text.KButton +import io.github.kakaocup.sample.R + +class ButtonClickScreen : Screen() { + val button = KButton { withId(R.id.button) } +} diff --git a/sample/src/androidTest/kotlin/io/github/kakaocup/sample/tools/ClickExtensions.kt b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/tools/ClickExtensions.kt new file mode 100644 index 000000000..adf9ba9d5 --- /dev/null +++ b/sample/src/androidTest/kotlin/io/github/kakaocup/sample/tools/ClickExtensions.kt @@ -0,0 +1,26 @@ +package io.github.kakaocup.sample.tools + +import io.github.kakaocup.kakao.Kakao +import io.github.kakaocup.kakao.common.actions.clicks.EspressoDoubleClick +import io.github.kakaocup.kakao.common.actions.clicks.EspressoLongClick +import io.github.kakaocup.kakao.common.actions.clicks.EspressoSingleClick +import io.github.kakaocup.kakao.ext.clicks.KakaoDoubleClick +import io.github.kakaocup.kakao.ext.clicks.KakaoLongClick +import io.github.kakaocup.kakao.ext.clicks.KakaoSingleClick +import io.github.kakaocup.kakao.ext.clicks.visualization.VisualClicksConfig + +fun applyKakaoClickExtension() { + Kakao { + singleClickAction = KakaoSingleClick(VisualClicksConfig()) + doubleClickAction = KakaoDoubleClick(VisualClicksConfig()) + longClickAction = KakaoLongClick(VisualClicksConfig()) + } +} + +fun applyEspressoClickExtension() { + Kakao { + singleClickAction = EspressoSingleClick() + doubleClickAction = EspressoDoubleClick() + longClickAction = EspressoLongClick() + } +} diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index fb59b721a..fb965d86a 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -103,7 +103,19 @@ + + + diff --git a/sample/src/main/kotlin/io/github/kakaocup/sample/AnimatedButtonClickActivity.kt b/sample/src/main/kotlin/io/github/kakaocup/sample/AnimatedButtonClickActivity.kt new file mode 100644 index 000000000..62c5c581c --- /dev/null +++ b/sample/src/main/kotlin/io/github/kakaocup/sample/AnimatedButtonClickActivity.kt @@ -0,0 +1,25 @@ +package io.github.kakaocup.sample + +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity + +class AnimatedButtonClickActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_animated_button_clicks) + setupAnimatedButton() + } + + @Suppress("MagicNumber") + private fun setupAnimatedButton() { + val view = findViewById(R.id.animated_view) + view.rotation = 45f + view.scaleY = 0.7f + + val clickIndicator = findViewById(R.id.animated_view_click_indicator) + view.setOnClickListener { + clickIndicator.visibility = View.VISIBLE + } + } +} diff --git a/sample/src/main/kotlin/io/github/kakaocup/sample/ButtonClickActivity.kt b/sample/src/main/kotlin/io/github/kakaocup/sample/ButtonClickActivity.kt new file mode 100644 index 000000000..978b99a0d --- /dev/null +++ b/sample/src/main/kotlin/io/github/kakaocup/sample/ButtonClickActivity.kt @@ -0,0 +1,21 @@ +package io.github.kakaocup.sample + +import android.os.Bundle +import android.widget.Button +import androidx.appcompat.app.AppCompatActivity + +class ButtonClickActivity : AppCompatActivity() { + private val button: Button by lazy { findViewById(R.id.button) } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_button_clicks) + button.setOnClickListener { + button.text = "Single Click" + } + + button.setOnLongClickListener { + button.text = "Long Click" + true + } + } +} diff --git a/sample/src/main/kotlin/io/github/kakaocup/sample/ButtonDoubleClickActivity.kt b/sample/src/main/kotlin/io/github/kakaocup/sample/ButtonDoubleClickActivity.kt new file mode 100644 index 000000000..7855cb107 --- /dev/null +++ b/sample/src/main/kotlin/io/github/kakaocup/sample/ButtonDoubleClickActivity.kt @@ -0,0 +1,64 @@ +package io.github.kakaocup.sample + +import android.os.Bundle +import android.view.GestureDetector +import android.view.MotionEvent +import android.widget.Button +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.GestureDetectorCompat + +class ButtonDoubleClickActivity : AppCompatActivity() { + private val button: Button by lazy { findViewById(R.id.button) } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_button_double_clicks) + val detector = GestureDetectorCompat(this, StubGestureListener()) + detector.setOnDoubleTapListener(DoubleClickListener(doOnDoubleClick = { + button.text = "Double click registered" + })) + button.setOnTouchListener { v, event -> + detector.onTouchEvent(event) + } + } + + private class DoubleClickListener(private val doOnDoubleClick: () -> Unit) : GestureDetector.OnDoubleTapListener { + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + return true + } + + override fun onDoubleTap(e: MotionEvent): Boolean { + return true + } + + override fun onDoubleTapEvent(e: MotionEvent): Boolean { + doOnDoubleClick.invoke() + return true + } + } + + private class StubGestureListener : GestureDetector.OnGestureListener { + override fun onDown(e: MotionEvent): Boolean { + return true + } + + override fun onShowPress(e: MotionEvent) { + // do nothing + } + + override fun onSingleTapUp(e: MotionEvent): Boolean { + return true + } + + override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean { + return true + } + + override fun onLongPress(e: MotionEvent) { + // do nothing + } + + override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { + return true + } + } +} diff --git a/sample/src/main/kotlin/io/github/kakaocup/sample/SwitchersActivity.kt b/sample/src/main/kotlin/io/github/kakaocup/sample/SwitchersActivity.kt index 7ac12e25a..334e3dd5d 100644 --- a/sample/src/main/kotlin/io/github/kakaocup/sample/SwitchersActivity.kt +++ b/sample/src/main/kotlin/io/github/kakaocup/sample/SwitchersActivity.kt @@ -20,7 +20,7 @@ class SwitchersActivity : AppCompatActivity() { setContentView(R.layout.activity_textswitcher) textSwitcher.setFactory { val textView = TextView(this) - textView.textSize = 24f + textView.textSize = TEXT_SIZE textView.gravity = Gravity.CENTER_HORIZONTAL textView.setTextColor(Color.parseColor("#0F9D58")) textView @@ -35,4 +35,8 @@ class SwitchersActivity : AppCompatActivity() { counter++ return "Counter: $counter" } + + private companion object { + const val TEXT_SIZE = 24f + } } diff --git a/sample/src/main/res/layout/activity_animated_button_clicks.xml b/sample/src/main/res/layout/activity_animated_button_clicks.xml new file mode 100644 index 000000000..80d43667d --- /dev/null +++ b/sample/src/main/res/layout/activity_animated_button_clicks.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/sample/src/main/res/layout/activity_button_clicks.xml b/sample/src/main/res/layout/activity_button_clicks.xml new file mode 100644 index 000000000..a4514c3b5 --- /dev/null +++ b/sample/src/main/res/layout/activity_button_clicks.xml @@ -0,0 +1,14 @@ + + + +