From 76f1355b52dc228f403e7de3978b380789a87272 Mon Sep 17 00:00:00 2001 From: Eike Thies Date: Wed, 16 Sep 2026 01:51:01 +0200 Subject: [PATCH 1/3] implement fulltext search across all files --- .../dependecyinjection/UseCaseModule.kt | 2 + .../dependecyinjection/ViewModelModule.kt | 2 +- .../files/filelist/FileListAdapter.kt | 13 +- .../files/filelist/MainFileListFragment.kt | 26 ++- .../files/filelist/MainFileListViewModel.kt | 79 ++++++-- .../src/main/res/values-de/strings.xml | 2 +- .../src/main/res/values-es/strings.xml | 2 +- .../src/main/res/values-fr/strings.xml | 2 +- .../src/main/res/values-it/strings.xml | 2 +- .../src/main/res/values-nl/strings.xml | 2 +- .../src/main/res/values-pl/strings.xml | 2 +- opencloudApp/src/main/res/values/strings.xml | 2 +- .../viewmodels/KeyAppViewModelsTest.kt | 80 ++++++++ .../http/methods/webdav/DavReportResource.kt | 72 +++++++ .../http/methods/webdav/SearchMethod.kt | 154 +++++++++++++++ .../android/lib/resources/files/RemoteFile.kt | 46 ++++- .../files/SearchRemoteFilesOperation.kt | 136 +++++++++++++ .../resources/files/services/FileService.kt | 6 + .../services/implementation/OCFileService.kt | 12 ++ .../opencloud/android/lib/RemoteFileTest.kt | 24 +++ .../http/methods/webdav/SearchMethodTest.kt | 126 ++++++++++++ .../files/SearchRemoteFilesOperationTest.kt | 185 ++++++++++++++++++ .../files/datasources/LocalFileDataSource.kt | 2 + .../files/datasources/RemoteFileDataSource.kt | 6 + .../implementation/OCLocalFileDataSource.kt | 28 ++- .../implementation/OCRemoteFileDataSource.kt | 12 ++ .../android/data/files/db/FileDao.kt | 12 ++ .../data/files/repository/OCFileRepository.kt | 50 +++++ .../OCLocalFileDataSourceTest.kt | 21 ++ .../OCRemoteFileDataSourceTest.kt | 28 +++ .../files/repository/OCFileRepositoryTest.kt | 32 +++ .../android/domain/files/FileRepository.kt | 5 + .../files/usecases/SearchFilesUseCase.kt | 39 ++++ .../files/usecases/SearchFilesUseCaseTest.kt | 71 +++++++ 34 files changed, 1248 insertions(+), 35 deletions(-) create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/DavReportResource.kt create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/SearchMethod.kt create mode 100644 opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/SearchRemoteFilesOperation.kt create mode 100644 opencloudComLibrary/src/test/java/eu/opencloud/android/lib/common/http/methods/webdav/SearchMethodTest.kt create mode 100644 opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/SearchRemoteFilesOperationTest.kt create mode 100644 opencloudDomain/src/main/java/eu/opencloud/android/domain/files/usecases/SearchFilesUseCase.kt create mode 100644 opencloudDomain/src/test/java/eu/opencloud/android/domain/files/usecases/SearchFilesUseCaseTest.kt diff --git a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/UseCaseModule.kt b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/UseCaseModule.kt index 9b61b812ab..3d1828af7c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/UseCaseModule.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/UseCaseModule.kt @@ -65,6 +65,7 @@ import eu.opencloud.android.domain.files.usecases.GetFolderContentUseCase import eu.opencloud.android.domain.files.usecases.GetFolderImagesUseCase import eu.opencloud.android.domain.files.usecases.GetPersonalRootFolderForAccountUseCase import eu.opencloud.android.domain.files.usecases.GetSearchFolderContentUseCase +import eu.opencloud.android.domain.files.usecases.SearchFilesUseCase import eu.opencloud.android.domain.files.usecases.GetSharedByLinkForAccountAsStreamUseCase import eu.opencloud.android.domain.files.usecases.GetSharesRootFolderForAccount import eu.opencloud.android.domain.files.usecases.GetWebDavUrlForSpaceUseCase @@ -175,6 +176,7 @@ val useCaseModule = module { factoryOf(::IsAnyFileAvailableLocallyAndNotAvailableOfflineUseCase) factoryOf(::GetPersonalRootFolderForAccountUseCase) factoryOf(::GetSearchFolderContentUseCase) + factoryOf(::SearchFilesUseCase) factoryOf(::GetSharedByLinkForAccountAsStreamUseCase) factoryOf(::GetSharesRootFolderForAccount) factoryOf(::GetUrlToOpenInWebUseCase) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/ViewModelModule.kt b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/ViewModelModule.kt index bc63f097c6..6921f8b87d 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/ViewModelModule.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/dependecyinjection/ViewModelModule.kt @@ -91,7 +91,7 @@ val viewModelModule = module { ShareViewModel(filePath, accountName, get(), get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { (initialFolderToDisplay: OCFile, fileListOption: FileListOption) -> - MainFileListViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), + MainFileListViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), initialFolderToDisplay, fileListOption) } viewModel { (ocFile: OCFile) -> ConflictsResolveViewModel(get(), get(), get(), get(), get(), ocFile) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt index b7a9b2e5ea..5a14e7efa3 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt @@ -62,6 +62,7 @@ class FileListAdapter( var files = mutableListOf() private var account: Account? = AccountUtils.getCurrentOpenCloudAccount(context) private var fileListOption: FileListOption = FileListOption.ALL_FILES + private var isSearchActive: Boolean = false private val disallowTouchesWithOtherWindows = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) @@ -69,7 +70,11 @@ class FileListAdapter( setHasStableIds(true) } - fun updateFileList(filesToAdd: List, fileListOption: FileListOption) { + fun updateFileList( + filesToAdd: List, + fileListOption: FileListOption, + isSearchActive: Boolean = false, + ) { val listWithFooter = mutableListOf() listWithFooter.addAll(filesToAdd) @@ -89,6 +94,7 @@ class FileListAdapter( files.clear() files.addAll(listWithFooter) this.fileListOption = fileListOption + this.isSearchActive = isSearchActive diffResult.dispatchUpdatesTo(this) } @@ -328,7 +334,10 @@ class FileListAdapter( it.fileListLastMod.text = DisplayUtils.getRelativeTimestamp(context, file.modificationTimestamp) it.threeDotMenu.isVisible = !hasActiveSelection it.threeDotMenu.contentDescription = context.getString(R.string.content_description_file_operations, file.fileName) - if (fileListOption.isAvailableOffline() || (fileListOption.isSharedByLink() && fileWithSyncInfo.space == null)) { + val showSpacePath = fileListOption.isAvailableOffline() || + (fileListOption.isSharedByLink() && fileWithSyncInfo.space == null) || + isSearchActive + if (showSpacePath) { it.spacePathLine.path.apply { text = file.getParentRemotePath() isVisible = true diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt index 21acbf63bb..6a19aa0d4f 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt @@ -40,6 +40,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.WindowManager +import android.view.inputmethod.InputMethodManager import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView @@ -185,6 +186,7 @@ class MainFileListFragment : Fragment(), private var menu: Menu? = null private var checkedFiles: List = emptyList() private var filesToRemove: List = emptyList() + private var searchView: SearchView? = null private var fileSingleFile: OCFile? = null private var fileOptionsBottomSheetSingleFileLayout: LinearLayout? = null private var succeededTransfers: List? = null @@ -786,9 +788,11 @@ class MainFileListFragment : Fragment(), collectLatestLifecycleFlow(mainFileListViewModel.fileListUiState) { fileListUiState -> if (fileListUiState !is MainFileListViewModel.FileListUiState.Success) return@collectLatestLifecycleFlow + val isSearchActive = !fileListUiState.searchFilter.isNullOrBlank() fileListAdapter.updateFileList( filesToAdd = fileListUiState.folderContent, fileListOption = fileListUiState.fileListOption, + isSearchActive = isSearchActive, ) showOrHideEmptyView(fileListUiState) @@ -928,7 +932,11 @@ class MainFileListFragment : Fragment(), with(binding.emptyDataParent) { root.isVisible = fileListUiState.folderContent.isEmpty() - if (fileListUiState.fileListOption.isSharedByLink() && fileListUiState.space != null) { + if (!fileListUiState.searchFilter.isNullOrBlank()) { + listEmptyDatasetIcon.setImageResource(R.drawable.ic_search) + listEmptyDatasetTitle.setText(R.string.local_file_list_search_with_no_matches) + listEmptyDatasetSubTitle.text = "" + } else if (fileListUiState.fileListOption.isSharedByLink() && fileListUiState.space != null) { // Temporary solution for shares space listEmptyDatasetIcon.setImageResource(R.drawable.ic_server_shares) listEmptyDatasetTitle.setText(R.string.shares_list_empty_title) @@ -1294,7 +1302,14 @@ class MainFileListFragment : Fragment(), dialog.show(requireActivity().supportFragmentManager, DIALOG_CREATE_FOLDER) } - override fun onQueryTextSubmit(query: String?): Boolean = false + override fun onQueryTextSubmit(query: String?): Boolean { + query?.let { mainFileListViewModel.updateSearchFilter(it) } + view?.findFocus()?.let { + val imm = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(it.windowToken, 0) + } + return true + } override fun onQueryTextChange(newText: String?): Boolean { newText?.let { mainFileListViewModel.updateSearchFilter(it) } @@ -1302,6 +1317,7 @@ class MainFileListFragment : Fragment(), } fun setSearchListener(searchView: SearchView) { + this.searchView = searchView searchView.setOnQueryTextListener(this) } @@ -1545,6 +1561,12 @@ class MainFileListFragment : Fragment(), val ocFile = ocFileWithSyncInfo.file if (ocFile.isFolder) { + searchView?.let { + if (!it.isIconified) { + it.setQuery("", false) + it.isIconified = true + } + } mainFileListViewModel.updateFolderToDisplay(ocFile) } else { // Click on a file fileActions?.onFileClicked(ocFile) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListViewModel.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListViewModel.kt index e97a73add3..3b85f5634a 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListViewModel.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListViewModel.kt @@ -42,6 +42,7 @@ import eu.opencloud.android.domain.files.usecases.GetFileByIdUseCase import eu.opencloud.android.domain.files.usecases.GetFileByRemotePathUseCase import eu.opencloud.android.domain.files.usecases.GetFolderContentAsStreamUseCase import eu.opencloud.android.domain.files.usecases.GetSharedByLinkForAccountAsStreamUseCase +import eu.opencloud.android.domain.files.usecases.SearchFilesUseCase import eu.opencloud.android.domain.files.usecases.SortFilesWithSyncInfoUseCase import eu.opencloud.android.domain.spaces.model.OCSpace import eu.opencloud.android.domain.spaces.usecases.GetSpaceWithSpecialsByIdForAccountUseCase @@ -58,6 +59,7 @@ import eu.opencloud.android.providers.CoroutinesDispatcherProvider import eu.opencloud.android.usecases.files.FilterFileMenuOptionsUseCase import eu.opencloud.android.usecases.synchronization.SynchronizeFolderUseCase import eu.opencloud.android.usecases.synchronization.SynchronizeFolderUseCase.SyncFolderMode.SYNC_CONTENTS +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -65,13 +67,16 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import eu.opencloud.android.domain.files.usecases.SortType.Companion as SortTypeDomain class MainFileListViewModel( @@ -83,6 +88,7 @@ class MainFileListViewModel( private val getSpaceWithSpecialsByIdForAccountUseCase: GetSpaceWithSpecialsByIdForAccountUseCase, private val sortFilesWithSyncInfoUseCase: SortFilesWithSyncInfoUseCase, private val synchronizeFolderUseCase: SynchronizeFolderUseCase, + private val searchFilesUseCase: SearchFilesUseCase, getAppRegistryWhichAllowCreationAsStreamUseCase: GetAppRegistryWhichAllowCreationAsStreamUseCase, private val getAppRegistryForMimeTypeAsStreamUseCase: GetAppRegistryForMimeTypeAsStreamUseCase, private val getUrlToOpenInWebUseCase: GetUrlToOpenInWebUseCase, @@ -99,6 +105,12 @@ class MainFileListViewModel( val currentFolderDisplayed: MutableStateFlow = MutableStateFlow(initialFolderToDisplay) val fileListOption: MutableStateFlow = MutableStateFlow(fileListOptionParam) private val searchFilter: MutableStateFlow = MutableStateFlow("") + + @OptIn(FlowPreview::class) + private val debouncedSearchFilter: Flow = searchFilter.debounce { query -> + if (query.isBlank()) 0L else SEARCH_DEBOUNCE_MS + } + private val sortTypeAndOrder = MutableStateFlow(Pair(SortType.SORT_TYPE_BY_NAME, SortOrder.SORT_ORDER_ASCENDING)) val space: MutableStateFlow = MutableStateFlow(null) val appRegistryToCreateFiles: StateFlow> = @@ -123,7 +135,7 @@ class MainFileListViewModel( combine( currentFolderDisplayed, fileListOption, - searchFilter, + debouncedSearchFilter, sortTypeAndOrder, space, ) { currentFolderDisplayed, fileListOption, searchFilter, sortTypeAndOrder, space -> @@ -370,18 +382,60 @@ class MainFileListViewModel( sortTypeAndOrder: Pair, space: OCSpace?, ): Flow = - when (fileListOption) { - FileListOption.ALL_FILES -> retrieveFlowForAllFiles(currentFolderDisplayed, currentFolderDisplayed.owner) - FileListOption.SHARED_BY_LINK -> retrieveFlowForShareByLink(currentFolderDisplayed, currentFolderDisplayed.owner) - FileListOption.AV_OFFLINE -> retrieveFlowForAvailableOffline(currentFolderDisplayed, currentFolderDisplayed.owner) - FileListOption.SPACES_LIST -> flowOf() - }.toFileListUiState( - currentFolderDisplayed, - fileListOption, - searchFilter, - sortTypeAndOrder, - space, + if (!searchFilter.isNullOrBlank()) { + retrieveFlowForSearch( + currentFolderDisplayed = currentFolderDisplayed, + fileListOption = fileListOption, + searchFilter = searchFilter, + sortTypeAndOrder = sortTypeAndOrder, + space = space, + ) + } else { + when (fileListOption) { + FileListOption.ALL_FILES -> retrieveFlowForAllFiles(currentFolderDisplayed, currentFolderDisplayed.owner) + FileListOption.SHARED_BY_LINK -> retrieveFlowForShareByLink(currentFolderDisplayed, currentFolderDisplayed.owner) + FileListOption.AV_OFFLINE -> retrieveFlowForAvailableOffline(currentFolderDisplayed, currentFolderDisplayed.owner) + FileListOption.SPACES_LIST -> flowOf() + }.toFileListUiState( + currentFolderDisplayed, + fileListOption, + searchFilter, + sortTypeAndOrder, + space, + ) + } + + private fun retrieveFlowForSearch( + currentFolderDisplayed: OCFile, + fileListOption: FileListOption, + searchFilter: String, + sortTypeAndOrder: Pair, + space: OCSpace?, + ): Flow = flow { + emit(FileListUiState.Loading) + val searchResult = withContext(coroutinesDispatcherProvider.io) { + searchFilesUseCase( + SearchFilesUseCase.Params( + searchQuery = searchFilter, + accountName = currentFolderDisplayed.owner, + spaceId = null, + ) + ) + } + val filesWithSyncInfo = (searchResult.getDataOrNull() ?: emptyList()) + .filter { showHiddenFiles || !it.file.fileName.startsWith(".") } + .let { sortList(it, sortTypeAndOrder) } + + emit( + FileListUiState.Success( + folderToDisplay = currentFolderDisplayed, + folderContent = filesWithSyncInfo, + fileListOption = fileListOption, + searchFilter = searchFilter, + space = space, + ) ) + } private fun retrieveFlowForAllFiles( currentFolderDisplayed: OCFile, @@ -456,6 +510,7 @@ class MainFileListViewModel( companion object { private const val RECYCLER_VIEW_PREFERRED = "RECYCLER_VIEW_PREFERRED" + private const val SEARCH_DEBOUNCE_MS = 300L } } diff --git a/opencloudApp/src/main/res/values-de/strings.xml b/opencloudApp/src/main/res/values-de/strings.xml index 7ffe960e1d..ac053b776c 100644 --- a/opencloudApp/src/main/res/values-de/strings.xml +++ b/opencloudApp/src/main/res/values-de/strings.xml @@ -5,7 +5,7 @@ Name Datum Größe - Diesen Ordner durchsuchen + Dateien durchsuchen Spaces durchsuchen Konto aktualisieren Hochladen diff --git a/opencloudApp/src/main/res/values-es/strings.xml b/opencloudApp/src/main/res/values-es/strings.xml index 6066711142..fe56a0dce1 100644 --- a/opencloudApp/src/main/res/values-es/strings.xml +++ b/opencloudApp/src/main/res/values-es/strings.xml @@ -5,7 +5,7 @@ Nombre Fecha Tamaño - Buscar aquí + Buscar archivos Buscar en Espacios Sincronizar cuenta Subir diff --git a/opencloudApp/src/main/res/values-fr/strings.xml b/opencloudApp/src/main/res/values-fr/strings.xml index c4b4881544..f27b7f87ea 100644 --- a/opencloudApp/src/main/res/values-fr/strings.xml +++ b/opencloudApp/src/main/res/values-fr/strings.xml @@ -5,7 +5,7 @@ Nom Date Taille - Rechercher dans ce dossier + Rechercher des fichiers Rechercher dans les espaces Actualiser le compte Téléverser diff --git a/opencloudApp/src/main/res/values-it/strings.xml b/opencloudApp/src/main/res/values-it/strings.xml index 2af9076f69..f5464fa7f1 100644 --- a/opencloudApp/src/main/res/values-it/strings.xml +++ b/opencloudApp/src/main/res/values-it/strings.xml @@ -5,7 +5,7 @@ Nome Data Dimensione - Cerca in questa cartella + Cerca file Cerca spazi Aggiorna account Carica diff --git a/opencloudApp/src/main/res/values-nl/strings.xml b/opencloudApp/src/main/res/values-nl/strings.xml index 3f5319e005..d78dd2d8f7 100644 --- a/opencloudApp/src/main/res/values-nl/strings.xml +++ b/opencloudApp/src/main/res/values-nl/strings.xml @@ -5,7 +5,7 @@ Naam Datum Grootte - Deze map doorzoeken + Bestanden zoeken Ruimtes doorzoeken Account vernieuwen Uploaden diff --git a/opencloudApp/src/main/res/values-pl/strings.xml b/opencloudApp/src/main/res/values-pl/strings.xml index 16f1b63812..dc9eedc74f 100644 --- a/opencloudApp/src/main/res/values-pl/strings.xml +++ b/opencloudApp/src/main/res/values-pl/strings.xml @@ -5,7 +5,7 @@ Nazwa Data Rozmiar - Szukaj w tym folderze + Szukaj plików Szukaj w przestrzeniach Odśwież konto Prześlij diff --git a/opencloudApp/src/main/res/values/strings.xml b/opencloudApp/src/main/res/values/strings.xml index 0a4de8366d..9da292294c 100644 --- a/opencloudApp/src/main/res/values/strings.xml +++ b/opencloudApp/src/main/res/values/strings.xml @@ -5,7 +5,7 @@ Name Date Size - Search this folder + Search files Search spaces Refresh account Upload diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt index a9dbea8d19..bde5716ece 100644 --- a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt @@ -32,6 +32,7 @@ import eu.opencloud.android.domain.files.model.FileListOption import eu.opencloud.android.domain.files.usecases.CreateFolderAsyncUseCase import eu.opencloud.android.domain.files.usecases.GetFileByIdUseCase import eu.opencloud.android.domain.files.usecases.GetFolderContentAsStreamUseCase +import eu.opencloud.android.domain.files.usecases.SearchFilesUseCase import eu.opencloud.android.domain.files.usecases.SortFilesWithSyncInfoUseCase import eu.opencloud.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase import eu.opencloud.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase @@ -53,6 +54,7 @@ import eu.opencloud.android.providers.ContextProvider import eu.opencloud.android.providers.WorkManagerProvider import eu.opencloud.android.testutil.OC_ACCOUNT_NAME import eu.opencloud.android.testutil.OC_BACKUP +import eu.opencloud.android.testutil.OC_FILE_WITH_SYNC_INFO import eu.opencloud.android.testutil.OC_FOLDER import eu.opencloud.android.testutil.OC_FOLDER_WITH_SPACE_ID import eu.opencloud.android.testutil.OC_ROOT_FOLDER @@ -69,6 +71,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After @@ -298,6 +302,7 @@ class KeyAppViewModelsTest : ViewModelTest() { getSpaceWithSpecialsByIdForAccountUseCase = getSpaceWithSpecialsByIdForAccountUseCase, sortFilesWithSyncInfoUseCase = SortFilesWithSyncInfoUseCase(), synchronizeFolderUseCase = synchronizeFolderUseCase, + searchFilesUseCase = mockk(relaxed = true), getAppRegistryWhichAllowCreationAsStreamUseCase = getAppRegistryWhichAllowCreationAsStreamUseCase, getAppRegistryForMimeTypeAsStreamUseCase = mockk(relaxed = true), getUrlToOpenInWebUseCase = mockk(relaxed = true), @@ -330,4 +335,79 @@ class KeyAppViewModelsTest : ViewModelTest() { ) } } + + @Test + fun `MainFileListViewModel search executes searchFilesUseCase and updates state`() = runTest(testCoroutineDispatcher) { + val sharedPreferencesProvider = mockk(relaxed = true) + every { sharedPreferencesProvider.getBoolean(any(), any()) } returns false + every { sharedPreferencesProvider.getInt(PREF_FILE_LIST_SORT_TYPE, any()) } returns SortType.SORT_TYPE_BY_NAME.ordinal + every { sharedPreferencesProvider.getInt(PREF_FILE_LIST_SORT_ORDER, any()) } returns SortOrder.SORT_ORDER_ASCENDING.ordinal + + val searchFilesUseCase = mockk() + every { + searchFilesUseCase( + SearchFilesUseCase.Params( + searchQuery = "document", + accountName = OC_ROOT_FOLDER.owner, + spaceId = null, + ) + ) + } returns UseCaseResult.Success(listOf(OC_FILE_WITH_SYNC_INFO)) + + val getFolderContentAsStreamUseCase = mockk() + every { getFolderContentAsStreamUseCase(any()) } returns flowOf(emptyList()) + + val getAppRegistryWhichAllowCreationAsStreamUseCase = mockk() + every { getAppRegistryWhichAllowCreationAsStreamUseCase(any()) } returns flowOf(emptyList()) + + val getSpaceWithSpecialsByIdForAccountUseCase = mockk() + every { getSpaceWithSpecialsByIdForAccountUseCase(any()) } returns OC_SPACE_PERSONAL + + val viewModel = MainFileListViewModel( + getFolderContentAsStreamUseCase = getFolderContentAsStreamUseCase, + getSharedByLinkForAccountAsStreamUseCase = mockk(relaxed = true), + getFilesAvailableOfflineFromAccountAsStreamUseCase = mockk(relaxed = true), + getFileByIdUseCase = mockk(relaxed = true), + getFileByRemotePathUseCase = mockk(relaxed = true), + getSpaceWithSpecialsByIdForAccountUseCase = getSpaceWithSpecialsByIdForAccountUseCase, + sortFilesWithSyncInfoUseCase = SortFilesWithSyncInfoUseCase(), + synchronizeFolderUseCase = mockk(relaxed = true), + searchFilesUseCase = searchFilesUseCase, + getAppRegistryWhichAllowCreationAsStreamUseCase = getAppRegistryWhichAllowCreationAsStreamUseCase, + getAppRegistryForMimeTypeAsStreamUseCase = mockk(relaxed = true), + getUrlToOpenInWebUseCase = mockk(relaxed = true), + filterFileMenuOptionsUseCase = mockk(relaxed = true), + contextProvider = contextProvider, + coroutinesDispatcherProvider = coroutineDispatcherProvider, + sharedPreferencesProvider = sharedPreferencesProvider, + initialFolderToDisplay = OC_ROOT_FOLDER, + fileListOptionParam = FileListOption.ALL_FILES, + ) + + val states = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.fileListUiState.collect { states.add(it) } + } + + viewModel.updateSearchFilter("document") + testScheduler.advanceTimeBy(350) + testScheduler.runCurrent() + + verify { + searchFilesUseCase( + SearchFilesUseCase.Params( + searchQuery = "document", + accountName = OC_ROOT_FOLDER.owner, + spaceId = null, + ) + ) + } + + val successState = states.filterIsInstance().lastOrNull() + assertTrue(successState != null) + assertEquals("document", successState?.searchFilter) + assertEquals(listOf(OC_FILE_WITH_SYNC_INFO), successState?.folderContent) + + job.cancel() + } } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/DavReportResource.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/DavReportResource.kt new file mode 100644 index 0000000000..a9435181eb --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/DavReportResource.kt @@ -0,0 +1,72 @@ +/* openCloud Android Library is available under MIT license + * Copyright (C) 2026 ownCloud GmbH. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package eu.opencloud.android.lib.common.http.methods.webdav + +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.DavResource +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.Response.HrefRelation +import at.bitfire.dav4jvm.exception.DavException +import at.bitfire.dav4jvm.exception.HttpException +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import java.io.IOException +import java.util.logging.Logger + +class DavReportResource( + httpClient: OkHttpClient, + location: HttpUrl, + log: Logger = Dav4jvm.log, +) : DavResource(httpClient, location, log) { + + @Throws(IOException::class, HttpException::class, DavException::class) + fun report( + requestBody: RequestBody, + listOfHeaders: HashMap, + callback: (Response, HrefRelation) -> Unit, + rawCallback: (okhttp3.Response) -> Unit, + ): List { + val httpResponse = followRedirects { + val requestBuilder = Request.Builder() + .url(location) + .method("REPORT", requestBody) + + listOfHeaders.forEach { (key, value) -> + if (value != null) { + requestBuilder.header(key, value) + } + } + + val currentCall = httpClient.newCall(requestBuilder.build()) + this.call = currentCall + currentCall.execute() + } + + rawCallback(httpResponse) + checkStatus(httpResponse) + return processMultiStatus(httpResponse, callback) + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/SearchMethod.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/SearchMethod.kt new file mode 100644 index 0000000000..be22425978 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/SearchMethod.kt @@ -0,0 +1,154 @@ +/* openCloud Android Library is available under MIT license + * Copyright (C) 2026 ownCloud GmbH. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package eu.opencloud.android.lib.common.http.methods.webdav + +import at.bitfire.dav4jvm.Dav4jvm +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.XmlUtils +import at.bitfire.dav4jvm.exception.HttpException +import at.bitfire.dav4jvm.exception.RedirectException +import eu.opencloud.android.lib.common.http.HttpConstants +import eu.opencloud.android.lib.common.http.methods.HttpBaseMethod +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCFileId +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCSpaceId +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody.Companion.toResponseBody +import java.io.StringWriter +import java.net.URL + +class SearchMethod( + url: URL, + private val searchQuery: String, + private val limit: Int = DEFAULT_SEARCH_LIMIT, + private val propertiesToRequest: Array = defaultSearchProperties, +) : HttpBaseMethod(url) { + + override lateinit var response: okhttp3.Response + val members: MutableList = arrayListOf() + private var davReportResource: DavReportResource? = null + + override val isAborted: Boolean + get() = davReportResource?.isCallAborted() ?: false + + override fun abort() { + davReportResource?.cancelCall() + } + + @Throws(Exception::class) + override fun onExecute(okHttpClient: OkHttpClient): Int = + try { + val resource = DavReportResource( + okHttpClient.newBuilder().followRedirects(false).build(), + httpUrl, + Dav4jvm.log + ) + davReportResource = resource + + val xmlBody = buildSearchXml(searchQuery, limit, propertiesToRequest) + val requestBody = xmlBody.toRequestBody("application/xml; charset=utf-8".toMediaType()) + + resource.report( + requestBody = requestBody, + listOfHeaders = getRequestHeadersAsHashMap(), + callback = { davResponse, _ -> + members.add(davResponse) + }, + rawCallback = { rawResponse -> + response = rawResponse + } + ) + + statusCode + } catch (httpException: RedirectException) { + response = okhttp3.Response.Builder() + .header(HttpConstants.LOCATION_HEADER, httpException.redirectLocation) + .code(httpException.code) + .request(request) + .message(httpException.message ?: "") + .protocol(Protocol.HTTP_1_1) + .build() + httpException.code + } catch (httpException: HttpException) { + if (::response.isInitialized && response.body?.contentType() != null) { + val responseBody = (httpException.responseBody ?: "").toResponseBody(response.body?.contentType()) + response = response.newBuilder() + .body(responseBody) + .build() + } + httpException.code + } + + companion object { + const val DEFAULT_SEARCH_LIMIT = 100 + + val defaultSearchProperties: Array + get() = DavUtils.allPropSet + arrayOf( + OCFileId.NAME, + OCSpaceId.NAME, + ) + + fun buildSearchXml( + pattern: String, + limit: Int, + properties: Array, + ): String { + val serializer = XmlUtils.newSerializer() + val writer = StringWriter() + serializer.setOutput(writer) + serializer.startDocument("UTF-8", null) + serializer.setPrefix("d", XmlUtils.NS_WEBDAV) + serializer.setPrefix("oc", XmlUtils.NS_OWNCLOUD) + + serializer.startTag(XmlUtils.NS_OWNCLOUD, "search-files") + + // + serializer.startTag(XmlUtils.NS_WEBDAV, "prop") + for (prop in properties) { + serializer.startTag(prop.namespace, prop.name) + serializer.endTag(prop.namespace, prop.name) + } + serializer.endTag(XmlUtils.NS_WEBDAV, "prop") + + // + serializer.startTag(XmlUtils.NS_OWNCLOUD, "search") + serializer.startTag(XmlUtils.NS_OWNCLOUD, "pattern") + serializer.text(pattern) + serializer.endTag(XmlUtils.NS_OWNCLOUD, "pattern") + if (limit > 0) { + serializer.startTag(XmlUtils.NS_OWNCLOUD, "limit") + serializer.text(limit.toString()) + serializer.endTag(XmlUtils.NS_OWNCLOUD, "limit") + } + serializer.endTag(XmlUtils.NS_OWNCLOUD, "search") + + serializer.endTag(XmlUtils.NS_OWNCLOUD, "search-files") + serializer.endDocument() + + return writer.toString() + } + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/RemoteFile.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/RemoteFile.kt index 719e3f3a76..ebe48be187 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/RemoteFile.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/RemoteFile.kt @@ -40,7 +40,9 @@ import at.bitfire.dav4jvm.property.OCPrivatelink import at.bitfire.dav4jvm.property.OCSize import eu.opencloud.android.lib.common.http.HttpConstants import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCChecksums +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCFileId import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCShareTypes +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCSpaceId import eu.opencloud.android.lib.common.utils.isOneOf import eu.opencloud.android.lib.resources.shares.ShareType import eu.opencloud.android.lib.resources.shares.ShareType.Companion.fromValue @@ -76,6 +78,7 @@ data class RemoteFile( var sharedWithSharee: Boolean = false, /** Server-reported checksums as raw "ALGORITHM:value" strings (e.g. "SHA1:1c68ea…"). */ var checksums: List = emptyList(), + var spaceId: String? = null, ) : Parcelable { // To do: Quotas not used. Use or remove them. @@ -97,6 +100,7 @@ data class RemoteFile( const val MIME_DIR = "DIR" const val MIME_DIR_UNIX = "httpd/unix-directory" + private const val DAV_SPACES_PATH = "/dav/spaces/" fun getRemoteFileFromDav( davResource: Response, @@ -105,7 +109,12 @@ data class RemoteFile( spaceWebDavUrl: String? = null ): RemoteFile { val remotePath = getRemotePathFromUrl(davResource.href, userId, spaceWebDavUrl) - val remoteFile = RemoteFile(remotePath = remotePath, owner = userName) + val extractedSpaceId = getSpaceIdFromUrl(davResource.href, spaceWebDavUrl) + val remoteFile = RemoteFile( + remotePath = remotePath, + owner = userName, + spaceId = extractedSpaceId, + ) val properties = getPropertiesEvenIfPostProcessing(davResource) for (property in properties) { @@ -131,6 +140,16 @@ data class RemoteFile( is OCId -> { remoteFile.remoteId = property.id } + is OCFileId -> { + if (remoteFile.remoteId == null) { + remoteFile.remoteId = property.fileId + } + } + is OCSpaceId -> { + if (remoteFile.spaceId == null) { + remoteFile.spaceId = property.spaceId + } + } is OCSize -> { remoteFile.size = property.size } @@ -187,10 +206,35 @@ data class RemoteFile( } else { URLDecoder.decode(url.encodedPath, StandardCharsets.UTF_8.name()) } + if (spaceWebDavUrl == null && absoluteDavPath.contains(DAV_SPACES_PATH)) { + val afterSpaces = absoluteDavPath.substringAfter(DAV_SPACES_PATH) + val slashIndex = afterSpaces.indexOf('/') + return if (slashIndex != -1) { + afterSpaces.substring(slashIndex) + } else { + "/" + } + } val pathToOc = absoluteDavPath.split(davFilesPath).first() return absoluteDavPath.replace(pathToOc + davFilesPath, "") } + fun getSpaceIdFromUrl( + url: HttpUrl, + spaceWebDavUrl: String? = null, + ): String? { + val sourcePath = if (spaceWebDavUrl != null) { + URLDecoder.decode(spaceWebDavUrl, StandardCharsets.UTF_8.name()) + } else { + URLDecoder.decode(url.encodedPath, StandardCharsets.UTF_8.name()) + } + if (sourcePath.contains(DAV_SPACES_PATH)) { + val afterSpaces = sourcePath.substringAfter(DAV_SPACES_PATH) + return afterSpaces.substringBefore('/').ifEmpty { null } + } + return null + } + private fun getPropertiesEvenIfPostProcessing(response: Response): List = if (response.isSuccess()) response.propstat.filter { propStat -> propStat.isSuccessOrPostProcessing() }.map { it.properties }.flatten() diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/SearchRemoteFilesOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/SearchRemoteFilesOperation.kt new file mode 100644 index 0000000000..c8ca9448e8 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/SearchRemoteFilesOperation.kt @@ -0,0 +1,136 @@ +/* openCloud Android Library is available under MIT license + * Copyright (C) 2026 ownCloud GmbH. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package eu.opencloud.android.lib.resources.files + +import at.bitfire.dav4jvm.PropertyRegistry +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.accounts.AccountUtils +import eu.opencloud.android.lib.common.http.HttpConstants +import eu.opencloud.android.lib.common.http.HttpConstants.HTTP_MULTI_STATUS +import eu.opencloud.android.lib.common.http.HttpConstants.HTTP_OK +import eu.opencloud.android.lib.common.http.methods.webdav.SearchMethod +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCChecksums +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCFileId +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCShareTypes +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCSpaceId +import eu.opencloud.android.lib.common.operations.RemoteOperation +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import eu.opencloud.android.lib.common.utils.isOneOf +import timber.log.Timber +import java.net.URL + +class SearchRemoteFilesOperation( + val searchQuery: String, + val spaceId: String? = null, + val limit: Int = SearchMethod.DEFAULT_SEARCH_LIMIT, +) : RemoteOperation>() { + + override fun run(client: OpenCloudClient): RemoteOperationResult> { + try { + PropertyRegistry.register(OCShareTypes.Factory()) + PropertyRegistry.register(OCChecksums.Factory()) + PropertyRegistry.register(OCFileId.Factory()) + PropertyRegistry.register(OCSpaceId.Factory()) + + val targetUrl = getTargetUrl(client) + Timber.d("REPORT search '$searchQuery' -> url=$targetUrl, spaceId=$spaceId") + var searchMethod = SearchMethod( + url = targetUrl, + searchQuery = searchQuery, + limit = limit, + ) + + var status = client.executeHttpMethod(searchMethod) + + // If /remote.php/dav/spaces returned 404 and no specific space was requested, + // fall back to the user files dav endpoint for legacy servers + if (status == HttpConstants.HTTP_NOT_FOUND && spaceId == null) { + val rawFallback = client.userFilesWebDavUri.toString() + val fallbackUrl = URL(rawFallback.replace("//remote.php", "/remote.php")) + Timber.d("Search on $targetUrl returned 404, falling back to $fallbackUrl") + searchMethod = SearchMethod( + url = fallbackUrl, + searchQuery = searchQuery, + limit = limit, + ) + status = client.executeHttpMethod(searchMethod) + } + + return if (isSuccess(status)) { + val remoteFiles = ArrayList() + val userId = try { + if (mAccount != null && mContext != null) { + AccountUtils.getUserId(mAccount, mContext) ?: mAccount.name + } else { + mAccount?.name ?: "" + } + } catch (e: Exception) { + Timber.d(e, "Could not get user id for account %s", mAccount?.name) + mAccount?.name ?: "" + } + val userName = mAccount?.name ?: "" + + searchMethod.members.forEach { resource -> + val remoteFile = RemoteFile.getRemoteFileFromDav( + davResource = resource, + userId = userId, + userName = userName, + ) + if (remoteFile.spaceId == null && spaceId != null) { + remoteFile.spaceId = spaceId + } + remoteFiles.add(remoteFile) + } + + RemoteOperationResult>(ResultCode.OK).apply { + data = remoteFiles + Timber.i("Search for '$searchQuery' completed with ${remoteFiles.size} files - HTTP status code: $status") + } + } else { + RemoteOperationResult>(searchMethod).also { + Timber.w("Search for '$searchQuery' failed: ${it.logMessage}") + } + } + } catch (e: Exception) { + return RemoteOperationResult>(e).also { + Timber.e(it.exception, "Search for '$searchQuery' encountered exception") + } + } + } + + private fun getTargetUrl(client: OpenCloudClient): URL { + val base = client.baseUri.toString().trimEnd('/') + return if (spaceId != null) { + URL("$base$SPACES_PATH/$spaceId") + } else { + URL("$base$SPACES_PATH") + } + } + + private fun isSuccess(status: Int): Boolean = status.isOneOf(HTTP_OK, HTTP_MULTI_STATUS) + + companion object { + private const val SPACES_PATH = "/remote.php/dav/spaces" + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/services/FileService.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/services/FileService.kt index a02fe844d2..bc25a9f3e8 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/services/FileService.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/services/FileService.kt @@ -89,4 +89,10 @@ interface FileService : Service { fileId: String, ): RemoteOperationResult + fun searchFiles( + searchQuery: String, + spaceId: String? = null, + limit: Int = 100, + ): RemoteOperationResult> + } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/services/implementation/OCFileService.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/services/implementation/OCFileService.kt index 6bb01f37a2..15a1c69f02 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/services/implementation/OCFileService.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/services/implementation/OCFileService.kt @@ -37,6 +37,7 @@ import eu.opencloud.android.lib.resources.files.RemoteFile import eu.opencloud.android.lib.resources.files.RemoteMetaFile import eu.opencloud.android.lib.resources.files.RemoveRemoteFileOperation import eu.opencloud.android.lib.resources.files.RenameRemoteFileOperation +import eu.opencloud.android.lib.resources.files.SearchRemoteFilesOperation import eu.opencloud.android.lib.resources.files.services.FileService class OCFileService(override val client: OpenCloudClient) : FileService { @@ -147,4 +148,15 @@ class OCFileService(override val client: OpenCloudClient) : FileService { fileId: String, ): RemoteOperationResult = GetRemoteMetaFileOperation(fileId).execute(client) + + override fun searchFiles( + searchQuery: String, + spaceId: String?, + limit: Int, + ): RemoteOperationResult> = + SearchRemoteFilesOperation( + searchQuery = searchQuery, + spaceId = spaceId, + limit = limit, + ).execute(client) } diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/RemoteFileTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/RemoteFileTest.kt index 3b1efc24cb..62c8ab0889 100644 --- a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/RemoteFileTest.kt +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/RemoteFileTest.kt @@ -56,4 +56,28 @@ class RemoteFileTest { val actualRemotePath = RemoteFile.Companion.getRemotePathFromUrl(httpUrlToTest, "username", spaceWebDavUrl) assertEquals(expectedRemotePath, actualRemotePath) } + + @Test + fun getRemotePathFromUrl_spacesWebDav_withoutSpaceWebDavUrl() { + val path = "8871f4f3-fc6f-4a66-8bed-62f175f76f38$05bca744-d89f-4e9c-a990-25a0d7f03fe9/Documents/text.txt" + val httpUrlToTest = "https://server.url/remote.php/dav/spaces/$path".toHttpUrl() + val expectedRemotePath = "/Documents/text.txt" + + val actualRemotePath = RemoteFile.Companion.getRemotePathFromUrl(httpUrlToTest, "username") + assertEquals(expectedRemotePath, actualRemotePath) + } + + @Test + fun getSpaceIdFromUrl() { + val path = "8871f4f3-fc6f-4a66-8bed-62f175f76f38$05bca744-d89f-4e9c-a990-25a0d7f03fe9/Documents/text.txt" + val spacesUrl = "https://server.url/remote.php/dav/spaces/$path".toHttpUrl() + val expectedSpaceId = "8871f4f3-fc6f-4a66-8bed-62f175f76f38$05bca744-d89f-4e9c-a990-25a0d7f03fe9" + + val actualSpaceId = RemoteFile.Companion.getSpaceIdFromUrl(spacesUrl) + assertEquals(expectedSpaceId, actualSpaceId) + + val legacyUrl = "https://server.url/remote.php/dav/files/username/Documents/text.txt".toHttpUrl() + val legacySpaceId = RemoteFile.Companion.getSpaceIdFromUrl(legacyUrl) + assertEquals(null, legacySpaceId) + } } diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/common/http/methods/webdav/SearchMethodTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/common/http/methods/webdav/SearchMethodTest.kt new file mode 100644 index 0000000000..4613d31d78 --- /dev/null +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/common/http/methods/webdav/SearchMethodTest.kt @@ -0,0 +1,126 @@ +/* openCloud Android Library is available under MIT license + * Copyright (C) 2026 ownCloud GmbH. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package eu.opencloud.android.lib.common.http.methods.webdav + +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import at.bitfire.dav4jvm.PropertyRegistry +import eu.opencloud.android.lib.common.http.HttpClient +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCFileId +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCSpaceId +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE) +class SearchMethodTest { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + PropertyRegistry.register(OCFileId.Factory()) + PropertyRegistry.register(OCSpaceId.Factory()) + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `buildSearchXml generates correct XML structure`() { + val xml = SearchMethod.buildSearchXml("test-query", 50, SearchMethod.defaultSearchProperties) + + assertTrue(xml.contains("")) + assertTrue(xml.contains("")) + assertTrue(xml.contains("test-query")) + assertTrue(xml.contains("50")) + assertTrue(xml.contains("")) + } + + @Test + fun `buildSearchXml escapes XML special characters`() { + val xml = SearchMethod.buildSearchXml("foo & bar ", 25, SearchMethod.defaultSearchProperties) + assertTrue(xml.contains("foo & bar <baz>")) + } + + @Test + fun `execute sends REPORT request and parses multistatus response`() { + val multiStatusXml = """ + + + /remote.php/dav/spaces/space123/Documents/file.txt + + HTTP/1.1 200 OK + + text/plain + 1234 + "abcd" + file-id-123 + RDNVW + + + + + """.trimIndent() + + server.enqueue( + MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody(multiStatusXml) + ) + + val searchMethod = SearchMethod( + url = server.url("/remote.php/dav/spaces").toUrl(), + searchQuery = "file", + limit = 50, + ) + + val context = ApplicationProvider.getApplicationContext() + val httpClient = object : HttpClient(context) {} + + val statusCode = searchMethod.execute(httpClient) + + assertEquals(207, statusCode) + assertEquals(1, searchMethod.members.size) + + val recordedRequest = server.takeRequest() + assertEquals("REPORT", recordedRequest.method) + assertEquals("/remote.php/dav/spaces", recordedRequest.path) + assertTrue(recordedRequest.body.readUtf8().contains("file")) + } +} diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/SearchRemoteFilesOperationTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/SearchRemoteFilesOperationTest.kt new file mode 100644 index 0000000000..6fd3fca83e --- /dev/null +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/SearchRemoteFilesOperationTest.kt @@ -0,0 +1,185 @@ +/* openCloud Android Library is available under MIT license + * Copyright (C) 2026 ownCloud GmbH. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package eu.opencloud.android.lib.resources.files + +import android.accounts.Account +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import at.bitfire.dav4jvm.PropertyRegistry +import eu.opencloud.android.lib.common.OpenCloudAccount +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.accounts.AccountUtils +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCFileId +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCSpaceId +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE) +class SearchRemoteFilesOperationTest { + + private lateinit var server: MockWebServer + private lateinit var client: OpenCloudClient + private lateinit var context: Context + private val accountType = "com.example" + + @Before + fun setUp() { + server = MockWebServer() + server.start() + + PropertyRegistry.register(OCFileId.Factory()) + PropertyRegistry.register(OCSpaceId.Factory()) + + context = ApplicationProvider.getApplicationContext() + val base = server.url("/").toString().trimEnd('/') + val account = Account("alice@server", accountType) + val am = android.accounts.AccountManager.get(context) + am.addAccountExplicitly(account, null, null) + am.setUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL, base) + am.setUserData(account, AccountUtils.Constants.KEY_ID, "alice") + val ocAccount = OpenCloudAccount(account, context) + client = OpenCloudClient( + android.net.Uri.parse(base), + null, + false, + null, + context + ).apply { + setAccount(ocAccount) + } + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun `search files across all spaces success`() { + val multiStatusXml = """ + + + /remote.php/dav/spaces/space-123/Documents/contract.pdf + + HTTP/1.1 200 OK + + application/pdf + 45678 + "etag123" + fileid-pdf-1 + RDNVW + + + + + """.trimIndent() + + server.enqueue( + MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody(multiStatusXml) + ) + + val operation = SearchRemoteFilesOperation( + searchQuery = "contract", + spaceId = null, + ) + + val result = operation.execute(client) + + assertTrue(result.isSuccess) + val files = result.data!! + assertEquals(1, files.size) + val file = files.first() + assertEquals("/Documents/contract.pdf", file.remotePath) + assertEquals("space-123", file.spaceId) + assertEquals("fileid-pdf-1", file.remoteId) + assertEquals("application/pdf", file.mimeType) + assertEquals(45678L, file.length) + + val request = server.takeRequest() + assertEquals("REPORT", request.method) + assertEquals("/remote.php/dav/spaces", request.path) + } + + @Test + fun `search files falls back to legacy user endpoint on 404`() { + server.enqueue( + MockResponse() + .setResponseCode(404) + ) + + val multiStatusXml = """ + + + /remote.php/dav/files/alice/notes.txt + + HTTP/1.1 200 OK + + text/plain + 100 + "etag-txt" + fileid-notes + + + + + """.trimIndent() + + server.enqueue( + MockResponse() + .setResponseCode(207) + .setHeader("Content-Type", "application/xml; charset=utf-8") + .setBody(multiStatusXml) + ) + + val operation = SearchRemoteFilesOperation( + searchQuery = "notes", + spaceId = null, + ) + + val result = operation.execute(client) + + val firstRequest = server.takeRequest() + assertEquals("/remote.php/dav/spaces", firstRequest.path) + + assertTrue(result.isSuccess) + val files = result.data!! + assertEquals(1, files.size) + assertEquals("/notes.txt", files.first().remotePath) + + val secondRequest = server.takeRequest() + assertTrue(secondRequest.path!!.startsWith("/remote.php/dav/files/")) + } +} diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/LocalFileDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/LocalFileDataSource.kt index c1eba6e387..5d39720294 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/LocalFileDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/LocalFileDataSource.kt @@ -36,6 +36,7 @@ interface LocalFileDataSource { fun getFileByRemoteId(remoteId: String): OCFile? fun getFolderContent(folderId: Long): List fun getSearchFolderContent(folderId: Long, search: String): List + fun getSearchFilesForAccount(accountName: String, search: String): List fun getSearchAvailableOfflineFolderContent(folderId: Long, search: String): List fun getSearchSharedByLinkFolderContent(folderId: Long, search: String): List fun getFolderContentWithSyncInfoAsFlow(folderId: Long): Flow> @@ -46,6 +47,7 @@ interface LocalFileDataSource { fun getFilesAvailableOfflineFromEveryAccount(): List fun getDownloadedFilesForAccount(owner: String): List fun getFileWithSyncInfoByIdAsFlow(id: Long): Flow + fun getFileWithSyncInfoById(id: Long): OCFileWithSyncInfo? fun getFilesWithLastUsageOlderThanGivenTime(milliseconds: Long): List fun moveFile(sourceFile: OCFile, targetFolder: OCFile, finalRemotePath: String, finalStoragePath: String) fun copyFile(sourceFile: OCFile, targetFolder: OCFile, finalRemotePath: String, remoteId: String, replace: Boolean?) diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/RemoteFileDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/RemoteFileDataSource.kt index b868ca574e..7ed73fa8c1 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/RemoteFileDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/RemoteFileDataSource.kt @@ -96,4 +96,10 @@ interface RemoteFileDataSource { accountName: String, ): OCMetaFile + fun searchFiles( + searchQuery: String, + accountName: String, + spaceId: String? = null, + ): List + } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/implementation/OCLocalFileDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/implementation/OCLocalFileDataSource.kt index 053165a6f5..1f28bf66b2 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/implementation/OCLocalFileDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/implementation/OCLocalFileDataSource.kt @@ -51,6 +51,9 @@ class OCLocalFileDataSource( override fun getFileWithSyncInfoByIdAsFlow(id: Long): Flow = fileDao.getFileWithSyncInfoByIdAsFlow(id).map { it?.toModel() } + override fun getFileWithSyncInfoById(id: Long): OCFileWithSyncInfo? = + fileDao.getFileWithSyncInfoById(id)?.toModel() + override fun getFileByRemotePath(remotePath: String, owner: String, spaceId: String?): OCFile? { fileDao.getFileByOwnerAndRemotePath(owner, remotePath, spaceId)?.let { return it.toModel() } @@ -86,6 +89,11 @@ class OCLocalFileDataSource( it.toModel() } + override fun getSearchFilesForAccount(accountName: String, search: String): List = + fileDao.getSearchFilesForAccount(accountName = accountName, search = search).map { + it.toModel() + } + override fun getSearchAvailableOfflineFolderContent(folderId: Long, search: String): List = fileDao.getSearchAvailableOfflineFolderContent(folderId = folderId, search = search).map { it.toModel() @@ -220,17 +228,17 @@ class OCLocalFileDataSource( fileDao.updateSyncStatusForFile(fileId, null) } - @VisibleForTesting - fun OCFileAndFileSync.toModel(): OCFileWithSyncInfo = - OCFileWithSyncInfo( - file = file.toModel(), - uploadWorkerUuid = fileSync?.uploadWorkerUuid, - downloadWorkerUuid = fileSync?.downloadWorkerUuid, - isSynchronizing = fileSync?.isSynchronizing == true, - space = space?.toModel(), - ) - companion object { + @VisibleForTesting + fun OCFileAndFileSync.toModel(): OCFileWithSyncInfo = + OCFileWithSyncInfo( + file = file.toModel(), + uploadWorkerUuid = fileSync?.uploadWorkerUuid, + downloadWorkerUuid = fileSync?.downloadWorkerUuid, + isSynchronizing = fileSync?.isSynchronizing == true, + space = space?.toModel(), + ) + @VisibleForTesting fun OCFileEntity.toModel(): OCFile = OCFile( diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/implementation/OCRemoteFileDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/implementation/OCRemoteFileDataSource.kt index 2404aca9f9..5301c970c8 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/implementation/OCRemoteFileDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/files/datasources/implementation/OCRemoteFileDataSource.kt @@ -212,6 +212,17 @@ class OCRemoteFileDataSource( clientManager.getFileService(accountName).getMetaFileInfo(fileId) }.toModel() + override fun searchFiles( + searchQuery: String, + accountName: String, + spaceId: String?, + ): List = executeRemoteOperation { + clientManager.getFileService(accountName).searchFiles( + searchQuery = searchQuery, + spaceId = spaceId, + ) + }.map { it.toModel() } + companion object { @VisibleForTesting fun RemoteFile.toModel(): OCFile = @@ -237,6 +248,7 @@ class OCRemoteFileDataSource( privateLink = privateLink, sharedWithSharee = sharedWithSharee, sharedByLink = sharedByLink, + spaceId = spaceId, ) @VisibleForTesting diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/files/db/FileDao.kt b/opencloudData/src/main/java/eu/opencloud/android/data/files/db/FileDao.kt index 9b09f2f9fc..da5b98e277 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/files/db/FileDao.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/files/db/FileDao.kt @@ -82,6 +82,12 @@ interface FileDao { search: String ): List + @Query(SELECT_FILTERED_FILES_FOR_ACCOUNT) + fun getSearchFilesForAccount( + accountName: String, + search: String, + ): List + @Query(SELECT_FILTERED_AVAILABLE_OFFLINE_FOLDER_CONTENT) fun getSearchAvailableOfflineFolderContent( folderId: Long, @@ -532,6 +538,12 @@ interface FileDao { WHERE parentId = :folderId AND remotePath LIKE '%' || :search || '%' """ + private const val SELECT_FILTERED_FILES_FOR_ACCOUNT = """ + SELECT * + FROM ${ProviderMeta.ProviderTableMeta.FILES_TABLE_NAME} + WHERE owner = :accountName AND remotePath LIKE '%' || :search || '%' + """ + private const val SELECT_FILTERED_AVAILABLE_OFFLINE_FOLDER_CONTENT = """ SELECT * FROM ${ProviderMeta.ProviderTableMeta.FILES_TABLE_NAME} diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/files/repository/OCFileRepository.kt b/opencloudData/src/main/java/eu/opencloud/android/data/files/repository/OCFileRepository.kt index bc43633e2d..f464e3756c 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/files/repository/OCFileRepository.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/files/repository/OCFileRepository.kt @@ -540,6 +540,56 @@ class OCFileRepository( localFileDataSource.cleanWorkersUuid(fileId) } + override fun searchFiles( + searchQuery: String, + accountName: String, + spaceId: String?, + ): List { + val files: List = try { + val remoteFiles = remoteFileDataSource.searchFiles( + searchQuery = searchQuery, + accountName = accountName, + spaceId = spaceId, + ) + remoteFiles.map { remoteFile -> + val localFile = localFileDataSource.getFileByRemotePath( + remotePath = remoteFile.remotePath, + owner = remoteFile.owner, + spaceId = remoteFile.spaceId, + ) + if (localFile != null) { + remoteFile.copyLocalPropertiesFrom(localFile) + localFileDataSource.saveFile(remoteFile) + remoteFile + } else { + localFileDataSource.saveFile(remoteFile) + localFileDataSource.getFileByRemotePath( + remotePath = remoteFile.remotePath, + owner = remoteFile.owner, + spaceId = remoteFile.spaceId, + ) ?: remoteFile + } + } + } catch (e: Exception) { + Timber.w(e, "Remote search failed, falling back to local files") + localFileDataSource.getSearchFilesForAccount(accountName, searchQuery) + } + + return files.map { file -> + val syncInfo = file.id?.let { localFileDataSource.getFileWithSyncInfoById(it) } + val space = file.spaceId?.let { sId -> + localSpacesDataSource.getSpaceByIdForAccount(spaceId = sId, accountName = accountName) + } + OCFileWithSyncInfo( + file = file, + uploadWorkerUuid = syncInfo?.uploadWorkerUuid, + downloadWorkerUuid = syncInfo?.downloadWorkerUuid, + isSynchronizing = syncInfo?.isSynchronizing ?: false, + space = space, + ) + } + } + private fun getFinalRemotePath( replace: List, expectedRemotePath: String, diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/files/datasources/implementation/OCLocalFileDataSourceTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/files/datasources/implementation/OCLocalFileDataSourceTest.kt index 073136b153..facd512321 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/files/datasources/implementation/OCLocalFileDataSourceTest.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/files/datasources/implementation/OCLocalFileDataSourceTest.kt @@ -24,6 +24,7 @@ package eu.opencloud.android.data.files.datasources.implementation import eu.opencloud.android.data.files.datasources.implementation.OCLocalFileDataSource.Companion.toEntity +import eu.opencloud.android.data.files.datasources.implementation.OCLocalFileDataSource.Companion.toModel import eu.opencloud.android.data.files.db.FileDao import eu.opencloud.android.data.files.db.OCFileEntity import eu.opencloud.android.domain.availableoffline.model.AvailableOfflineStatus @@ -656,4 +657,24 @@ class OCLocalFileDataSourceTest { verify(exactly = 1) { fileDao.updateSyncStatusForFile(OC_FILE_ENTITY.id, null) } } + + @Test + fun `getSearchFilesForAccount returns matching files`() { + every { fileDao.getSearchFilesForAccount(OC_ACCOUNT_NAME, "image") } returns listOf(OC_FILE_ENTITY) + + val result = ocLocalFileDataSource.getSearchFilesForAccount(OC_ACCOUNT_NAME, "image") + + assertEquals(listOf(OC_FILE_ENTITY.toModel()), result) + verify(exactly = 1) { fileDao.getSearchFilesForAccount(OC_ACCOUNT_NAME, "image") } + } + + @Test + fun `getFileWithSyncInfoById returns file with sync info`() { + every { fileDao.getFileWithSyncInfoById(OC_FILE_ENTITY.id) } returns OC_FILE_AND_FILE_SYNC + + val result = ocLocalFileDataSource.getFileWithSyncInfoById(OC_FILE_ENTITY.id) + + assertEquals(OC_FILE_AND_FILE_SYNC.toModel(), result) + verify(exactly = 1) { fileDao.getFileWithSyncInfoById(OC_FILE_ENTITY.id) } + } } diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/files/datasources/implementation/OCRemoteFileDataSourceTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/files/datasources/implementation/OCRemoteFileDataSourceTest.kt index caa98627c9..f87178ab2f 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/files/datasources/implementation/OCRemoteFileDataSourceTest.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/files/datasources/implementation/OCRemoteFileDataSourceTest.kt @@ -402,4 +402,32 @@ class OCRemoteFileDataSourceTest { ocFileService.getMetaFileInfo(OC_FILE.remoteId!!) } } + + @Test + fun `searchFiles returns a list of OCFile`() { + val remoteResult = createRemoteOperationResultMock( + data = arrayListOf(REMOTE_FILE), + isSuccess = true, + ) + + every { + ocFileService.searchFiles( + searchQuery = "image", + spaceId = null, + ) + } returns remoteResult + + val result = ocRemoteFileDataSource.searchFiles( + searchQuery = "image", + accountName = OC_ACCOUNT_NAME, + spaceId = null, + ) + + assertEquals(listOf(REMOTE_FILE.toModel()), result) + + verify(exactly = 1) { + clientManager.getFileService(OC_ACCOUNT_NAME) + ocFileService.searchFiles("image", null) + } + } } diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/files/repository/OCFileRepositoryTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/files/repository/OCFileRepositoryTest.kt index 0fa000d639..35e1f50843 100644 --- a/opencloudData/src/test/java/eu/opencloud/android/data/files/repository/OCFileRepositoryTest.kt +++ b/opencloudData/src/test/java/eu/opencloud/android/data/files/repository/OCFileRepositoryTest.kt @@ -2068,4 +2068,36 @@ class OCFileRepositoryTest { localFileDataSource.cleanWorkersUuid(OC_FILE_WITH_SPACE_ID.id!!) } } + + @Test + fun `searchFiles returns a list of OCFileWithSyncInfo from remote search`() { + every { + remoteFileDataSource.searchFiles( + searchQuery = "image", + accountName = OC_FILE.owner, + spaceId = null, + ) + } returns listOf(OC_FILE) + every { + localFileDataSource.getFileByRemotePath( + remotePath = OC_FILE.remotePath, + owner = OC_FILE.owner, + spaceId = OC_FILE.spaceId, + ) + } returns OC_FILE + every { localFileDataSource.saveFile(any()) } returns Unit + every { localFileDataSource.getFileWithSyncInfoById(OC_FILE.id!!) } returns OC_FILE_WITH_SYNC_INFO + + val result = ocFileRepository.searchFiles( + searchQuery = "image", + accountName = OC_FILE.owner, + spaceId = null, + ) + + assertEquals(1, result.size) + assertEquals(OC_FILE, result.first().file) + verify(exactly = 1) { + remoteFileDataSource.searchFiles("image", OC_FILE.owner, null) + } + } } diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/FileRepository.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/FileRepository.kt index 6f62a56397..96897d1b3a 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/FileRepository.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/FileRepository.kt @@ -74,5 +74,10 @@ interface FileRepository { fun updateDownloadedFilesStorageDirectoryInStoragePath(oldDirectory: String, newDirectory: String) fun saveDownloadWorkerUuid(fileId: Long, workerUuid: UUID) fun cleanWorkersUuid(fileId: Long) + fun searchFiles( + searchQuery: String, + accountName: String, + spaceId: String? = null, + ): List } diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/usecases/SearchFilesUseCase.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/usecases/SearchFilesUseCase.kt new file mode 100644 index 0000000000..7942a2cfae --- /dev/null +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/usecases/SearchFilesUseCase.kt @@ -0,0 +1,39 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 ownCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package eu.opencloud.android.domain.files.usecases + +import eu.opencloud.android.domain.BaseUseCaseWithResult +import eu.opencloud.android.domain.files.FileRepository +import eu.opencloud.android.domain.files.model.OCFileWithSyncInfo + +class SearchFilesUseCase( + private val repository: FileRepository, +) : BaseUseCaseWithResult, SearchFilesUseCase.Params>() { + + override fun run(params: Params): List = repository.searchFiles( + searchQuery = params.searchQuery, + accountName = params.accountName, + spaceId = params.spaceId, + ) + + data class Params( + val searchQuery: String, + val accountName: String, + val spaceId: String? = null, + ) +} diff --git a/opencloudDomain/src/test/java/eu/opencloud/android/domain/files/usecases/SearchFilesUseCaseTest.kt b/opencloudDomain/src/test/java/eu/opencloud/android/domain/files/usecases/SearchFilesUseCaseTest.kt new file mode 100644 index 0000000000..f70a4e5c36 --- /dev/null +++ b/opencloudDomain/src/test/java/eu/opencloud/android/domain/files/usecases/SearchFilesUseCaseTest.kt @@ -0,0 +1,71 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 ownCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package eu.opencloud.android.domain.files.usecases + +import eu.opencloud.android.domain.exceptions.UnauthorizedException +import eu.opencloud.android.domain.files.FileRepository +import eu.opencloud.android.testutil.OC_ACCOUNT_NAME +import eu.opencloud.android.testutil.OC_FILE_WITH_SYNC_INFO +import io.mockk.every +import io.mockk.spyk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SearchFilesUseCaseTest { + + private val repository: FileRepository = spyk() + private val useCase = SearchFilesUseCase(repository) + private val useCaseParams = SearchFilesUseCase.Params( + searchQuery = "test", + accountName = OC_ACCOUNT_NAME, + spaceId = null, + ) + + @Test + fun `search files - ok`() { + every { repository.searchFiles(useCaseParams.searchQuery, useCaseParams.accountName, useCaseParams.spaceId) } returns listOf( + OC_FILE_WITH_SYNC_INFO + ) + + val useCaseResult = useCase(useCaseParams) + + assertTrue(useCaseResult.isSuccess) + assertEquals(listOf(OC_FILE_WITH_SYNC_INFO), useCaseResult.getDataOrNull()) + + verify(exactly = 1) { + repository.searchFiles(useCaseParams.searchQuery, useCaseParams.accountName, useCaseParams.spaceId) + } + } + + @Test + fun `search files - ko`() { + every { + repository.searchFiles(useCaseParams.searchQuery, useCaseParams.accountName, useCaseParams.spaceId) + } throws UnauthorizedException() + + val useCaseResult = useCase(useCaseParams) + + assertTrue(useCaseResult.isError) + + verify(exactly = 1) { + repository.searchFiles(useCaseParams.searchQuery, useCaseParams.accountName, useCaseParams.spaceId) + } + } +} From 2c273748877b5fa4336b1a24dfd3a41e48bbf45b Mon Sep 17 00:00:00 2001 From: Eike Thies Date: Thu, 17 Sep 2026 00:43:30 +0200 Subject: [PATCH 2/3] Handle search state change in file list updates Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../android/presentation/files/filelist/FileListAdapter.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt index 5a14e7efa3..edd215c957 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/FileListAdapter.kt @@ -91,12 +91,17 @@ class FileListAdapter( ) val diffResult = DiffUtil.calculateDiff(diffUtilCallback) + val searchStateChanged = this.isSearchActive != isSearchActive files.clear() files.addAll(listWithFooter) this.fileListOption = fileListOption this.isSearchActive = isSearchActive - diffResult.dispatchUpdatesTo(this) + if (searchStateChanged) { + notifyDataSetChanged() + } else { + diffResult.dispatchUpdatesTo(this) + } } override fun getItemId(position: Int): Long { From 373517b7f67183cefca4b938d4aa90047ee8a06b Mon Sep 17 00:00:00 2001 From: Eike Thies Date: Thu, 17 Sep 2026 01:47:42 +0200 Subject: [PATCH 3/3] Fix folder content display and back navigation for orphaned directory entries - Check remotePath == ROOT_PATH instead of parentId == ROOT_PARENT_ID in FileDisplayActivity and FolderPickerActivity onBackPressed() to prevent subfolders with null/0 parent IDs from exiting the app - Resolve parent directory via getParentRemotePath() in MainFileListViewModel.manageBrowseUp() when parentId is missing instead of throwing NotImplementedError via TODO() - Avoid using java.io.File path operations in OCFile for remote WebDAV paths to ensure forward slashes across all platforms - Prioritize valid parent IDs in FileDao.getFileByOwnerAndRemotePath and reuse existing row IDs in insertFilesInFolder... to prevent and clean up duplicate rows for the same remotePath - Resolve parentId from parent remote path in OCFileRepository.searchFiles() when saving newly discovered items - Fix OCFileEntity.fromCursor() reading NULL parent IDs as 0L instead of null --- .../files/filelist/MainFileListViewModel.kt | 45 +++++++++-- .../ui/activity/FileDisplayActivity.kt | 3 +- .../ui/activity/FolderPickerActivity.kt | 2 +- .../viewmodels/KeyAppViewModelsTest.kt | 80 +++++++++++++++++++ .../android/data/files/db/FileDao.kt | 72 +++++++++++++++-- .../android/data/files/db/OCFileEntity.kt | 27 +++++-- .../data/files/repository/OCFileRepository.kt | 18 +++++ .../android/domain/files/model/OCFile.kt | 11 ++- 8 files changed, 232 insertions(+), 26 deletions(-) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListViewModel.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListViewModel.kt index 3b85f5634a..e0c58bf3fc 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListViewModel.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListViewModel.kt @@ -223,7 +223,7 @@ class MainFileListViewModel( viewModelScope.launch(coroutinesDispatcherProvider.io) { val currentFolder = currentFolderDisplayed.value val parentId = currentFolder.parentId - val parentDir: OCFile? + var parentDir: OCFile? = null // browsing back to not shared by link or av offline should update to root if (parentId != null && parentId != ROOT_PARENT_ID) { @@ -237,8 +237,12 @@ class MainFileListViewModel( FileListOption.SHARED_BY_LINK -> { val fileById = fileByIdResult.getDataOrNull() parentDir = - if (fileById != null && (!fileById.sharedByLink || fileById.sharedWithSharee != true) && fileById.spaceId == null) { - getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(fileById.owner, ROOT_PATH)).getDataOrNull() + if (fileById != null && (!fileById.sharedByLink || fileById.sharedWithSharee != true) && + fileById.spaceId == null + ) { + getFileByRemotePathUseCase( + GetFileByRemotePathUseCase.Params(fileById.owner, ROOT_PATH) + ).getDataOrNull() } else { fileById } @@ -247,14 +251,16 @@ class MainFileListViewModel( FileListOption.AV_OFFLINE -> { val fileById = fileByIdResult.getDataOrNull() parentDir = if (fileById != null && (!fileById.isAvailableOffline)) { - getFileByRemotePathUseCase(GetFileByRemotePathUseCase.Params(fileById.owner, ROOT_PATH)).getDataOrNull() + getFileByRemotePathUseCase( + GetFileByRemotePathUseCase.Params(fileById.owner, ROOT_PATH) + ).getDataOrNull() } else { fileById } } FileListOption.SPACES_LIST -> { - parentDir = TODO("Move it to usecase if possible") + parentDir = null } } } else if (parentId == ROOT_PARENT_ID) { @@ -263,12 +269,35 @@ class MainFileListViewModel( GetFileByRemotePathUseCase.Params( remotePath = ROOT_PATH, owner = currentFolder.owner, + spaceId = currentFolder.spaceId, ) ) parentDir = rootFolderForAccountResult.getDataOrNull() - } else { - // Browsing to non existing parent folder. - TODO() + } + + // Fallback: If parent was not resolved by ID (e.g. parentId was null, 0, or not found in DB) + if (parentDir == null) { + if (currentFolder.remotePath != ROOT_PATH) { + val parentRemotePath = currentFolder.getParentRemotePath() + parentDir = getFileByRemotePathUseCase( + GetFileByRemotePathUseCase.Params( + remotePath = parentRemotePath, + owner = currentFolder.owner, + spaceId = currentFolder.spaceId, + ) + ).getDataOrNull() + } + + // If still null or at root, fallback to space/personal root folder + if (parentDir == null) { + parentDir = getFileByRemotePathUseCase( + GetFileByRemotePathUseCase.Params( + remotePath = ROOT_PATH, + owner = currentFolder.owner, + spaceId = currentFolder.spaceId, + ) + ).getDataOrNull() + } } parentDir?.let { updateFolderToDisplay(it) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt index a4c9c95f16..ca4dae02fa 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FileDisplayActivity.kt @@ -73,7 +73,6 @@ import eu.opencloud.android.domain.exceptions.SSLRecoverablePeerUnverifiedExcept import eu.opencloud.android.domain.exceptions.UnauthorizedException import eu.opencloud.android.domain.files.model.FileListOption import eu.opencloud.android.domain.files.model.OCFile -import eu.opencloud.android.domain.files.model.OCFile.Companion.ROOT_PARENT_ID import eu.opencloud.android.domain.spaces.model.OCSpace import eu.opencloud.android.domain.utils.Event import eu.opencloud.android.extensions.checkPasscodeEnforced @@ -785,7 +784,7 @@ class FileDisplayActivity : FileActivity(), return } // If current file is root folder - else if (currentDirDisplayed.parentId == ROOT_PARENT_ID) { + else if (currentDirDisplayed.remotePath == OCFile.ROOT_PATH) { // If current space is a project space or personal in a multi-personal account, navigate back to the spaces list if (mainFileListFragment?.getCurrentSpace()?.isProject == true || (mainFileListFragment?.getCurrentSpace()?.isPersonal == true && isMultiPersonal)) { diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FolderPickerActivity.kt b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FolderPickerActivity.kt index fa244ca3e6..cf04a51b15 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FolderPickerActivity.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/FolderPickerActivity.kt @@ -190,7 +190,7 @@ open class FolderPickerActivity : FileActivity(), return } // If current file is root folder - else if (currentDirDisplayed.parentId == OCFile.ROOT_PARENT_ID) { + else if (currentDirDisplayed.remotePath == OCFile.ROOT_PATH) { // If we are not in COPY or CAMERA_FOLDER mode, or if we are in COPY or CAMERA_FOLDER mode and spaces are not allowed, close the activity if (pickerModeIsNotCopyAndCameraFolder() || (pickerMode == PickerMode.COPY && currentDirDisplayed.spaceId == null) || diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt index bde5716ece..ef9ad0490c 100644 --- a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt @@ -31,6 +31,7 @@ import eu.opencloud.android.domain.automaticuploads.usecases.SaveVideoUploadsCon import eu.opencloud.android.domain.files.model.FileListOption import eu.opencloud.android.domain.files.usecases.CreateFolderAsyncUseCase import eu.opencloud.android.domain.files.usecases.GetFileByIdUseCase +import eu.opencloud.android.domain.files.usecases.GetFileByRemotePathUseCase import eu.opencloud.android.domain.files.usecases.GetFolderContentAsStreamUseCase import eu.opencloud.android.domain.files.usecases.SearchFilesUseCase import eu.opencloud.android.domain.files.usecases.SortFilesWithSyncInfoUseCase @@ -410,4 +411,83 @@ class KeyAppViewModelsTest : ViewModelTest() { job.cancel() } + + @Test + fun `MainFileListViewModel manageBrowseUp resolves parent via remote path when parentId is null`() = + runTest(testCoroutineDispatcher) { + val sharedPreferencesProvider = mockk(relaxed = true) + every { sharedPreferencesProvider.getBoolean(any(), any()) } returns false + every { sharedPreferencesProvider.getInt(PREF_FILE_LIST_SORT_TYPE, any()) } returns + SortType.SORT_TYPE_BY_NAME.ordinal + every { sharedPreferencesProvider.getInt(PREF_FILE_LIST_SORT_ORDER, any()) } returns + SortOrder.SORT_ORDER_ASCENDING.ordinal + + val parentFolder = OC_ROOT_FOLDER.copy( + id = 10L, + remotePath = "/ebooks/", + ) + val currentFolderWithNullParent = OC_ROOT_FOLDER.copy( + id = 20L, + parentId = null, + remotePath = "/ebooks/Öko Test/", + ) + + val getFileByRemotePathUseCase = mockk() + every { + getFileByRemotePathUseCase( + GetFileByRemotePathUseCase.Params( + owner = currentFolderWithNullParent.owner, + remotePath = "/ebooks/", + spaceId = currentFolderWithNullParent.spaceId, + ) + ) + } returns UseCaseResult.Success(parentFolder) + + val getFolderContentAsStreamUseCase = mockk() + every { getFolderContentAsStreamUseCase(any()) } returns flowOf(emptyList()) + + val getAppRegistryWhichAllowCreationAsStreamUseCase = + mockk() + every { getAppRegistryWhichAllowCreationAsStreamUseCase(any()) } returns flowOf(emptyList()) + + val getSpaceWithSpecialsByIdForAccountUseCase = mockk() + every { getSpaceWithSpecialsByIdForAccountUseCase(any()) } returns OC_SPACE_PERSONAL + + val viewModel = MainFileListViewModel( + getFolderContentAsStreamUseCase = getFolderContentAsStreamUseCase, + getSharedByLinkForAccountAsStreamUseCase = mockk(relaxed = true), + getFilesAvailableOfflineFromAccountAsStreamUseCase = mockk(relaxed = true), + getFileByIdUseCase = mockk(relaxed = true), + getFileByRemotePathUseCase = getFileByRemotePathUseCase, + getSpaceWithSpecialsByIdForAccountUseCase = getSpaceWithSpecialsByIdForAccountUseCase, + sortFilesWithSyncInfoUseCase = SortFilesWithSyncInfoUseCase(), + synchronizeFolderUseCase = mockk(relaxed = true), + searchFilesUseCase = mockk(relaxed = true), + getAppRegistryWhichAllowCreationAsStreamUseCase = getAppRegistryWhichAllowCreationAsStreamUseCase, + getAppRegistryForMimeTypeAsStreamUseCase = mockk(relaxed = true), + getUrlToOpenInWebUseCase = mockk(relaxed = true), + filterFileMenuOptionsUseCase = mockk(relaxed = true), + contextProvider = contextProvider, + coroutinesDispatcherProvider = coroutineDispatcherProvider, + sharedPreferencesProvider = sharedPreferencesProvider, + initialFolderToDisplay = currentFolderWithNullParent, + fileListOptionParam = FileListOption.ALL_FILES, + ) + + assertEquals(currentFolderWithNullParent, viewModel.getFile()) + + viewModel.manageBrowseUp() + testScheduler.advanceUntilIdle() + + assertEquals(parentFolder, viewModel.getFile()) + verify { + getFileByRemotePathUseCase( + GetFileByRemotePathUseCase.Params( + owner = currentFolderWithNullParent.owner, + remotePath = "/ebooks/", + spaceId = currentFolderWithNullParent.spaceId, + ) + ) + } + } } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/files/db/FileDao.kt b/opencloudData/src/main/java/eu/opencloud/android/data/files/db/FileDao.kt index da5b98e277..e0d974defa 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/files/db/FileDao.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/files/db/FileDao.kt @@ -36,6 +36,7 @@ import eu.opencloud.android.domain.availableoffline.model.AvailableOfflineStatus import eu.opencloud.android.domain.extensions.isOneOf import eu.opencloud.android.domain.files.model.OCFile import eu.opencloud.android.domain.files.model.OCFile.Companion.ROOT_PARENT_ID +import eu.opencloud.android.domain.files.model.OCFile.Companion.ROOT_PATH import kotlinx.coroutines.flow.Flow import java.io.File.separatorChar import java.util.UUID @@ -71,6 +72,14 @@ interface FileDao { spaceId: String?, ): OCFileEntity? + @Query(DELETE_DUPLICATE_FILES) + fun deleteDuplicateFiles( + owner: String, + remotePath: String, + spaceId: String?, + keepId: Long, + ) + @Query(SELECT_FILE_WITH_REMOTE_ID) fun getFileByRemoteId( remoteId: String @@ -216,18 +225,63 @@ interface FileDao { folder: OCFileEntity, folderContent: List, ): List { - var folderId = insertOrIgnore(folder) - // If it was already in database - if (folderId == -1L) { + var folderId: Long + if (folder.id > 0L) { updateFile(folder) folderId = folder.id + } else { + val existingFolder = getFileByOwnerAndRemotePath(folder.owner, folder.remotePath, folder.spaceId) + if (existingFolder != null) { + folder.id = existingFolder.id + if (folder.parentId == null || (folder.parentId == ROOT_PARENT_ID && folder.remotePath != ROOT_PATH)) { + folder.parentId = existingFolder.parentId + } + updateFile(folder) + folderId = existingFolder.id + } else { + folderId = insertOrIgnore(folder) + if (folderId == -1L) { + updateFile(folder) + folderId = folder.id + } + } } + deleteDuplicateFiles(folder.owner, folder.remotePath, folder.spaceId, folderId) folderContent.forEach { fileToInsert -> - upsert(fileToInsert.apply { + val resolvedChild = if (fileToInsert.id <= 0L) { + val existingChild = getFileByOwnerAndRemotePath( + fileToInsert.owner, + fileToInsert.remotePath, + fileToInsert.spaceId, + ) + if (existingChild != null) { + fileToInsert.copy( + storagePath = fileToInsert.storagePath ?: existingChild.storagePath + ).apply { + id = existingChild.id + } + } else { + fileToInsert + } + } else { + fileToInsert + } + upsert(resolvedChild.apply { parentId = folderId - availableOfflineStatus = getNewAvailableOfflineStatus(folder.availableOfflineStatus, fileToInsert.availableOfflineStatus) + availableOfflineStatus = getNewAvailableOfflineStatus( + folder.availableOfflineStatus, + resolvedChild.availableOfflineStatus, + ) }) + if (resolvedChild.id > 0L) { + deleteDuplicateFiles( + resolvedChild.owner, + resolvedChild.remotePath, + resolvedChild.spaceId, + resolvedChild.id, + ) + } } val folderContentLocal = getFolderContent(folderId) @@ -513,6 +567,14 @@ interface FileDao { SELECT * FROM ${ProviderMeta.ProviderTableMeta.FILES_TABLE_NAME} WHERE owner = :owner AND remotePath = :remotePath AND spaceId IS :spaceId + ORDER BY CASE WHEN parentId IS NOT NULL AND parentId != 0 THEN 0 ELSE 1 END, id DESC + LIMIT 1 + """ + + private const val DELETE_DUPLICATE_FILES = """ + DELETE + FROM ${ProviderMeta.ProviderTableMeta.FILES_TABLE_NAME} + WHERE owner = :owner AND remotePath = :remotePath AND spaceId IS :spaceId AND id != :keepId """ private const val DELETE_FILE_WITH_ID = """ diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/files/db/OCFileEntity.kt b/opencloudData/src/main/java/eu/opencloud/android/data/files/db/OCFileEntity.kt index 67f50c4514..81fa29faf6 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/files/db/OCFileEntity.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/files/db/OCFileEntity.kt @@ -109,33 +109,46 @@ data class OCFileEntity( companion object { fun fromCursor(cursor: Cursor): OCFileEntity = OCFileEntity( - parentId = cursor.getLong(cursor.getColumnIndexOrThrow(FILE_PARENT)), + parentId = cursor.getLongOrNull(FILE_PARENT), remotePath = cursor.getString(cursor.getColumnIndexOrThrow(FILE_PATH)), owner = cursor.getString(cursor.getColumnIndexOrThrow(FILE_ACCOUNT_OWNER)), permissions = cursor.getString(cursor.getColumnIndexOrThrow(FILE_PERMISSIONS)), remoteId = cursor.getString(cursor.getColumnIndexOrThrow(FILE_REMOTE_ID)), privateLink = cursor.getString(cursor.getColumnIndexOrThrow(FILE_PRIVATE_LINK)), - creationTimestamp = cursor.getLong(cursor.getColumnIndexOrThrow(FILE_CREATION)), + creationTimestamp = cursor.getLongOrNull(FILE_CREATION), modificationTimestamp = cursor.getLong(cursor.getColumnIndexOrThrow(FILE_MODIFIED)), etag = cursor.getString(cursor.getColumnIndexOrThrow(FILE_ETAG)), - remoteEtag = cursor.getString(cursor.getColumnIndexOrThrow(FILE_REMOTE_ETAG)), + remoteEtag = cursor.getStringOrNull(FILE_REMOTE_ETAG), mimeType = cursor.getStringFromColumnOrEmpty(FILE_CONTENT_TYPE), length = cursor.getLong(cursor.getColumnIndexOrThrow(FILE_CONTENT_LENGTH)), storagePath = cursor.getString(cursor.getColumnIndexOrThrow(FILE_STORAGE_PATH)), name = cursor.getString(cursor.getColumnIndexOrThrow(FILE_NAME)), treeEtag = cursor.getString(cursor.getColumnIndexOrThrow(FILE_TREE_ETAG)), - lastSyncDateForData = cursor.getLong(cursor.getColumnIndexOrThrow(FILE_LAST_SYNC_DATE_FOR_DATA)), + lastSyncDateForData = cursor.getLongOrNull(FILE_LAST_SYNC_DATE_FOR_DATA), availableOfflineStatus = cursor.getInt(cursor.getColumnIndexOrThrow(FILE_KEEP_IN_SYNC)), - fileShareViaLink = cursor.getInt(cursor.getColumnIndexOrThrow(FILE_SHARED_VIA_LINK)), + fileShareViaLink = cursor.getIntOrNull(FILE_SHARED_VIA_LINK), needsToUpdateThumbnail = cursor.getInt(cursor.getColumnIndexOrThrow(FILE_UPDATE_THUMBNAIL)) == 1, - modifiedAtLastSyncForData = cursor.getLong(cursor.getColumnIndexOrThrow(FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA)), + modifiedAtLastSyncForData = cursor.getLongOrNull(FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA), etagInConflict = cursor.getString(cursor.getColumnIndexOrThrow(FILE_ETAG_IN_CONFLICT)), fileIsDownloading = cursor.getInt(cursor.getColumnIndexOrThrow(FILE_IS_DOWNLOADING)) == 1, - sharedWithSharee = cursor.getInt(cursor.getColumnIndexOrThrow(FILE_SHARED_WITH_SHAREE)) == 1 + sharedWithSharee = cursor.getInt(cursor.getColumnIndexOrThrow(FILE_SHARED_WITH_SHAREE)) == 1, + spaceId = cursor.getStringOrNull(FILE_SPACE_ID), ).apply { id = cursor.getLong(cursor.getColumnIndexOrThrow(_ID)) } + private fun Cursor.getLongOrNull( + columnName: String + ): Long? = getColumnIndex(columnName).takeUnless { it < 0 || isNull(it) }?.let { getLong(it) } + + private fun Cursor.getIntOrNull( + columnName: String + ): Int? = getColumnIndex(columnName).takeUnless { it < 0 || isNull(it) }?.let { getInt(it) } + + private fun Cursor.getStringOrNull( + columnName: String + ): String? = getColumnIndex(columnName).takeUnless { it < 0 || isNull(it) }?.let { getString(it) } + private fun Cursor.getStringFromColumnOrEmpty( columnName: String ): String = getColumnIndex(columnName).takeUnless { it < 0 }?.let { getString(it) }.orEmpty() diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/files/repository/OCFileRepository.kt b/opencloudData/src/main/java/eu/opencloud/android/data/files/repository/OCFileRepository.kt index f464e3756c..f9e5972d4b 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/files/repository/OCFileRepository.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/files/repository/OCFileRepository.kt @@ -39,6 +39,7 @@ import eu.opencloud.android.domain.files.model.FileListOption import eu.opencloud.android.domain.files.model.MIME_DIR import eu.opencloud.android.domain.files.model.OCFile import eu.opencloud.android.domain.files.model.OCFile.Companion.PATH_SEPARATOR +import eu.opencloud.android.domain.files.model.OCFile.Companion.ROOT_PARENT_ID import eu.opencloud.android.domain.files.model.OCFile.Companion.ROOT_PATH import eu.opencloud.android.domain.files.model.OCFileWithSyncInfo import kotlinx.coroutines.flow.Flow @@ -358,10 +359,16 @@ class OCFileRepository( // If folder doesn't exists in database, insert everything. Easy path if (localFolderByRemotePath == null) { + if (remoteFolder.remotePath == ROOT_PATH) { + remoteFolder.parentId = ROOT_PARENT_ID + } folderContentUpdated.addAll(remoteFolderContent.map { it.apply { needsToUpdateThumbnail = !it.isFolder } }) } else { // Keep the current local properties or we will miss relevant things. remoteFolder.copyLocalPropertiesFrom(localFolderByRemotePath) + if (remoteFolder.parentId == null && remoteFolder.remotePath == ROOT_PATH) { + remoteFolder.parentId = ROOT_PARENT_ID + } // Folder already exists in database, get database content to update files accordingly val localFolderContent = localFileDataSource.getFolderContent(folderId = localFolderByRemotePath.id!!) @@ -562,6 +569,17 @@ class OCFileRepository( localFileDataSource.saveFile(remoteFile) remoteFile } else { + if (remoteFile.parentId == null && remoteFile.remotePath != ROOT_PATH) { + val parentRemotePath = remoteFile.getParentRemotePath() + val localParent = localFileDataSource.getFileByRemotePath( + remotePath = parentRemotePath, + owner = remoteFile.owner, + spaceId = remoteFile.spaceId, + ) + if (localParent != null) { + remoteFile.parentId = localParent.id + } + } localFileDataSource.saveFile(remoteFile) localFileDataSource.getFileByRemotePath( remotePath = remoteFile.remotePath, diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/model/OCFile.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/model/OCFile.kt index c34cc674d5..95908ed1ab 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/model/OCFile.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/files/model/OCFile.kt @@ -62,7 +62,7 @@ data class OCFile( ) : Parcelable { val fileName: String - get() = File(remotePath).name.let { it.ifBlank { ROOT_PATH } } + get() = remotePath.trimEnd(PATH_SEPARATOR).substringAfterLast(PATH_SEPARATOR).ifBlank { ROOT_PATH } /** * Use this to find out if this file is a folder. @@ -196,8 +196,13 @@ data class OCFile( * @return remote path */ fun getParentRemotePath(): String { - val parentPath: String = File(remotePath).parent ?: throw IllegalArgumentException("Parent path is null") - return if (parentPath.endsWith("$PATH_SEPARATOR")) parentPath else "$parentPath$PATH_SEPARATOR" + val normalized = remotePath.trimEnd(PATH_SEPARATOR) + val lastSlash = normalized.lastIndexOf(PATH_SEPARATOR) + if (lastSlash == -1) { + return ROOT_PATH + } + val parent = normalized.substring(0, lastSlash + 1) + return if (parent.isEmpty()) ROOT_PATH else parent } fun copyLocalPropertiesFrom(sourceFile: OCFile) {