Skip to content

Commit 8ab5833

Browse files
committed
Testing slideshow deletion for remote files
The remote + offline test case had to be ignored due to two reasons 1) Broken App behavior - The UX is indeed broken, as from a user perspective, nothing happens with the file when deleting it. The offlineOperation is put on the worker stack, but the user doesn't see anything from it - Even when coming back online, it is completely unreliable when the deletion will be finally done. It might happen 5 or 10 minutes later 2) Broken test mock - The mocked connectivityService doesn't work as expected, because the OfflineOperationsWorker has its own service, and thus might still execute the deletion, but just at an unforseable time during the test execution - see problem 1). Signed-off-by: Philipp Hasper <[email protected]>
1 parent cef0b78 commit 8ab5833

File tree

2 files changed

+160
-29
lines changed

2 files changed

+160
-29
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2025 Philipp Hasper <[email protected]>
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
package com.nextcloud.test
8+
9+
import androidx.test.espresso.IdlingResource
10+
import com.owncloud.android.datamodel.FileDataStorageManager
11+
import com.owncloud.android.datamodel.OCFile
12+
import java.util.concurrent.atomic.AtomicLong
13+
import java.util.concurrent.atomic.AtomicReference
14+
15+
/**
16+
* IdlingResource that can be reused to watch the removal of different file ids sequentially.
17+
*
18+
* Use setFileId(fileId) before triggering the deletion. The resource will call the Espresso callback
19+
* once the file no longer exists. Call unregister from IdlingRegistry in @After.
20+
*/
21+
class FileRemovedIdlingResource(private val storageManager: FileDataStorageManager) : IdlingResource {
22+
private var resourceCallback: IdlingResource.ResourceCallback? = null
23+
24+
// null means "no file set"
25+
private var currentFile = AtomicReference<OCFile>(null)
26+
27+
override fun getName(): String = "${this::class.java.simpleName}"
28+
29+
override fun isIdleNow(): Boolean {
30+
val file = currentFile.get()
31+
// If no file set, consider idle. If file set, idle only if it doesn't exist.
32+
val idle = file == null || (!storageManager.fileExists(file.fileId) && !file.exists())
33+
if (idle && file != null) {
34+
// if we detect it's already removed, notify and clear
35+
resourceCallback?.onTransitionToIdle()
36+
currentFile.set(null)
37+
}
38+
return idle
39+
}
40+
41+
override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback) {
42+
this.resourceCallback = callback
43+
}
44+
45+
/**
46+
* Start watching the given file. Call this right before performing the UI action that triggers deletion.
47+
*/
48+
fun setFile(file: OCFile) {
49+
currentFile.set(file)
50+
}
51+
}

app/src/androidTest/java/com/owncloud/android/ui/preview/PreviewImageActivityIT.kt

Lines changed: 109 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,20 +22,34 @@ import androidx.test.espresso.matcher.ViewMatchers.isRoot
2222
import androidx.test.espresso.matcher.ViewMatchers.withId
2323
import androidx.test.espresso.matcher.ViewMatchers.withText
2424
import com.nextcloud.test.ConnectivityServiceOfflineMock
25+
import com.nextcloud.test.FileRemovedIdlingResource
2526
import com.nextcloud.test.LoopFailureHandler
2627
import com.owncloud.android.AbstractOnServerIT
2728
import com.owncloud.android.R
2829
import com.owncloud.android.datamodel.OCFile
30+
import com.owncloud.android.db.OCUpload
31+
import com.owncloud.android.files.services.NameCollisionPolicy
32+
import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation
2933
import org.hamcrest.Matchers.allOf
34+
import org.junit.After
35+
import org.junit.Assert.assertFalse
36+
import org.junit.Assert.assertTrue
37+
import org.junit.Before
38+
import org.junit.Ignore
3039
import org.junit.Test
3140
import java.io.File
3241

