diff --git a/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt b/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt index 3af2a2c815..e65da94d4d 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/extensions/ActivityExt.kt @@ -135,6 +135,17 @@ fun Activity.goToUrl( } } +/** + * Vaults (folders and spaces) are encrypted by the OpenCloud Web vault app, so they can only be opened in the browser. + */ +fun Activity.openVaultInBrowser(url: String?) { + if (url.isNullOrBlank()) { + showMessageInSnackbar(message = getString(R.string.vault_link_unavailable)) + } else { + goToUrl(url) + } +} + fun Activity.openPrivacyPolicy() { val urlPrivacyPolicy = getString(R.string.url_privacy_policy) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt index 13877b9638..16d91d5558 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/DocumentsStorageProvider.kt @@ -55,6 +55,7 @@ import eu.opencloud.android.domain.files.usecases.MoveFileUseCase import eu.opencloud.android.domain.files.usecases.RemoveFileUseCase import eu.opencloud.android.domain.files.usecases.RenameFileUseCase import eu.opencloud.android.domain.spaces.usecases.GetPersonalAndProjectSpacesForAccountUseCase +import eu.opencloud.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase import eu.opencloud.android.domain.spaces.usecases.RefreshSpacesFromServerAsyncUseCase import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.presentation.documentsprovider.cursors.FileCursor @@ -288,6 +289,12 @@ class DocumentsStorageProvider : DocumentsProvider() { // Folder id is not null, so this is a regular folder resultCursor = FileCursor(projection) + if (isVault(getFileByIdOrException(folderId.toInt()))) { + // Vault contents are encrypted by the web vault app, so they are neither listed nor synced + resultCursor.setNotificationUri(context?.contentResolver, toNotifyUri(toUri(parentDocumentId))) + return resultCursor + } + // Create result cursor before syncing folder again, in order to enable faster loading getFolderContent(folderId.toInt()).forEach { file -> resultCursor.addFile(file) } @@ -406,6 +413,7 @@ class DocumentsStorageProvider : DocumentsProvider() { ): String { Timber.d("Create Document ParentID $parentDocumentId Type $mimeType DisplayName $displayName") val parentDocument = getFileByIdOrException(parentDocumentId.toInt()) + checkIsNotVault(parentDocument) return if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { createFolder(parentDocument, displayName) @@ -446,6 +454,8 @@ class DocumentsStorageProvider : DocumentsProvider() { val sourceFile = getFileByIdOrException(sourceDocumentId.toInt()) val targetParentFile = getFileByIdOrException(targetParentDocumentId.toInt()) + checkIsNotVault(sourceFile) + checkIsNotVault(targetParentFile) val copyFileUseCase: CopyFileUseCase by inject() @@ -476,6 +486,8 @@ class DocumentsStorageProvider : DocumentsProvider() { val sourceFile = getFileByIdOrException(sourceDocumentId.toInt()) val targetParentFile = getFileByIdOrException(targetParentDocumentId.toInt()) + checkIsNotVault(sourceFile) + checkIsNotVault(targetParentFile) val moveFileUseCase: MoveFileUseCase by inject() @@ -770,7 +782,7 @@ class DocumentsStorageProvider : DocumentsProvider() { folderContent.forEach { if (it.fileName.contains(query)) { result.add(it) - if (it.isFolder) result.addAll(findFiles(it, query)) + if (it.isFolder && !it.isVault) result.addAll(findFiles(it, query)) } } return result @@ -786,6 +798,24 @@ class DocumentsStorageProvider : DocumentsProvider() { private fun toUri(documentId: String): Uri = Uri.parse(documentId) + /** + * Vaults are encrypted by the OpenCloud Web vault app: folders carrying the vault marker, + * and the root folders of vault spaces. Their contents can't be used by this client. + */ + private fun isVault(file: OCFile): Boolean { + if (file.isVault) return true + if (file.remotePath != ROOT_PATH || file.spaceId == null) return false + + val getSpaceByIdForAccountUseCase: GetSpaceByIdForAccountUseCase by inject() + return getSpaceByIdForAccountUseCase( + GetSpaceByIdForAccountUseCase.Params(accountName = file.owner, spaceId = file.spaceId) + )?.isVault == true + } + + private fun checkIsNotVault(file: OCFile) { + if (isVault(file)) throw UnsupportedOperationException("${file.remotePath} is an encrypted vault") + } + private fun getFileByIdOrException(id: Int): OCFile { val getFileByIdUseCase: GetFileByIdUseCase by inject() val result = getFileByIdUseCase(GetFileByIdUseCase.Params(id.toLong())) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/cursors/FileCursor.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/cursors/FileCursor.kt index d79e257839..0767aa761a 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/cursors/FileCursor.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/cursors/FileCursor.kt @@ -45,15 +45,17 @@ class FileCursor(projection: Array?) : MatrixCursor(projection ?: DEFAUL val imagePath = if (file.isImage && file.isAvailableLocally) file.storagePath else null var flags = if (imagePath != null) Document.FLAG_SUPPORTS_THUMBNAIL else 0 - flags = flags or Document.FLAG_SUPPORTS_DELETE - flags = flags or Document.FLAG_SUPPORTS_RENAME - flags = flags or Document.FLAG_SUPPORTS_COPY - flags = flags or Document.FLAG_SUPPORTS_MOVE + if (!file.isVault) { // Vault folders are encrypted by the web vault app, so no action can be performed on them + flags = flags or Document.FLAG_SUPPORTS_DELETE + flags = flags or Document.FLAG_SUPPORTS_RENAME + flags = flags or Document.FLAG_SUPPORTS_COPY + flags = flags or Document.FLAG_SUPPORTS_MOVE - if (mimeType != Document.MIME_TYPE_DIR) { // If it is a file - flags = flags or Document.FLAG_SUPPORTS_WRITE - } else if (file.hasAddFilePermission && file.hasAddSubdirectoriesPermission) { // If it is a folder with writing permissions - flags = flags or Document.FLAG_DIR_SUPPORTS_CREATE + if (mimeType != Document.MIME_TYPE_DIR) { // If it is a file + flags = flags or Document.FLAG_SUPPORTS_WRITE + } else if (file.hasAddFilePermission && file.hasAddSubdirectoriesPermission) { // If it is a folder with writing permissions + flags = flags or Document.FLAG_DIR_SUPPORTS_CREATE + } } newRow() diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/cursors/SpaceCursor.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/cursors/SpaceCursor.kt index 53f5c76315..75ccdca9e6 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/cursors/SpaceCursor.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/documentsprovider/cursors/SpaceCursor.kt @@ -40,7 +40,8 @@ class SpaceCursor(projection: Array?) : MatrixCursor(projection ?: DEFAU } fun addSpace(space: OCSpace, rootFolder: OCFile, context: Context?) { - val flags = if (rootFolder.hasAddFilePermission && rootFolder.hasAddSubdirectoriesPermission) { + // Vault spaces are encrypted by the web vault app, so nothing can be created in them + val flags = if (!space.isVault && rootFolder.hasAddFilePermission && rootFolder.hasAddSubdirectoriesPermission) { Document.FLAG_DIR_SUPPORTS_CREATE } else { 0 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 b6c9ad1ff6..a76f9abb03 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 @@ -200,6 +200,10 @@ class FileListAdapter( return checkedItems } + // Vault folders are encrypted by the web vault app, so no action can be performed on them + override fun isSelectable(position: Int): Boolean = + (files.getOrNull(position) as? OCFileWithSyncInfo)?.file?.isVault == false + fun selectAll() { // Last item on list is the footer, so that element must be excluded from selection selectAll(totalItems = selectableItemCount()) @@ -273,7 +277,7 @@ class FileListAdapter( ) } val checkBoxV = holder.itemView.findViewById(R.id.custom_checkbox).apply { - isVisible = hasActiveSelection + isVisible = hasActiveSelection && !file.isVault } if (isSelected(position)) { @@ -341,7 +345,7 @@ class FileListAdapter( it.Filename.text = file.fileName it.fileListSize.text = DisplayUtils.bytesToHumanReadable(file.length, context, true) it.fileListLastMod.text = DisplayUtils.getRelativeTimestamp(context, file.modificationTimestamp) - it.threeDotMenu.isVisible = !hasActiveSelection + it.threeDotMenu.isVisible = !hasActiveSelection && !file.isVault // Vaults only open in the browser it.threeDotMenu.contentDescription = context.getString(R.string.content_description_file_operations, file.fileName) if (fileListOption.isAvailableOffline() || (fileListOption.isSharedByLink() && fileWithSyncInfo.space == null)) { it.spacePathLine.path.apply { 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 b73419d087..09fe6e8f1f 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 @@ -85,6 +85,7 @@ import eu.opencloud.android.extensions.collectLatestLifecycleFlow import eu.opencloud.android.extensions.filterMenuOptions import eu.opencloud.android.extensions.parseError import eu.opencloud.android.extensions.sendDownloadedFilesByShareSheet +import eu.opencloud.android.extensions.openVaultInBrowser import eu.opencloud.android.extensions.SNACKBAR_DURATION_ERROR_MS import eu.opencloud.android.extensions.showErrorInSnackbar import eu.opencloud.android.extensions.showMessageInSnackbar @@ -1570,7 +1571,10 @@ class MainFileListFragment : Fragment(), val ocFile = ocFileWithSyncInfo.file - if (ocFile.isFolder) { + if (ocFile.isVault) { + // Vault folders are encrypted by the web vault app, so they are opened in the browser instead of being listed + requireActivity().openVaultInBrowser(ocFile.privateLink) + } else if (ocFile.isFolder) { mainFileListViewModel.updateFolderToDisplay(ocFile) } else { // Click on a file fileActions?.onFileClicked(ocFile) @@ -1578,7 +1582,7 @@ class MainFileListFragment : Fragment(), } override fun onLongItemClick(position: Int): Boolean { - if (isPickingAFolder()) return false + if (isPickingAFolder() || !fileListAdapter.isSelectable(position)) return false if (actionMode == null) { actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(actionModeCallback) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/SelectableAdapter.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/SelectableAdapter.kt index 17471ccd71..066511e7e0 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/SelectableAdapter.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/SelectableAdapter.kt @@ -42,11 +42,17 @@ abstract class SelectableAdapter : fun isSelected(position: Int): Boolean = getSelectedItems().contains(position) + /** + * Indicates if the item at position position can be selected at all + */ + open fun isSelectable(position: Int): Boolean = true + /** * Toggle the selection status of the item at a given position * @param position Position of the item to toggle the selection status for */ fun toggleSelection(position: Int) { + if (!isSelectable(position)) return if (selectedItems[position, false]) { selectedItems.delete(position) } else { @@ -81,6 +87,7 @@ abstract class SelectableAdapter : */ fun toggleSelectionInBulk(totalItems: Int) { for (i in 0 until totalItems) { + if (!isSelectable(i)) continue if (selectedItems[i, false]) { selectedItems.delete(i) } else { @@ -92,6 +99,7 @@ abstract class SelectableAdapter : fun selectAll(totalItems: Int) { for (i in 0 until totalItems) { + if (!isSelectable(i)) continue selectedItems.put(i, true) } notifyDataSetChanged() diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/spaces/SpacesListFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/spaces/SpacesListFragment.kt index 9ea9608e0b..f7d5add93d 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/spaces/SpacesListFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/spaces/SpacesListFragment.kt @@ -39,6 +39,7 @@ import eu.opencloud.android.databinding.SpacesListFragmentBinding import eu.opencloud.android.domain.files.model.FileListOption import eu.opencloud.android.domain.spaces.model.OCSpace import eu.opencloud.android.extensions.collectLatestLifecycleFlow +import eu.opencloud.android.extensions.openVaultInBrowser import eu.opencloud.android.extensions.showErrorInSnackbar import eu.opencloud.android.extensions.toDrawableRes import eu.opencloud.android.extensions.toSubtitleStringRes @@ -134,6 +135,11 @@ class SpacesListFragment : SpacesListAdapter.SpacesListAdapterListener, Fragment } override fun onItemClick(ocSpace: OCSpace) { + if (ocSpace.isVault) { + // Vault spaces are encrypted by the web vault app, so they are opened in the browser instead of being listed + requireActivity().openVaultInBrowser(ocSpace.webUrl) + return + } spacesListViewModel.getRootFileForSpace(ocSpace) } 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 0701cc22e3..587ee65719 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 @@ -84,6 +84,7 @@ import eu.opencloud.android.extensions.isDownloadPending import eu.opencloud.android.extensions.manageOptionLockSelected import eu.opencloud.android.extensions.observeWorkerTillItFinishes import eu.opencloud.android.extensions.openOCFile +import eu.opencloud.android.extensions.openVaultInBrowser import eu.opencloud.android.extensions.parseError import eu.opencloud.android.extensions.sendDownloadedFilesByShareSheet import eu.opencloud.android.extensions.SNACKBAR_DURATION_ERROR_MS @@ -2119,6 +2120,11 @@ class FileDisplayActivity : FileActivity(), } private fun manageItem(file: OCFile) { + if (file.isVault) { + // Vault folders are encrypted by the web vault app, so they are opened in the browser instead of being listed + openVaultInBrowser(file.privateLink) + return + } setFile(file) account = eu.opencloud.android.presentation.authentication.AccountUtils.getOpenCloudAccountByName(this, file.owner) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/ReceiveExternalFilesActivity.java b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/ReceiveExternalFilesActivity.java index a1374fa7dd..294164213c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/ReceiveExternalFilesActivity.java +++ b/opencloudApp/src/main/java/eu/opencloud/android/ui/activity/ReceiveExternalFilesActivity.java @@ -501,7 +501,10 @@ public void onItemClick(AdapterView parent, View view, int position, long id) if (files.size() < position) { throw new IndexOutOfBoundsException("Incorrect item selected"); } - if (files.get(position).isFolder()) { + if (files.get(position).isVault()) { + // Vault folders are encrypted by the web vault app, so they are opened in the browser instead of being listed + ActivityExtKt.openVaultInBrowser(this, files.get(position).getPrivateLink()); + } else if (files.get(position).isFolder()) { OCFile folderToEnter = files.get(position); startSyncFolderOperation(folderToEnter); mParents.push(folderToEnter.getFileName()); diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/files/FilterFileMenuOptionsUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/files/FilterFileMenuOptionsUseCase.kt index 7554d853ff..a1d6e5929c 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/files/FilterFileMenuOptionsUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/files/FilterFileMenuOptionsUseCase.kt @@ -123,6 +123,10 @@ class FilterFileMenuOptionsUseCase( if (params.displaySelectInverse) { optionsToShow.add(FileMenuOption.SELECT_INVERSE) } + if (files.any { it.isVault }) { + // Vault folders are encrypted by the web vault app, so no action can be performed on them + return optionsToShow + } if (!params.onlyAvailableOfflineFiles && shareViaLinkOrWithUsersAllowed && state.resharingAllowed && state.isPersonalSpace && state.hasResharePermission diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFolderUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFolderUseCase.kt index c26120bd98..d907efd3d8 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFolderUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/synchronization/SynchronizeFolderUseCase.kt @@ -37,6 +37,9 @@ class SynchronizeFolderUseCase( val remotePath = params.remotePath val accountName = params.accountName + // Vault folders are encrypted by the web vault app, so their contents are never listed or synced + if (fileRepository.getFileByRemotePath(remotePath, accountName, params.spaceId)?.isVault == true) return + val folderContent = fileRepository.refreshFolder( remotePath = remotePath, accountName = accountName, @@ -68,7 +71,8 @@ class SynchronizeFolderUseCase( } private fun shouldSyncFolder(syncMode: SyncFolderMode, ocFolder: OCFile) = - syncMode.isOneOf(REFRESH_FOLDER_RECURSIVELY, SYNC_FOLDER_RECURSIVELY) || syncMode == SYNC_CONTENTS && ocFolder.isAvailableOffline + !ocFolder.isVault && + (syncMode.isOneOf(REFRESH_FOLDER_RECURSIVELY, SYNC_FOLDER_RECURSIVELY) || syncMode == SYNC_CONTENTS && ocFolder.isAvailableOffline) private fun shouldSyncFile(syncMode: SyncFolderMode, ocFile: OCFile) = syncMode == SYNC_FOLDER_RECURSIVELY || (syncMode == SYNC_CONTENTS && (ocFile.isAvailableLocally || ocFile.isAvailableOffline)) diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/AccountDiscoveryWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/AccountDiscoveryWorker.kt index 851868753f..1746cff2f9 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/AccountDiscoveryWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/AccountDiscoveryWorker.kt @@ -87,7 +87,8 @@ class AccountDiscoveryWorker( } // Then we discover the root of the rest of spaces - val spacesWithoutPersonal = spaces.filterNot { it.isPersonal } + // Vault spaces are encrypted by the web vault app, so their contents are not discovered + val spacesWithoutPersonal = spaces.filterNot { it.isPersonal || it.isVault } spacesWithoutPersonal.forEach { space -> // Create the root file for each space val rootFolderForSpace = diff --git a/opencloudApp/src/main/res/values/strings.xml b/opencloudApp/src/main/res/values/strings.xml index 10b8353059..a9fbebf5d4 100644 --- a/opencloudApp/src/main/res/values/strings.xml +++ b/opencloudApp/src/main/res/values/strings.xml @@ -676,6 +676,7 @@ The password is empty. Private link: Only people who have access to the file/folder can use it. Use it as a permanent link for yourself or to point others to files within shares. Private link is not available for this file. + This encrypted vault can only be opened in the browser, but no link is available for it. Downloads See the download file name and progress. Uploads diff --git a/opencloudApp/src/test/java/eu/opencloud/android/usecases/files/FilterFileMenuOptionsUseCaseTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/usecases/files/FilterFileMenuOptionsUseCaseTest.kt new file mode 100644 index 0000000000..2e5660c242 --- /dev/null +++ b/opencloudApp/src/test/java/eu/opencloud/android/usecases/files/FilterFileMenuOptionsUseCaseTest.kt @@ -0,0 +1,82 @@ +/* + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud 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.usecases.files + +import androidx.work.WorkManager +import eu.opencloud.android.domain.capabilities.CapabilityRepository +import eu.opencloud.android.domain.files.model.FileMenuOption +import eu.opencloud.android.domain.files.model.OCFile +import eu.opencloud.android.domain.files.model.OCFileSyncInfo +import eu.opencloud.android.domain.spaces.usecases.GetSpaceWithSpecialsByIdForAccountUseCase +import eu.opencloud.android.testutil.OC_ACCOUNT_NAME +import eu.opencloud.android.testutil.OC_CAPABILITY +import eu.opencloud.android.testutil.OC_FILE +import eu.opencloud.android.testutil.OC_FOLDER +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class FilterFileMenuOptionsUseCaseTest { + + private val capabilityRepository = mockk { + every { getStoredCapabilities(any()) } returns OC_CAPABILITY + } + private val getSpaceWithSpecialsByIdForAccountUseCase = mockk { + every { this@mockk(any()) } returns null + } + private val useCase = FilterFileMenuOptionsUseCase(mockk(), capabilityRepository, getSpaceWithSpecialsByIdForAccountUseCase) + + private fun params(vararg files: OCFile, displaySelectAll: Boolean = false) = + FilterFileMenuOptionsUseCase.Params( + files = files.toList(), + filesSyncInfo = files.map { + OCFileSyncInfo(fileId = it.id!!, uploadWorkerUuid = null, downloadWorkerUuid = null, isSynchronizing = false) + }, + accountName = OC_ACCOUNT_NAME, + isAnyFileVideoPreviewing = false, + displaySelectAll = displaySelectAll, + displaySelectInverse = false, + onlyAvailableOfflineFiles = false, + onlySharedByLinkFiles = false, + shareViaLinkAllowed = true, + shareWithUsersAllowed = true, + sendAllowed = true, + ) + + @Test + fun `regular folder offers file actions`() { + assertTrue(useCase(params(OC_FOLDER)).contains(FileMenuOption.COPY)) + } + + @Test + fun `vault folder offers no file actions`() { + assertEquals(emptyList(), useCase(params(OC_FOLDER.copy(isVault = true)))) + } + + @Test + fun `selection containing a vault only offers selection actions`() { + assertEquals( + listOf(FileMenuOption.SELECT_ALL), + useCase(params(OC_FILE, OC_FOLDER.copy(isVault = true), displaySelectAll = true)) + ) + } +} diff --git a/opencloudApp/src/test/java/eu/opencloud/android/usecases/synchronization/SynchronizeFolderUseCaseTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/usecases/synchronization/SynchronizeFolderUseCaseTest.kt new file mode 100644 index 0000000000..9f2fd2e7b0 --- /dev/null +++ b/opencloudApp/src/test/java/eu/opencloud/android/usecases/synchronization/SynchronizeFolderUseCaseTest.kt @@ -0,0 +1,73 @@ +/* + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud 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.usecases.synchronization + +import eu.opencloud.android.domain.files.FileRepository +import eu.opencloud.android.testutil.OC_ACCOUNT_NAME +import eu.opencloud.android.testutil.OC_FOLDER +import eu.opencloud.android.testutil.OC_PARENT_FOLDER_WITH_SPACE_ID +import eu.opencloud.android.usecases.synchronization.SynchronizeFolderUseCase.SyncFolderMode.REFRESH_FOLDER_RECURSIVELY +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +/** + * Vault folders are encrypted by the OpenCloud Web vault app, so they must never be listed or synced. + */ +class SynchronizeFolderUseCaseTest { + + private val fileRepository = mockk(relaxed = true) + private val useCase = SynchronizeFolderUseCase(mockk(relaxed = true), fileRepository) + + private fun params(remotePath: String) = SynchronizeFolderUseCase.Params( + remotePath = remotePath, + accountName = OC_ACCOUNT_NAME, + spaceId = PARENT.spaceId, + syncMode = REFRESH_FOLDER_RECURSIVELY, + ) + + @Test + fun `vault folder is not refreshed`() { + every { fileRepository.getFileByRemotePath(VAULT.remotePath, OC_ACCOUNT_NAME, PARENT.spaceId) } returns VAULT + + useCase(params(VAULT.remotePath)) + + verify(exactly = 0) { fileRepository.refreshFolder(any(), any(), any(), any()) } + } + + @Test + fun `recursive refresh does not descend into vault folders`() { + every { fileRepository.getFileByRemotePath(PARENT.remotePath, OC_ACCOUNT_NAME, PARENT.spaceId) } returns PARENT + every { fileRepository.refreshFolder(PARENT.remotePath, OC_ACCOUNT_NAME, PARENT.spaceId, false) } returns listOf(VAULT, REGULAR) + every { fileRepository.refreshFolder(REGULAR.remotePath, OC_ACCOUNT_NAME, PARENT.spaceId, false) } returns emptyList() + + useCase(params(PARENT.remotePath)) + + verify(exactly = 1) { fileRepository.refreshFolder(REGULAR.remotePath, OC_ACCOUNT_NAME, PARENT.spaceId, false) } + verify(exactly = 0) { fileRepository.refreshFolder(VAULT.remotePath, any(), any(), any()) } + } + + companion object { + private val PARENT = OC_PARENT_FOLDER_WITH_SPACE_ID + private val VAULT = OC_FOLDER.copy(remotePath = "/Folder/Secret.vault/", spaceId = PARENT.spaceId, isVault = true) + private val REGULAR = OC_FOLDER.copy(remotePath = "/Folder/Regular/", spaceId = PARENT.spaceId) + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/DavUtils.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/DavUtils.kt index 49feddbed9..c665173be3 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/DavUtils.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/DavUtils.kt @@ -38,6 +38,7 @@ import at.bitfire.dav4jvm.property.OCSize import at.bitfire.dav4jvm.property.ResourceType import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCChecksums import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCHasPreview +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCIntegrityId import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCShareTypes object DavUtils { @@ -57,6 +58,7 @@ object DavUtils { OCShareTypes.NAME, OCChecksums.NAME, OCHasPreview.NAME, + OCIntegrityId.NAME, ) val quotaPropSet: Array diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/properties/OCIntegrityId.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/properties/OCIntegrityId.kt new file mode 100644 index 0000000000..253183084c --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/http/methods/webdav/properties/OCIntegrityId.kt @@ -0,0 +1,50 @@ +/* openCloud Android Library is available under MIT license + * Copyright (C) 2026 OpenCloud 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.properties + +import at.bitfire.dav4jvm.Property +import at.bitfire.dav4jvm.PropertyFactory +import at.bitfire.dav4jvm.XmlUtils +import org.xmlpull.v1.XmlPullParser + +/** + * Parses `` from a PROPFIND response. The property is set by the + * OpenCloud Web vault app on end-to-end encrypted folders ("vaults"). Its contents can't be used + * by this client, so a non-empty value marks the folder as a vault. + */ +data class OCIntegrityId(val isVault: Boolean) : Property { + class Factory : PropertyFactory { + override fun getName() = NAME + + override fun create(parser: XmlPullParser): OCIntegrityId = + OCIntegrityId(!XmlUtils.readText(parser).isNullOrBlank()) + } + + companion object { + const val NS_OCRCLONE = "ocrclone" + + @JvmField + val NAME = Property.Name(NS_OCRCLONE, "integrity-id") + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/ReadRemoteFileOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/ReadRemoteFileOperation.kt index 64d077d58d..621ba85ed4 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/ReadRemoteFileOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/ReadRemoteFileOperation.kt @@ -33,6 +33,7 @@ import eu.opencloud.android.lib.common.http.methods.webdav.DavUtils import eu.opencloud.android.lib.common.http.methods.webdav.PropfindMethod import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCChecksums import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCHasPreview +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCIntegrityId import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCShareTypes import eu.opencloud.android.lib.common.network.WebdavUtils import eu.opencloud.android.lib.common.operations.RemoteOperation @@ -69,6 +70,7 @@ class ReadRemoteFileOperation( PropertyRegistry.register(OCShareTypes.Factory()) PropertyRegistry.register(OCChecksums.Factory()) PropertyRegistry.register(OCHasPreview.Factory()) + PropertyRegistry.register(OCIntegrityId.Factory()) val propFind = PropfindMethod( url = getFinalWebDavUrl(), diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/ReadRemoteFolderOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/ReadRemoteFolderOperation.kt index d7af64ca59..a827b32fc1 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/ReadRemoteFolderOperation.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/ReadRemoteFolderOperation.kt @@ -33,6 +33,7 @@ import eu.opencloud.android.lib.common.http.methods.webdav.DavUtils import eu.opencloud.android.lib.common.http.methods.webdav.PropfindMethod import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCChecksums import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCHasPreview +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCIntegrityId import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCShareTypes import eu.opencloud.android.lib.common.network.WebdavUtils import eu.opencloud.android.lib.common.operations.RemoteOperation @@ -64,6 +65,7 @@ class ReadRemoteFolderOperation( PropertyRegistry.register(OCShareTypes.Factory()) PropertyRegistry.register(OCChecksums.Factory()) PropertyRegistry.register(OCHasPreview.Factory()) + PropertyRegistry.register(OCIntegrityId.Factory()) val finalWebDavUrl = getFinalWebDavUrl() Timber.d("PROPFIND $remotePath -> url=$finalWebDavUrl, spaceWebDavUrl=$spaceWebDavUrl") 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 32909502d5..e2f4edcd12 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 @@ -41,6 +41,7 @@ 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.OCHasPreview +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCIntegrityId import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCShareTypes import eu.opencloud.android.lib.common.utils.isOneOf import eu.opencloud.android.lib.resources.shares.ShareType @@ -79,6 +80,8 @@ data class RemoteFile( var checksums: List = emptyList(), /** Whether the server can render a thumbnail for this file (from ``). */ var hasPreview: Boolean = false, + /** Whether this folder is an encrypted vault created by the web vault app (from ``). */ + var isVault: Boolean = false, ) : Parcelable { // To do: Quotas not used. Use or remove them. @@ -146,6 +149,9 @@ data class RemoteFile( is OCHasPreview -> { remoteFile.hasPreview = property.hasPreview } + is OCIntegrityId -> { + remoteFile.isVault = property.isVault + } is OCShareTypes -> { val list = property.shareTypes for (i in list.indices) { diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/spaces/responses/SpacesResponse.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/spaces/responses/SpacesResponse.kt index a108b3bd95..d7778238fc 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/spaces/responses/SpacesResponse.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/spaces/responses/SpacesResponse.kt @@ -23,6 +23,7 @@ package eu.opencloud.android.lib.resources.spaces.responses +import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) @@ -43,6 +44,8 @@ data class SpaceResponse( val root: RootResponse, val special: List?, val webUrl: String?, + @Json(name = "@libre.graph.contentType") + val libreGraphContentType: String? = null, ) @JsonClass(generateAdapter = true) diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/VaultParsingTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/VaultParsingTest.kt new file mode 100644 index 0000000000..bee1d7e488 --- /dev/null +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/VaultParsingTest.kt @@ -0,0 +1,139 @@ +/* openCloud Android Library is available under MIT license + * Copyright (C) 2026 OpenCloud 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.os.Build +import at.bitfire.dav4jvm.PropertyRegistry +import at.bitfire.dav4jvm.Response +import at.bitfire.dav4jvm.XmlUtils +import com.squareup.moshi.Moshi +import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCIntegrityId +import eu.opencloud.android.lib.resources.spaces.responses.SpaceResponse +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.xmlpull.v1.XmlPullParser +import java.io.StringReader + +/** + * Vaults are encrypted folders and spaces created by the OpenCloud Web vault app. + * See https://github.com/opencloud-eu/android/issues/228 + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.O], manifest = Config.NONE) +class VaultParsingTest { + + private val spaceAdapter = Moshi.Builder().build().adapter(SpaceResponse::class.java) + + @Test + fun vaultFolderIsDetected() { + val vaultFolder = parseRemoteFiles().single { it.remotePath == "/Neuer Ordner.vault/" } + assertTrue(vaultFolder.isVault) + } + + @Test + fun regularFolderIsNotAVault() { + // The property is also listed for folders without it - in the 404 propstat, which must be ignored + val regularFolder = parseRemoteFiles().single { it.remotePath == "/Regular/" } + assertFalse(regularFolder.isVault) + } + + @Test + fun vaultSpaceContentTypeIsParsed() { + val space = spaceAdapter.fromJson(spaceJson(""""@libre.graph.contentType": "application/vnd.opencloud.vault","""))!! + assertEquals("application/vnd.opencloud.vault", space.libreGraphContentType) + } + + @Test + fun regularSpaceHasNoContentType() { + val space = spaceAdapter.fromJson(spaceJson(""))!! + assertNull(space.libreGraphContentType) + } + + private fun spaceJson(extra: String) = """ + { + $extra + "driveType": "project", + "id": "$SPACE_ID", + "name": "Encrypted Space", + "webUrl": "https://demo.opencloud.eu/f/$SPACE_ID", + "root": { "id": "$SPACE_ID!5fd14bf4", "webDavUrl": "https://demo.opencloud.eu/dav/spaces/$SPACE_ID" } + } + """.trimIndent() + + private fun parseRemoteFiles(): List { + PropertyRegistry.register(OCIntegrityId.Factory()) + + val responses = mutableListOf() + val parser = XmlUtils.newPullParser().apply { setInput(StringReader(PROPFIND_RESPONSE)) } + var eventType = parser.eventType + while (eventType != XmlPullParser.END_DOCUMENT) { + if (eventType == XmlPullParser.START_TAG && parser.namespace == XmlUtils.NS_WEBDAV && parser.name == "response") { + Response.parse(parser, SPACE_WEBDAV_URL.toHttpUrl()) { response, _ -> responses.add(response) } + } + eventType = parser.next() + } + + return responses.map { RemoteFile.getRemoteFileFromDav(it, "alan", "alan", SPACE_WEBDAV_URL) } + } + + companion object { + private const val SPACE_ID = "d21381aa-01dc-45cb-a5b8-e6880c47c954\$4a67caa3-cff0-4899-a69d-816848d23b9d" + private const val SPACE_WEBDAV_URL = "https://demo.opencloud.eu/dav/spaces/$SPACE_ID" + + // Vault folder response as returned by demo.opencloud.eu for a folder created by the OpenCloud Web vault app. + // The regular folder lists the unknown property in the 404 propstat. + private val PROPFIND_RESPONSE = """ + + + /dav/spaces/$SPACE_ID/Neuer%20Ordner.vault/ + + Neuer Ordner.vault + "137340bf94d665aa48a209236c5e5246" + + UkNMT05FAACfQDChK3k6+BoVBNQIgayGqNhjvJ2254BsRcxPn2He3iQUiq9hFlY89aId6jz54Brnyodo3h3ADKJORPJbBsmE9ZOV0rf8VQaxydgb + + HTTP/1.1 200 OK + + + /dav/spaces/$SPACE_ID/Regular/ + + Regular + "0123456789abcdef0123456789abcdef" + + HTTP/1.1 200 OK + + + HTTP/1.1 404 Not Found + + + """.trimIndent() + } +} diff --git a/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/51.json b/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/51.json new file mode 100644 index 0000000000..05e65c34cf --- /dev/null +++ b/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/51.json @@ -0,0 +1,1244 @@ +{ + "formatVersion": 1, + "database": { + "version": 51, + "identityHash": "0ad648e4be73a83c3e4a4ee5ea799184", + "entities": [ + { + "tableName": "app_registry", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_name` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `ext` TEXT, `app_providers` TEXT NOT NULL, `name` TEXT, `icon` TEXT, `description` TEXT, `allow_creation` INTEGER, `default_application` TEXT, PRIMARY KEY(`account_name`, `mime_type`))", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ext", + "columnName": "ext", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders", + "columnName": "app_providers", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "allowCreation", + "columnName": "allow_creation", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "defaultApplication", + "columnName": "default_application", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "account_name", + "mime_type" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "folder_backup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountName` TEXT NOT NULL, `behavior` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `uploadPath` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `chargingOnly` INTEGER NOT NULL, `name` TEXT NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `spaceId` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "behavior", + "columnName": "behavior", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadPath", + "columnName": "uploadPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chargingOnly", + "columnName": "chargingOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spaceId", + "columnName": "spaceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account` TEXT, `version_major` INTEGER NOT NULL, `version_minor` INTEGER NOT NULL, `version_micro` INTEGER NOT NULL, `version_string` TEXT, `version_edition` TEXT, `core_pollinterval` INTEGER NOT NULL, `dav_chunking_version` TEXT NOT NULL, `sharing_api_enabled` INTEGER NOT NULL DEFAULT -1, `sharing_public_enabled` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced_read_only` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced_read_write` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced_public_only` INTEGER NOT NULL DEFAULT -1, `sharing_public_expire_date_enabled` INTEGER NOT NULL DEFAULT -1, `sharing_public_expire_date_days` INTEGER NOT NULL, `sharing_public_expire_date_enforced` INTEGER NOT NULL DEFAULT -1, `sharing_public_upload` INTEGER NOT NULL DEFAULT -1, `sharing_public_multiple` INTEGER NOT NULL DEFAULT -1, `supports_upload_only` INTEGER NOT NULL DEFAULT -1, `sharing_resharing` INTEGER NOT NULL DEFAULT -1, `sharing_federation_outgoing` INTEGER NOT NULL DEFAULT -1, `sharing_federation_incoming` INTEGER NOT NULL DEFAULT -1, `sharing_user_profile_picture` INTEGER NOT NULL DEFAULT -1, `files_bigfilechunking` INTEGER NOT NULL DEFAULT -1, `files_undelete` INTEGER NOT NULL DEFAULT -1, `files_versioning` INTEGER NOT NULL DEFAULT -1, `files_private_links` INTEGER NOT NULL DEFAULT -1, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `app_providers_enabled` INTEGER, `app_providers_version` TEXT, `app_providers_appsUrl` TEXT, `app_providers_openUrl` TEXT, `app_providers_openWebUrl` TEXT, `app_providers_newUrl` TEXT, `tus_support_version` TEXT, `tus_support_resumable` TEXT, `tus_support_extension` TEXT, `tus_support_maxChunkSize` INTEGER, `tus_support_httpMethodOverride` TEXT, `spaces_enabled` INTEGER, `spaces_projects` INTEGER, `spaces_shareJail` INTEGER, `spaces_hasMultiplePersonalSpaces` INTEGER, `password_policy_maxCharacters` INTEGER, `password_policy_minCharacters` INTEGER, `password_policy_minDigits` INTEGER, `password_policy_minLowercaseCharacters` INTEGER, `password_policy_minSpecialCharacters` INTEGER, `password_policy_minUppercaseCharacters` INTEGER)", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_major", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEdition", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "corePollInterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "davChunkingVersion", + "columnName": "dav_chunking_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filesSharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforcedReadOnly", + "columnName": "sharing_public_password_enforced_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforcedReadWrite", + "columnName": "sharing_public_password_enforced_read_write", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforcedUploadOnly", + "columnName": "sharing_public_password_enforced_public_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filesSharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicMultiple", + "columnName": "sharing_public_multiple", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicSupportsUploadOnly", + "columnName": "supports_upload_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingUserProfilePicture", + "columnName": "sharing_user_profile_picture", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesBigFileChunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesPrivateLinks", + "columnName": "files_private_links", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appProviders.enabled", + "columnName": "app_providers_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "appProviders.version", + "columnName": "app_providers_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.appsUrl", + "columnName": "app_providers_appsUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.openUrl", + "columnName": "app_providers_openUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.openWebUrl", + "columnName": "app_providers_openWebUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.newUrl", + "columnName": "app_providers_newUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.version", + "columnName": "tus_support_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.resumable", + "columnName": "tus_support_resumable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.extension", + "columnName": "tus_support_extension", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.maxChunkSize", + "columnName": "tus_support_maxChunkSize", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tusSupport.httpMethodOverride", + "columnName": "tus_support_httpMethodOverride", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "spaces.enabled", + "columnName": "spaces_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spaces.projects", + "columnName": "spaces_projects", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spaces.shareJail", + "columnName": "spaces_shareJail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spaces.hasMultiplePersonalSpaces", + "columnName": "spaces_hasMultiplePersonalSpaces", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.maxCharacters", + "columnName": "password_policy_maxCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minCharacters", + "columnName": "password_policy_minCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minDigits", + "columnName": "password_policy_minDigits", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minLowercaseCharacters", + "columnName": "password_policy_minLowercaseCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minSpecialCharacters", + "columnName": "password_policy_minSpecialCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minUppercaseCharacters", + "columnName": "password_policy_minUppercaseCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`parentId` INTEGER, `owner` TEXT NOT NULL, `remotePath` TEXT NOT NULL, `remoteId` TEXT, `length` INTEGER NOT NULL, `creationTimestamp` INTEGER, `modificationTimestamp` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `etag` TEXT, `remoteEtag` TEXT, `permissions` TEXT, `privateLink` TEXT, `storagePath` TEXT, `name` TEXT, `treeEtag` TEXT, `keepInSync` INTEGER, `lastSyncDateForData` INTEGER, `lastUsage` INTEGER, `fileShareViaLink` INTEGER, `needsToUpdateThumbnail` INTEGER NOT NULL, `modifiedAtLastSyncForData` INTEGER, `etagInConflict` TEXT, `fileIsDownloading` INTEGER, `sharedWithSharee` INTEGER, `sharedByLink` INTEGER NOT NULL, `spaceId` TEXT, `has_preview` INTEGER NOT NULL DEFAULT 0, `is_vault` INTEGER NOT NULL DEFAULT 0, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`owner`, `spaceId`) REFERENCES `spaces`(`account_name`, `space_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "length", + "columnName": "length", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTimestamp", + "columnName": "creationTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modificationTimestamp", + "columnName": "modificationTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteEtag", + "columnName": "remoteEtag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "privateLink", + "columnName": "privateLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "storagePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "treeEtag", + "columnName": "treeEtag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "availableOfflineStatus", + "columnName": "keepInSync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "lastSyncDateForData", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUsage", + "columnName": "lastUsage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileShareViaLink", + "columnName": "fileShareViaLink", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "needsToUpdateThumbnail", + "columnName": "needsToUpdateThumbnail", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modifiedAtLastSyncForData", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etagInConflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsDownloading", + "columnName": "fileIsDownloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "sharedWithSharee", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedByLink", + "columnName": "sharedByLink", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spaceId", + "columnName": "spaceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "hasPreview", + "columnName": "has_preview", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isVault", + "columnName": "is_vault", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "spaces", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "owner", + "spaceId" + ], + "referencedColumns": [ + "account_name", + "space_id" + ] + } + ] + }, + { + "tableName": "files_sync", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` INTEGER NOT NULL, `uploadWorkerUuid` BLOB, `downloadWorkerUuid` BLOB, `isSynchronizing` INTEGER NOT NULL, PRIMARY KEY(`fileId`), FOREIGN KEY(`fileId`) REFERENCES `files`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadWorkerUuid", + "columnName": "uploadWorkerUuid", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "downloadWorkerUuid", + "columnName": "downloadWorkerUuid", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "isSynchronizing", + "columnName": "isSynchronizing", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fileId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "files", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "fileId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`share_type` INTEGER NOT NULL, `share_with` TEXT, `path` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `shared_date` INTEGER NOT NULL, `expiration_date` INTEGER NOT NULL, `token` TEXT, `shared_with_display_name` TEXT, `share_with_additional_info` TEXT, `is_directory` INTEGER NOT NULL, `id_remote_shared` TEXT NOT NULL, `owner_share` TEXT NOT NULL, `name` TEXT, `url` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareWith", + "columnName": "share_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithAdditionalInfo", + "columnName": "share_with_additional_info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isFolder", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "id_remote_shared", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transfers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localPath` TEXT NOT NULL, `remotePath` TEXT NOT NULL, `accountName` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `status` INTEGER NOT NULL, `localBehaviour` INTEGER NOT NULL, `forceOverwrite` INTEGER NOT NULL, `transferEndTimestamp` INTEGER, `lastResult` INTEGER, `createdBy` INTEGER NOT NULL, `transferId` TEXT, `spaceId` TEXT, `sourcePath` TEXT, `tusUploadUrl` TEXT, `tusUploadLength` INTEGER, `tusUploadMetadata` TEXT, `tusUploadChecksum` TEXT, `tusResumableVersion` TEXT, `tusUploadExpires` INTEGER, `tusUploadConcat` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localBehaviour", + "columnName": "localBehaviour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceOverwrite", + "columnName": "forceOverwrite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferEndTimestamp", + "columnName": "transferEndTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "lastResult", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "createdBy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferId", + "columnName": "transferId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "spaceId", + "columnName": "spaceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadUrl", + "columnName": "tusUploadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadLength", + "columnName": "tusUploadLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tusUploadMetadata", + "columnName": "tusUploadMetadata", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadChecksum", + "columnName": "tusUploadChecksum", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusResumableVersion", + "columnName": "tusResumableVersion", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadExpires", + "columnName": "tusUploadExpires", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tusUploadConcat", + "columnName": "tusUploadConcat", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spaces", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_name` TEXT NOT NULL, `drive_alias` TEXT, `drive_type` TEXT NOT NULL, `space_id` TEXT NOT NULL, `last_modified_date_time` TEXT, `name` TEXT NOT NULL, `owner_id` TEXT, `web_url` TEXT, `description` TEXT, `content_type` TEXT, `quota_remaining` INTEGER, `quota_state` TEXT, `quota_total` INTEGER, `quota_used` INTEGER, `root_etag` TEXT, `root_id` TEXT NOT NULL, `root_web_dav_url` TEXT NOT NULL, `root_deleted_state` TEXT, PRIMARY KEY(`account_name`, `space_id`))", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "driveAlias", + "columnName": "drive_alias", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "driveType", + "columnName": "drive_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "space_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModifiedDateTime", + "columnName": "last_modified_date_time", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "webUrl", + "columnName": "web_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quota.remaining", + "columnName": "quota_remaining", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quota.state", + "columnName": "quota_state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quota.total", + "columnName": "quota_total", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quota.used", + "columnName": "quota_used", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "root.eTag", + "columnName": "root_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "root.id", + "columnName": "root_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "root.webDavUrl", + "columnName": "root_web_dav_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "root.deleteState", + "columnName": "root_deleted_state", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "account_name", + "space_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spaces_special", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`spaces_special_account_name` TEXT NOT NULL, `spaces_special_space_id` TEXT NOT NULL, `spaces_special_etag` TEXT NOT NULL, `file_mime_type` TEXT NOT NULL, `special_id` TEXT NOT NULL, `last_modified_date_time` TEXT, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `special_folder_name` TEXT NOT NULL, `special_web_dav_url` TEXT NOT NULL, PRIMARY KEY(`spaces_special_space_id`, `special_id`), FOREIGN KEY(`spaces_special_account_name`, `spaces_special_space_id`) REFERENCES `spaces`(`account_name`, `space_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "spaces_special_account_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spaceId", + "columnName": "spaces_special_space_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "spaces_special_etag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileMimeType", + "columnName": "file_mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "special_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModifiedDateTime", + "columnName": "last_modified_date_time", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "specialFolderName", + "columnName": "special_folder_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "webDavUrl", + "columnName": "special_web_dav_url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "spaces_special_space_id", + "special_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "spaces", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spaces_special_account_name", + "spaces_special_space_id" + ], + "referencedColumns": [ + "account_name", + "space_id" + ] + } + ] + }, + { + "tableName": "user_quotas", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountName` TEXT NOT NULL, `used` INTEGER NOT NULL, `available` INTEGER NOT NULL, `total` INTEGER, `state` TEXT, PRIMARY KEY(`accountName`))", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "used", + "columnName": "used", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "available", + "columnName": "available", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "total", + "columnName": "total", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountName" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0ad648e4be73a83c3e4a4ee5ea799184')" + ] + } +} \ No newline at end of file diff --git a/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationTest.kt b/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationTest.kt index d820eb442a..f5f35b87b7 100644 --- a/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationTest.kt +++ b/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationTest.kt @@ -30,9 +30,11 @@ import org.junit.Rule open class MigrationTest { @Rule @JvmField + // Passing the database class (instead of its name) makes the helper also run the generated AutoMigrations val helper = MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), - OpencloudDatabase::class.java.canonicalName, + OpencloudDatabase::class.java, + emptyList(), FrameworkSQLiteOpenHelperFactory() ) diff --git a/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationToDB51Test.kt b/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationToDB51Test.kt new file mode 100644 index 0000000000..b1310d6134 --- /dev/null +++ b/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationToDB51Test.kt @@ -0,0 +1,90 @@ +/* + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud 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.data.roommigrations + +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.test.filters.SmallTest +import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILES_TABLE_NAME +import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILE_IS_VAULT +import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.SPACES_TABLE_NAME +import eu.opencloud.android.data.spaces.db.SpacesEntity.Companion.SPACES_CONTENT_TYPE +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Auto migration 50 -> 51 adds the vault markers of files and spaces. + * See https://github.com/opencloud-eu/android/issues/228 + */ +@SmallTest +class MigrationToDB51Test : MigrationTest() { + + @Test + fun migrationFrom50to51_preservesDataAndAddsVaultColumns() { + performMigrationTest( + previousVersion = 50, + currentVersion = 51, + insertData = { database -> insertDataToTest(database) }, + validateMigration = { database -> validateMigrationTo51(database) }, + listOfMigrations = emptyArray() + ) + } + + private fun insertDataToTest(database: SupportSQLiteDatabase) { + database.execSQL( + "INSERT INTO `$SPACES_TABLE_NAME`" + + "(account_name, drive_type, space_id, name, root_id, root_web_dav_url)" + + " VALUES (?, ?, ?, ?, ?, ?)", + arrayOf(ACCOUNT_NAME, "project", SPACE_ID, "Space", SPACE_ID, "https://server.url/dav/spaces/$SPACE_ID") + ) + database.execSQL( + "INSERT INTO `$FILES_TABLE_NAME`" + + "(owner, remotePath, length, modificationTimestamp, mimeType, needsToUpdateThumbnail, sharedByLink, spaceId)" + + " VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + arrayOf(ACCOUNT_NAME, "/Secret.vault/", 0, 1_700_000_000, "DIR", 0, 0, SPACE_ID) + ) + } + + private fun validateMigrationTo51(database: SupportSQLiteDatabase) { + database.query("SELECT * FROM `$FILES_TABLE_NAME`").use { cursor -> + assertTrue(cursor.moveToFirst()) + val isVaultIndex = cursor.getColumnIndex(FILE_IS_VAULT) + assertTrue(isVaultIndex != -1) + assertEquals(0, cursor.getInt(isVaultIndex)) // Corrected by the next folder refresh + assertEquals("/Secret.vault/", cursor.getString(cursor.getColumnIndex("remotePath"))) + } + + database.query("SELECT * FROM `$SPACES_TABLE_NAME`").use { cursor -> + assertTrue(cursor.moveToFirst()) + val contentTypeIndex = cursor.getColumnIndex(SPACES_CONTENT_TYPE) + assertTrue(contentTypeIndex != -1) + assertTrue(cursor.isNull(contentTypeIndex)) // Corrected by the next spaces refresh + assertEquals(SPACE_ID, cursor.getString(cursor.getColumnIndex("space_id"))) + } + + database.close() + } + + companion object { + private const val ACCOUNT_NAME = "user@example.com" + private const val SPACE_ID = "8871f4f3-fc6f-4a66-8bed-62f175f76f38\$0aa0e03c-ec36-498c-bb9f-1234566789" + } +} diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt b/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt index 26ea9eceb8..aa27b458d1 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt @@ -85,6 +85,7 @@ import eu.opencloud.android.data.user.db.UserQuotaEntity AutoMigration(from = 45, to = 46), AutoMigration(from = 46, to = 47), AutoMigration(from = 49, to = 50), + AutoMigration(from = 50, to = 51), ], version = ProviderMeta.DB_VERSION, exportSchema = true diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/ProviderMeta.java b/opencloudData/src/main/java/eu/opencloud/android/data/ProviderMeta.java index fda1195ad2..ed5a4f1740 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/ProviderMeta.java +++ b/opencloudData/src/main/java/eu/opencloud/android/data/ProviderMeta.java @@ -31,7 +31,7 @@ public class ProviderMeta { public static final String DB_NAME = "filelist"; public static final String NEW_DB_NAME = "opencloud_database"; - public static final int DB_VERSION = 50; + public static final int DB_VERSION = 51; private ProviderMeta() { } @@ -130,6 +130,7 @@ static public class ProviderTableMeta implements BaseColumns { public static final String FILE_TREE_ETAG = "tree_etag"; public static final String FILE_UPDATE_THUMBNAIL = "update_thumbnail"; public static final String FILE_HAS_PREVIEW = "has_preview"; + public static final String FILE_IS_VAULT = "is_vault"; // Columns of list_of_uploads table public static final String UPLOAD_ACCOUNT_NAME = "account_name"; 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 5a9797435a..445fda43db 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 @@ -260,6 +260,7 @@ class OCLocalFileDataSource( treeEtag = treeEtag, spaceId = spaceId, hasPreview = hasPreview, + isVault = isVault, ) @VisibleForTesting @@ -291,6 +292,7 @@ class OCLocalFileDataSource( name = fileName, spaceId = spaceId, hasPreview = hasPreview, + isVault = isVault, ).apply { this@toEntity.id?.let { modelId -> this.id = modelId } } } } 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 1dbab788dc..65a62560cb 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 @@ -238,6 +238,7 @@ class OCRemoteFileDataSource( sharedWithSharee = sharedWithSharee, sharedByLink = sharedByLink, hasPreview = hasPreview, + isVault = isVault, ) @VisibleForTesting 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 4285086ea5..4e487b7caf 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 @@ -31,6 +31,7 @@ import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILE_CREATION import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILE_ETAG import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILE_ETAG_IN_CONFLICT import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILE_HAS_PREVIEW +import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILE_IS_VAULT import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILE_REMOTE_ETAG import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILE_IS_DOWNLOADING import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILE_KEEP_IN_SYNC @@ -97,6 +98,8 @@ data class OCFileEntity( val spaceId: String? = null, @ColumnInfo(name = FILE_HAS_PREVIEW, defaultValue = "0") val hasPreview: Boolean = false, + @ColumnInfo(name = FILE_IS_VAULT, defaultValue = "0") + val isVault: Boolean = false, ) { @PrimaryKey(autoGenerate = true) var id: Long = 0 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..e6ee69f9b4 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 @@ -387,8 +387,14 @@ class OCFileRepository( }) } else if (localChildToSync.etag != remoteChild.etag || localChildToSync.localModificationTimestamp > remoteChild.lastSyncDateForData!! || + localChildToSync.isVault != remoteChild.isVault || isActionSetFolderAvailableOfflineOrSynchronize ) { + if (remoteChild.isVault) { + // Vault contents are encrypted. Remove anything cached before the folder became a vault + // (or before vaults were detected), so that it is no longer shown or synced. + deleteLocalFolderContentRecursively(localChildToSync) + } // File exists in the database, we need to check several stuff. folderContentUpdated.add( remoteChild.apply { @@ -599,6 +605,16 @@ class OCFileRepository( deleteLocalFolderIfItHasNoFilesInside(ocFolder = ocFile, onlyFromLocalStorage = onlyFromLocalStorage) } + private fun deleteLocalFolderContentRecursively(ocFolder: OCFile) { + localFileDataSource.getFolderContent(ocFolder.id!!).forEach { file -> + if (file.isFolder) { + deleteLocalFolderRecursively(ocFile = file, onlyFromLocalStorage = false) + } else { + deleteLocalFile(ocFile = file, onlyFromLocalStorage = false) + } + } + } + private fun deleteLocalFolderIfItHasNoFilesInside(ocFolder: OCFile, onlyFromLocalStorage: Boolean) { localStorageProvider.deleteLocalFolderIfItHasNoFilesInside(ocFolder = ocFolder) deleteOrResetFileFromDatabase(ocFolder, onlyFromLocalStorage) diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/spaces/datasources/implementation/OCLocalSpacesDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/spaces/datasources/implementation/OCLocalSpacesDataSource.kt index df141c08e8..5575c31aaf 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/spaces/datasources/implementation/OCLocalSpacesDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/spaces/datasources/implementation/OCLocalSpacesDataSource.kt @@ -167,6 +167,7 @@ class OCLocalSpacesDataSource( webUrl = webUrl, description = description, special = specials?.map { it.toModel() }, + contentType = contentType, ) @VisibleForTesting @@ -187,6 +188,7 @@ class OCLocalSpacesDataSource( }, webUrl = webUrl, description = description, + contentType = contentType, ) @VisibleForTesting diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/spaces/datasources/implementation/OCRemoteSpacesDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/spaces/datasources/implementation/OCRemoteSpacesDataSource.kt index b5ef247740..c4b1285f81 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/spaces/datasources/implementation/OCRemoteSpacesDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/spaces/datasources/implementation/OCRemoteSpacesDataSource.kt @@ -89,7 +89,8 @@ class OCRemoteSpacesDataSource( specialFolder = SpaceSpecialFolder(name = specialResponse.specialFolder.name), webDavUrl = specialResponse.webDavUrl ) - } + }, + contentType = libreGraphContentType, ) } } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/spaces/db/SpacesEntity.kt b/opencloudData/src/main/java/eu/opencloud/android/data/spaces/db/SpacesEntity.kt index abcf7f667c..b37cae2d41 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/spaces/db/SpacesEntity.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/spaces/db/SpacesEntity.kt @@ -62,6 +62,8 @@ data class SpacesEntity( @ColumnInfo(name = SPACES_WEB_URL) val webUrl: String?, val description: String?, + @ColumnInfo(name = SPACES_CONTENT_TYPE) + val contentType: String? = null, ) { companion object { @@ -71,6 +73,7 @@ data class SpacesEntity( const val SPACES_DRIVE_TYPE = "drive_type" const val SPACES_LAST_MODIFIED_DATE_TIME = "last_modified_date_time" const val SPACES_WEB_URL = "web_url" + const val SPACES_CONTENT_TYPE = "content_type" const val SPACES_OWNER_ID = "owner_id" const val SPACES_QUOTA_REMAINING = "quota_remaining" const val SPACES_QUOTA_STATE = "quota_state" diff --git a/opencloudData/src/test/java/eu/opencloud/android/data/VaultMappingTest.kt b/opencloudData/src/test/java/eu/opencloud/android/data/VaultMappingTest.kt new file mode 100644 index 0000000000..692a18b60e --- /dev/null +++ b/opencloudData/src/test/java/eu/opencloud/android/data/VaultMappingTest.kt @@ -0,0 +1,80 @@ +/* + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud 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.data + +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.datasources.implementation.OCRemoteFileDataSource.Companion.toModel +import eu.opencloud.android.data.spaces.datasources.implementation.OCLocalSpacesDataSource.Companion.toEntity +import eu.opencloud.android.data.spaces.datasources.implementation.OCLocalSpacesDataSource.Companion.toModel +import eu.opencloud.android.data.spaces.datasources.implementation.OCRemoteSpacesDataSource.Companion.toModel +import eu.opencloud.android.domain.spaces.model.OCSpace.Companion.CONTENT_TYPE_VAULT +import eu.opencloud.android.lib.resources.files.RemoteFile +import eu.opencloud.android.testutil.OC_ACCOUNT_NAME +import eu.opencloud.android.testutil.OC_FOLDER_WITH_SPACE_ID +import eu.opencloud.android.testutil.OC_SPACE_PROJECT_WITHOUT_IMAGE +import eu.opencloud.android.testutil.SPACE_RESPONSE +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Vaults are encrypted folders and spaces created by the OpenCloud Web vault app. + * See https://github.com/opencloud-eu/android/issues/228 + */ +class VaultMappingTest { + + @Test + fun `remote vault folder is mapped to a vault OCFile`() { + val remoteFile = RemoteFile(remotePath = "/Secret.vault/", owner = OC_ACCOUNT_NAME, mimeType = "DIR", isVault = true) + assertTrue(remoteFile.toModel().isVault) + assertFalse(remoteFile.copy(isVault = false).toModel().isVault) + } + + @Test + fun `vault flag of OCFile survives the database`() { + val vaultFolder = OC_FOLDER_WITH_SPACE_ID.copy(isVault = true) + assertTrue(vaultFolder.toEntity().isVault) + assertTrue(vaultFolder.toEntity().toModel().isVault) + assertFalse(OC_FOLDER_WITH_SPACE_ID.toEntity().toModel().isVault) + } + + @Test + fun `remote vault space is mapped to a vault OCSpace`() { + val vaultSpace = SPACE_RESPONSE.copy(libreGraphContentType = CONTENT_TYPE_VAULT).toModel(OC_ACCOUNT_NAME) + assertEquals(CONTENT_TYPE_VAULT, vaultSpace.contentType) + assertTrue(vaultSpace.isVault) + } + + @Test + fun `spaces without or with another content type are no vaults`() { + assertFalse(SPACE_RESPONSE.toModel(OC_ACCOUNT_NAME).isVault) + assertFalse(SPACE_RESPONSE.copy(libreGraphContentType = "application/vnd.opencloud.something-else").toModel(OC_ACCOUNT_NAME).isVault) + } + + @Test + fun `vault content type of OCSpace survives the database`() { + val vaultSpace = OC_SPACE_PROJECT_WITHOUT_IMAGE.copy(contentType = CONTENT_TYPE_VAULT) + assertEquals(CONTENT_TYPE_VAULT, vaultSpace.toEntity().contentType) + assertTrue(vaultSpace.toEntity().toModel().isVault) + } +} 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..ab1db49e12 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,53 @@ class OCFileRepositoryTest { localFileDataSource.cleanWorkersUuid(OC_FILE_WITH_SPACE_ID.id!!) } } + + @Suppress("MaxLineLength") + @Test + fun `refreshFolder updates a child folder that became a vault even if its etag did not change and removes its cached content`() { + val ocParentFolderWithoutSpaceId = OC_PARENT_FOLDER_WITH_SPACE_ID.copy(spaceId = null) + val remoteVaultFolder = OC_FOLDER_WITH_SPACE_ID.copy(spaceId = null, isVault = true) + val expectedVaultFolder = OC_FOLDER_WITH_SPACE_ID.copy(isVault = true) + val cachedFileInsideVault = OC_FILE_WITH_SPACE_ID.copy(parentId = OC_FOLDER_WITH_SPACE_ID.id) + every { + remoteFileDataSource.refreshFolder( + remotePath = OC_PARENT_FOLDER_WITH_SPACE_ID.remotePath, + accountName = OC_PARENT_FOLDER_WITH_SPACE_ID.owner, + spaceWebDavUrl = OC_SPACE_PERSONAL.root.webDavUrl + ) + } returns listOf(ocParentFolderWithoutSpaceId, remoteVaultFolder) + every { + localFileDataSource.getFileByRemotePath( + remotePath = OC_PARENT_FOLDER_WITH_SPACE_ID.remotePath, + owner = OC_PARENT_FOLDER_WITH_SPACE_ID.owner, + spaceId = OC_PARENT_FOLDER_WITH_SPACE_ID.spaceId + ) + } returns OC_PARENT_FOLDER_WITH_SPACE_ID + every { localFileDataSource.getFolderContent(folderId = OC_PARENT_FOLDER_WITH_SPACE_ID.id!!) } returns listOf(OC_FOLDER_WITH_SPACE_ID) + every { localFileDataSource.getFolderContent(folderId = OC_FOLDER_WITH_SPACE_ID.id!!) } returns listOf(cachedFileInsideVault) + every { localStorageProvider.deleteLocalFile(cachedFileInsideVault) } returns true + every { + localFileDataSource.saveFilesInFolderAndReturnTheFilesThatChanged( + listOfFiles = listOf(expectedVaultFolder), + folder = OC_PARENT_FOLDER_WITH_SPACE_ID, + ) + } returns listOf(expectedVaultFolder) + + val listOfFiles = ocFileRepository.refreshFolder( + OC_PARENT_FOLDER_WITH_SPACE_ID.remotePath, + OC_PARENT_FOLDER_WITH_SPACE_ID.owner, + OC_PARENT_FOLDER_WITH_SPACE_ID.spaceId, + false + ) + assertEquals(listOf(expectedVaultFolder), listOfFiles) + + verify(exactly = 1) { + localStorageProvider.deleteLocalFile(cachedFileInsideVault) + localFileDataSource.deleteFile(cachedFileInsideVault.id!!) + localFileDataSource.saveFilesInFolderAndReturnTheFilesThatChanged( + listOfFiles = listOf(expectedVaultFolder), + folder = OC_PARENT_FOLDER_WITH_SPACE_ID, + ) + } + } } diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/availableoffline/usecases/SetFilesAsAvailableOfflineUseCase.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/availableoffline/usecases/SetFilesAsAvailableOfflineUseCase.kt index 058a9e53d7..04e486a294 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/availableoffline/usecases/SetFilesAsAvailableOfflineUseCase.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/availableoffline/usecases/SetFilesAsAvailableOfflineUseCase.kt @@ -30,8 +30,8 @@ class SetFilesAsAvailableOfflineUseCase( override fun run(params: Params) { params.filesToSetAsAvailableOffline.forEach { fileToSetAsAvailableOffline -> // Its possible to multiselect several files including already available offline files. - // If it is already available offline, we will ignore it. - if (!fileToSetAsAvailableOffline.isAvailableOffline) { + // If it is already available offline, we will ignore it. Vaults are encrypted, so they can't be available offline. + if (!fileToSetAsAvailableOffline.isAvailableOffline && !fileToSetAsAvailableOffline.isVault) { fileRepository.updateFileWithNewAvailableOfflineStatus( ocFile = fileToSetAsAvailableOffline, newAvailableOfflineStatus = AvailableOfflineStatus.AVAILABLE_OFFLINE, 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 910e47ff22..3c0e133fe1 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 @@ -60,6 +60,8 @@ data class OCFile( var sharedByLink: Boolean = false, val spaceId: String? = null, val hasPreview: Boolean = false, + /** Encrypted folder created by the OpenCloud Web vault app. Its contents can't be used by this client. */ + val isVault: Boolean = false, ) : Parcelable { val fileName: String diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/spaces/model/OCSpace.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/spaces/model/OCSpace.kt index 421efbd0d7..544c7d038c 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/spaces/model/OCSpace.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/spaces/model/OCSpace.kt @@ -34,12 +34,17 @@ data class OCSpace( val webUrl: String?, val description: String?, val special: List?, + /** Value of `@libre.graph.contentType`, e.g. [CONTENT_TYPE_VAULT] for encrypted vault spaces. */ + val contentType: String? = null, ) { val isPersonal get() = driveType == DRIVE_TYPE_PERSONAL val isProject get() = driveType == DRIVE_TYPE_PROJECT val isDisabled get() = root.deleted?.state == DRIVE_DISABLED + /** Encrypted space created by the OpenCloud Web vault app. Its contents can't be used by this client. */ + val isVault get() = contentType == CONTENT_TYPE_VAULT + fun getSpaceSpecialImage(): SpaceSpecial? { val imageSpecial = special?.filter { it.specialFolder.name == "image" @@ -51,6 +56,7 @@ data class OCSpace( const val DRIVE_TYPE_PERSONAL = "personal" const val DRIVE_TYPE_PROJECT = "project" private const val DRIVE_DISABLED = "trashed" + const val CONTENT_TYPE_VAULT = "application/vnd.opencloud.vault" // https://github.com/opencloud/web/blob/2ab7e6da432cb344cd86f3451eea1678461c5c90/packages/web-client/src/helpers/space/types.ts#L11 const val SPACE_ID_SHARES = "a0ca6a90-a365-4782-871e-d44447bbc668\$a0ca6a90-a365-4782-871e-d44447bbc668"