Skip to content

Commit bba226a

Browse files
committed
UI test for multi selection in media gallery
This was originally meant to find the cause of crashes caused by non-unique IDs in the ViewHolder (#15918 and #16194). But the UI test still succeeded because it didn't carefully craft the fileIds to cause the issue. Once the actual reason, a collision of an improper hash function, was found, a unit test for that scenario was added instead - see prior commit. Committing this UI test anyways, as it might be able to catch other, future regressions. Signed-off-by: Philipp Hasper <vcs@hasper.info>
1 parent 13fa1c8 commit bba226a

1 file changed

Lines changed: 106 additions & 3 deletions

File tree

app/src/androidTest/java/com/owncloud/android/ui/fragment/GalleryFragmentIT.kt

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/*
22
* Nextcloud - Android Client
33
*
4+
* SPDX-FileCopyrightText: 2026 Philipp Hasper <vcs@hasper.info>
45
* SPDX-FileCopyrightText: 2025 Alper Ozturk <alper.ozturk@nextcloud.com>
56
* SPDX-FileCopyrightText: 2022 Tobias Kaminsky <tobias@kaminsky.me>
67
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH
@@ -12,20 +13,34 @@ import android.graphics.Bitmap
1213
import android.graphics.Canvas
1314
import android.graphics.Color
1415
import android.graphics.Paint
16+
import android.view.View
17+
import android.view.ViewGroup
18+
import android.widget.FrameLayout
19+
import androidx.recyclerview.widget.RecyclerView
1520
import androidx.test.core.app.launchActivity
1621
import androidx.test.espresso.Espresso.onView
22+
import androidx.test.espresso.UiController
23+
import androidx.test.espresso.ViewAction
1724
import androidx.test.espresso.assertion.ViewAssertions.matches
25+
import androidx.test.espresso.contrib.RecyclerViewActions
26+
import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition
27+
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
1828
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
1929
import androidx.test.espresso.matcher.ViewMatchers.isRoot
30+
import androidx.test.espresso.matcher.ViewMatchers.withId
2031
import com.nextcloud.test.TestActivity
2132
import com.owncloud.android.AbstractIT
33+
import com.owncloud.android.R
2234
import com.owncloud.android.datamodel.OCFile
2335
import com.owncloud.android.datamodel.ThumbnailsCacheManager
2436
import com.owncloud.android.datamodel.ThumbnailsCacheManager.PREFIX_RESIZED_IMAGE
2537
import com.owncloud.android.lib.common.utils.Log_OC
2638
import com.owncloud.android.lib.resources.files.model.ImageDimension
39+
import com.owncloud.android.ui.adapter.GalleryRowHolder
2740
import com.owncloud.android.utils.ScreenshotTest
41+
import org.hamcrest.Matchers.allOf
2842
import org.junit.After
43+
import org.junit.Assert.assertEquals
2944
import org.junit.Assert.assertNotNull
3045
import org.junit.Before
3146
import org.junit.Test
@@ -85,14 +100,102 @@ class GalleryFragmentIT : AbstractIT() {
85100
}
86101
}
87102

88-
private fun createImage(id: Int, width: Int? = null, height: Int? = null) {
103+
@Test
104+
fun multiSelect() {
105+
val imageCount = 100
106+
for (num in 1..imageCount) {
107+
// Spread the files over multiple days to also get multiple sections
108+
val secondsPerDay = 1L * 24 * 60 * 60
109+
createImage(10000000 + num * 7 * secondsPerDay, 700, 300)
110+
}
111+
112+
// Test that scrolling through the whole list is possible without a crash
113+
launchActivity<TestActivity>().use { scenario ->
114+
lateinit var galleryFragment: GalleryFragment
115+
scenario.onActivity { testActivity ->
116+
galleryFragment = GalleryFragment()
117+
testActivity.addFragment(galleryFragment)
118+
}
119+
onView(isRoot()).check(matches(isDisplayed()))
120+
121+
onView(withId(R.id.list_root))
122+
.perform(RecyclerViewActions.scrollToLastPosition<GalleryRowHolder>())
123+
.perform(RecyclerViewActions.scrollToPosition<GalleryRowHolder>(0))
124+
}
125+
126+
// Test selection of all entries
127+
launchActivity<TestActivity>().use { scenario ->
128+
lateinit var galleryFragment: GalleryFragment
129+
scenario.onActivity { testActivity ->
130+
galleryFragment = GalleryFragment()
131+
testActivity.addFragment(galleryFragment)
132+
}
133+
onView(isRoot()).check(matches(isDisplayed()))
134+
135+
// get the RecyclerView and itemCount on the UI thread
136+
val recyclerView = findRecyclerViewRecursively(galleryFragment.view)
137+
?: throw AssertionError("RecyclerView not found")
138+
val adapterCount = recyclerView.adapter?.itemCount ?: 0
139+
140+
// Perform the view action on each adapter position (row)
141+
// Match the visible RecyclerView: if there is only one displayed RecyclerView we can match it by class+displayed
142+
val recyclerMatcher = allOf(isAssignableFrom(RecyclerView::class.java), isDisplayed())
143+
144+
for (pos in 0 until adapterCount) {
145+
onView(recyclerMatcher)
146+
.perform(actionOnItemAtPosition<RecyclerView.ViewHolder>(pos, longClickAllThumbnailsInRow()))
147+
}
148+
149+
val checked = galleryFragment.commonAdapter.getCheckedItems()
150+
assertEquals(imageCount, checked.size)
151+
}
152+
}
153+
154+
/** Recursively walk view tree to find the first RecyclerView. Runs on the same thread that calls it. */
155+
private fun findRecyclerViewRecursively(root: View?): RecyclerView? {
156+
if (root == null) return null
157+
if (root is RecyclerView) return root
158+
if (root !is ViewGroup) return null
159+
for (i in 0 until root.childCount) {
160+
val child = root.getChildAt(i)
161+
val found = findRecyclerViewRecursively(child)
162+
if (found != null) return found
163+
}
164+
return null
165+
}
166+
167+
/**
168+
* For the given row view, long-click each thumbnail inside its FrameLayouts
169+
*/
170+
fun longClickAllThumbnailsInRow(): ViewAction = object : ViewAction {
171+
override fun getConstraints() = isDisplayed()
172+
173+
override fun getDescription() = "Long-click all thumbnail ImageViews inside a GalleryRowHolder"
174+
175+
override fun perform(uiController: UiController, view: View) {
176+
if (view is ViewGroup) {
177+
// each child of the row is a FrameLayout representing one gallery cell
178+
for (i in 0 until view.childCount) {
179+
val cell = view.getChildAt(i)
180+
if (cell is FrameLayout) {
181+
// GalleryRowHolder builds FrameLayout with children:
182+
// 0 = shimmer, 1 = thumbnail ImageView, 2 = checkbox
183+
val thumbnail = if (cell.childCount > 1) cell.getChildAt(1) else cell
184+
thumbnail.performLongClick()
185+
}
186+
}
187+
}
188+
}
189+
}
190+
191+
private fun createImage(id: Long, width: Int? = null, height: Int? = null) {
89192
val defaultSize = ThumbnailsCacheManager.getThumbnailDimension().toFloat()
90193
val file = OCFile("/$id.png").apply {
91-
fileId = id.toLong()
194+
fileId = id
92195
remoteId = "$id"
93196
mimeType = "image/png"
94197
isPreviewAvailable = true
95-
modificationTimestamp = (1658475504 + id.toLong()) * 1000
198+
modificationTimestamp = (1658475504 + id) * 1000
96199
imageDimension = ImageDimension(width?.toFloat() ?: defaultSize, height?.toFloat() ?: defaultSize)
97200
storageManager.saveFile(this)
98201
}

0 commit comments

Comments
 (0)