blob: 00719bf4a269022ac96220d3adb2cbadd43896af [file] [log] [blame]
Jakub Gielzak325a1982018-07-27 17:10:38 +01001/*
Jakub Gielzakbf618422019-01-21 15:58:47 +00002 * Copyright 2018 The Android Open Source Project
Jakub Gielzak325a1982018-07-27 17:10:38 +01003 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package androidx.viewpager2.widget
18
Jelle Fresen9b269ad2018-10-15 14:13:41 +010019import android.content.Intent
Jakub Gielzak325a1982018-07-27 17:10:38 +010020import android.os.Build
Jelle Fresenfa6c9312019-05-01 18:06:23 +010021import android.util.Log
Jakub Gielzak325a1982018-07-27 17:10:38 +010022import android.view.View
23import android.view.View.OVER_SCROLL_NEVER
Jakub Gielzak866fb5a2019-07-11 17:03:27 +010024import android.view.ViewConfiguration
sallyyuen0b984ad2019-03-01 11:38:19 -080025import android.view.accessibility.AccessibilityNodeInfo
Jelle Fresen9b269ad2018-10-15 14:13:41 +010026import androidx.core.view.ViewCompat
sallyyuen65b52fc2019-01-25 16:09:00 -080027import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
sallyyuen0b984ad2019-03-01 11:38:19 -080028import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD
29import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD
Jelle Fresen25c0d5f2018-09-14 15:29:48 +010030import androidx.recyclerview.widget.LinearLayoutManager
Jakub Gielzak325a1982018-07-27 17:10:38 +010031import androidx.recyclerview.widget.RecyclerView
Alan Viverettebadf2f82018-12-18 12:14:10 -050032import androidx.test.core.app.ApplicationProvider
Jakub Gielzak325a1982018-07-27 17:10:38 +010033import androidx.test.espresso.Espresso.onView
34import androidx.test.espresso.action.CoordinatesProvider
35import androidx.test.espresso.action.GeneralLocation
36import androidx.test.espresso.action.GeneralSwipeAction
37import androidx.test.espresso.action.Press
38import androidx.test.espresso.action.Swipe
39import androidx.test.espresso.action.ViewActions.actionWithAssertions
40import androidx.test.espresso.assertion.ViewAssertions.matches
Jelle Fresen25c0d5f2018-09-14 15:29:48 +010041import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
Jelle Fresen450e0c32019-08-13 10:18:41 +010042import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed
Jakub Gielzak325a1982018-07-27 17:10:38 +010043import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
44import androidx.test.espresso.matcher.ViewMatchers.withId
45import androidx.test.espresso.matcher.ViewMatchers.withText
Jakub Gielzak325a1982018-07-27 17:10:38 +010046import androidx.test.rule.ActivityTestRule
Cătălin Tudor212b9272019-05-09 16:38:28 +010047import androidx.testutils.LocaleTestUtils
Ian Lakec9b7a7a2019-05-16 14:58:30 -070048import androidx.testutils.recreate
Jelle Fresen51a3aa62019-07-31 09:45:59 +010049import androidx.testutils.waitForExecution
Jakub Gielzakacc30212018-11-23 15:10:33 +000050import androidx.viewpager2.adapter.FragmentStateAdapter
Jakub Gielzak325a1982018-07-27 17:10:38 +010051import androidx.viewpager2.test.R
Jakub Gielzakb60fc002018-10-19 16:36:59 +010052import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL
Jelle Fresen86dbc422019-01-31 17:01:40 +000053import androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_DRAGGING
Jakub Gielzakb60fc002018-10-19 16:36:59 +010054import androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_IDLE
Jelle Fresen86dbc422019-01-31 17:01:40 +000055import androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_SETTLING
Jakub Gielzakca2641d2018-09-25 16:32:32 +010056import androidx.viewpager2.widget.swipe.FragmentAdapter
Jakub Gielzak325a1982018-07-27 17:10:38 +010057import androidx.viewpager2.widget.swipe.PageSwiper
Jelle Fresena6628e62018-11-14 19:39:35 +000058import androidx.viewpager2.widget.swipe.PageSwiperEspresso
Jelle Fresen86dbc422019-01-31 17:01:40 +000059import androidx.viewpager2.widget.swipe.PageSwiperFakeDrag
Jelle Fresena6628e62018-11-14 19:39:35 +000060import androidx.viewpager2.widget.swipe.PageSwiperManual
Jakub Gielzakd1112492019-02-19 12:30:28 +000061import androidx.viewpager2.widget.swipe.SelfChecking
Jakub Gielzakca2641d2018-09-25 16:32:32 +010062import androidx.viewpager2.widget.swipe.TestActivity
63import androidx.viewpager2.widget.swipe.ViewAdapter
Jelle Fresen5e102162019-05-15 15:35:13 +010064import androidx.viewpager2.widget.swipe.WaitForInjectMotionEventsAction.Companion.waitForInjectMotionEvents
Jakub Gielzak325a1982018-07-27 17:10:38 +010065import org.hamcrest.CoreMatchers.equalTo
66import org.hamcrest.Matcher
67import org.hamcrest.Matchers.allOf
68import org.hamcrest.Matchers.greaterThanOrEqualTo
69import org.hamcrest.Matchers.lessThan
70import org.hamcrest.Matchers.lessThanOrEqualTo
Jelle Fresen9b269ad2018-10-15 14:13:41 +010071import org.junit.After
Jakub Gielzak325a1982018-07-27 17:10:38 +010072import org.junit.Assert.assertThat
Jelle Fresen9b269ad2018-10-15 14:13:41 +010073import org.junit.Before
Jelle Fresen9684c932018-12-04 15:52:08 +000074import org.junit.Rule
Jakub Gielzak325a1982018-07-27 17:10:38 +010075import java.util.concurrent.CountDownLatch
Jelle Fresen2436f952018-08-14 17:16:49 +010076import java.util.concurrent.TimeUnit
Jelle Fresen61641392018-10-02 12:07:57 +010077import kotlin.math.abs
Jakub Gielzak325a1982018-07-27 17:10:38 +010078
Jakub Gielzak325a1982018-07-27 17:10:38 +010079open class BaseTest {
Jelle Fresenfa6c9312019-05-01 18:06:23 +010080 companion object {
81 const val TAG = "VP2_TESTS"
Jakub Gielzakcef25ef2019-07-15 17:52:09 +010082 const val ACTION_ID_PAGE_LEFT = android.R.id.accessibilityActionPageLeft
83 const val ACTION_ID_PAGE_RIGHT = android.R.id.accessibilityActionPageRight
84 const val ACTION_ID_PAGE_UP = android.R.id.accessibilityActionPageUp
85 const val ACTION_ID_PAGE_DOWN = android.R.id.accessibilityActionPageDown
Jelle Fresenfa6c9312019-05-01 18:06:23 +010086 }
87
Jelle Fresen9b269ad2018-10-15 14:13:41 +010088 lateinit var localeUtil: LocaleTestUtils
89
Jelle Fresen9684c932018-12-04 15:52:08 +000090 @get:Rule
91 val activityTestRule = ActivityTestRule<TestActivity>(TestActivity::class.java, false, false)
92
Jelle Fresen9b269ad2018-10-15 14:13:41 +010093 @Before
94 open fun setUp() {
Jelle Fresend92b2c42018-12-21 12:28:32 +000095 localeUtil = LocaleTestUtils(
Cătălin Tudor212b9272019-05-09 16:38:28 +010096 ApplicationProvider.getApplicationContext() as android.content.Context
97 )
Jelle Fresen9b269ad2018-10-15 14:13:41 +010098 // Ensure a predictable test environment by explicitly setting a locale
99 localeUtil.setLocale(LocaleTestUtils.DEFAULT_TEST_LANGUAGE)
100 }
101
102 @After
103 open fun tearDown() {
104 localeUtil.resetLocale()
105 }
106
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100107 fun setUpTest(@ViewPager2.Orientation orientation: Int): Context {
Jelle Fresen9684c932018-12-04 15:52:08 +0000108 val intent = Intent()
109 if (localeUtil.isLocaleChangedAndLock()) {
110 intent.putExtra(TestActivity.EXTRA_LANGUAGE, localeUtil.getLocale().toString())
Jelle Fresen9b269ad2018-10-15 14:13:41 +0100111 }
Jelle Fresen9684c932018-12-04 15:52:08 +0000112 activityTestRule.launchActivity(intent)
Jelle Fresen5e102162019-05-15 15:35:13 +0100113 onView(withId(R.id.view_pager)).perform(waitForInjectMotionEvents())
Jakub Gielzak325a1982018-07-27 17:10:38 +0100114
115 val viewPager: ViewPager2 = activityTestRule.activity.findViewById(R.id.view_pager)
116 activityTestRule.runOnUiThread { viewPager.orientation = orientation }
117 onView(withId(R.id.view_pager)).check(matches(isDisplayed()))
118
Jakub Gielzak325a1982018-07-27 17:10:38 +0100119 // animations getting in the way on API < 16
120 if (Build.VERSION.SDK_INT < 16) {
Jelle Fresen6c1744d2019-04-05 11:26:12 +0100121 viewPager.recyclerView.overScrollMode = OVER_SCROLL_NEVER
Jakub Gielzak325a1982018-07-27 17:10:38 +0100122 }
123
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100124 return Context(activityTestRule)
Jakub Gielzak325a1982018-07-27 17:10:38 +0100125 }
126
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100127 data class Context(val activityTestRule: ActivityTestRule<TestActivity>) {
Jelle Fresen489dd2d2018-11-16 12:32:10 +0000128 fun recreateActivity(
129 adapterProvider: AdapterProvider,
130 onCreateCallback: ((ViewPager2) -> Unit) = { }
131 ) {
Jelle Fresenb35fd562019-04-08 12:30:32 +0100132 val orientation = viewPager.orientation
133 val isUserInputEnabled = viewPager.isUserInputEnabled
Jelle Fresen489dd2d2018-11-16 12:32:10 +0000134 TestActivity.onCreateCallback = { activity ->
135 val viewPager = activity.findViewById<ViewPager2>(R.id.view_pager)
Jelle Fresenb35fd562019-04-08 12:30:32 +0100136 viewPager.orientation = orientation
137 viewPager.isUserInputEnabled = isUserInputEnabled
Jelle Fresen489dd2d2018-11-16 12:32:10 +0000138 viewPager.adapter = adapterProvider(activity)
139 onCreateCallback(viewPager)
140 }
Ian Lakec9b7a7a2019-05-16 14:58:30 -0700141 activity = activityTestRule.recreate()
Jelle Fresen489dd2d2018-11-16 12:32:10 +0000142 TestActivity.onCreateCallback = { }
Jelle Fresen5e102162019-05-15 15:35:13 +0100143 onView(withId(R.id.view_pager)).perform(waitForInjectMotionEvents())
Jakub Gielzak325a1982018-07-27 17:10:38 +0100144 }
145
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100146 var activity: TestActivity = activityTestRule.activity
Jakub Gielzak325a1982018-07-27 17:10:38 +0100147 private set(value) {
148 field = value
149 }
150
Jelle Fresen3a6d49d2019-07-30 14:22:07 +0100151 fun runOnUiThreadSync(f: () -> Unit) {
Jelle Fresen0057dd52019-07-29 18:00:39 +0100152 var thrownError: Throwable? = null
153 activityTestRule.runOnUiThread {
154 try {
155 f()
156 } catch (t: Throwable) {
157 thrownError = t
158 }
159 }
160 val caughtError = thrownError
161 if (caughtError != null) {
162 throw caughtError
163 }
164 }
Jakub Gielzak325a1982018-07-27 17:10:38 +0100165
166 val viewPager: ViewPager2 get() = activity.findViewById(R.id.view_pager)
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100167
Jelle Fresena6628e62018-11-14 19:39:35 +0000168 fun peekForward() {
Jakub Gielzak866fb5a2019-07-11 17:03:27 +0100169 peek(adjustForRtl(adjustForTouchSlop(-50f)))
Jelle Fresen9b269ad2018-10-15 14:13:41 +0100170 }
Jakub Gielzak325a1982018-07-27 17:10:38 +0100171
Jelle Fresena6628e62018-11-14 19:39:35 +0000172 fun peekBackward() {
Jakub Gielzak866fb5a2019-07-11 17:03:27 +0100173 peek(adjustForRtl(adjustForTouchSlop(50f)))
Jelle Fresen9b269ad2018-10-15 14:13:41 +0100174 }
175
Jelle Fresena6628e62018-11-14 19:39:35 +0000176 enum class SwipeMethod {
177 ESPRESSO,
Jelle Fresen86dbc422019-01-31 17:01:40 +0000178 MANUAL,
179 FAKE_DRAG
Jelle Fresen9b269ad2018-10-15 14:13:41 +0100180 }
181
Jelle Fresena6628e62018-11-14 19:39:35 +0000182 fun swipe(currentPageIx: Int, nextPageIx: Int, method: SwipeMethod = SwipeMethod.ESPRESSO) {
Jakub Gielzak4368b722019-01-07 14:49:53 +0000183 val lastPageIx = viewPager.adapter!!.itemCount - 1
Jelle Fresena6628e62018-11-14 19:39:35 +0000184
185 if (nextPageIx > lastPageIx) {
186 throw IllegalArgumentException("Invalid next page: beyond last page.")
187 }
188
189 if (currentPageIx == nextPageIx) { // dedicated for testing edge behaviour
190 if (nextPageIx == 0) {
191 swipeBackward(method) // bounce off the "left" edge
192 return
193 }
194 if (nextPageIx == lastPageIx) { // bounce off the "right" edge
195 swipeForward(method)
196 return
197 }
198 throw IllegalArgumentException(
199 "Invalid sequence. Not on an edge, and current page = next page."
200 )
201 }
202
203 if (Math.abs(nextPageIx - currentPageIx) > 1) {
204 throw IllegalArgumentException(
205 "Specified next page not adjacent to the current page."
206 )
207 }
208
209 if (nextPageIx > currentPageIx) {
210 swipeForward(method)
211 } else {
212 swipeBackward(method)
213 }
214 }
215
216 fun swipeForward(method: SwipeMethod = SwipeMethod.ESPRESSO) {
217 swiper(method).swipeNext()
218 }
219
220 fun swipeBackward(method: SwipeMethod = SwipeMethod.ESPRESSO) {
221 swiper(method).swipePrevious()
222 }
223
224 private fun swiper(method: SwipeMethod = SwipeMethod.ESPRESSO): PageSwiper {
225 return when (method) {
Jelle Fresena9132902019-05-02 16:35:39 +0100226 SwipeMethod.ESPRESSO -> PageSwiperEspresso(viewPager)
227 SwipeMethod.MANUAL -> PageSwiperManual(viewPager)
Jelle Fresene94be332019-04-23 17:56:35 +0100228 SwipeMethod.FAKE_DRAG -> PageSwiperFakeDrag(viewPager) { viewPager.pageSize }
Jelle Fresena6628e62018-11-14 19:39:35 +0000229 }
230 }
231
Jakub Gielzak866fb5a2019-07-11 17:03:27 +0100232 private fun adjustForTouchSlop(offset: Float): Float {
233 val touchSlop = ViewConfiguration.get(viewPager.context).scaledPagingTouchSlop
234 return when {
235 offset < 0 -> offset - touchSlop
236 offset > 0 -> offset + touchSlop
237 else -> 0f
238 }
239 }
240
Jelle Fresena6628e62018-11-14 19:39:35 +0000241 private fun adjustForRtl(offset: Float): Float {
Jelle Fresena9132902019-05-02 16:35:39 +0100242 return if (viewPager.isHorizontal && viewPager.isRtl) -offset else offset
Jelle Fresena6628e62018-11-14 19:39:35 +0000243 }
244
245 private fun peek(offset: Float) {
Jelle Fresen9b269ad2018-10-15 14:13:41 +0100246 onView(allOf(isDisplayed(), isAssignableFrom(ViewPager2::class.java)))
Jakub Gielzak1c9c5832018-12-02 18:42:16 +0000247 .perform(
248 actionWithAssertions(
249 GeneralSwipeAction(
250 Swipe.SLOW, GeneralLocation.CENTER,
251 CoordinatesProvider { view ->
252 val coordinates = GeneralLocation.CENTER.calculateCoordinates(view)
253 if (viewPager.orientation == ORIENTATION_HORIZONTAL) {
254 coordinates[0] += offset
255 } else {
256 coordinates[1] += offset
257 }
258 coordinates
259 }, Press.FINGER
260 )
261 )
262 )
Jelle Fresen9b269ad2018-10-15 14:13:41 +0100263 }
sallyyuen65b52fc2019-01-25 16:09:00 -0800264
265 fun assertPageActions() {
Jakub Gielzak37028882019-06-20 16:59:17 +0100266 if (!ViewPager2.sFeatureEnhancedA11yEnabled) {
267 return // these assertions only apply to enhanced a11y
268 }
269
sallyyuen0b984ad2019-03-01 11:38:19 -0800270 var customActions = getActionList(viewPager)
sallyyuen65b52fc2019-01-25 16:09:00 -0800271 var currentPage = viewPager.currentItem
272 var numPages = viewPager.adapter!!.itemCount
273 var isUserInputEnabled = viewPager.isUserInputEnabled
274 var isHorizontalOrientation = viewPager.orientation == ViewPager2.ORIENTATION_HORIZONTAL
275 var isVerticalOrientation = viewPager.orientation == ViewPager2.ORIENTATION_VERTICAL
276
sallyyuen0b984ad2019-03-01 11:38:19 -0800277 val expectPageLeftAction = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP &&
278 isUserInputEnabled && isHorizontalOrientation &&
Aurimas Liutikas95dcc6c2019-05-24 12:27:10 -0700279 (if (viewPager.isRtl) currentPage < numPages - 1 else currentPage > 0)
sallyyuen65b52fc2019-01-25 16:09:00 -0800280
sallyyuen0b984ad2019-03-01 11:38:19 -0800281 val expectPageRightAction = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP &&
282 isUserInputEnabled && isHorizontalOrientation &&
Aurimas Liutikas95dcc6c2019-05-24 12:27:10 -0700283 (if (viewPager.isRtl) currentPage > 0 else currentPage < numPages - 1)
sallyyuen65b52fc2019-01-25 16:09:00 -0800284
sallyyuen0b984ad2019-03-01 11:38:19 -0800285 val expectPageUpAction = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP &&
286 isUserInputEnabled && isVerticalOrientation &&
sallyyuen65b52fc2019-01-25 16:09:00 -0800287 currentPage > 0
288
sallyyuen0b984ad2019-03-01 11:38:19 -0800289 val expectPageDownAction = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP &&
290 isUserInputEnabled && isVerticalOrientation &&
sallyyuen65b52fc2019-01-25 16:09:00 -0800291 currentPage < numPages - 1
292
sallyyuen84e5caf2019-04-24 17:59:13 -0700293 val expectScrollBackwardAction =
294 Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && isUserInputEnabled &&
295 currentPage > 0
sallyyuen0b984ad2019-03-01 11:38:19 -0800296
sallyyuen84e5caf2019-04-24 17:59:13 -0700297 val expectScrollForwardAction =
298 Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && isUserInputEnabled &&
299 currentPage < numPages - 1
sallyyuen0b984ad2019-03-01 11:38:19 -0800300
sallyyuen65b52fc2019-01-25 16:09:00 -0800301 assertThat("Left action expected: $expectPageLeftAction",
Jakub Gielzakcef25ef2019-07-15 17:52:09 +0100302 hasPageAction(customActions, ACTION_ID_PAGE_LEFT),
sallyyuen65b52fc2019-01-25 16:09:00 -0800303 equalTo(expectPageLeftAction)
304 )
305
306 assertThat("Right action expected: $expectPageRightAction",
Jakub Gielzakcef25ef2019-07-15 17:52:09 +0100307 hasPageAction(customActions, ACTION_ID_PAGE_RIGHT),
sallyyuen65b52fc2019-01-25 16:09:00 -0800308 equalTo(expectPageRightAction)
309 )
310 assertThat("Up action expected: $expectPageUpAction",
Jakub Gielzakcef25ef2019-07-15 17:52:09 +0100311 hasPageAction(customActions, ACTION_ID_PAGE_UP),
sallyyuen65b52fc2019-01-25 16:09:00 -0800312 equalTo(expectPageUpAction)
313 )
314 assertThat("Down action expected: $expectPageDownAction",
Jakub Gielzakcef25ef2019-07-15 17:52:09 +0100315 hasPageAction(customActions, ACTION_ID_PAGE_DOWN),
sallyyuen65b52fc2019-01-25 16:09:00 -0800316 equalTo(expectPageDownAction)
317 )
sallyyuen0b984ad2019-03-01 11:38:19 -0800318
319 var node = AccessibilityNodeInfo.obtain()
Jelle Fresen3a6d49d2019-07-30 14:22:07 +0100320 runOnUiThreadSync { viewPager.onInitializeAccessibilityNodeInfo(node) }
Aurimas Liutikas6b2ae5a2019-05-15 16:15:30 -0700321 @Suppress("DEPRECATION") var standardActions = node.actions
sallyyuen0b984ad2019-03-01 11:38:19 -0800322
323 assertThat("scroll backward action expected: $expectScrollBackwardAction",
324 hasScrollAction(standardActions, ACTION_SCROLL_BACKWARD),
325 equalTo(expectScrollBackwardAction)
326 )
327
328 assertThat("Scroll forward action expected: $expectScrollForwardAction",
329 hasScrollAction(standardActions, ACTION_SCROLL_FORWARD),
330 equalTo(expectScrollForwardAction)
331 )
332 }
333
334 private fun hasScrollAction(
335 actions: Int,
336 accessibilityActionId: Int
337 ): Boolean {
338 return actions and accessibilityActionId != 0
sallyyuen65b52fc2019-01-25 16:09:00 -0800339 }
340
341 private fun hasPageAction(
342 actions: List<AccessibilityNodeInfoCompat.AccessibilityActionCompat>,
343 accessibilityActionId: Int
344 ): Boolean {
345 return actions.any { it.id == accessibilityActionId }
346 }
347
Aurimas Liutikas6b2ae5a2019-05-15 16:15:30 -0700348 @Suppress("UNCHECKED_CAST")
sallyyuen65b52fc2019-01-25 16:09:00 -0800349 private fun getActionList(view: View):
350 List<AccessibilityNodeInfoCompat.AccessibilityActionCompat> {
351 return view.getTag(R.id.tag_accessibility_actions) as?
352 ArrayList<AccessibilityNodeInfoCompat.AccessibilityActionCompat> ?: ArrayList()
353 }
Jakub Gielzak325a1982018-07-27 17:10:38 +0100354 }
355
356 /**
357 * Note: returned latch relies on the tested API, so it's critical to check that the final
358 * visible page is correct using [assertBasicState].
359 */
360 fun ViewPager2.addWaitForScrolledLatch(
361 targetPage: Int,
362 waitForIdle: Boolean = true
363 ): CountDownLatch {
364 val latch = CountDownLatch(if (waitForIdle) 2 else 1)
365 var lastScrollFired = false
366
Jakub Gielzak4368b722019-01-07 14:49:53 +0000367 registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
Jakub Gielzak325a1982018-07-27 17:10:38 +0100368 override fun onPageScrollStateChanged(state: Int) {
Jakub Gielzakb60fc002018-10-19 16:36:59 +0100369 if (lastScrollFired && state == SCROLL_STATE_IDLE) {
Jakub Gielzak325a1982018-07-27 17:10:38 +0100370 latch.countDown()
371 }
372 }
373
Jakub Gielzak325a1982018-07-27 17:10:38 +0100374 override fun onPageScrolled(
375 position: Int,
376 positionOffset: Float,
377 positionOffsetPixels: Int
378 ) {
379 if (position == targetPage && positionOffsetPixels == 0) {
380 latch.countDown()
381 lastScrollFired = true
382 }
383 }
384 })
385
386 return latch
387 }
388
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100389 fun Context.setAdapterSync(adapterProvider: AdapterProvider) {
Jelle Fresena2cb4bc2019-03-06 18:59:54 +0000390 lateinit var waitForRenderLatch: CountDownLatch
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100391
Jelle Fresen3a6d49d2019-07-30 14:22:07 +0100392 runOnUiThreadSync {
Jelle Fresena2cb4bc2019-03-06 18:59:54 +0000393 waitForRenderLatch = viewPager.addWaitForLayoutChangeLatch()
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100394 viewPager.adapter = adapterProvider(activity)
395 }
396
397 waitForRenderLatch.await(5, TimeUnit.SECONDS)
Jelle Fresena6628e62018-11-14 19:39:35 +0000398
399 if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) {
400 // Give slow devices some time to warm up,
401 // to prevent severe frame drops in the smooth scroll
402 Thread.sleep(1000)
403 }
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100404 }
405
Jakub Gielzak1dec56a2018-11-22 23:28:01 +0000406 fun ViewPager2.addWaitForLayoutChangeLatch(): CountDownLatch {
407 return CountDownLatch(1).also {
408 addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> it.countDown() }
409 }
410 }
411
Jelle Fresen25c0d5f2018-09-14 15:29:48 +0100412 fun ViewPager2.addWaitForIdleLatch(): CountDownLatch {
Jelle Fresen86dbc422019-01-31 17:01:40 +0000413 return addWaitForStateLatch(SCROLL_STATE_IDLE)
414 }
415
416 fun ViewPager2.addWaitForStateLatch(targetState: Int): CountDownLatch {
Jelle Fresen25c0d5f2018-09-14 15:29:48 +0100417 val latch = CountDownLatch(1)
418
Jakub Gielzak4368b722019-01-07 14:49:53 +0000419 registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
Jelle Fresen25c0d5f2018-09-14 15:29:48 +0100420 override fun onPageScrollStateChanged(state: Int) {
Jelle Fresen86dbc422019-01-31 17:01:40 +0000421 if (state == targetState) {
Jelle Fresen25c0d5f2018-09-14 15:29:48 +0100422 latch.countDown()
Jakub Gielzak4368b722019-01-07 14:49:53 +0000423 post { unregisterOnPageChangeCallback(this) }
Jelle Fresen25c0d5f2018-09-14 15:29:48 +0100424 }
425 }
426 })
427
428 return latch
429 }
430
Jelle Fresen61641392018-10-02 12:07:57 +0100431 fun ViewPager2.addWaitForDistanceToTarget(target: Int, distance: Float): CountDownLatch {
432 val latch = CountDownLatch(1)
433
Jakub Gielzak4368b722019-01-07 14:49:53 +0000434 registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
Jelle Fresen61641392018-10-02 12:07:57 +0100435 override fun onPageScrolled(
436 position: Int,
437 positionOffset: Float,
438 positionOffsetPixels: Int
439 ) {
440 if (abs(target - position - positionOffset) <= distance) {
441 latch.countDown()
Jakub Gielzak4368b722019-01-07 14:49:53 +0000442 post { unregisterOnPageChangeCallback(this) }
Jelle Fresen61641392018-10-02 12:07:57 +0100443 }
444 }
Jelle Fresen61641392018-10-02 12:07:57 +0100445 })
446
447 return latch
448 }
449
Jelle Fresenfa6c9312019-05-01 18:06:23 +0100450 fun ViewPager2.addWaitForFirstScrollEventLatch(): CountDownLatch {
451 val latch = CountDownLatch(1)
452 registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
453 override fun onPageScrolled(position: Int, offset: Float, offsetPx: Int) {
454 latch.countDown()
455 post { unregisterOnPageChangeCallback(this) }
456 }
457 })
458 return latch
459 }
460
Jelle Fresen3e47bc72019-07-26 12:16:43 +0100461 val ViewPager2.linearLayoutManager: LinearLayoutManager
462 get() = recyclerView.layoutManager as LinearLayoutManager
463
Jelle Fresen6c1744d2019-04-05 11:26:12 +0100464 val ViewPager2.recyclerView: RecyclerView
465 get() {
466 return getChildAt(0) as RecyclerView
467 }
468
Jelle Fresen25c0d5f2018-09-14 15:29:48 +0100469 val ViewPager2.currentCompletelyVisibleItem: Int
470 get() {
Jelle Fresen97b098a2019-07-01 17:00:30 +0100471 var position = RecyclerView.NO_POSITION
472 activityTestRule.runOnUiThread {
Jelle Fresen3e47bc72019-07-26 12:16:43 +0100473 position = linearLayoutManager.findFirstCompletelyVisibleItemPosition()
Jelle Fresen97b098a2019-07-01 17:00:30 +0100474 }
475 return position
Jelle Fresen25c0d5f2018-09-14 15:29:48 +0100476 }
477
Jakub Gielzak325a1982018-07-27 17:10:38 +0100478 /**
479 * Checks:
480 * 1. Expected page is the current ViewPager2 page
Jelle Fresen86dbc422019-01-31 17:01:40 +0000481 * 2. Expected state is SCROLL_STATE_IDLE
482 * 3. Expected text is displayed
483 * 4. Internal activity state is valid (as per activity self-test)
Jakub Gielzak325a1982018-07-27 17:10:38 +0100484 */
Jelle Fresena2cb4bc2019-03-06 18:59:54 +0000485 fun Context.assertBasicState(
486 pageIx: Int,
Jelle Fresen2ed00fe2019-07-01 11:45:15 +0100487 value: String? = pageIx.toString(),
Jelle Fresena2cb4bc2019-03-06 18:59:54 +0000488 performSelfCheck: Boolean = true
489 ) {
Jakub Gielzak1c9c5832018-12-02 18:42:16 +0000490 assertThat<Int>(
491 "viewPager.getCurrentItem() should return $pageIx",
492 viewPager.currentItem, equalTo(pageIx)
493 )
Jelle Fresen81f1c972019-07-10 14:07:43 +0100494 assertThat("viewPager should be IDLE", viewPager.scrollState, equalTo(SCROLL_STATE_IDLE))
Jelle Fresen2ed00fe2019-07-01 11:45:15 +0100495 if (value != null) {
Jelle Fresen450e0c32019-08-13 10:18:41 +0100496 onView(allOf<View>(withId(R.id.text_view), isCompletelyDisplayed())).check(
Jelle Fresen2ed00fe2019-07-01 11:45:15 +0100497 matches(withText(value))
498 )
499 }
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100500
Jelle Fresena2cb4bc2019-03-06 18:59:54 +0000501 // TODO(b/130153109): Wire offscreenPageLimit into FragmentAdapter, remove performSelfCheck
502 if (performSelfCheck && viewPager.adapter is SelfChecking) {
Jakub Gielzakd1112492019-02-19 12:30:28 +0000503 (viewPager.adapter as SelfChecking).selfCheck()
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100504 }
sallyyuen65b52fc2019-01-25 16:09:00 -0800505 assertPageActions()
Jakub Gielzak325a1982018-07-27 17:10:38 +0100506 }
507
Jelle Fresen51a3aa62019-07-31 09:45:59 +0100508 fun Context.resetViewPagerTo(page: Int) {
509 viewPager.setCurrentItemSync(page, false, 2, TimeUnit.SECONDS)
510 // VP2 was potentially settling while the RetryException was raised,
511 // in which case we must wait until the IDLE event has been fired
512 activityTestRule.waitForExecution(1)
513 }
514
Jelle Fresenf418aaa2019-07-29 13:16:41 +0100515 fun Context.modifyDataSetSync(block: () -> Unit) {
516 val layoutChangedLatch = viewPager.addWaitForLayoutChangeLatch()
Jelle Fresen3a6d49d2019-07-30 14:22:07 +0100517 runOnUiThreadSync {
Jelle Fresenf418aaa2019-07-29 13:16:41 +0100518 block()
519 }
520 layoutChangedLatch.await(1, TimeUnit.SECONDS)
521
522 // Let animations run
523 val animationLatch = CountDownLatch(1)
524 viewPager.recyclerView.itemAnimator!!.isRunning {
525 animationLatch.countDown()
526 }
527 animationLatch.await(1, TimeUnit.SECONDS)
528 }
529
Jelle Fresen2436f952018-08-14 17:16:49 +0100530 fun ViewPager2.setCurrentItemSync(
531 targetPage: Int,
532 smoothScroll: Boolean,
533 timeout: Long,
Jelle Fresen965e50a2019-01-25 12:52:30 +0000534 unit: TimeUnit,
535 expectEvents: Boolean = (targetPage != currentItem)
Jelle Fresen2436f952018-08-14 17:16:49 +0100536 ) {
Jelle Fresen965e50a2019-01-25 12:52:30 +0000537 val latch =
538 if (expectEvents)
539 addWaitForScrolledLatch(targetPage, smoothScroll)
540 else
541 CountDownLatch(1)
542 post {
543 setCurrentItem(targetPage, smoothScroll)
544 if (!expectEvents) {
545 latch.countDown()
546 }
547 }
Jelle Fresen2436f952018-08-14 17:16:49 +0100548 latch.await(timeout, unit)
Jelle Fresen2436f952018-08-14 17:16:49 +0100549 }
550
Jakub Gielzak325a1982018-07-27 17:10:38 +0100551 enum class SortOrder(val sign: Int) {
552 ASC(1),
553 DESC(-1)
554 }
555
556 fun <T, R : Comparable<R>> List<T>.assertSorted(selector: (T) -> R) {
557 assertThat(this, equalTo(this.sortedBy(selector)))
558 }
559
560 /**
Jelle Fresena0148432019-05-17 11:20:01 +0100561 * Returns the slice between the first and second element. First and second element are not
562 * included in the results. Search for the second element starts on the element after the first
563 * element. If first element is not found, an empty list is returned. If second element is not
564 * found, all elements after the first are returned.
565 *
566 * @return A list with all elements between the first and the second element
567 */
568 fun <T> List<T>.slice(first: T, second: T): List<T> {
569 return dropWhile { it != first }.drop(1).takeWhile { it != second }
570 }
571
572 /**
Jakub Gielzak325a1982018-07-27 17:10:38 +0100573 * Is between [min, max)
574 * @param min - inclusive
575 * @param max - exclusive
576 */
577 fun <T : Comparable<T>> isBetweenInEx(min: T, max: T): Matcher<T> {
578 return allOf(greaterThanOrEqualTo<T>(min), lessThan<T>(max))
579 }
580
581 /**
582 * Is between [min, max]
583 * @param min - inclusive
584 * @param max - inclusive
585 */
586 fun <T : Comparable<T>> isBetweenInIn(min: T, max: T): Matcher<T> {
587 return allOf(greaterThanOrEqualTo<T>(min), lessThanOrEqualTo<T>(max))
588 }
Jelle Fresen0c779ff2018-09-07 14:58:16 +0100589
590 /**
591 * Is between [min(a, b), max(a, b)]
592 * @param a - inclusive
593 * @param b - inclusive
594 */
595 fun <T : Comparable<T>> isBetweenInInMinMax(a: T, b: T): Matcher<T> {
Jelle Fresen2c342f22018-10-03 11:58:08 +0100596 return isBetweenInIn(minOf(a, b), maxOf(a, b))
Jelle Fresen0c779ff2018-09-07 14:58:16 +0100597 }
Jakub Gielzak325a1982018-07-27 17:10:38 +0100598}
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100599
600typealias AdapterProvider = (TestActivity) -> RecyclerView.Adapter<out RecyclerView.ViewHolder>
601
602typealias AdapterProviderForItems = (items: List<String>) -> AdapterProvider
603
604val fragmentAdapterProvider: AdapterProviderForItems = { items ->
Jakub Gielzakbd58b642019-03-25 16:54:39 +0000605 { activity: TestActivity -> FragmentAdapter(activity, items) }
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100606}
607
Jakub Gielzakacc30212018-11-23 15:10:33 +0000608/**
609 * Same as [fragmentAdapterProvider] but with a custom implementation of
610 * [FragmentStateAdapter.getItemId] and [FragmentStateAdapter.containsItem].
Jakub Gielzak1c9c5832018-12-02 18:42:16 +0000611 * Not suitable for testing [RecyclerView.Adapter.notifyDataSetChanged].
Jakub Gielzakacc30212018-11-23 15:10:33 +0000612 */
613val fragmentAdapterProviderCustomIds: AdapterProviderForItems = { items ->
614 { activity ->
615 fragmentAdapterProvider(items)(activity).also {
616 // more than position can represent, so a good test if ids are used consistently
617 val offset = 3L * Int.MAX_VALUE
618 val adapter = it as FragmentAdapter
619 adapter.positionToItemId = { position -> position + offset }
620 adapter.itemIdToContains = { itemId ->
621 val position = itemId - offset
622 position in (0 until adapter.itemCount)
623 }
624 }
625 }
626}
627
Jakub Gielzak1c9c5832018-12-02 18:42:16 +0000628/**
629 * Same as [fragmentAdapterProvider] but with a custom implementation of
630 * [FragmentStateAdapter.getItemId] and [FragmentStateAdapter.containsItem].
631 * Suitable for testing [RecyclerView.Adapter.notifyDataSetChanged].
632 */
633val fragmentAdapterProviderValueId: AdapterProviderForItems = { items ->
634 { activity ->
635 fragmentAdapterProvider(items)(activity).also {
636 val adapter = it as FragmentAdapter
637 adapter.positionToItemId = { position -> items[position].getId() }
638 adapter.itemIdToContains = { itemId -> items.any { item -> item.getId() == itemId } }
639 }
640 }
641}
642
643/** Extracts the sole number from a [String] and converts it to a [Long] */
644private fun (String).getId(): Long {
645 val matches = Regex("[0-9]+").findAll(this).toList()
646 if (matches.size != 1) {
647 throw IllegalStateException("There should be exactly one number in the input string")
648 }
649 return matches.first().value.toLong()
650}
651
652/**
653 * Same as [viewAdapterProvider] but with a custom implementation of
654 * [RecyclerView.Adapter.getItemId].
655 * Suitable for testing [RecyclerView.Adapter.notifyDataSetChanged].mu
656 */
657val viewAdapterProviderValueId: AdapterProviderForItems = { items ->
658 { activity ->
659 viewAdapterProvider(items)(activity).also {
660 val adapter = it as ViewAdapter
661 adapter.positionToItemId = { position -> items[position].getId() }
662 adapter.setHasStableIds(true)
663 }
664 }
665}
666
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100667val viewAdapterProvider: AdapterProviderForItems = { items -> { ViewAdapter(items) } }
668
Jakub Gielzak788ff152019-01-03 12:51:55 +0000669fun stringSequence(pageCount: Int) = (0 until pageCount).map { it.toString() }
Jakub Gielzakca2641d2018-09-25 16:32:32 +0100670
671val AdapterProviderForItems.supportsMutations: Boolean
672 get() {
673 return this == fragmentAdapterProvider
674 }
Jelle Fresen86dbc422019-01-31 17:01:40 +0000675
676fun scrollStateToString(@ViewPager2.ScrollState state: Int): String {
677 return when (state) {
678 SCROLL_STATE_IDLE -> "IDLE"
679 SCROLL_STATE_DRAGGING -> "DRAGGING"
680 SCROLL_STATE_SETTLING -> "SETTLING"
681 else -> throw IllegalArgumentException("Scroll state $state doesn't exist")
682 }
683}
684
685fun scrollStateGlossary(): String {
686 return "Scroll states: " +
687 "$SCROLL_STATE_IDLE=${scrollStateToString(SCROLL_STATE_IDLE)}, " +
688 "$SCROLL_STATE_DRAGGING=${scrollStateToString(SCROLL_STATE_DRAGGING)}, " +
689 "$SCROLL_STATE_SETTLING=${scrollStateToString(SCROLL_STATE_SETTLING)})"
690}
Jelle Fresenfa6c9312019-05-01 18:06:23 +0100691
692class RetryException(msg: String) : Exception(msg)
693
694fun tryNTimes(n: Int, resetBlock: () -> Unit, tryBlock: () -> Unit) {
695 repeat(n) { i ->
696 try {
697 tryBlock()
698 return
699 } catch (e: RetryException) {
700 if (i < n - 1) {
701 Log.w(BaseTest.TAG, "Bad state, retrying block", e)
702 } else {
703 throw AssertionError("Block hit bad state $n times", e)
704 }
705 resetBlock()
706 }
707 }
708}
Jelle Fresena9132902019-05-02 16:35:39 +0100709
710val View.isRtl: Boolean
711 get() = ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL
712
713val ViewPager2.isHorizontal: Boolean get() = orientation == ORIENTATION_HORIZONTAL