From 5a3d4e464846103d6f4ebe2dbb1ea8178214590f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 22 May 2026 10:54:45 +0300 Subject: [PATCH 1/8] wip Signed-off-by: alperozturk96 --- .../nextcloud/client/database/dao/FileDao.kt | 54 ++++++++ .../datamodel/FileDataStorageManager.java | 131 +++++------------- 2 files changed, 92 insertions(+), 93 deletions(-) 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..de061ac340f3 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,58 @@ interface FileDao { @Query("DELETE FROM filelist WHERE file_owner = :fileOwner AND path = :remotePath") fun deleteFileByRemotePath(fileOwner: String, remotePath: String): Int + + @Query( + """ + UPDATE filelist + SET path = :newPathPrefix || SUBSTR(path, :oldPrefixLength + 1) + WHERE path LIKE :oldPathPattern AND file_owner = :fileOwner + """ + ) + fun moveDescendantPaths(oldPathPattern: String, oldPrefixLength: Int, newPathPrefix: String, fileOwner: String) + + @Query( + """ + UPDATE filelist + SET path_decrypted = :newPathPrefix || SUBSTR(path_decrypted, :oldPrefixLength + 1) + WHERE path LIKE :oldPathPattern AND file_owner = :fileOwner AND is_encrypted = 0 + """ + ) + fun moveDescendantDecryptedPaths( + oldPathPattern: String, + oldPrefixLength: Int, + newPathPrefix: String, + fileOwner: String + ) + + @Query( + """ + UPDATE filelist + SET media_path = :newStoragePrefix || SUBSTR(media_path, :oldStoragePrefixLength + 1) + WHERE path LIKE :oldPathPattern AND file_owner = :fileOwner + AND media_path IS NOT NULL AND media_path LIKE :oldStoragePrefix || '%' + """ + ) + fun moveDescendantStoragePaths( + oldPathPattern: String, + oldStoragePrefix: String, + oldStoragePrefixLength: Int, + newStoragePrefix: String, + fileOwner: String + ) + + @Query( + "UPDATE filelist SET parent = :newParentId WHERE path = :filePath AND file_owner = :fileOwner" + ) + fun updateParent(filePath: String, fileOwner: String, newParentId: Long) + + @Query( + """ + SELECT media_path FROM filelist + WHERE path LIKE :oldPathPattern AND file_owner = :fileOwner + AND media_path IS NOT NULL AND media_path != '' + AND (content_type LIKE 'image/%' OR content_type LIKE 'video/%') + """ + ) + fun getMediaPathsUnderPath(oldPathPattern: String, fileOwner: String): List } 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..cc90eade1f82 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -1112,108 +1112,53 @@ private boolean removeLocalFolder(File localFolder) { * 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 (!ocFile.fileExists() || OCFile.ROOT_PATH.equals(ocFile.getFileName())) { + return; + } - if (childFile.getRemotePath().equals(ocFile.getRemotePath())) { - contentValues.put(ProviderTableMeta.FILE_PARENT, targetParent.getFileId()); - } + OCFile targetParent = getFileByPath(targetParentPath); + if (targetParent == null) { + throw new IllegalStateException("Parent folder of the target path does not exist!!"); + } - operations.add( - ContentProviderOperation.newUpdate(ProviderTableMeta.CONTENT_URI) - .withValues(contentValues) - .withSelection(ProviderTableMeta._ID + " = ?", new String[]{String.valueOf(childFile.getFileId())}) - .build()); + String oldPath = ocFile.getRemotePath(); + String accountName = user.getAccountName(); + String oldPathPattern = oldPath + "%"; + String defaultSavePath = FileStorageUtils.getSavePath(accountName); + String oldStoragePrefix = defaultSavePath + oldPath; + String newStoragePrefix = defaultSavePath + targetPath; - } + List originalMediaPaths = fileDao.getMediaPathsUnderPath(oldPathPattern, accountName); - /// 3. apply updates in batch - try { - if (getContentResolver() != null) { - getContentResolver().applyBatch(MainApp.getAuthority(), operations); - } else { - getContentProviderClient().applyBatch(operations); - } + fileDao.moveDescendantDecryptedPaths(oldPathPattern, oldPath.length(), targetPath, accountName); + fileDao.moveDescendantPaths(oldPathPattern, oldPath.length(), targetPath, accountName); + fileDao.moveDescendantStoragePaths( + targetPath + "%", oldStoragePrefix, oldStoragePrefix.length(), newStoragePrefix, accountName + ); + fileDao.updateParent(targetPath, accountName, targetParent.getFileId()); - } catch (Exception e) { - Log_OC.e(TAG, "Fail to update " + ocFile.getFileId() + " and descendants in database", e); - } + String originalLocalPath = FileStorageUtils.getDefaultSavePathFor(accountName, ocFile); + File localFile = new File(originalLocalPath); - /// 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()) { + return; + } - 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); - } + File targetFile = new File(defaultSavePath + targetPath); + File targetFolder = targetFile.getParentFile(); + if (targetFolder != null && !targetFolder.exists() && !targetFolder.mkdirs()) { + Log_OC.e(TAG, "Unable to create parent folder " + targetFolder.getAbsolutePath()); + } - if (renamed) { - Iterator pathIterator = originalPathsToTriggerMediaScan.iterator(); - while (pathIterator.hasNext()) { - // Notify MediaScanner about removed file - deleteFileInMediaScan(pathIterator.next()); - } + boolean renamed = localFile.renameTo(targetFile); + if (!renamed) { + return; + } - pathIterator = newPathsToTriggerMediaScan.iterator(); - while (pathIterator.hasNext()) { - // Notify MediaScanner about new file/folder - triggerMediaScan(pathIterator.next()); - } - } + for (String originalMediaPath : originalMediaPaths) { + deleteFileInMediaScan(originalMediaPath); + String newMediaPath = newStoragePrefix + originalMediaPath.substring(oldStoragePrefix.length()); + triggerMediaScan(newMediaPath); } } From 0a10115656218d7c8efce1162638660d795ac1bb Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 22 May 2026 11:25:39 +0300 Subject: [PATCH 2/8] wip Signed-off-by: alperozturk96 --- .../nextcloud/client/database/dao/FileDao.kt | 55 +------- .../FileDataStorageManagerExtensions.kt | 121 ++++++++++++++++++ .../datamodel/FileDataStorageManager.java | 57 +-------- 3 files changed, 127 insertions(+), 106 deletions(-) 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 de061ac340f3..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 @@ -172,57 +172,6 @@ interface FileDao { @Query("DELETE FROM filelist WHERE file_owner = :fileOwner AND path = :remotePath") fun deleteFileByRemotePath(fileOwner: String, remotePath: String): Int - @Query( - """ - UPDATE filelist - SET path = :newPathPrefix || SUBSTR(path, :oldPrefixLength + 1) - WHERE path LIKE :oldPathPattern AND file_owner = :fileOwner - """ - ) - fun moveDescendantPaths(oldPathPattern: String, oldPrefixLength: Int, newPathPrefix: String, fileOwner: String) - - @Query( - """ - UPDATE filelist - SET path_decrypted = :newPathPrefix || SUBSTR(path_decrypted, :oldPrefixLength + 1) - WHERE path LIKE :oldPathPattern AND file_owner = :fileOwner AND is_encrypted = 0 - """ - ) - fun moveDescendantDecryptedPaths( - oldPathPattern: String, - oldPrefixLength: Int, - newPathPrefix: String, - fileOwner: String - ) - - @Query( - """ - UPDATE filelist - SET media_path = :newStoragePrefix || SUBSTR(media_path, :oldStoragePrefixLength + 1) - WHERE path LIKE :oldPathPattern AND file_owner = :fileOwner - AND media_path IS NOT NULL AND media_path LIKE :oldStoragePrefix || '%' - """ - ) - fun moveDescendantStoragePaths( - oldPathPattern: String, - oldStoragePrefix: String, - oldStoragePrefixLength: Int, - newStoragePrefix: String, - fileOwner: String - ) - - @Query( - "UPDATE filelist SET parent = :newParentId WHERE path = :filePath AND file_owner = :fileOwner" - ) - fun updateParent(filePath: String, fileOwner: String, newParentId: Long) - - @Query( - """ - SELECT media_path FROM filelist - WHERE path LIKE :oldPathPattern AND file_owner = :fileOwner - AND media_path IS NOT NULL AND media_path != '' - AND (content_type LIKE 'image/%' OR content_type LIKE 'video/%') - """ - ) - fun getMediaPathsUnderPath(oldPathPattern: String, fileOwner: String): List + @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..a03abe86ffee 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,119 @@ fun FileDataStorageManager.getNonEncryptedSubfolders(id: Long, accountName: Stri suspend fun FileDataStorageManager.getCapabilitiesByAccountName(accountName: String): OCCapability = capabilityDao.getByAccountName(accountName).toOCCapability() + +fun FileDataStorageManager.moveLocalFile(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.e(FileDataStorageManager.TAG, "moveLocalFile: cannot move root path") + return + } + + if (ocFile.remotePath == targetPath) { + Log_OC.e(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 originalMediaPaths = + fileDao.moveFileEntities(oldPath, targetPath, defaultSavePath, targetParent.getFileId(), accountName) + + val localFile = File(FileStorageUtils.getDefaultSavePathFor(accountName, ocFile)) + if (!localFile.exists()) { + Log_OC.d(FileDataStorageManager.TAG, "moveLocalFile: no local file to move at " + localFile.getAbsolutePath()) + return + } + + 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 + } + + for (originalMediaPath in originalMediaPaths) { + deleteFileInMediaScan(originalMediaPath) + val newMediaPath = defaultSavePath + targetPath + originalMediaPath.substring( + (defaultSavePath + oldPath).length + ) + FileDataStorageManager.triggerMediaScan(newMediaPath) + } +} + + +private fun FileDao.moveFileEntities( + 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 cc90eade1f82..0db9b5d482eb 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,58 +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())) { - return; - } - - OCFile targetParent = getFileByPath(targetParentPath); - if (targetParent == null) { - throw new IllegalStateException("Parent folder of the target path does not exist!!"); - } - - String oldPath = ocFile.getRemotePath(); - String accountName = user.getAccountName(); - String oldPathPattern = oldPath + "%"; - String defaultSavePath = FileStorageUtils.getSavePath(accountName); - String oldStoragePrefix = defaultSavePath + oldPath; - String newStoragePrefix = defaultSavePath + targetPath; - - List originalMediaPaths = fileDao.getMediaPathsUnderPath(oldPathPattern, accountName); - - fileDao.moveDescendantDecryptedPaths(oldPathPattern, oldPath.length(), targetPath, accountName); - fileDao.moveDescendantPaths(oldPathPattern, oldPath.length(), targetPath, accountName); - fileDao.moveDescendantStoragePaths( - targetPath + "%", oldStoragePrefix, oldStoragePrefix.length(), newStoragePrefix, accountName - ); - fileDao.updateParent(targetPath, accountName, targetParent.getFileId()); - - String originalLocalPath = FileStorageUtils.getDefaultSavePathFor(accountName, ocFile); - File localFile = new File(originalLocalPath); - - if (!localFile.exists()) { - return; - } - - File targetFile = new File(defaultSavePath + targetPath); - File targetFolder = targetFile.getParentFile(); - if (targetFolder != null && !targetFolder.exists() && !targetFolder.mkdirs()) { - Log_OC.e(TAG, "Unable to create parent folder " + targetFolder.getAbsolutePath()); - } - - boolean renamed = localFile.renameTo(targetFile); - if (!renamed) { - return; - } - - for (String originalMediaPath : originalMediaPaths) { - deleteFileInMediaScan(originalMediaPath); - String newMediaPath = newStoragePrefix + originalMediaPath.substring(oldStoragePrefix.length()); - triggerMediaScan(newMediaPath); - } + FileDataStorageManagerExtensionsKt.moveLocalFile(this, ocFile, targetPath, targetParentPath); } public void copyLocalFile(OCFile ocFile, String targetPath) { From 22e12ffdf3f8cd32643a8f0c7c666173a3efd8f5 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 22 May 2026 11:47:40 +0300 Subject: [PATCH 3/8] wip Signed-off-by: alperozturk96 --- .../FileDataStorageManagerExtensions.kt | 29 ++-- .../datamodel/FileDataStorageManager.java | 2 +- .../datamodel/MoveFileEntitiesUpdateTest.kt | 110 ++++++++++++ .../MoveFilesFilesystemAndMediaTest.kt | 136 +++++++++++++++ .../android/datamodel/MoveFilesGuardTest.kt | 83 +++++++++ .../datamodel/MoveFilesHierarchyTest.kt | 125 ++++++++++++++ .../android/datamodel/MoveFilesTestBase.kt | 161 ++++++++++++++++++ 7 files changed, 632 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/com/owncloud/android/datamodel/MoveFileEntitiesUpdateTest.kt create mode 100644 app/src/test/java/com/owncloud/android/datamodel/MoveFilesFilesystemAndMediaTest.kt create mode 100644 app/src/test/java/com/owncloud/android/datamodel/MoveFilesGuardTest.kt create mode 100644 app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt create mode 100644 app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt 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 a03abe86ffee..45908270bbea 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt @@ -64,7 +64,7 @@ fun FileDataStorageManager.getNonEncryptedSubfolders(id: Long, accountName: Stri suspend fun FileDataStorageManager.getCapabilitiesByAccountName(accountName: String): OCCapability = capabilityDao.getByAccountName(accountName).toOCCapability() -fun FileDataStorageManager.moveLocalFile(ocFile: OCFile?, targetPath: String, targetParentPath: String) { +fun FileDataStorageManager.moveFiles(ocFile: OCFile?, targetPath: String, targetParentPath: String) { Log_OC.d( FileDataStorageManager.TAG, ("moveLocalFile ==> ocFile: " @@ -111,11 +111,23 @@ fun FileDataStorageManager.moveLocalFile(ocFile: OCFile?, targetPath: String, ta val defaultSavePath = FileStorageUtils.getSavePath(accountName) val originalMediaPaths = - fileDao.moveFileEntities(oldPath, targetPath, defaultSavePath, targetParent.getFileId(), accountName) + fileDao.moveFilesInDb(oldPath, targetPath, defaultSavePath, targetParent.fileId, accountName) + moveLocalFiles(accountName, ocFile, defaultSavePath, targetPath) + + for (originalMediaPath in originalMediaPaths) { + deleteFileInMediaScan(originalMediaPath) + val newMediaPath = defaultSavePath + targetPath + originalMediaPath.substring( + (defaultSavePath + oldPath).length + ) + FileDataStorageManager.triggerMediaScan(newMediaPath) + } +} + +private fun moveLocalFiles(accountName: String, ocFile: OCFile, defaultSavePath: String, targetPath: String) { val localFile = File(FileStorageUtils.getDefaultSavePathFor(accountName, ocFile)) if (!localFile.exists()) { - Log_OC.d(FileDataStorageManager.TAG, "moveLocalFile: no local file to move at " + localFile.getAbsolutePath()) + Log_OC.d(FileDataStorageManager.TAG, "moveLocalFile: no local file to move at " + localFile.absolutePath) return } @@ -135,18 +147,9 @@ fun FileDataStorageManager.moveLocalFile(ocFile: OCFile?, targetPath: String, ta ) return } - - for (originalMediaPath in originalMediaPaths) { - deleteFileInMediaScan(originalMediaPath) - val newMediaPath = defaultSavePath + targetPath + originalMediaPath.substring( - (defaultSavePath + oldPath).length - ) - FileDataStorageManager.triggerMediaScan(newMediaPath) - } } - -private fun FileDao.moveFileEntities( +private fun FileDao.moveFilesInDb( oldPath: String, targetPath: String, defaultSavePath: String, 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 0db9b5d482eb..465b1d556ebd 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -1110,7 +1110,7 @@ private boolean removeLocalFolder(File localFolder) { * Updates database and file system for a file or folder that was moved to a different location. */ public void moveLocalFile(OCFile ocFile, String targetPath, String targetParentPath) { - FileDataStorageManagerExtensionsKt.moveLocalFile(this, ocFile, targetPath, targetParentPath); + 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..8a34f4b55d52 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFileEntitiesUpdateTest.kt @@ -0,0 +1,110 @@ +/* + * 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)) + arrangeAndMove(entities) + + assertEquals(TARGET_PATH, capturedEntities.captured.single().path) + } + + @Test + fun testMoveLocalFileWhenNonEncryptedFileShouldUpdatePathDecryptedToNewPath() { + val entities = listOf(createFileEntity(path = OLD_PATH, pathDecrypted = OLD_PATH, isEncrypted = 0)) + arrangeAndMove(entities) + + assertEquals(TARGET_PATH, capturedEntities.captured.single().pathDecrypted) + } + + @Test + fun testMoveLocalFileWhenEncryptedFileShouldNotUpdatePathDecrypted() { + val encryptedDecryptedPath = "/documents/encrypted_name" + val entities = listOf( + createFileEntity(path = OLD_PATH, pathDecrypted = encryptedDecryptedPath, isEncrypted = 1) + ) + arrangeAndMove(entities) + + assertEquals(encryptedDecryptedPath, capturedEntities.captured.single().pathDecrypted) + } + + @Test + fun testMoveLocalFileWhenFileHasStoragePathUnderSavePathShouldUpdateStoragePath() { + val originalStorage = "$SAVE_PATH$OLD_PATH" + val entities = listOf(createFileEntity(path = OLD_PATH, storagePath = originalStorage)) + arrangeAndMove(entities) + + assertEquals("$SAVE_PATH$TARGET_PATH", capturedEntities.captured.single().storagePath) + } + + @Test + fun testMoveLocalFileWhenFileHasStoragePathOutsideSavePathShouldKeepOriginalStoragePath() { + val externalPath = "/sdcard/downloads/report.pdf" + val entities = listOf(createFileEntity(path = OLD_PATH, storagePath = externalPath)) + arrangeAndMove(entities) + + assertEquals(externalPath, 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 targetParentId = 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 = targetParentId + 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(targetParentId, capturedEntities.captured.single().parent) + } +} \ No newline at end of file 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..36cc165a3115 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesFilesystemAndMediaTest.kt @@ -0,0 +1,136 @@ +/* + * 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.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() { + doMove() + + verify(exactly = 0) { manager.deleteFileInMediaScan(any()) } + verify(exactly = 0) { MediaScannerConnection.scanFile(any(), any(), any(), any()) } + } + + @Test + fun testMoveLocalFileWhenLocalFilePresentShouldRenameToTargetLocation() { + val sourceFile = File("${tempDir.absolutePath}$OLD_PATH").also { + it.parentFile?.mkdirs() + it.createNewFile() + } + val targetFile = File("${tempDir.absolutePath}$TARGET_PATH") + + doMove() + + assert(!sourceFile.exists()) { "Source file should have been moved" } + assert(targetFile.exists()) { "Target file should exist after rename" } + } + + @Test + fun testMoveLocalFileWhenRenameFailsShouldNotTriggerMediaScan() { + val oldStoragePath = "${tempDir.absolutePath}$OLD_PATH" + // Source file is NOT created → renameTo returns false + val mediaEntity = createFileEntity( + path = OLD_PATH, + storagePath = oldStoragePath, + contentType = "image/jpeg" + ) + doMove(entities = listOf(mediaEntity)) + + verify(exactly = 0) { manager.deleteFileInMediaScan(any()) } + verify(exactly = 0) { MediaScannerConnection.scanFile(any(), any(), any(), any()) } + } + + @Test + fun testMoveLocalFileWhenMediaFileIsMovedShouldDeleteFromMediaScanAtOriginalPath() { + val oldStoragePath = "${tempDir.absolutePath}$OLD_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(exactly = 1) { manager.deleteFileInMediaScan(oldStoragePath) } + } + + @Test + fun testMoveLocalFileWhenMediaFileIsMovedShouldTriggerMediaScanAtNewStoragePath() { + val savePath = tempDir.absolutePath + val oldStoragePath = "$savePath$OLD_PATH" + val expectedNewStoragePath = "$savePath$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.any { it == 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()) } + } +} \ No newline at end of file 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..8a823447b9b6 --- /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 = 0 } + + 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()) } + } +} \ No newline at end of file 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..11947ddab1fa --- /dev/null +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt @@ -0,0 +1,125 @@ +/* + * 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.MimeType +import io.mockk.every +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Test + +@Suppress("TooManyFunctions") +class MoveFilesHierarchyTest : MoveFilesTestBase() { + + private val capturedEntities = slot>() + + 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 } + manager.moveLocalFile(folder, targetFolderPath, targetParentPath) + } + + @Test + fun testMoveLocalFileWhenMovingFolderWithChildrenShouldUpdateAllDescendantPaths() { + val folderPath = "/docs/" + val targetFolderPath = "/archive/docs/" + 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(targetFolderPath, updated.find { it.id == 1L }?.path) + assertEquals("${targetFolderPath}report.pdf", updated.find { it.id == 2L }?.path) + assertEquals("${targetFolderPath}notes.txt", updated.find { it.id == 3L }?.path) + } + + @Test + fun testMoveLocalFileWhenMovingFolderWithDeepNestedHierarchyShouldUpdateAllLevelPaths() { + val folderPath = "/projects/" + val targetFolderPath = "/archive/projects/" + 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(targetFolderPath, updated.find { it.id == 1L }?.path) + assertEquals("${targetFolderPath}src/", updated.find { it.id == 2L }?.path) + assertEquals("${targetFolderPath}src/main/App.kt", updated.find { it.id == 4L }?.path) + assertEquals("${targetFolderPath}src/test/AppTest.kt", updated.find { it.id == 6L }?.path) + assertEquals("${targetFolderPath}README.md", updated.find { it.id == 7L }?.path) + } + + @Test + fun testMoveLocalFileWhenMovingFolderShouldOnlyUpdateParentForTopLevelMovedFolder() { + val folderPath = "/docs/" + val targetFolderPath = "/archive/docs/" + val originalParentId = 10L + val targetParentId = 99L + val entities = listOf( + createFileEntity(id = 1L, path = folderPath, parent = originalParentId), + 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(targetParentId, updated.find { it.id == 1L }?.parent) + assertEquals(1L, updated.find { it.id == 2L }?.parent) + assertEquals(1L, updated.find { it.id == 3L }?.parent) + assertEquals(3L, updated.find { it.id == 4L }?.parent) + } + + @Test + fun testMoveLocalFileWhenFolderContainsMixedMediaAndDocumentsShouldTriggerMediaScanOnlyForMedia() { + val folderPath = "/gallery/" + val targetFolderPath = "/backup/gallery/" + val photoStorage = "$SAVE_PATH${folderPath}photo.jpg" + val docStorage = "$SAVE_PATH${folderPath}notes.pdf" + val videoStorage = "$SAVE_PATH${folderPath}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") + ) + + arrangeFolderMove(folderPath, targetFolderPath, "/backup/", entities) + + verify(exactly = 1) { manager.deleteFileInMediaScan(photoStorage) } + verify(exactly = 1) { manager.deleteFileInMediaScan(videoStorage) } + verify(exactly = 0) { manager.deleteFileInMediaScan(docStorage) } + } +} \ No newline at end of file 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..0b3c76eb3b97 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt @@ -0,0 +1,161 @@ +/* + * 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.mockkStatic +import io.mockk.runs +import io.mockk.spyk +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Before + +@Suppress("TooManyFunctions", "DEPRECATION") +abstract class MoveFilesTestBase { + + companion object { + const val ACCOUNT_NAME = "user@nextcloud.example.com" + const val SAVE_PATH = "/storage/emulated/0/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) + mockkStatic(NextcloudDatabase::class) + 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 + } + + 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() + } + + 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 + ) +} From 839077f985728ab52d3c981008c4b5a0a0bdd44f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 22 May 2026 12:10:24 +0300 Subject: [PATCH 4/8] wip Signed-off-by: alperozturk96 --- .../FileDataStorageManagerExtensions.kt | 34 ++++-- .../datamodel/MoveFileEntitiesUpdateTest.kt | 42 ++++--- .../MoveFilesFilesystemAndMediaTest.kt | 60 +++++---- .../android/datamodel/MoveFilesGuardTest.kt | 2 +- .../datamodel/MoveFilesHierarchyTest.kt | 115 ++++++++++++++---- .../android/datamodel/MoveFilesTestBase.kt | 3 +- 6 files changed, 177 insertions(+), 79 deletions(-) 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 45908270bbea..0f3a74ca59b5 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt @@ -64,15 +64,18 @@ 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) + ( + "moveLocalFile ==> ocFile: " + + (ocFile?.remotePath) + + " targetPath: " + + targetPath + + " targetParentPath: " + + targetParentPath + ) ) if (ocFile == null) { @@ -113,7 +116,8 @@ fun FileDataStorageManager.moveFiles(ocFile: OCFile?, targetPath: String, target val originalMediaPaths = fileDao.moveFilesInDb(oldPath, targetPath, defaultSavePath, targetParent.fileId, accountName) - moveLocalFiles(accountName, ocFile, defaultSavePath, targetPath) + val moved = moveLocalFiles(accountName, ocFile, defaultSavePath, targetPath) + if (!moved) return for (originalMediaPath in originalMediaPaths) { deleteFileInMediaScan(originalMediaPath) @@ -124,11 +128,12 @@ fun FileDataStorageManager.moveFiles(ocFile: OCFile?, targetPath: String, target } } -private fun moveLocalFiles(accountName: String, ocFile: OCFile, defaultSavePath: String, targetPath: String) { +@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 + return false } val targetFile = File(defaultSavePath + targetPath) @@ -142,11 +147,16 @@ private fun moveLocalFiles(accountName: String, ocFile: OCFile, defaultSavePath: if (!localFile.renameTo(targetFile)) { Log_OC.e( - FileDataStorageManager.TAG, ("moveLocalFile: failed to rename " + localFile.absolutePath - + " to " + targetFile.absolutePath) + FileDataStorageManager.TAG, + ( + "moveLocalFile: failed to rename " + localFile.absolutePath + + " to " + targetFile.absolutePath + ) ) - return + return false } + + return true } private fun FileDao.moveFilesInDb( diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFileEntitiesUpdateTest.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFileEntitiesUpdateTest.kt index 8a34f4b55d52..3e8f68382c0e 100644 --- a/app/src/test/java/com/owncloud/android/datamodel/MoveFileEntitiesUpdateTest.kt +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFileEntitiesUpdateTest.kt @@ -19,10 +19,7 @@ class MoveFileEntitiesUpdateTest : MoveFilesTestBase() { private val capturedEntities = slot>() - private fun arrangeAndMove( - entities: List, - targetPath: String = TARGET_PATH - ) { + 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 @@ -41,51 +38,62 @@ class MoveFileEntitiesUpdateTest : MoveFilesTestBase() { @Test fun testMoveLocalFileWhenValidFileShouldUpdatePathToTargetPath() { val entities = listOf(createFileEntity(path = OLD_PATH)) + val expectedPath = TARGET_PATH + arrangeAndMove(entities) - assertEquals(TARGET_PATH, capturedEntities.captured.single().path) + 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(TARGET_PATH, capturedEntities.captured.single().pathDecrypted) + assertEquals(expectedDecryptedPath, capturedEntities.captured.single().pathDecrypted) } @Test fun testMoveLocalFileWhenEncryptedFileShouldNotUpdatePathDecrypted() { - val encryptedDecryptedPath = "/documents/encrypted_name" + val originalDecryptedPath = "/documents/encrypted_name" val entities = listOf( - createFileEntity(path = OLD_PATH, pathDecrypted = encryptedDecryptedPath, isEncrypted = 1) + createFileEntity(path = OLD_PATH, pathDecrypted = originalDecryptedPath, isEncrypted = 1) ) + val expectedDecryptedPath = originalDecryptedPath + arrangeAndMove(entities) - assertEquals(encryptedDecryptedPath, capturedEntities.captured.single().pathDecrypted) + 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("$SAVE_PATH$TARGET_PATH", capturedEntities.captured.single().storagePath) + assertEquals(expectedStoragePath, capturedEntities.captured.single().storagePath) } @Test fun testMoveLocalFileWhenFileHasStoragePathOutsideSavePathShouldKeepOriginalStoragePath() { - val externalPath = "/sdcard/downloads/report.pdf" - val entities = listOf(createFileEntity(path = OLD_PATH, storagePath = externalPath)) + val originalStorage = "/sdcard/downloads/report.pdf" + val expectedStoragePath = originalStorage + val entities = listOf(createFileEntity(path = OLD_PATH, storagePath = originalStorage)) + arrangeAndMove(entities) - assertEquals(externalPath, capturedEntities.captured.single().storagePath) + 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) @@ -93,18 +101,18 @@ class MoveFileEntitiesUpdateTest : MoveFilesTestBase() { @Test fun testMoveLocalFileWhenMovingFileShouldUpdateParentIdToTargetParentId() { - val targetParentId = 99L + 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 = targetParentId + 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(targetParentId, capturedEntities.captured.single().parent) + assertEquals(expectedParentId, capturedEntities.captured.single().parent) } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesFilesystemAndMediaTest.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesFilesystemAndMediaTest.kt index 36cc165a3115..cc9806808ead 100644 --- a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesFilesystemAndMediaTest.kt +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesFilesystemAndMediaTest.kt @@ -11,6 +11,9 @@ 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 @@ -47,35 +50,39 @@ class MoveFilesFilesystemAndMediaTest : MoveFilesTestBase() { @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 sourceFile = File("${tempDir.absolutePath}$OLD_PATH").also { + val expectedSourcePath = "${tempDir.absolutePath}$OLD_PATH" + val expectedTargetPath = "${tempDir.absolutePath}$TARGET_PATH" + File(expectedSourcePath).also { it.parentFile?.mkdirs() it.createNewFile() } - val targetFile = File("${tempDir.absolutePath}$TARGET_PATH") doMove() - assert(!sourceFile.exists()) { "Source file should have been moved" } - assert(targetFile.exists()) { "Target file should exist after rename" } + 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" - // Source file is NOT created → renameTo returns false - val mediaEntity = createFileEntity( - path = OLD_PATH, - storagePath = oldStoragePath, - contentType = "image/jpeg" - ) + 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()) } @@ -84,35 +91,42 @@ class MoveFilesFilesystemAndMediaTest : MoveFilesTestBase() { @Test fun testMoveLocalFileWhenMediaFileIsMovedShouldDeleteFromMediaScanAtOriginalPath() { - val oldStoragePath = "${tempDir.absolutePath}$OLD_PATH" - File(oldStoragePath).also { it.parentFile?.mkdirs(); it.createNewFile() } + val expectedDeletedPath = "${tempDir.absolutePath}$OLD_PATH" + File(expectedDeletedPath).also { + it.parentFile?.mkdirs() + it.createNewFile() + } val mediaEntity = createFileEntity( path = OLD_PATH, - storagePath = oldStoragePath, + storagePath = expectedDeletedPath, contentType = "image/jpeg" ) + doMove(entities = listOf(mediaEntity)) - verify(exactly = 1) { manager.deleteFileInMediaScan(oldStoragePath) } + verify(exactly = 1) { manager.deleteFileInMediaScan(expectedDeletedPath) } } @Test fun testMoveLocalFileWhenMediaFileIsMovedShouldTriggerMediaScanAtNewStoragePath() { - val savePath = tempDir.absolutePath - val oldStoragePath = "$savePath$OLD_PATH" - val expectedNewStoragePath = "$savePath$TARGET_PATH" - File(oldStoragePath).also { it.parentFile?.mkdirs(); it.createNewFile() } + 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.any { it == expectedNewStoragePath } }, + match { paths -> paths.single() == expectedNewStoragePath }, any(), any() ) @@ -122,15 +136,19 @@ class MoveFilesFilesystemAndMediaTest : MoveFilesTestBase() { @Test fun testMoveLocalFileWhenNonMediaFileIsMovedShouldNotTriggerAnyMediaScan() { val oldStoragePath = "${tempDir.absolutePath}$OLD_PATH" - File(oldStoragePath).also { it.parentFile?.mkdirs(); it.createNewFile() } + 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()) } } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesGuardTest.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesGuardTest.kt index 8a823447b9b6..01261827c3ad 100644 --- a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesGuardTest.kt +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesGuardTest.kt @@ -80,4 +80,4 @@ class MoveFilesGuardTest : MoveFilesTestBase() { verify(exactly = 0) { mockFileDao.getFolderWithDescendants(any(), any()) } verify(exactly = 0) { mockFileDao.updateAll(any()) } } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt index 11947ddab1fa..b73a7afc7068 100644 --- a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt @@ -7,29 +7,55 @@ 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() + } + private fun arrangeFolderMove( folderPath: String, targetFolderPath: String, targetParentPath: String, entities: List ) { - val parent = OCFile(targetParentPath).apply { fileId = 99L; mimeType = MimeType.DIRECTORY } + 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 } + val folder = OCFile(folderPath).apply { + fileId = 1 + mimeType = MimeType.DIRECTORY + } manager.moveLocalFile(folder, targetFolderPath, targetParentPath) } @@ -37,6 +63,9 @@ class MoveFilesHierarchyTest : MoveFilesTestBase() { 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"), @@ -47,15 +76,22 @@ class MoveFilesHierarchyTest : MoveFilesTestBase() { val updated = capturedEntities.captured assertEquals(3, updated.size) - assertEquals(targetFolderPath, updated.find { it.id == 1L }?.path) - assertEquals("${targetFolderPath}report.pdf", updated.find { it.id == 2L }?.path) - assertEquals("${targetFolderPath}notes.txt", updated.find { it.id == 3L }?.path) + 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/"), @@ -70,21 +106,25 @@ class MoveFilesHierarchyTest : MoveFilesTestBase() { val updated = capturedEntities.captured assertEquals(7, updated.size) - assertEquals(targetFolderPath, updated.find { it.id == 1L }?.path) - assertEquals("${targetFolderPath}src/", updated.find { it.id == 2L }?.path) - assertEquals("${targetFolderPath}src/main/App.kt", updated.find { it.id == 4L }?.path) - assertEquals("${targetFolderPath}src/test/AppTest.kt", updated.find { it.id == 6L }?.path) - assertEquals("${targetFolderPath}README.md", updated.find { it.id == 7L }?.path) + 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 originalParentId = 10L - val targetParentId = 99L + val expectedTopLevelParentId = 99L + val expectedChildParentId = 1L + val expectedSubFolderParentId = 1L + val expectedDeepFileParentId = 3L val entities = listOf( - createFileEntity(id = 1L, path = folderPath, parent = originalParentId), + 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) @@ -93,33 +133,54 @@ class MoveFilesHierarchyTest : MoveFilesTestBase() { arrangeFolderMove(folderPath, targetFolderPath, "/archive/", entities) val updated = capturedEntities.captured - assertEquals(targetParentId, updated.find { it.id == 1L }?.parent) - assertEquals(1L, updated.find { it.id == 2L }?.parent) - assertEquals(1L, updated.find { it.id == 3L }?.parent) - assertEquals(3L, updated.find { it.id == 4L }?.parent) + 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 photoStorage = "$SAVE_PATH${folderPath}photo.jpg" - val docStorage = "$SAVE_PATH${folderPath}notes.pdf" - val videoStorage = "$SAVE_PATH${folderPath}clip.mp4" + 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") + 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) } } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt index 0b3c76eb3b97..4300db5d3d02 100644 --- a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt @@ -21,6 +21,7 @@ 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 @@ -53,7 +54,7 @@ abstract class MoveFilesTestBase { every { MainApp.getAppContext() } returns mockk(relaxed = true) val mockDb = mockk(relaxed = true) - mockkStatic(NextcloudDatabase::class) + mockkObject(NextcloudDatabase.Companion) every { NextcloudDatabase.getInstance(any()) } returns mockDb every { NextcloudDatabase.instance() } returns mockDb every { mockDb.fileDao() } returns mockFileDao From c461a50e546eff5b671e2854c94f54068564ab2a Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Fri, 22 May 2026 12:16:42 +0300 Subject: [PATCH 5/8] wip Signed-off-by: alperozturk96 --- .../java/com/owncloud/android/datamodel/MoveFilesTestBase.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt index 4300db5d3d02..321a0ab09485 100644 --- a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt @@ -29,7 +29,7 @@ import io.mockk.unmockkAll import org.junit.After import org.junit.Before -@Suppress("TooManyFunctions", "DEPRECATION") +@Suppress("LongParameterList","TooManyFunctions", "DEPRECATION") abstract class MoveFilesTestBase { companion object { From 841200eefd7f15a99ee1703ecf11eb68573cca70 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 10:28:49 +0300 Subject: [PATCH 6/8] wip Signed-off-by: alperozturk96 --- .../extensions/FileDataStorageManagerExtensions.kt | 10 +++++----- .../owncloud/android/datamodel/MoveFilesGuardTest.kt | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) 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 0f3a74ca59b5..1d5c7772a985 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileDataStorageManagerExtensions.kt @@ -89,12 +89,12 @@ fun FileDataStorageManager.moveFiles(ocFile: OCFile?, targetPath: String, target } if (OCFile.ROOT_PATH == ocFile.fileName) { - Log_OC.e(FileDataStorageManager.TAG, "moveLocalFile: cannot move root path") + Log_OC.w(FileDataStorageManager.TAG, "moveLocalFile: cannot move root path") return } if (ocFile.remotePath == targetPath) { - Log_OC.e(FileDataStorageManager.TAG, "moveLocalFile: source and target paths are identical, skipping") + Log_OC.w(FileDataStorageManager.TAG, "moveLocalFile: source and target paths are identical, skipping") return } @@ -113,12 +113,12 @@ fun FileDataStorageManager.moveFiles(ocFile: OCFile?, targetPath: String, target val accountName = user.accountName val defaultSavePath = FileStorageUtils.getSavePath(accountName) - val originalMediaPaths = - fileDao.moveFilesInDb(oldPath, targetPath, defaultSavePath, targetParent.fileId, 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( diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesGuardTest.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesGuardTest.kt index 01261827c3ad..ac30847f7e88 100644 --- a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesGuardTest.kt +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesGuardTest.kt @@ -24,7 +24,7 @@ class MoveFilesGuardTest : MoveFilesTestBase() { @Test fun testMoveLocalFileWhenFileDoesNotExistShouldReturnEarlyWithoutInteractingWithDatabase() { - val file = OCFile(OLD_PATH).apply { fileId = 0 } + val file = OCFile(OLD_PATH).apply { fileId = -1 } manager.moveLocalFile(file, TARGET_PATH, TARGET_PARENT_PATH) From e02cf5b74eff41bfd0527af8e90f82aecfb713f2 Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 10:45:04 +0300 Subject: [PATCH 7/8] fix tests Signed-off-by: alperozturk96 --- .../datamodel/MoveFilesHierarchyTest.kt | 17 +++++++++++++++++ .../android/datamodel/MoveFilesTestBase.kt | 18 ++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt index b73a7afc7068..f54a589d0558 100644 --- a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt @@ -56,6 +56,23 @@ class MoveFilesHierarchyTest : MoveFilesTestBase() { 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) } diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt index 321a0ab09485..d24f2c15d173 100644 --- a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesTestBase.kt @@ -29,12 +29,12 @@ import io.mockk.unmockkAll import org.junit.After import org.junit.Before -@Suppress("LongParameterList","TooManyFunctions", "DEPRECATION") +@Suppress("LongParameterList", "TooManyFunctions", "DEPRECATION") abstract class MoveFilesTestBase { companion object { const val ACCOUNT_NAME = "user@nextcloud.example.com" - const val SAVE_PATH = "/storage/emulated/0/nextcloud/$ACCOUNT_NAME" + 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/" @@ -71,6 +71,15 @@ abstract class MoveFilesTestBase { 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() } @@ -86,6 +95,11 @@ abstract class MoveFilesTestBase { @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 { From 50a79e9ebd3e4f6296c0b2c2178739da89389f3f Mon Sep 17 00:00:00 2001 From: alperozturk96 Date: Wed, 27 May 2026 10:48:29 +0300 Subject: [PATCH 8/8] wip Signed-off-by: alperozturk96 --- .../com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt index f54a589d0558..412922789c6e 100644 --- a/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt +++ b/app/src/test/java/com/owncloud/android/datamodel/MoveFilesHierarchyTest.kt @@ -39,6 +39,7 @@ class MoveFilesHierarchyTest : MoveFilesTestBase() { tempDir.deleteRecursively() } + @Suppress("NestedBlockDepth") private fun arrangeFolderMove( folderPath: String, targetFolderPath: String,