diff --git a/app/src/androidTest/java/com/nextcloud/test/FileDeletionTests.kt b/app/src/androidTest/java/com/nextcloud/test/FileDeletionTests.kt new file mode 100644 index 000000000000..a24c592a12d4 --- /dev/null +++ b/app/src/androidTest/java/com/nextcloud/test/FileDeletionTests.kt @@ -0,0 +1,296 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.test + +import com.owncloud.android.AbstractIT +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeType +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import junit.framework.TestCase.fail +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.File +import kotlin.random.Random + +@Suppress("TooManyFunctions", "MagicNumber") +class FileDeletionTests : AbstractIT() { + + private lateinit var tempDir: File + + @Before + fun setup() { + val parent = System.getProperty("java.io.tmpdir") + val childPath = "file_deletion_test_${System.currentTimeMillis()}" + tempDir = File(parent, childPath) + tempDir.mkdirs() + } + + @After + fun cleanup() { + tempDir.deleteRecursively() + } + + private fun getRandomRemoteId(): String = Random + .nextLong(10_000_000L, 99_999_999L) + .toString() + .padEnd(32, '0') + + private fun createAndSaveSingleFileWithLocalCopy(): OCFile { + val now = System.currentTimeMillis() + + val file = OCFile("/TestFile.txt").apply { + fileId = Random.nextLong(1, 10_000) + parentId = 0 + remoteId = getRandomRemoteId() + fileLength = 1024 + mimeType = MimeType.TEXT_PLAIN + creationTimestamp = now + modificationTimestamp = now + permissions = "RWDNV" + } + + val localFile = File(tempDir, "TestFile_${file.fileId}.txt").apply { + parentFile?.mkdirs() + createNewFile() + writeText("Temporary test content") + } + file.storagePath = localFile.absolutePath + + storageManager.saveFile(file) + + return file + } + + private fun createAndSaveFolderTree(): OCFile { + val now = System.currentTimeMillis() + val rootFolder = OCFile("/TestFolder").apply { + fileId = Random.nextLong(1, 10_000) + parentId = 0 + remoteId = getRandomRemoteId() + mimeType = MimeType.DIRECTORY + creationTimestamp = now + modificationTimestamp = now + permissions = "RWDNVCK" + } + + val subFolder = OCFile("/TestFolder/Sub").apply { + fileId = rootFolder.fileId + 1 + parentId = rootFolder.fileId + remoteId = getRandomRemoteId() + mimeType = MimeType.DIRECTORY + creationTimestamp = now + modificationTimestamp = now + permissions = "RWDNVCK" + } + + val file1 = OCFile("/TestFolder/file1.txt").apply { + fileId = rootFolder.fileId + 2 + parentId = rootFolder.fileId + remoteId = getRandomRemoteId() + fileLength = 512 + mimeType = MimeType.TEXT_PLAIN + creationTimestamp = now + modificationTimestamp = now + permissions = "RWDNV" + } + + val file2 = OCFile("/TestFolder/Sub/file2.txt").apply { + fileId = rootFolder.fileId + 3 + parentId = subFolder.fileId + remoteId = getRandomRemoteId() + fileLength = 256 + mimeType = MimeType.TEXT_PLAIN + creationTimestamp = now + modificationTimestamp = now + permissions = "RWDNV" + } + + listOf(rootFolder, subFolder, file1, file2).forEach { storageManager.saveFile(it) } + + val file1Path = File(tempDir, "file1_${file1.fileId}.txt").apply { createNewFile() } + val file2Path = File(tempDir, "file2_${file2.fileId}.txt").apply { createNewFile() } + + file1.storagePath = file1Path.absolutePath + file2.storagePath = file2Path.absolutePath + + storageManager.saveFile(file1) + storageManager.saveFile(file2) + + return rootFolder + } + + private fun getMixedOcFiles(): List { + val now = System.currentTimeMillis() + + fun createFolder(id: Long, parentId: Long, path: String): OCFile = OCFile(path).apply { + fileId = id + this.parentId = parentId + remoteId = getRandomRemoteId() + mimeType = MimeType.DIRECTORY + creationTimestamp = now + modificationTimestamp = now + permissions = "RWDNVCK" + } + + fun createFile(id: Long, parentId: Long, path: String, size: Long, mime: String): OCFile = OCFile(path).apply { + fileId = id + this.parentId = parentId + remoteId = getRandomRemoteId() + fileLength = size + creationTimestamp = now + mimeType = mime + modificationTimestamp = now + permissions = "RWDNV" + } + + val list = mutableListOf() + + list.add(createFolder(1, 0, "/")) + + list.add(createFolder(5, 2, "/Documents/Projects")) + list.add(createFile(9, 5, "/Documents/Projects/spec.txt", 12000, MimeType.TEXT_PLAIN)) + list.add(createFolder(2, 1, "/Documents")) + list.add(createFile(11, 7, "/Photos/Vacation/img2.jpg", 300000, MimeType.JPEG)) + list.add(createFolder(7, 3, "/Photos/Vacation")) + list.add(createFile(4, 2, "/Documents/example.pdf", 150000, MimeType.PDF)) + list.add(createFolder(3, 1, "/Photos")) + list.add(createFile(12, 3, "/Photos/cover.png", 80000, MimeType.PNG)) + list.add(createFile(6, 5, "/Documents/Projects/readme.txt", 2000, MimeType.TEXT_PLAIN)) + list.add(createFolder(8, 5, "/Documents/Projects/Archive")) + list.add(createFile(13, 8, "/Documents/Projects/Archive/old.bmp", 900000, MimeType.BMP)) + list.add(createFile(10, 7, "/Photos/Vacation/img1.jpg", 250000, MimeType.JPEG)) + list.add(createFolder(14, 1, "/Temp")) + list.add(createFile(15, 14, "/Temp/tmp_file_1.txt", 400, MimeType.TEXT_PLAIN)) + list.add(createFile(16, 14, "/Temp/tmp_file_2.txt", 800, MimeType.TEXT_PLAIN)) + list.add(createFolder(17, 14, "/Temp/Nested")) + list.add(createFile(18, 17, "/Temp/Nested/deep.txt", 100, MimeType.TEXT_PLAIN)) + list.add(createFile(19, 2, "/Documents/notes.txt", 1500, MimeType.TEXT_PLAIN)) + list.add(createFolder(20, 3, "/Photos/EmptyFolder")) + + list.forEach { ocFile -> + if (!ocFile.isFolder) { + val localFile = File(tempDir, ocFile.remoteId).apply { + parentFile?.mkdirs() + createNewFile() + writeText("test content") + } + ocFile.storagePath = localFile.absolutePath + storageManager.saveFile(ocFile) + } else { + // For folders, create the folder in tempDir + val localFolder = File(tempDir, ocFile.remoteId).apply { mkdirs() } + ocFile.storagePath = localFolder.absolutePath + storageManager.saveFile(ocFile) + } + } + + return list + } + + @Test + fun deleteMixedFiles() { + var result = false + val files = getMixedOcFiles() + + files.forEach { + result = storageManager.removeFile(it, true, true) + if (!result) { + fail("remove operation is failed") + } + } + + assert(result) + } + + @Test + fun removeNullFileShouldReturnsFalse() { + val result = storageManager.removeFile(null, true, true) + assertFalse(result) + } + + @Test + fun deleteFileOnlyFromDb() { + val file = createAndSaveSingleFileWithLocalCopy() + + val result = storageManager.removeFile(file, true, false) + + assertTrue(result) + + // verify DB no longer contains file + val fromDb = storageManager.getFileById(file.fileId) + assertNull(fromDb) + + // verify local file still exists + assertTrue(File(file.storagePath).exists()) + } + + @Test + fun deleteFileOnlyLocalCopy() { + val file = createAndSaveSingleFileWithLocalCopy() + + val result = storageManager.removeFile(file, false, true) + + assertTrue(result) + + // DB should still contain file + val fromDb = storageManager.getFileById(file.fileId) + assertNotNull(fromDb) + + // Storage path should be null + assertNull(fromDb?.storagePath) + } + + @Test + fun deleteFileDBAndLocal() { + val file = createAndSaveSingleFileWithLocalCopy() + + val result = storageManager.removeFile(file, true, true) + + assertTrue(result) + + assertNull(storageManager.getFileById(file.fileId)) + assertFalse(File(file.storagePath).exists()) + } + + @Test + fun deleteFolderRecursive() { + val folder = createAndSaveFolderTree() + + val result = storageManager.removeFile(folder, true, true) + + assertTrue(result) + + // Folder removed from DB + assertNull(storageManager.getFileById(folder.fileId)) + + // subdirectories and files are removed + val children = storageManager.getAllFilesRecursivelyInsideFolder(folder) + assertTrue(children.isEmpty()) + + // local folder removed + val localPath = FileStorageUtils.getDefaultSavePathFor(user.accountName, folder) + assertFalse(File(localPath).exists()) + } + + @Test + fun removeFolderFileIdMinusOneSkipsDBDeletion() { + val folder = OCFile("/Test").apply { + fileId = -1 + mimeType = MimeType.DIRECTORY + } + + val result = storageManager.removeFile(folder, true, false) + + assertTrue(result) + } +} diff --git a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt index 86d02064c7a3..f298128bd585 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileDao.kt @@ -146,4 +146,21 @@ interface FileDao { @Query("SELECT remote_id FROM filelist WHERE file_owner = :accountName AND remote_id IS NOT NULL") fun getAllRemoteIds(accountName: String): List + + @Query( + """ + WITH RECURSIVE descendants AS ( + SELECT _id FROM filelist WHERE _id = :folderId AND file_owner = :fileOwner + UNION ALL + SELECT f._id FROM filelist f + INNER JOIN descendants d ON f.parent = d._id + WHERE f.file_owner = :fileOwner + ) + DELETE FROM filelist WHERE _id IN (SELECT _id FROM descendants) +""" + ) + fun deleteFolderWithDescendants(fileOwner: String, folderId: Long): Int + + @Query("DELETE FROM filelist WHERE file_owner = :fileOwner AND path = :remotePath") + fun deleteFileByRemotePath(fileOwner: String, remotePath: String): Int } diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index c999706f9fe3..99918cce5c0d 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -886,140 +886,208 @@ private ContentValues createContentValuesForFile(OCFile file) { return cv; } + // region remove file/folder public boolean removeFile(OCFile ocFile, boolean removeDBData, boolean removeLocalCopy) { + if (ocFile == null) { + Log_OC.e(TAG, "oc file is null, cannot delete it"); + return false; + } + + if (ocFile.isFolder()) { + Log_OC.d(TAG, "deleting folder"); + return removeFolder(ocFile, removeDBData, removeLocalCopy); + } + boolean success = true; + if (removeDBData) { + Log_OC.d(TAG, "deleting db data of file"); + success = fileDao.deleteFileByRemotePath(user.getAccountName(), ocFile.getRemotePath()) > 0; + } - if (ocFile != null) { - if (ocFile.isFolder()) { - success = removeFolder(ocFile, removeDBData, removeLocalCopy); - } else { + if (success) { + Log_OC.d(TAG, "deleting local copy of file"); + success = removeLocalCopyIfNeeded(ocFile, removeLocalCopy, removeDBData); + } - if (removeDBData) { - //Uri file_uri = Uri.withAppendedPath(ProviderTableMeta.CONTENT_URI_FILE, - // ""+file.getFileId()); - Uri file_uri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_FILE, ocFile.getFileId()); - String where = ProviderTableMeta.FILE_ACCOUNT_OWNER + AND + ProviderTableMeta.FILE_PATH + "=?"; + return success; + } - String[] whereArgs = new String[]{user.getAccountName(), ocFile.getRemotePath()}; - int deleted = 0; - if (getContentProviderClient() != null) { - try { - deleted = getContentProviderClient().delete(file_uri, where, whereArgs); - } catch (RemoteException e) { - Log_OC.d(TAG, e.getMessage(), e); - } - } else { - deleted = getContentResolver().delete(file_uri, where, whereArgs); - } - success = deleted > 0; - } + private boolean removeLocalCopyIfNeeded(OCFile ocFile, boolean removeLocalCopy, boolean removeDBData) { + String localPath = ocFile.getStoragePath(); - String localPath = ocFile.getStoragePath(); - if (removeLocalCopy && ocFile.isDown() && localPath != null && success) { - success = new File(localPath).delete(); - if (success) { - deleteFileInMediaScan(localPath); - } + if (!removeLocalCopy) { + Log_OC.d(TAG, "removeLocalCopyIfNeeded: removeLocalCopy=false"); + return true; + } - if (success && !removeDBData) { - // maybe unnecessary, but should be checked TODO remove if unnecessary - ocFile.setStoragePath(null); - saveFile(ocFile); - saveConflict(ocFile, null); - } - } - } - } else { + if (!ocFile.isDown()) { + Log_OC.d(TAG, "removeLocalCopyIfNeeded: file not downloaded -> skip"); + return true; + } + + if (localPath == null) { + Log_OC.d(TAG, "removeLocalCopyIfNeeded: localPath is null -> skip"); + return true; + } + + Log_OC.d(TAG, "removeLocalCopyIfNeeded: deleting local file -> " + localPath); + + boolean success = new File(localPath).delete(); + Log_OC.d(TAG, "removeLocalCopyIfNeeded: file deletion result=" + success); + + if (!success) { return false; } - return success; - } + deleteFileInMediaScan(localPath); + if (!removeDBData) { + Log_OC.d(TAG, "removeLocalCopyIfNeeded: updating DB after local deletion"); + ocFile.setStoragePath(null); + saveFile(ocFile); + saveConflict(ocFile, null); + } + + return true; + } public boolean removeFolder(OCFile folder, boolean removeDBData, boolean removeLocalContent) { + if (folder == null) { + Log_OC.d(TAG,"removeFolder: folder is null"); + return false; + } + + if (!folder.isFolder()) { + Log_OC.d(TAG,"removeFolder: not a folder -> " + folder.getRemotePath()); + return false; + } + + Log_OC.d(TAG,"removeFolder: start -> " + folder.getRemotePath() + + " | removeDBData=" + removeDBData + + " | removeLocalContent=" + removeLocalContent); + boolean success = true; - if (folder != null && folder.isFolder()) { - if (removeDBData && folder.getFileId() != -1) { - success = removeFolderInDb(folder); - } - if (removeLocalContent && success) { - success = removeLocalFolder(folder); - } - } else { - success = false; + + if (removeDBData && folder.getFileId() != -1) { + Log_OC.d(TAG,"removeFolder: removing from DB -> fileId=" + folder.getFileId()); + success = removeFolderInDb(folder); + Log_OC.d(TAG,"removeFolder: DB removal result=" + success); } + if (success && removeLocalContent) { + Log_OC.d(TAG,"removeFolder: removing local content -> " + folder.getStoragePath()); + success = removeLocalFolder(folder); + Log_OC.d(TAG,"removeFolder: local removal result=" + success); + } + + Log_OC.d(TAG, "removeFolder: finished -> result=" + success); return success; } private boolean removeFolderInDb(OCFile folder) { - Uri folderUri = Uri.withAppendedPath(ProviderTableMeta.CONTENT_URI_DIR, String.valueOf(folder.getFileId())); - // for recursive deletion - String where = ProviderTableMeta.FILE_ACCOUNT_OWNER + AND + ProviderTableMeta.FILE_PATH + "=?"; - String[] whereArgs = new String[]{user.getAccountName(), folder.getRemotePath()}; - int deleted = 0; - if (getContentProviderClient() != null) { - try { - deleted = getContentProviderClient().delete(folderUri, where, whereArgs); - } catch (RemoteException e) { - Log_OC.d(TAG, e.getMessage(), e); - } - } else { - deleted = getContentResolver().delete(folderUri, where, whereArgs); - } - return deleted > 0; + return fileDao.deleteFolderWithDescendants(user.getAccountName(), folder.getFileId()) > 0; } private boolean removeLocalFolder(OCFile folder) { - boolean success = true; - String localFolderPath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), folder); + if (folder == null) { + Log_OC.d(TAG, "removeLocalFolder: folder is null"); + return false; + } + + String localFolderPath = FileStorageUtils + .getDefaultSavePathFor(user.getAccountName(), folder); File localFolder = new File(localFolderPath); - if (localFolder.exists()) { - // stage 1: remove the local files already registered in the files database - List files = getFolderContent(folder.getFileId(), false); - for (OCFile ocFile : files) { - if (ocFile.isFolder()) { - success &= removeLocalFolder(ocFile); - } else if (ocFile.isDown()) { - File localFile = new File(ocFile.getStoragePath()); - success &= localFile.delete(); - - if (success) { - // notify MediaScanner about removed file - deleteFileInMediaScan(ocFile.getStoragePath()); - ocFile.setStoragePath(null); - saveFile(ocFile); - } + if (!localFolder.exists()) { + Log_OC.d(TAG, "removeLocalFolder: local folder does not exist -> " + localFolderPath); + return true; + } + + Log_OC.d(TAG, "removeLocalFolder: start -> " + localFolderPath); + + boolean success = true; + + // remove DB content + List files = getFolderContent(folder.getFileId(), false); + Log_OC.d(TAG, "removeLocalFolder: found " + files.size() + " entries in DB"); + + for (OCFile ocFile : files) { + if (!success) { + break; + } + + if (ocFile.isFolder()) { + Log_OC.d(TAG, "removeLocalFolder: removing subfolder -> " + ocFile.getRemotePath()); + success = removeLocalFolder(ocFile); + Log_OC.d(TAG, "removeLocalFolder: subfolder removal result=" + success); + + } else if (ocFile.isDown()) { + + File localFile = new File(ocFile.getStoragePath()); + Log_OC.d(TAG, "removeLocalFolder: deleting file -> " + ocFile.getStoragePath()); + + boolean deleted = localFile.delete(); + success = deleted; + + Log_OC.d(TAG, "removeLocalFolder: file deletion result=" + deleted); + + if (deleted) { + deleteFileInMediaScan(ocFile.getStoragePath()); + ocFile.setStoragePath(null); + saveFile(ocFile); } } + } - // stage 2: remove the folder itself and any local file inside out of sync; - // for instance, after clearing the app cache or reinstalling - success &= removeLocalFolder(localFolder); + // remove folder itself (and any untracked content) + if (success) { + Log_OC.d(TAG, "removeLocalFolder: deleting folder -> " + localFolder.getAbsolutePath()); + success = removeLocalFolder(localFolder); + Log_OC.d(TAG, "removeLocalFolder: folder deletion result=" + success); } + Log_OC.d(TAG, "removeLocalFolder: finished -> result=" + success); return success; } private boolean removeLocalFolder(File localFolder) { - boolean success = true; - File[] localFiles = localFolder.listFiles(); + if (localFolder == null) { + Log_OC.d(TAG, "removeLocalFolder(File): folder is null"); + return false; + } + + if (!localFolder.exists()) { + Log_OC.d(TAG, "removeLocalFolder(File): folder does not exist -> " + localFolder.getAbsolutePath()); + return true; + } + + Log_OC.d(TAG, "removeLocalFolder(File): start -> " + localFolder.getAbsolutePath()); - if (localFiles != null) { - for (File localFile : localFiles) { - if (localFile.isDirectory()) { - success &= removeLocalFolder(localFile); + File[] children = localFolder.listFiles(); + if (children != null) { + for (File child : children) { + boolean childDeleted; + + if (child.isDirectory()) { + childDeleted = removeLocalFolder(child); } else { - success &= localFile.delete(); + childDeleted = child.delete(); + Log_OC.d(TAG, "removeLocalFolder(File): deleting file -> " + child.getAbsolutePath() + " result=" + childDeleted); + } + + if (!childDeleted) { + Log_OC.d(TAG, "removeLocalFolder(File): failed at -> " + child.getAbsolutePath()); + return false; } } } - success &= localFolder.delete(); - return success; + boolean folderDeleted = localFolder.delete(); + Log_OC.d(TAG, "removeLocalFolder(File): deleting folder -> " + localFolder.getAbsolutePath() + " result=" + folderDeleted); + + return folderDeleted; } + // endregion /** * Updates database and file system for a file or folder that was moved to a different location.