3342
class PreviewImageActivityIT : AbstractOnServerIT() {
34-
lateinit var testFiles: List<OCFile>
43+
companion object {
44+
private const val REMOTE_FOLDER: String = "/PreviewImageActivityIT/"
45+
}
46+
47+
var fileRemovedIdlingResource = FileRemovedIdlingResource(storageManager)
3548

36-
fun createMockedImageFiles(count: Int, localOnly: Boolean) {
49+
@Suppress("SameParameterValue")
50+
private fun createLocalMockedImageFiles(count: Int): List<OCFile> {
3751
val srcPngFile = getFile("imageFile.png")
38-
testFiles = (0 until count).map { i ->
52+
return (0 until count).map { i ->
3953
val pngFile = File(srcPngFile.parent ?: ".", "image$i.png")
4054
srcPngFile.copyTo(pngFile, overwrite = true)
4155

@@ -44,27 +58,56 @@ class PreviewImageActivityIT : AbstractOnServerIT() {
4458
mimeType = "image/png"
4559
modificationTimestamp = 1000000
4660
permissions = "D" // OCFile.PERMISSION_CAN_DELETE_OR_LEAVE_SHARE. Required for deletion button to show
47-
remoteId = if (localOnly) null else "abc-mocked-remote-id" // mocking the file to be on the server
4861
}.also {
4962
storageManager.saveNewFile(it)
5063
}
5164
}
5265
}
5366

54-
fun veryImageThenDelete(index: Int) {
55-
val currentFileName = testFiles[index].fileName
67+
/**
68+
* Create image files and upload them to the connected server.
69+
*
70+
* This function relies on the images not existing beforehand, as AbstractOnServerIT#deleteAllFilesOnServer()
71+
* should clean up. If it does fail, likely because that clean up didn't work and there are leftovers from
72+
* a previous run
73+
* @param count Number of files to create
74+
* @param folder Parent folder to which to upload. Must start and end with a slash
75+
*/
76+
private fun createAndUploadImageFiles(count: Int, folder: String = REMOTE_FOLDER): List<OCFile> {
77+
val srcPngFile = getFile("imageFile.png")
78+
return (0 until count).map { i ->
79+
val pngFile = File(srcPngFile.parent ?: ".", "image$i.png")
80+
srcPngFile.copyTo(pngFile, overwrite = true)
81+
82+
val ocUpload = OCUpload(
83+
pngFile.absolutePath,
84+
folder + pngFile.name,
85+
account.name
86+
).apply {
87+
nameCollisionPolicy = NameCollisionPolicy.OVERWRITE
88+
}
89+
uploadOCUpload(ocUpload)
90+
91+
fileDataStorageManager.getFileByDecryptedRemotePath(folder + pngFile.name)!!
92+
}
93+
}
94+
95+
private fun veryImageThenDelete(testFile: OCFile) {
5696
Espresso.setFailureHandler(
57-
LoopFailureHandler(targetContext, "Test failed with image file index $index, $currentFileName")
97+
LoopFailureHandler(targetContext, "Test failed with image file ${testFile.fileName}")
5898
)
5999

100+
assertTrue(testFile.exists())
101+
assertTrue(testFile.fileExists())
102+
60103
onView(withId(R.id.image))
61104
.check(matches(isDisplayed()))
62105

63106
// Check that the Action Bar shows the file name as title
64107
onView(
65108
allOf(
66109
isDescendantOfA(isAssignableFrom(ActionBarContainer::class.java)),
67-
withText(currentFileName)
110+
withText(testFile.fileName)
68111
)
69112
).check(matches(isDisplayed()))
70113

@@ -82,7 +125,7 @@ class PreviewImageActivityIT : AbstractOnServerIT() {
82125
onView(withText(R.string.common_remove)).perform(ViewActions.click())
83126

84127
// Check confirmation dialog and then confirm the deletion by clicking the main button of the dialog
85-
val expectedText = targetContext.getString(R.string.confirmation_remove_file_alert, currentFileName)
128+
val expectedText = targetContext.getString(R.string.confirmation_remove_file_alert, testFile.fileName)
86129
onView(withId(android.R.id.message))
87130
.inRoot(isDialog())
88131
.check(matches(withText(expectedText)))
@@ -92,43 +135,80 @@ class PreviewImageActivityIT : AbstractOnServerIT() {
92135
.check(matches(withText(R.string.file_delete)))
93136
.perform(ViewActions.click())
94137

138+
// Register the idling resource to wait for successful deletion
139+
fileRemovedIdlingResource.setFile(testFile)
140+
141+
// Wait for idle, then verify that the file is gone. Somehow waitForIdleSync() doesn't work and we need onIdle()
142+
Espresso.onIdle()
143+
assertFalse("test file still exists: ${testFile.fileName}", testFile.exists())
144+
95145
Espresso.setFailureHandler(DefaultFailureHandler(targetContext))
96146
}
97147

98-
@Test
99-
fun deleteFromSlideshow_localOnly_online() {
148+
@Before
149+
fun bringUp() {
150+
IdlingRegistry.getInstance().register(fileRemovedIdlingResource)
151+
}
152+
153+
@After
154+
fun tearDown() {
155+
IdlingRegistry.getInstance().unregister(fileRemovedIdlingResource)
156+
}
157+
158+
private fun testDeleteFromSlideshow_impl(localOnly: Boolean, offline: Boolean) {
100159
// Prepare local test data
101160
val imageCount = 5
102-
createMockedImageFiles(imageCount, localOnly = true)
161+
val testFiles = if (localOnly) {
162+
createLocalMockedImageFiles(
163+
imageCount
164+
)
165+
} else {
166+
createAndUploadImageFiles(imageCount)
167+
}
103168

104169
// Launch the activity with the first image
105170
val intent = PreviewImageActivity.previewFileIntent(targetContext, user, testFiles[0])
106-
launchActivity<PreviewImageActivity>(intent).use {
171+
launchActivity<PreviewImageActivity>(intent).use { scenario ->
172+
if (offline) {
173+
scenario.onActivity { activity ->
174+
activity.connectivityService = ConnectivityServiceOfflineMock()
175+
}
176+
}
107177
onView(isRoot()).check(matches(isDisplayed()))
108178

109-
for (i in 0 until imageCount) {
110-
veryImageThenDelete(i)
179+
for (testFile in testFiles) {
180+
veryImageThenDelete(testFile)
181+
assertTrue(
182+
"Test file still exists on the server: ${testFile.remotePath}",
183+
ExistenceCheckRemoteOperation(testFile.remotePath, true).execute(client).isSuccess
184+
)
111185
}
112186
}
113187
}
114188

189+
@Test
190+
fun deleteFromSlideshow_localOnly_online() {
191+
testDeleteFromSlideshow_impl(localOnly = true, offline = false)
192+
}
193+
115194
@Test
116195
fun deleteFromSlideshow_localOnly_offline() {
117-
// Prepare local test data
118-
val imageCount = 5
119-
createMockedImageFiles(imageCount, localOnly = true)
196+
testDeleteFromSlideshow_impl(localOnly = true, offline = true)
197+
}
120198

121-
// Launch the activity with the first image
122-
val intent = PreviewImageActivity.previewFileIntent(targetContext, user, testFiles[0])
123-
launchActivity<PreviewImageActivity>(intent).use { scenario ->
124-
scenario.onActivity { activity ->
125-
activity.connectivityService = ConnectivityServiceOfflineMock()
126-
}
127-
onView(isRoot()).check(matches(isDisplayed()))
199+
@Test
200+
fun deleteFromSlideshow_remote_online() {
201+
testDeleteFromSlideshow_impl(localOnly = false, offline = false)
202+
}
128203

129-
for (i in 0 until imageCount) {
130-
veryImageThenDelete(i)
131-
}
132-
}
204+
@Test
205+
@Ignore(
206+
"Offline deletion is following a different UX and it is also brittle: Deletion might happen 10 minutes later"
207+
)
208+
fun deleteFromSlideshow_remote_offline() {
209+
// Note: the offline mock doesn't actually do what it is supposed to. The OfflineOperationsWorker uses its
210+
// own connectivityService, which is online, and may still execute the server deletion.
211+
// You'll need to address this, should you activate that test. Otherwise it might not catch all error cases
212+
testDeleteFromSlideshow_impl(localOnly = false, offline = true)
133213
}
134214
}

0 commit comments

Comments
 (0)