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 f8d5f3855c30..d1ed3c1e6b5f 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 @@ -171,4 +171,7 @@ interface FileDao { @Query("DELETE FROM filelist WHERE file_owner = :fileOwner AND path = :remotePath") fun deleteFileByRemotePath(fileOwner: String, remotePath: String): Int + + @Update + fun updateAll(entities: List) } diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt index e163c5a861a8..1d5c7772a985 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt @@ -7,13 +7,18 @@ package com.nextcloud.utils.extensions +import com.nextcloud.client.database.dao.FileDao import com.nextcloud.client.database.entity.toOCCapability import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.shares.OCShare import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeTypeUtil import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.io.File suspend fun FileDataStorageManager.saveShares(shares: List, accountName: String) { withContext(Dispatchers.IO) { @@ -58,3 +63,132 @@ fun FileDataStorageManager.getNonEncryptedSubfolders(id: Long, accountName: Stri suspend fun FileDataStorageManager.getCapabilitiesByAccountName(accountName: String): OCCapability = capabilityDao.getByAccountName(accountName).toOCCapability() + +@Suppress("ReturnCount") +fun FileDataStorageManager.moveFiles(ocFile: OCFile?, targetPath: String, targetParentPath: String) { + Log_OC.d( + FileDataStorageManager.TAG, + ( + "moveLocalFile ==> ocFile: " + + (ocFile?.remotePath) + + " targetPath: " + + targetPath + + " targetParentPath: " + + targetParentPath + ) + ) + + if (ocFile == null) { + Log_OC.e(FileDataStorageManager.TAG, "moveLocalFile: file is null, skipping") + return + } + + if (!ocFile.fileExists()) { + Log_OC.e(FileDataStorageManager.TAG, "moveLocalFile: file does not exist, skipping") + return + } + + if (OCFile.ROOT_PATH == ocFile.fileName) { + Log_OC.w(FileDataStorageManager.TAG, "moveLocalFile: cannot move root path") + return + } + + if (ocFile.remotePath == targetPath) { + Log_OC.w(FileDataStorageManager.TAG, "moveLocalFile: source and target paths are identical, skipping") + return + } + + val targetParent = getFileByPath(targetParentPath) + if (targetParent == null) { + Log_OC.e(FileDataStorageManager.TAG, "moveLocalFile: target parent folder not found: $targetParentPath") + return + } + + if (!targetParent.isFolder) { + Log_OC.e(FileDataStorageManager.TAG, "moveLocalFile: target parent is not a folder: $targetParentPath") + return + } + + val oldPath: String = ocFile.remotePath + val accountName = user.accountName + val defaultSavePath = FileStorageUtils.getSavePath(accountName) + + val moved = moveLocalFiles(accountName, ocFile, defaultSavePath, targetPath) + if (!moved) return + + val originalMediaPaths = + fileDao.moveFilesInDb(oldPath, targetPath, defaultSavePath, targetParent.fileId, accountName) + + for (originalMediaPath in originalMediaPaths) { + deleteFileInMediaScan(originalMediaPath) + val newMediaPath = defaultSavePath + targetPath + originalMediaPath.substring( + (defaultSavePath + oldPath).length + ) + FileDataStorageManager.triggerMediaScan(newMediaPath) + } +} + +@Suppress("ReturnCount") +private fun moveLocalFiles(accountName: String, ocFile: OCFile, defaultSavePath: String, targetPath: String): Boolean { + val localFile = File(FileStorageUtils.getDefaultSavePathFor(accountName, ocFile)) + if (!localFile.exists()) { + Log_OC.d(FileDataStorageManager.TAG, "moveLocalFile: no local file to move at " + localFile.absolutePath) + return false + } + + val targetFile = File(defaultSavePath + targetPath) + val targetFolder = targetFile.getParentFile() + if (targetFolder != null && !targetFolder.exists() && !targetFolder.mkdirs()) { + Log_OC.e( + FileDataStorageManager.TAG, + "moveLocalFile: failed to create parent folder " + targetFolder.absolutePath + ) + } + + if (!localFile.renameTo(targetFile)) { + Log_OC.e( + FileDataStorageManager.TAG, + ( + "moveLocalFile: failed to rename " + localFile.absolutePath + + " to " + targetFile.absolutePath + ) + ) + return false + } + + return true +} + +private fun FileDao.moveFilesInDb( + oldPath: String, + targetPath: String, + defaultSavePath: String, + targetParentId: Long, + accountName: String +): List { + val entities = getFolderWithDescendants("$oldPath%", accountName) + val oldStoragePrefix = defaultSavePath + oldPath + val newStoragePrefix = defaultSavePath + targetPath + + val originalMediaPaths = entities + .filter { MimeTypeUtil.isMedia(it.contentType) && it.storagePath?.startsWith(oldStoragePrefix) == true } + .mapNotNull { it.storagePath } + + val updated = entities.map { entity -> + val currentPath = entity.path.orEmpty() + val newPath = targetPath + currentPath.substring(oldPath.length) + entity.copy( + path = newPath, + pathDecrypted = if (entity.isEncrypted == 0) newPath else entity.pathDecrypted, + storagePath = if (entity.storagePath?.startsWith(oldStoragePrefix) == true) { + newStoragePrefix + entity.storagePath.substring(oldStoragePrefix.length) + } else { + entity.storagePath + }, + parent = if (currentPath == oldPath) targetParentId else entity.parent + ) + } + + updateAll(updated) + return originalMediaPaths +} 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 a82ad391bb5d..465b1d556ebd 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -21,9 +21,9 @@ import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; -import android.media.MediaScannerConnection; import android.content.OperationApplicationException; import android.database.Cursor; +import android.media.MediaScannerConnection; import android.net.Uri; import android.os.RemoteException; import android.provider.MediaStore; @@ -48,8 +48,8 @@ import com.nextcloud.model.ShareeEntry; import com.nextcloud.utils.date.DateFormatPattern; import com.nextcloud.utils.extensions.DateExtensionsKt; +import com.nextcloud.utils.extensions.FileDataStorageManagerExtensionsKt; import com.nextcloud.utils.extensions.FileExtensionsKt; -import com.nextcloud.utils.extensions.StringExtensionsKt; import com.owncloud.android.MainApp; import com.owncloud.android.db.ProviderMeta.ProviderTableMeta; import com.owncloud.android.lib.common.network.WebdavEntry; @@ -94,7 +94,7 @@ @SuppressFBWarnings("CE") public class FileDataStorageManager { - private static final String TAG = FileDataStorageManager.class.getSimpleName(); + public static final String TAG = FileDataStorageManager.class.getSimpleName(); private static final String AND = " = ? AND "; private static final String FAILED_TO_INSERT_MSG = "Fail to insert insert file to database "; @@ -1108,113 +1108,9 @@ private boolean removeLocalFolder(File localFolder) { /** * Updates database and file system for a file or folder that was moved to a different location. - *

- * TODO explore better (faster) implementations TODO throw exceptions up ! */ public void moveLocalFile(OCFile ocFile, String targetPath, String targetParentPath) { - if (ocFile.fileExists() && !OCFile.ROOT_PATH.equals(ocFile.getFileName())) { - - OCFile targetParent = getFileByPath(targetParentPath); - if (targetParent == null) { - throw new IllegalStateException("Parent folder of the target path does not exist!!"); - } - - String oldPath = ocFile.getRemotePath(); - - /// 1. get all the descendants of the moved element in a single QUERY - List fileEntities = - fileDao.getFolderWithDescendants(oldPath + "%", user.getAccountName()); - - /// 2. prepare a batch of update operations to change all the descendants - ArrayList operations = new ArrayList<>(fileEntities.size()); - String defaultSavePath = FileStorageUtils.getSavePath(user.getAccountName()); - List originalPathsToTriggerMediaScan = new ArrayList<>(); - List newPathsToTriggerMediaScan = new ArrayList<>(); - - int lengthOfOldPath = oldPath.length(); - int lengthOfOldStoragePath = defaultSavePath.length() + lengthOfOldPath; - for (FileEntity fileEntity : fileEntities) { - ContentValues contentValues = new ContentValues(); // keep construction in the loop - OCFile childFile = createFileInstance(fileEntity); - contentValues.put( - ProviderTableMeta.FILE_PATH, - targetPath + childFile.getRemotePath().substring(lengthOfOldPath) - ); - - if (!childFile.isEncrypted()) { - contentValues.put( - ProviderTableMeta.FILE_PATH_DECRYPTED, - targetPath + childFile.getRemotePath().substring(lengthOfOldPath) - ); - } - - if (childFile.getStoragePath() != null && childFile.getStoragePath().startsWith(defaultSavePath)) { - // update link to downloaded content - but local move is not done here! - String targetLocalPath = defaultSavePath + targetPath + - childFile.getStoragePath().substring(lengthOfOldStoragePath); - - contentValues.put(ProviderTableMeta.FILE_STORAGE_PATH, targetLocalPath); - - if (MimeTypeUtil.isMedia(childFile.getMimeType())) { - originalPathsToTriggerMediaScan.add(childFile.getStoragePath()); - newPathsToTriggerMediaScan.add(targetLocalPath); - } - - } - - if (childFile.getRemotePath().equals(ocFile.getRemotePath())) { - contentValues.put(ProviderTableMeta.FILE_PARENT, targetParent.getFileId()); - } - - operations.add( - ContentProviderOperation.newUpdate(ProviderTableMeta.CONTENT_URI) - .withValues(contentValues) - .withSelection(ProviderTableMeta._ID + " = ?", new String[]{String.valueOf(childFile.getFileId())}) - .build()); - - } - - /// 3. apply updates in batch - try { - if (getContentResolver() != null) { - getContentResolver().applyBatch(MainApp.getAuthority(), operations); - } else { - getContentProviderClient().applyBatch(operations); - } - - } catch (Exception e) { - Log_OC.e(TAG, "Fail to update " + ocFile.getFileId() + " and descendants in database", e); - } - - /// 4. move in local file system - String originalLocalPath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), ocFile); - String targetLocalPath = defaultSavePath + targetPath; - File localFile = new File(originalLocalPath); - boolean renamed = false; - - if (localFile.exists()) { - File targetFile = new File(targetLocalPath); - File targetFolder = targetFile.getParentFile(); - if (targetFolder != null && !targetFolder.exists() && !targetFolder.mkdirs()) { - Log_OC.e(TAG, "Unable to create parent folder " + targetFolder.getAbsolutePath()); - } - renamed = localFile.renameTo(targetFile); - } - - if (renamed) { - Iterator pathIterator = originalPathsToTriggerMediaScan.iterator(); - while (pathIterator.hasNext()) { - // Notify MediaScanner about removed file - deleteFileInMediaScan(pathIterator.next()); - } - - pathIterator = newPathsToTriggerMediaScan.iterator(); - while (pathIterator.hasNext()) { - // Notify MediaScanner about new file/folder - triggerMediaScan(pathIterator.next()); - } - } - } + FileDataStorageManagerExtensionsKt.moveFiles(this, ocFile, targetPath, targetParentPath); } public void copyLocalFile(OCFile ocFile, String targetPath) { diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFileEntitiesUpdateTest.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFileEntitiesUpdateTest.kt new file mode 100644 index 000000000000..3e8f68382c0e --- /dev/null +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFileEntitiesUpdateTest.kt @@ -0,0 +1,118 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.datamodel + +import com.nextcloud.client.database.entity.FileEntity +import io.mockk.every +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +@Suppress("TooManyFunctions") +class MoveFileEntitiesUpdateTest : MoveFilesTestBase() { + + private val capturedEntities = slot>() + + private fun arrangeAndMove(entities: List, targetPath: String = TARGET_PATH) { + stubTargetParent() + every { mockFileDao.getFolderWithDescendants("${entities.first().path}%", ACCOUNT_NAME) } returns entities + every { mockFileDao.updateAll(capture(capturedEntities)) } returns Unit + val file = OCFile(entities.first().path!!).apply { fileId = 1 } + manager.moveLocalFile(file, targetPath, TARGET_PARENT_PATH) + } + + @Test + fun testMoveLocalFileWhenValidFileShouldCallUpdateAllOnFileDao() { + val entities = listOf(createFileEntity(path = OLD_PATH)) + arrangeAndMove(entities) + + verify(exactly = 1) { mockFileDao.updateAll(any()) } + } + + @Test + fun testMoveLocalFileWhenValidFileShouldUpdatePathToTargetPath() { + val entities = listOf(createFileEntity(path = OLD_PATH)) + val expectedPath = TARGET_PATH + + arrangeAndMove(entities) + + assertEquals(expectedPath, capturedEntities.captured.single().path) + } + + @Test + fun testMoveLocalFileWhenNonEncryptedFileShouldUpdatePathDecryptedToNewPath() { + val entities = listOf(createFileEntity(path = OLD_PATH, pathDecrypted = OLD_PATH, isEncrypted = 0)) + val expectedDecryptedPath = TARGET_PATH + + arrangeAndMove(entities) + + assertEquals(expectedDecryptedPath, capturedEntities.captured.single().pathDecrypted) + } + + @Test + fun testMoveLocalFileWhenEncryptedFileShouldNotUpdatePathDecrypted() { + val originalDecryptedPath = "/documents/encrypted_name" + val entities = listOf( + createFileEntity(path = OLD_PATH, pathDecrypted = originalDecryptedPath, isEncrypted = 1) + ) + val expectedDecryptedPath = originalDecryptedPath + + arrangeAndMove(entities) + + assertEquals(expectedDecryptedPath, capturedEntities.captured.single().pathDecrypted) + } + + @Test + fun testMoveLocalFileWhenFileHasStoragePathUnderSavePathShouldUpdateStoragePath() { + val originalStorage = "$SAVE_PATH$OLD_PATH" + val expectedStoragePath = "$SAVE_PATH$TARGET_PATH" + val entities = listOf(createFileEntity(path = OLD_PATH, storagePath = originalStorage)) + + arrangeAndMove(entities) + + assertEquals(expectedStoragePath, capturedEntities.captured.single().storagePath) + } + + @Test + fun testMoveLocalFileWhenFileHasStoragePathOutsideSavePathShouldKeepOriginalStoragePath() { + val originalStorage = "/sdcard/downloads/report.pdf" + val expectedStoragePath = originalStorage + val entities = listOf(createFileEntity(path = OLD_PATH, storagePath = originalStorage)) + + arrangeAndMove(entities) + + assertEquals(expectedStoragePath, capturedEntities.captured.single().storagePath) + } + + @Test + fun testMoveLocalFileWhenFileHasNoStoragePathShouldKeepStoragePathNull() { + val entities = listOf(createFileEntity(path = OLD_PATH, storagePath = null)) + + arrangeAndMove(entities) + + assertNull(capturedEntities.captured.single().storagePath) + } + + @Test + fun testMoveLocalFileWhenMovingFileShouldUpdateParentIdToTargetParentId() { + val expectedParentId = 99L + val entities = listOf(createFileEntity(path = OLD_PATH, parent = 10L)) + every { mockFileDao.getFolderWithDescendants("$OLD_PATH%", ACCOUNT_NAME) } returns entities + every { mockFileDao.updateAll(capture(capturedEntities)) } returns Unit + val parent = OCFile(TARGET_PARENT_PATH).apply { + fileId = expectedParentId + mimeType = com.owncloud.android.utils.MimeType.DIRECTORY + } + every { manager.getFileByPath(TARGET_PARENT_PATH) } returns parent + + manager.moveLocalFile(OCFile(OLD_PATH).apply { fileId = 1 }, TARGET_PATH, TARGET_PARENT_PATH) + + assertEquals(expectedParentId, capturedEntities.captured.single().parent) + } +} diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesFilesystemAndMediaTest.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesFilesystemAndMediaTest.kt new file mode 100644 index 000000000000..cc9806808ead --- /dev/null +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesFilesystemAndMediaTest.kt @@ -0,0 +1,154 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.datamodel + +import android.media.MediaScannerConnection +import com.owncloud.android.utils.FileStorageUtils +import io.mockk.every +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +@Suppress("TooManyFunctions") +class MoveFilesFilesystemAndMediaTest : MoveFilesTestBase() { + + private lateinit var tempDir: File + + @Before + fun setUpTempDir() { + tempDir = Files.createTempDirectory("moveLocalFileTest").toFile() + every { FileStorageUtils.getSavePath(any()) } returns tempDir.absolutePath + every { FileStorageUtils.getDefaultSavePathFor(any(), any()) } answers { + tempDir.absolutePath + secondArg().remotePath + } + } + + @After + fun tearDownTempDir() { + tempDir.deleteRecursively() + } + + private fun doMove( + oldPath: String = OLD_PATH, + targetPath: String = TARGET_PATH, + entities: List = emptyList() + ) { + stubTargetParent() + every { mockFileDao.getFolderWithDescendants("$oldPath%", ACCOUNT_NAME) } returns entities + manager.moveLocalFile(OCFile(oldPath).apply { fileId = 1 }, targetPath, TARGET_PARENT_PATH) + } + + @Test + fun testMoveLocalFileWhenNoLocalFilePresentShouldSkipRenameAndMediaScan() { + val expectedSourcePath = "${tempDir.absolutePath}$OLD_PATH" + val expectedTargetPath = "${tempDir.absolutePath}$TARGET_PATH" + + doMove() + + assertFalse(File(expectedSourcePath).exists()) + assertFalse(File(expectedTargetPath).exists()) + verify(exactly = 0) { manager.deleteFileInMediaScan(any()) } + verify(exactly = 0) { MediaScannerConnection.scanFile(any(), any(), any(), any()) } + } + + @Test + fun testMoveLocalFileWhenLocalFilePresentShouldRenameToTargetLocation() { + val expectedSourcePath = "${tempDir.absolutePath}$OLD_PATH" + val expectedTargetPath = "${tempDir.absolutePath}$TARGET_PATH" + File(expectedSourcePath).also { + it.parentFile?.mkdirs() + it.createNewFile() + } + + doMove() + + assertFalse("Source at $expectedSourcePath should no longer exist", File(expectedSourcePath).exists()) + assertTrue("Target at $expectedTargetPath should exist after move", File(expectedTargetPath).exists()) + assertEquals(expectedTargetPath, File(expectedTargetPath).absolutePath) + } + + @Test + fun testMoveLocalFileWhenRenameFailsShouldNotTriggerMediaScan() { + val oldStoragePath = "${tempDir.absolutePath}$OLD_PATH" + val mediaEntity = createFileEntity(path = OLD_PATH, storagePath = oldStoragePath, contentType = "image/jpeg") + // Source file intentionally NOT created → renameTo returns false + + doMove(entities = listOf(mediaEntity)) + + verify(exactly = 0) { manager.deleteFileInMediaScan(any()) } + verify(exactly = 0) { MediaScannerConnection.scanFile(any(), any(), any(), any()) } + } + + @Test + fun testMoveLocalFileWhenMediaFileIsMovedShouldDeleteFromMediaScanAtOriginalPath() { + val expectedDeletedPath = "${tempDir.absolutePath}$OLD_PATH" + File(expectedDeletedPath).also { + it.parentFile?.mkdirs() + it.createNewFile() + } + val mediaEntity = createFileEntity( + path = OLD_PATH, + storagePath = expectedDeletedPath, + contentType = "image/jpeg" + ) + + doMove(entities = listOf(mediaEntity)) + + verify(exactly = 1) { manager.deleteFileInMediaScan(expectedDeletedPath) } + } + + @Test + fun testMoveLocalFileWhenMediaFileIsMovedShouldTriggerMediaScanAtNewStoragePath() { + val oldStoragePath = "${tempDir.absolutePath}$OLD_PATH" + val expectedNewStoragePath = "${tempDir.absolutePath}$TARGET_PATH" + File(oldStoragePath).also { + it.parentFile?.mkdirs() + it.createNewFile() + } + val mediaEntity = createFileEntity( + path = OLD_PATH, + storagePath = oldStoragePath, + contentType = "image/jpeg" + ) + + doMove(entities = listOf(mediaEntity)) + + verify { + MediaScannerConnection.scanFile( + any(), + match { paths -> paths.single() == expectedNewStoragePath }, + any(), + any() + ) + } + } + + @Test + fun testMoveLocalFileWhenNonMediaFileIsMovedShouldNotTriggerAnyMediaScan() { + val oldStoragePath = "${tempDir.absolutePath}$OLD_PATH" + File(oldStoragePath).also { + it.parentFile?.mkdirs() + it.createNewFile() + } + val docEntity = createFileEntity( + path = OLD_PATH, + storagePath = oldStoragePath, + contentType = "application/pdf" + ) + + doMove(entities = listOf(docEntity)) + + verify(exactly = 0) { manager.deleteFileInMediaScan(any()) } + verify(exactly = 0) { MediaScannerConnection.scanFile(any(), any(), any(), any()) } + } +} diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesGuardTest.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesGuardTest.kt new file mode 100644 index 000000000000..ac30847f7e88 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesGuardTest.kt @@ -0,0 +1,83 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.datamodel + +import com.owncloud.android.utils.MimeType +import io.mockk.every +import io.mockk.verify +import org.junit.Test + +@Suppress("TooManyFunctions") +class MoveFilesGuardTest : MoveFilesTestBase() { + + @Test + fun testMoveLocalFileWhenFileIsNullShouldReturnEarlyWithoutInteractingWithDatabase() { + manager.moveLocalFile(null, TARGET_PATH, TARGET_PARENT_PATH) + + verify(exactly = 0) { mockFileDao.getFolderWithDescendants(any(), any()) } + verify(exactly = 0) { mockFileDao.updateAll(any()) } + } + + @Test + fun testMoveLocalFileWhenFileDoesNotExistShouldReturnEarlyWithoutInteractingWithDatabase() { + val file = OCFile(OLD_PATH).apply { fileId = -1 } + + manager.moveLocalFile(file, TARGET_PATH, TARGET_PARENT_PATH) + + verify(exactly = 0) { mockFileDao.getFolderWithDescendants(any(), any()) } + verify(exactly = 0) { mockFileDao.updateAll(any()) } + } + + @Test + fun testMoveLocalFileWhenFileNameIsRootPathShouldReturnEarlyWithoutInteractingWithDatabase() { + val rootFile = OCFile(OCFile.ROOT_PATH).apply { + fileId = 1 + mimeType = MimeType.DIRECTORY + } + + manager.moveLocalFile(rootFile, TARGET_PATH, TARGET_PARENT_PATH) + + verify(exactly = 0) { mockFileDao.getFolderWithDescendants(any(), any()) } + verify(exactly = 0) { mockFileDao.updateAll(any()) } + } + + @Test + fun testMoveLocalFileWhenSourceAndTargetPathsAreIdenticalShouldReturnEarlyWithoutInteractingWithDatabase() { + val file = OCFile(OLD_PATH).apply { fileId = 1 } + + manager.moveLocalFile(file, OLD_PATH, TARGET_PARENT_PATH) + + verify(exactly = 0) { mockFileDao.getFolderWithDescendants(any(), any()) } + verify(exactly = 0) { mockFileDao.updateAll(any()) } + } + + @Test + fun testMoveLocalFileWhenTargetParentNotFoundShouldReturnEarlyWithoutInteractingWithDatabase() { + val file = OCFile(OLD_PATH).apply { fileId = 1 } + // getFileByPath returns null by default from base setUp + + manager.moveLocalFile(file, TARGET_PATH, TARGET_PARENT_PATH) + + verify(exactly = 0) { mockFileDao.getFolderWithDescendants(any(), any()) } + verify(exactly = 0) { mockFileDao.updateAll(any()) } + } + + @Test + fun testMoveLocalFileWhenTargetParentIsNotFolderShouldReturnEarlyWithoutInteractingWithDatabase() { + val file = OCFile(OLD_PATH).apply { fileId = 1 } + val notAFolder = OCFile(TARGET_PARENT_PATH).apply { + fileId = 99 + mimeType = "application/pdf" + } + every { manager.getFileByPath(TARGET_PARENT_PATH) } returns notAFolder + + manager.moveLocalFile(file, TARGET_PATH, TARGET_PARENT_PATH) + + verify(exactly = 0) { mockFileDao.getFolderWithDescendants(any(), any()) } + verify(exactly = 0) { mockFileDao.updateAll(any()) } + } +} diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt new file mode 100644 index 000000000000..412922789c6e --- /dev/null +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt @@ -0,0 +1,204 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.datamodel + +import com.nextcloud.client.database.entity.FileEntity +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeType +import io.mockk.every +import io.mockk.slot +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files + +@Suppress("TooManyFunctions") +class MoveFilesHierarchyTest : MoveFilesTestBase() { + + private lateinit var tempDir: File + private val capturedEntities = slot>() + + @Before + fun setUpTempDir() { + tempDir = Files.createTempDirectory("moveHierarchyTest").toFile() + every { FileStorageUtils.getSavePath(any()) } returns tempDir.absolutePath + every { FileStorageUtils.getDefaultSavePathFor(any(), any()) } answers { + tempDir.absolutePath + secondArg().remotePath + } + } + + @After + fun tearDownTempDir() { + tempDir.deleteRecursively() + } + + @Suppress("NestedBlockDepth") + private fun arrangeFolderMove( + folderPath: String, + targetFolderPath: String, + targetParentPath: String, + entities: List + ) { + val parent = OCFile(targetParentPath).apply { + fileId = 99L + mimeType = MimeType.DIRECTORY + } + every { manager.getFileByPath(targetParentPath) } returns parent + every { mockFileDao.getFolderWithDescendants("$folderPath%", ACCOUNT_NAME) } returns entities + every { mockFileDao.updateAll(capture(capturedEntities)) } returns Unit + val folder = OCFile(folderPath).apply { + fileId = 1 + mimeType = MimeType.DIRECTORY + } + + try { + for (entity in entities) { + // entity.path may be nullable; treat as empty string if so + val path = entity.path.orEmpty() + val relative = path.removePrefix("/") + val local = File(tempDir, relative) + if (path.endsWith("/")) { + local.mkdirs() + } else { + local.parentFile?.mkdirs() + if (!local.exists()) local.createNewFile() + } + } + } catch (_: Exception) { + } + + manager.moveLocalFile(folder, targetFolderPath, targetParentPath) + } + + @Test + fun testMoveLocalFileWhenMovingFolderWithChildrenShouldUpdateAllDescendantPaths() { + val folderPath = "/docs/" + val targetFolderPath = "/archive/docs/" + val expectedFolderPath = targetFolderPath + val expectedReportPath = "${targetFolderPath}report.pdf" + val expectedNotesPath = "${targetFolderPath}notes.txt" + val entities = listOf( + createFileEntity(id = 1L, path = folderPath), + createFileEntity(id = 2L, path = "${folderPath}report.pdf"), + createFileEntity(id = 3L, path = "${folderPath}notes.txt") + ) + + arrangeFolderMove(folderPath, targetFolderPath, "/archive/", entities) + + val updated = capturedEntities.captured + assertEquals(3, updated.size) + assertEquals(expectedFolderPath, updated.find { it.id == 1L }?.path) + assertEquals(expectedReportPath, updated.find { it.id == 2L }?.path) + assertEquals(expectedNotesPath, updated.find { it.id == 3L }?.path) + } + + @Test + fun testMoveLocalFileWhenMovingFolderWithDeepNestedHierarchyShouldUpdateAllLevelPaths() { + val folderPath = "/projects/" + val targetFolderPath = "/archive/projects/" + val expectedFolderPath = targetFolderPath + val expectedSrcPath = "${targetFolderPath}src/" + val expectedMainPath = "${targetFolderPath}src/main/" + val expectedAppPath = "${targetFolderPath}src/main/App.kt" + val expectedTestPath = "${targetFolderPath}src/test/" + val expectedTestFilePath = "${targetFolderPath}src/test/AppTest.kt" + val expectedReadmePath = "${targetFolderPath}README.md" + val entities = listOf( + createFileEntity(id = 1L, path = folderPath), + createFileEntity(id = 2L, path = "${folderPath}src/"), + createFileEntity(id = 3L, path = "${folderPath}src/main/"), + createFileEntity(id = 4L, path = "${folderPath}src/main/App.kt"), + createFileEntity(id = 5L, path = "${folderPath}src/test/"), + createFileEntity(id = 6L, path = "${folderPath}src/test/AppTest.kt"), + createFileEntity(id = 7L, path = "${folderPath}README.md") + ) + + arrangeFolderMove(folderPath, targetFolderPath, "/archive/", entities) + + val updated = capturedEntities.captured + assertEquals(7, updated.size) + assertEquals(expectedFolderPath, updated.find { it.id == 1L }?.path) + assertEquals(expectedSrcPath, updated.find { it.id == 2L }?.path) + assertEquals(expectedMainPath, updated.find { it.id == 3L }?.path) + assertEquals(expectedAppPath, updated.find { it.id == 4L }?.path) + assertEquals(expectedTestPath, updated.find { it.id == 5L }?.path) + assertEquals(expectedTestFilePath, updated.find { it.id == 6L }?.path) + assertEquals(expectedReadmePath, updated.find { it.id == 7L }?.path) + } + + @Test + fun testMoveLocalFileWhenMovingFolderShouldOnlyUpdateParentForTopLevelMovedFolder() { + val folderPath = "/docs/" + val targetFolderPath = "/archive/docs/" + val expectedTopLevelParentId = 99L + val expectedChildParentId = 1L + val expectedSubFolderParentId = 1L + val expectedDeepFileParentId = 3L + val entities = listOf( + createFileEntity(id = 1L, path = folderPath, parent = 10L), + createFileEntity(id = 2L, path = "${folderPath}child.txt", parent = 1L), + createFileEntity(id = 3L, path = "${folderPath}sub/", parent = 1L), + createFileEntity(id = 4L, path = "${folderPath}sub/deep.txt", parent = 3L) + ) + + arrangeFolderMove(folderPath, targetFolderPath, "/archive/", entities) + + val updated = capturedEntities.captured + assertEquals(expectedTopLevelParentId, updated.find { it.id == 1L }?.parent) + assertEquals(expectedChildParentId, updated.find { it.id == 2L }?.parent) + assertEquals(expectedSubFolderParentId, updated.find { it.id == 3L }?.parent) + assertEquals(expectedDeepFileParentId, updated.find { it.id == 4L }?.parent) + } + + @Test + fun testMoveLocalFileWhenFolderContainsMixedMediaAndDocumentsShouldTriggerMediaScanOnlyForMedia() { + val folderPath = "/gallery/" + val targetFolderPath = "/backup/gallery/" + val savePath = tempDir.absolutePath + val photoStorage = "$savePath${folderPath}photo.jpg" + val docStorage = "$savePath${folderPath}notes.pdf" + val videoStorage = "$savePath${folderPath}clip.mp4" + val expectedPhotoStorageAfterMove = "$savePath${targetFolderPath}photo.jpg" + val expectedDocStorageAfterMove = "$savePath${targetFolderPath}notes.pdf" + val expectedVideoStorageAfterMove = "$savePath${targetFolderPath}clip.mp4" + val entities = listOf( + createFileEntity(id = 1L, path = folderPath), + createFileEntity( + id = 2L, + path = "${folderPath}photo.jpg", + storagePath = photoStorage, + contentType = "image/jpeg" + ), + createFileEntity( + id = 3L, + path = "${folderPath}notes.pdf", + storagePath = docStorage, + contentType = "application/pdf" + ), + createFileEntity( + id = 4L, + path = "${folderPath}clip.mp4", + storagePath = videoStorage, + contentType = "video/mp4" + ) + ) + File(tempDir, "gallery").mkdirs() + + arrangeFolderMove(folderPath, targetFolderPath, "/backup/", entities) + + val updated = capturedEntities.captured + assertEquals(expectedPhotoStorageAfterMove, updated.find { it.id == 2L }?.storagePath) + assertEquals(expectedDocStorageAfterMove, updated.find { it.id == 3L }?.storagePath) + assertEquals(expectedVideoStorageAfterMove, updated.find { it.id == 4L }?.storagePath) + verify(exactly = 1) { manager.deleteFileInMediaScan(photoStorage) } + verify(exactly = 1) { manager.deleteFileInMediaScan(videoStorage) } + verify(exactly = 0) { manager.deleteFileInMediaScan(docStorage) } + } +} diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt new file mode 100644 index 000000000000..d24f2c15d173 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt @@ -0,0 +1,176 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.datamodel + +import android.content.ContentResolver +import android.media.MediaScannerConnection +import android.text.TextUtils +import com.nextcloud.client.account.User +import com.nextcloud.client.database.NextcloudDatabase +import com.nextcloud.client.database.dao.FileDao +import com.nextcloud.client.database.entity.FileEntity +import com.nextcloud.client.jobs.offlineOperations.repository.OfflineOperationsRepository +import com.owncloud.android.MainApp +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeType +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.spyk +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Before + +@Suppress("LongParameterList", "TooManyFunctions", "DEPRECATION") +abstract class MoveFilesTestBase { + + companion object { + const val ACCOUNT_NAME = "user@nextcloud.example.com" + val SAVE_PATH: String = (System.getProperty("java.io.tmpdir") ?: "") + "/nextcloud/" + ACCOUNT_NAME + const val OLD_PATH = "/documents/report.pdf" + const val TARGET_PATH = "/archive/report.pdf" + const val TARGET_PARENT_PATH = "/archive/" + } + + lateinit var manager: FileDataStorageManager + lateinit var mockFileDao: FileDao + lateinit var mockUser: User + + @Before + fun setUpBase() { + mockFileDao = mockk(relaxed = true) + mockUser = mockk(relaxed = true) + every { mockUser.accountName } returns ACCOUNT_NAME + + mockkStatic(MainApp::class) + every { MainApp.getAppContext() } returns mockk(relaxed = true) + + val mockDb = mockk(relaxed = true) + mockkObject(NextcloudDatabase.Companion) + every { NextcloudDatabase.getInstance(any()) } returns mockDb + every { NextcloudDatabase.instance() } returns mockDb + every { mockDb.fileDao() } returns mockFileDao + every { mockDb.recommendedFileDao() } returns mockk(relaxed = true) + every { mockDb.offlineOperationDao() } returns mockk(relaxed = true) + every { mockDb.shareDao() } returns mockk(relaxed = true) + every { mockDb.capabilityDao() } returns mockk(relaxed = true) + + mockkConstructor(OfflineOperationsRepository::class) + + mockkStatic(FileStorageUtils::class) + every { FileStorageUtils.getSavePath(any()) } returns SAVE_PATH + every { FileStorageUtils.getDefaultSavePathFor(any(), any()) } answers { + SAVE_PATH + secondArg().remotePath + } + + try { + val localOldFile = java.io.File(SAVE_PATH + OLD_PATH) + localOldFile.parentFile?.mkdirs() + if (!localOldFile.exists()) { + localOldFile.createNewFile() + } + } catch (_: Exception) { + } + + mockkStatic(TextUtils::class) + every { TextUtils.isEmpty(any()) } answers { arg(0).isNullOrEmpty() } + + mockkStatic(MediaScannerConnection::class) + every { MediaScannerConnection.scanFile(any(), any(), any(), any()) } just runs + + val real = FileDataStorageManager(mockUser, null as ContentResolver?) + manager = spyk(real) + every { manager.getFileByPath(any()) } returns null + every { manager.deleteFileInMediaScan(any()) } just runs + } + + @After + fun tearDownBase() { + unmockkAll() + try { + val tmp = java.io.File(SAVE_PATH) + if (tmp.exists()) tmp.deleteRecursively() + } catch (_: Exception) { + } + } + + fun stubTargetParent(path: String = TARGET_PARENT_PATH, id: Long = 99L): OCFile { + val parent = OCFile(path).apply { + fileId = id + mimeType = MimeType.DIRECTORY + } + every { manager.getFileByPath(path) } returns parent + return parent + } + + fun createFileEntity( + id: Long = 1L, + path: String, + pathDecrypted: String? = path, + storagePath: String? = null, + contentType: String? = null, + isEncrypted: Int? = 0, + parent: Long? = null + ): FileEntity = FileEntity( + id = id, + name = path.substringAfterLast("/"), + encryptedName = null, + path = path, + pathDecrypted = pathDecrypted, + parent = parent, + creation = null, + modified = null, + contentType = contentType, + contentLength = null, + storagePath = storagePath, + accountOwner = null, + lastSyncDate = null, + lastSyncDateForData = null, + modifiedAtLastSyncForData = null, + etag = null, + etagOnServer = null, + sharedViaLink = null, + permissions = null, + remoteId = null, + localId = -1L, + updateThumbnail = null, + isDownloading = null, + favorite = null, + hidden = null, + isEncrypted = isEncrypted, + etagInConflict = null, + sharedWithSharee = null, + mountType = null, + hasPreview = null, + unreadCommentsCount = null, + ownerId = null, + ownerDisplayName = null, + note = null, + sharees = null, + richWorkspace = null, + metadataSize = null, + metadataLivePhoto = null, + locked = null, + lockType = null, + lockOwner = null, + lockOwnerDisplayName = null, + lockOwnerEditor = null, + lockTimestamp = null, + lockTimeout = null, + lockToken = null, + tags = null, + metadataGPS = null, + e2eCounter = null, + internalTwoWaySync = null, + internalTwoWaySyncResult = null, + uploaded = null + ) +}