diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 382590f6a945..864df474b964 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -1575,7 +1575,13 @@ class FileDisplayActivity : private fun handleRemovedFolder(syncFolderRemotePath: String?) { DisplayUtils.showSnackMessage(this, R.string.sync_current_folder_was_removed, syncFolderRemotePath) - browseToRoot() + fileListFragment?.let { + it.parentFolderFinder.getParentOnFirstParentRemoved(syncFolderRemotePath, storageManager)?.let { target -> + it.listDirectory(target, MainApp.isOnlyOnDevice()) + updateActionBarTitleAndHomeButton(target) + file = target + } + } } private fun updateFileList( @@ -1899,13 +1905,13 @@ class FileDisplayActivity : // endregion fun browseToRoot() { - val listOfFiles = this.listOfFilesFragment - if (listOfFiles != null) { // should never be null, indeed + listOfFilesFragment?.let { val root = storageManager.getFileByPath(OCFile.ROOT_PATH) - listOfFiles.resetSearchAttributes() - file = listOfFiles.currentFile + it.resetSearchAttributes() + file = it.currentFile startSyncFolderOperation(root, false) } + binding.fabMain.setImageResource(R.drawable.ic_plus) resetScrollingAndUpdateActionBar() } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java index ae434dbebba3..9a1d35e2dfb7 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java +++ b/app/src/main/java/com/owncloud/android/ui/fragment/OCFileListFragment.java @@ -80,7 +80,6 @@ import com.owncloud.android.lib.resources.e2ee.ToggleEncryptionRemoteOperation; import com.owncloud.android.lib.resources.files.SearchRemoteOperation; import com.owncloud.android.lib.resources.files.ToggleFavoriteRemoteOperation; -import com.owncloud.android.lib.resources.files.model.RemoteFile; import com.owncloud.android.lib.resources.status.E2EVersion; import com.owncloud.android.lib.resources.status.OCCapability; import com.owncloud.android.lib.resources.status.Type; @@ -106,6 +105,7 @@ import com.owncloud.android.ui.events.FavoriteEvent; import com.owncloud.android.ui.events.FileLockEvent; import com.owncloud.android.ui.events.SearchEvent; +import com.owncloud.android.ui.fragment.helper.ParentFolderFinder; import com.owncloud.android.ui.helpers.FileOperationsHelper; import com.owncloud.android.ui.interfaces.OCFileListFragmentInterface; import com.owncloud.android.ui.preview.PreviewImageFragment; @@ -124,7 +124,6 @@ import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; -import java.io.File; import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; @@ -228,6 +227,7 @@ public class OCFileListFragment extends ExtendedListFragment implements protected String mLimitToMimeType; private FloatingActionButton mFabMain; public static boolean isMultipleFileSelectedForCopyOrMove = false; + public final ParentFolderFinder parentFolderFinder = new ParentFolderFinder(); private static final Intent scanIntentExternalApp = new Intent("org.fairscan.app.action.SCAN_TO_PDF"); @@ -1019,7 +1019,6 @@ private void updateSortAndGridMenuItems() { } } - /** * Call this, when the user presses the up button. *

@@ -1029,62 +1028,27 @@ private void updateSortAndGridMenuItems() { * return Count of folder levels browsed up. */ public int onBrowseUp() { - if (mFile == null) { + if (mFile == null || mFile.isRootDirectory()) { + return 0; + } + + final var result = parentFolderFinder.getParent(mFile, mContainerActivity.getStorageManager()); + OCFile target = result.getSecond(); + + if (target == null) { + Log_OC.e(TAG, "onBrowseUp: could not resolve parent, staying put"); return 0; } - Pair result = getPreviousFile(); - mFile = result.second; + mFile = target; setFileDepth(mFile); - // since on browse down sets it to the false, browse up should set back to true if current search type is not NO_SEARCH if (mFile.isRootDirectory() && currentSearchType != NO_SEARCH) { searchFragment = true; } updateFileList(); - return result.first; - } - - private Pair getPreviousFile() { - if (mFile == null) { - return new Pair<>(0, null); - } - - FileDataStorageManager storageManager = mContainerActivity.getStorageManager(); - int moveCount = 0; - String parentPath; - OCFile parentDir; - - if (mFile.getParentId() != FileDataStorageManager.ROOT_PARENT_ID) { - parentPath = new File(mFile.getRemotePath()).getParent(); - parentPath = ensureTrailingSeparator(parentPath); - parentDir = storageManager.getFileByPath(parentPath); - moveCount++; - } else { - parentDir = storageManager.getFileByPath(ROOT_PATH); - parentPath = ROOT_PATH; - } - - // Keep going up until we find a valid folder - while (parentDir == null && !ROOT_PATH.equals(parentPath)) { - parentPath = new File(parentPath).getParent(); - if (parentPath == null) { - parentPath = ROOT_PATH; // fallback to root - } - parentPath = ensureTrailingSeparator(parentPath); - parentDir = storageManager.getFileByPath(parentPath); - moveCount++; - } - - return new Pair<>(moveCount, parentDir); - } - - private String ensureTrailingSeparator(String path) { - if (path == null) { - return ROOT_PATH; - } - return path.endsWith(OCFile.PATH_SEPARATOR) ? path : path + OCFile.PATH_SEPARATOR; + return result.getFirst(); } private void updateFileList() { diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/helper/ParentFolderFinder.kt b/app/src/main/java/com/owncloud/android/ui/fragment/helper/ParentFolderFinder.kt new file mode 100644 index 000000000000..30bd4c4c49e3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/fragment/helper/ParentFolderFinder.kt @@ -0,0 +1,132 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.fragment.helper + +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC + +@Suppress("ReturnCount") +class ParentFolderFinder { + companion object { + private const val TAG = "ParentFolderFinder" + } + + /** + * User tries to move up but parent folder was deleted thus parent of parent + * will be used as destination else ROOT directory + */ + fun getParentOnFirstParentRemoved(path: String?, storageManager: FileDataStorageManager?): OCFile? { + if (storageManager == null) { + Log_OC.e(TAG, "StorageManager is null") + return null + } + + if (path.isNullOrEmpty() || path == OCFile.ROOT_PATH) { + Log_OC.w(TAG, "Path is null, empty, or already at root. Falling back to ROOT.") + return storageManager.getFileByEncryptedRemotePath(OCFile.ROOT_PATH) + } + + return walkUpByPath(path, storageManager).second + } + + fun getParent(file: OCFile?, storageManager: FileDataStorageManager?): Pair { + if (file == null || file.isRootDirectory) { + Log_OC.e(TAG, "File is null or already at root") + return 0 to file + } + + if (storageManager == null) { + Log_OC.e(TAG, "StorageManager is null") + return 0 to file + } + + // id based lookup + val parentId = file.parentId + if (parentId > FileDataStorageManager.ROOT_PARENT_ID && parentId != file.fileId) { + storageManager.getFileById(parentId)?.let { parentById -> + if (parentById.isFolder) { + return 1 to parentById + } + } + + Log_OC.w(TAG, "ID lookup missed for parentId=$parentId, falling back to path walk") + } + + // path-based walk up + return walkUpByPath(file.remotePath, storageManager) + } + + /** + * Walks the remote path upward one segment at a time until a valid folder + * is found in the DB, or we reach root. + */ + private fun walkUpByPath(initialPath: String?, storageManager: FileDataStorageManager): Pair { + var path = initialPath + var moveCount = 0 + + while (!path.isNullOrEmpty() && path != OCFile.ROOT_PATH) { + val parentPath = resolveParentPath(path) + moveCount++ + + if (parentPath == null) { + Log_OC.w(TAG, "Failed to resolve parent path for $path, fallback to root") + break + } + + // Check if this resolved parent exists in DB + storageManager.getFileByEncryptedRemotePath(parentPath)?.let { candidate -> + if (candidate.isFolder) { + return moveCount to candidate + } + } + + if (parentPath == OCFile.ROOT_PATH) { + Log_OC.w(TAG, "Root path reached but not found in DB loop, forcing fallback") + break + } + + // Move up one more level for the next iteration + path = parentPath + } + + // fallback to root + val root = storageManager.getFileByEncryptedRemotePath(OCFile.ROOT_PATH) + return moveCount to root + } + + /** + * Returns the parent path of `path` with a guaranteed trailing "/", + * or `null` if `path` is already root or invalid. + * + * + * Correctly handles paths with or without a trailing separator, so both + * "/Foo/Bar" and "/Foo/Bar/" return "/Foo/". + */ + private fun resolveParentPath(path: String?): String? { + if (path.isNullOrEmpty() || path == OCFile.ROOT_PATH) { + return null + } + + // Strip trailing "/" before computing parent + val normalized = if (path.endsWith(OCFile.PATH_SEPARATOR)) { + path.substring(0, path.length - 1) + } else { + path + } + + val lastSep = normalized.lastIndexOf(OCFile.PATH_SEPARATOR) + if (lastSep <= 0) { + Log_OC.w(TAG, "No separator found, or only one at the start") + return OCFile.ROOT_PATH + } + + // e.g. "/Foo/Bar" → "/Foo/" + return normalized.substring(0, lastSep + 1) + } +} diff --git a/app/src/test/java/com/owncloud/android/ui/db/ParentFolderFinderTest.kt b/app/src/test/java/com/owncloud/android/ui/db/ParentFolderFinderTest.kt new file mode 100644 index 000000000000..1128c7a6b751 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/ui/db/ParentFolderFinderTest.kt @@ -0,0 +1,205 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.db + +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.ui.fragment.helper.ParentFolderFinder +import com.owncloud.android.utils.MimeType +import io.mockk.every +import io.mockk.mockk +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull +import org.junit.Before +import org.junit.Test + +@Suppress("TooManyFunctions", "MagicNumber") +class ParentFolderFinderTest { + + private lateinit var storageManager: FileDataStorageManager + private lateinit var finder: ParentFolderFinder + + @Before + fun setup() { + storageManager = mockk(relaxed = true) + finder = ParentFolderFinder() + } + + @Test + fun getParentWithNullFileReturnsZeroAndNull() { + val result = finder.getParent(null, storageManager) + + assertEquals(0, result.first) + assertNull(result.second) + } + + @Test + fun getParentWithNullStorageManagerReturnsZeroAndFile() { + val file = OCFile("/test.txt") + + val result = finder.getParent(file, null) + + assertEquals(0, result.first) + assertEquals(file, result.second) + } + + @Test + fun getParentWithValidParentIdReturnsOneAndParentFile() { + val parentFolder = OCFile("/parent/").apply { + fileId = 10 + mimeType = MimeType.DIRECTORY + } + val childFile = OCFile("/parent/child.txt").apply { + fileId = 11 + parentId = 10 + } + + every { storageManager.getFileById(10L) } returns parentFolder + + val result = finder.getParent(childFile, storageManager) + + assertEquals(1, result.first) + assertEquals(parentFolder, result.second) + } + + @Test + fun getParentWithInvalidParentIdWalksUpPath() { + val parentFolder = OCFile("/parent/").apply { + fileId = 10 + mimeType = MimeType.DIRECTORY + } + val childFile = OCFile("/parent/child.txt").apply { + fileId = 11 + parentId = 99 + remotePath = "/parent/child.txt" + } + + every { storageManager.getFileById(99L) } returns null + every { storageManager.getFileByEncryptedRemotePath("/parent/") } returns parentFolder + + val result = finder.getParent(childFile, storageManager) + + assertEquals(1, result.first) + assertEquals(parentFolder, result.second) + } + + @Test + fun getParentWalksMultipleLevelsUp() { + val grandParentFolder = OCFile("/parent/").apply { + fileId = 10 + mimeType = MimeType.DIRECTORY + } + val childFile = OCFile("/parent/sub/child.txt").apply { + fileId = 12 + parentId = 11 + remotePath = "/parent/sub/child.txt" + } + + every { storageManager.getFileById(11L) } returns null + every { storageManager.getFileByEncryptedRemotePath("/parent/sub/") } returns null + every { storageManager.getFileByEncryptedRemotePath("/parent/") } returns grandParentFolder + + val result = finder.getParent(childFile, storageManager) + + assertEquals(2, result.first) + assertEquals(grandParentFolder, result.second) + } + + @Test + fun getParentWalksUpToRootAndReturnsRootFallback() { + val rootFolder = OCFile(OCFile.ROOT_PATH).apply { + fileId = 1 + mimeType = MimeType.DIRECTORY + } + val childFile = OCFile("/a/b/c.txt").apply { + fileId = 12 + parentId = 11 + remotePath = "/a/b/c.txt" + } + + every { storageManager.getFileById(11L) } returns null + every { storageManager.getFileByEncryptedRemotePath("/a/b/") } returns null + every { storageManager.getFileByEncryptedRemotePath("/a/") } returns null + every { storageManager.getFileByEncryptedRemotePath(OCFile.ROOT_PATH) } returns rootFolder + + val result = finder.getParent(childFile, storageManager) + + assertEquals(3, result.first) + assertEquals(rootFolder, result.second) + } + + @Test + fun getParentWalksUpToRootWithTrailingSlash() { + val rootFolder = OCFile(OCFile.ROOT_PATH).apply { + fileId = 1 + mimeType = MimeType.DIRECTORY + } + val childFolder = OCFile("/folder/").apply { + fileId = 2 + parentId = 99 + remotePath = "/folder/" + } + + every { storageManager.getFileById(99L) } returns null + every { storageManager.getFileByEncryptedRemotePath(OCFile.ROOT_PATH) } returns rootFolder + + val result = finder.getParent(childFolder, storageManager) + + assertEquals(1, result.first) + assertEquals(rootFolder, result.second) + } + + @Test + fun getParentWhenInFolderCAndCIsRemovedRemotelyShouldReturnFolderB() { + val folderB = OCFile("/A/B/").apply { + fileId = 20 + mimeType = MimeType.DIRECTORY + } + val folderC = OCFile("/A/B/C/").apply { + fileId = 30 + parentId = 20 + remotePath = "/A/B/C/" + } + + // Even if C is deleted remotely, we still have its object in memory, + // and its parentId still correctly maps to B in the local database. + every { storageManager.getFileById(20L) } returns folderB + + val result = finder.getParent(folderC, storageManager) + + assertEquals(1, result.first) + assertEquals(folderB, result.second) + } + + @Test + fun getParentWhenInFolderCAndBIsRemovedRemotelyShouldReturnFolderA() { + val folderA = OCFile("/A/").apply { + fileId = 10 + mimeType = MimeType.DIRECTORY + } + val folderC = OCFile("/A/B/C/").apply { + fileId = 30 + parentId = 20 // Points to B + remotePath = "/A/B/C/" + } + + // ID lookup for B fails because it was removed + every { storageManager.getFileById(20L) } returns null + + // Iteration 1: Tries to find /A/B/ -> also fails + every { storageManager.getFileByEncryptedRemotePath("/A/B/") } returns null + + // Iteration 2: Tries to find /A/ -> Succeeds! + every { storageManager.getFileByEncryptedRemotePath("/A/") } returns folderA + + val result = finder.getParent(folderC, storageManager) + + assertEquals(2, result.first) + assertEquals(folderA, result.second) + } +}