Skip to content

Commit 1e8aa7e

Browse files
Merge pull request #16621 from nextcloud/fix/file-deletion
fix(db): file-deletion
2 parents a259d67 + 1571a0f commit 1e8aa7e

3 files changed

Lines changed: 474 additions & 93 deletions

File tree

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.test
9+
10+
import com.owncloud.android.AbstractIT
11+
import com.owncloud.android.datamodel.OCFile
12+
import com.owncloud.android.utils.FileStorageUtils
13+
import com.owncloud.android.utils.MimeType
14+
import junit.framework.TestCase.assertFalse
15+
import junit.framework.TestCase.assertNotNull
16+
import junit.framework.TestCase.assertNull
17+
import junit.framework.TestCase.assertTrue
18+
import junit.framework.TestCase.fail
19+
import org.junit.After
20+
import org.junit.Before
21+
import org.junit.Test
22+
import java.io.File
23+
import kotlin.random.Random
24+
25+
@Suppress("TooManyFunctions", "MagicNumber")
26+
class FileDeletionTests : AbstractIT() {
27+
28+
private lateinit var tempDir: File
29+
30+
@Before
31+
fun setup() {
32+
val parent = System.getProperty("java.io.tmpdir")
33+
val childPath = "file_deletion_test_${System.currentTimeMillis()}"
34+
tempDir = File(parent, childPath)
35+
tempDir.mkdirs()
36+
}
37+
38+
@After
39+
fun cleanup() {
40+
tempDir.deleteRecursively()
41+
}
42+
43+
private fun getRandomRemoteId(): String = Random
44+
.nextLong(10_000_000L, 99_999_999L)
45+
.toString()
46+
.padEnd(32, '0')
47+
48+
private fun createAndSaveSingleFileWithLocalCopy(): OCFile {
49+
val now = System.currentTimeMillis()
50+
51+
val file = OCFile("/TestFile.txt").apply {
52+
fileId = Random.nextLong(1, 10_000)
53+
parentId = 0
54+
remoteId = getRandomRemoteId()
55+
fileLength = 1024
56+
mimeType = MimeType.TEXT_PLAIN
57+
creationTimestamp = now
58+
modificationTimestamp = now
59+
permissions = "RWDNV"
60+
}
61+
62+
val localFile = File(tempDir, "TestFile_${file.fileId}.txt").apply {
63+
parentFile?.mkdirs()
64+
createNewFile()
65+
writeText("Temporary test content")
66+
}
67+
file.storagePath = localFile.absolutePath
68+
69+
storageManager.saveFile(file)
70+
71+
return file
72+
}
73+
74+
private fun createAndSaveFolderTree(): OCFile {
75+
val now = System.currentTimeMillis()
76+
val rootFolder = OCFile("/TestFolder").apply {
77+
fileId = Random.nextLong(1, 10_000)
78+
parentId = 0
79+
remoteId = getRandomRemoteId()
80+
mimeType = MimeType.DIRECTORY
81+
creationTimestamp = now
82+
modificationTimestamp = now
83+
permissions = "RWDNVCK"
84+
}
85+
86+
val subFolder = OCFile("/TestFolder/Sub").apply {
87+
fileId = rootFolder.fileId + 1
88+
parentId = rootFolder.fileId
89+
remoteId = getRandomRemoteId()
90+
mimeType = MimeType.DIRECTORY
91+
creationTimestamp = now
92+
modificationTimestamp = now
93+
permissions = "RWDNVCK"
94+
}
95+
96+
val file1 = OCFile("/TestFolder/file1.txt").apply {
97+
fileId = rootFolder.fileId + 2
98+
parentId = rootFolder.fileId
99+
remoteId = getRandomRemoteId()
100+
fileLength = 512
101+
mimeType = MimeType.TEXT_PLAIN
102+
creationTimestamp = now
103+
modificationTimestamp = now
104+
permissions = "RWDNV"
105+
}
106+
107+
val file2 = OCFile("/TestFolder/Sub/file2.txt").apply {
108+
fileId = rootFolder.fileId + 3
109+
parentId = subFolder.fileId
110+
remoteId = getRandomRemoteId()
111+
fileLength = 256
112+
mimeType = MimeType.TEXT_PLAIN
113+
creationTimestamp = now
114+
modificationTimestamp = now
115+
permissions = "RWDNV"
116+
}
117+
118+
listOf(rootFolder, subFolder, file1, file2).forEach { storageManager.saveFile(it) }
119+
120+
val file1Path = File(tempDir, "file1_${file1.fileId}.txt").apply { createNewFile() }
121+
val file2Path = File(tempDir, "file2_${file2.fileId}.txt").apply { createNewFile() }
122+
123+
file1.storagePath = file1Path.absolutePath
124+
file2.storagePath = file2Path.absolutePath
125+
126+
storageManager.saveFile(file1)
127+
storageManager.saveFile(file2)
128+
129+
return rootFolder
130+
}
131+
132+
private fun getMixedOcFiles(): List<OCFile> {
133+
val now = System.currentTimeMillis()
134+
135+
fun createFolder(id: Long, parentId: Long, path: String): OCFile = OCFile(path).apply {
136+
fileId = id
137+
this.parentId = parentId
138+
remoteId = getRandomRemoteId()
139+
mimeType = MimeType.DIRECTORY
140+
creationTimestamp = now
141+
modificationTimestamp = now
142+
permissions = "RWDNVCK"
143+
}
144+
145+
fun createFile(id: Long, parentId: Long, path: String, size: Long, mime: String): OCFile = OCFile(path).apply {
146+
fileId = id
147+
this.parentId = parentId
148+
remoteId = getRandomRemoteId()
149+
fileLength = size
150+
creationTimestamp = now
151+
mimeType = mime
152+
modificationTimestamp = now
153+
permissions = "RWDNV"
154+
}
155+
156+
val list = mutableListOf<OCFile>()
157+
158+
list.add(createFolder(1, 0, "/"))
159+
160+
list.add(createFolder(5, 2, "/Documents/Projects"))
161+
list.add(createFile(9, 5, "/Documents/Projects/spec.txt", 12000, MimeType.TEXT_PLAIN))
162+
list.add(createFolder(2, 1, "/Documents"))
163+
list.add(createFile(11, 7, "/Photos/Vacation/img2.jpg", 300000, MimeType.JPEG))
164+
list.add(createFolder(7, 3, "/Photos/Vacation"))
165+
list.add(createFile(4, 2, "/Documents/example.pdf", 150000, MimeType.PDF))
166+
list.add(createFolder(3, 1, "/Photos"))
167+
list.add(createFile(12, 3, "/Photos/cover.png", 80000, MimeType.PNG))
168+
list.add(createFile(6, 5, "/Documents/Projects/readme.txt", 2000, MimeType.TEXT_PLAIN))
169+
list.add(createFolder(8, 5, "/Documents/Projects/Archive"))
170+
list.add(createFile(13, 8, "/Documents/Projects/Archive/old.bmp", 900000, MimeType.BMP))
171+
list.add(createFile(10, 7, "/Photos/Vacation/img1.jpg", 250000, MimeType.JPEG))
172+
list.add(createFolder(14, 1, "/Temp"))
173+
list.add(createFile(15, 14, "/Temp/tmp_file_1.txt", 400, MimeType.TEXT_PLAIN))
174+
list.add(createFile(16, 14, "/Temp/tmp_file_2.txt", 800, MimeType.TEXT_PLAIN))
175+
list.add(createFolder(17, 14, "/Temp/Nested"))
176+
list.add(createFile(18, 17, "/Temp/Nested/deep.txt", 100, MimeType.TEXT_PLAIN))
177+
list.add(createFile(19, 2, "/Documents/notes.txt", 1500, MimeType.TEXT_PLAIN))
178+
list.add(createFolder(20, 3, "/Photos/EmptyFolder"))
179+
180+
list.forEach { ocFile ->
181+
if (!ocFile.isFolder) {
182+
val localFile = File(tempDir, ocFile.remoteId).apply {
183+
parentFile?.mkdirs()
184+
createNewFile()
185+
writeText("test content")
186+
}
187+
ocFile.storagePath = localFile.absolutePath
188+
storageManager.saveFile(ocFile)
189+
} else {
190+
// For folders, create the folder in tempDir
191+
val localFolder = File(tempDir, ocFile.remoteId).apply { mkdirs() }
192+
ocFile.storagePath = localFolder.absolutePath
193+
storageManager.saveFile(ocFile)
194+
}
195+
}
196+
197+
return list
198+
}
199+
200+
@Test
201+
fun deleteMixedFiles() {
202+
var result = false
203+
val files = getMixedOcFiles()
204+
205+
files.forEach {
206+
result = storageManager.removeFile(it, true, true)
207+
if (!result) {
208+
fail("remove operation is failed")
209+
}
210+
}
211+
212+
assert(result)
213+
}
214+
215+
@Test
216+
fun removeNullFileShouldReturnsFalse() {
217+
val result = storageManager.removeFile(null, true, true)
218+
assertFalse(result)
219+
}
220+
221+
@Test
222+
fun deleteFileOnlyFromDb() {
223+
val file = createAndSaveSingleFileWithLocalCopy()
224+
225+
val result = storageManager.removeFile(file, true, false)
226+
227+
assertTrue(result)
228+
229+
// verify DB no longer contains file
230+
val fromDb = storageManager.getFileById(file.fileId)
231+
assertNull(fromDb)
232+
233+
// verify local file still exists
234+
assertTrue(File(file.storagePath).exists())
235+
}
236+
237+
@Test
238+
fun deleteFileOnlyLocalCopy() {
239+
val file = createAndSaveSingleFileWithLocalCopy()
240+
241+
val result = storageManager.removeFile(file, false, true)
242+
243+
assertTrue(result)
244+
245+
// DB should still contain file
246+
val fromDb = storageManager.getFileById(file.fileId)
247+
assertNotNull(fromDb)
248+
249+
// Storage path should be null
250+
assertNull(fromDb?.storagePath)
251+
}
252+
253+
@Test
254+
fun deleteFileDBAndLocal() {
255+
val file = createAndSaveSingleFileWithLocalCopy()
256+
257+
val result = storageManager.removeFile(file, true, true)
258+
259+
assertTrue(result)
260+
261+
assertNull(storageManager.getFileById(file.fileId))
262+
assertFalse(File(file.storagePath).exists())
263+
}
264+
265+
@Test
266+
fun deleteFolderRecursive() {
267+
val folder = createAndSaveFolderTree()
268+
269+
val result = storageManager.removeFile(folder, true, true)
270+
271+
assertTrue(result)
272+
273+
// Folder removed from DB
274+
assertNull(storageManager.getFileById(folder.fileId))
275+
276+
// subdirectories and files are removed
277+
val children = storageManager.getAllFilesRecursivelyInsideFolder(folder)
278+
assertTrue(children.isEmpty())
279+
280+
// local folder removed
281+
val localPath = FileStorageUtils.getDefaultSavePathFor(user.accountName, folder)
282+
assertFalse(File(localPath).exists())
283+
}
284+
285+
@Test
286+
fun removeFolderFileIdMinusOneSkipsDBDeletion() {
287+
val folder = OCFile("/Test").apply {
288+
fileId = -1
289+
mimeType = MimeType.DIRECTORY
290+
}
291+
292+
val result = storageManager.removeFile(folder, true, false)
293+
294+
assertTrue(result)
295+
}
296+
}

app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,21 @@ interface FileDao {
146146

147147
@Query("SELECT remote_id FROM filelist WHERE file_owner = :accountName AND remote_id IS NOT NULL")
148148
fun getAllRemoteIds(accountName: String): List<String>
149+
150+
@Query(
151+
"""
152+
WITH RECURSIVE descendants AS (
153+
SELECT _id FROM filelist WHERE _id = :folderId AND file_owner = :fileOwner
154+
UNION ALL
155+
SELECT f._id FROM filelist f
156+
INNER JOIN descendants d ON f.parent = d._id
157+
WHERE f.file_owner = :fileOwner
158+
)
159+
DELETE FROM filelist WHERE _id IN (SELECT _id FROM descendants)
160+
"""
161+
)
162+
fun deleteFolderWithDescendants(fileOwner: String, folderId: Long): Int
163+
164+
@Query("DELETE FROM filelist WHERE file_owner = :fileOwner AND path = :remotePath")
165+
fun deleteFileByRemotePath(fileOwner: String, remotePath: String): Int
149166
}

0 commit comments

Comments
 (0)