From c6c5300b506366d1104c4ae1fbe21cd61f75327d Mon Sep 17 00:00:00 2001 From: Li Wei Date: Thu, 12 Nov 2026 09:47:53 +0800 Subject: [PATCH 1/2] feat(auto-upload): support end-to-end encrypted destinations Reuse encrypted folder metadata and existing upload operations for automatic uploads, including account ownership, collision handling, and server rollback. Assisted-by: Codex:GPT-6 Signed-off-by: Li Wei --- .../datamodel/UploadStorageManagerTest.kt | 24 +++ .../client/database/dao/UploadDao.kt | 8 +- .../client/jobs/BackgroundJobFactory.kt | 4 +- .../jobs/autoUpload/AutoUploadWorker.kt | 39 ++-- .../jobs/autoUpload/SyncFolderHelper.kt | 5 +- .../upload/FileUploadBroadcastReceiver.kt | 9 +- .../client/jobs/upload/FileUploadHelper.kt | 61 ++++-- .../client/jobs/upload/FileUploadWorker.kt | 19 +- .../jobs/upload/UploadBroadcastAction.kt | 2 +- .../utils/UploadErrorNotificationManager.kt | 2 +- .../nextcloud/repository/ClientRepository.kt | 3 + .../repository/RemoteClientRepository.kt | 13 ++ .../nextcloud/utils/e2ee/E2ECounterHelper.kt | 9 +- .../datamodel/FileDataStorageManager.java | 8 + .../datamodel/UploadsStorageManager.kt | 31 +-- .../operations/CreateFolderOperation.java | 28 ++- .../operations/RefreshFolderOperation.java | 19 +- .../operations/UploadFileOperation.java | 184 ++++++++++++++--- .../factory/UploadFileOperationFactory.kt | 4 +- .../operations/upload/UploadFileException.kt | 3 + .../operations/upload/UploadFileResolver.kt | 35 +++- .../operations/upload/UploadFolderBackend.kt | 104 ++++++++++ .../upload/UploadFolderException.kt | 11 + .../operations/upload/UploadFolderResolver.kt | 39 ++++ .../ui/activity/ConflictsResolveActivity.kt | 12 ++ .../ui/activity/FileDisplayActivity.kt | 9 +- .../ui/activity/FolderPickerActivity.kt | 3 +- .../helper/UploadListAdapterAction.kt | 4 +- .../helper/UploadListAdapterActionHandler.kt | 10 +- .../helper/UploadListAdapterHelper.kt | 15 +- .../SyncedFolderPreferencesDialogFragment.kt | 7 +- .../handler/AutoUploadClickHandler.kt | 4 +- .../autoUpload/handler/AutoUploadPickers.kt | 3 +- .../uploadList/UploadListConflictHandler.kt | 21 +- .../fragment/uploadList/UploadListFragment.kt | 1 - .../android/utils/EncryptionUtils.java | 2 +- .../android/utils/ErrorMessageAdapter.kt | 8 + app/src/main/res/values/strings.xml | 2 + .../utils/e2ee/E2ECounterHelperTest.kt | 28 +++ .../operations/CreateFolderCollisionTest.kt | 43 ++++ .../operations/RefreshEncryptedFolderTest.kt | 136 ++++++++++++ .../upload/AutoUploadIdentityTest.kt | 65 ++++++ .../upload/AutoUploadRollbackTest.kt | 115 +++++++++++ .../upload/UploadFileResolverTest.kt | 69 +++++++ .../upload/UploadFolderBackendTest.kt | 194 ++++++++++++++++++ .../upload/UploadFolderResolverTest.kt | 124 +++++++++++ 46 files changed, 1394 insertions(+), 145 deletions(-) create mode 100644 app/src/main/java/com/owncloud/android/operations/upload/UploadFolderBackend.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/upload/UploadFolderException.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/upload/UploadFolderResolver.kt create mode 100644 app/src/test/java/com/nextcloud/utils/e2ee/E2ECounterHelperTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/CreateFolderCollisionTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/RefreshEncryptedFolderTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/AutoUploadIdentityTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/AutoUploadRollbackTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/UploadFolderBackendTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/UploadFolderResolverTest.kt diff --git a/app/src/androidTest/java/com/owncloud/android/datamodel/UploadStorageManagerTest.kt b/app/src/androidTest/java/com/owncloud/android/datamodel/UploadStorageManagerTest.kt index 04ffe036c818..1c09db292ca6 100644 --- a/app/src/androidTest/java/com/owncloud/android/datamodel/UploadStorageManagerTest.kt +++ b/app/src/androidTest/java/com/owncloud/android/datamodel/UploadStorageManagerTest.kt @@ -33,6 +33,7 @@ import java.io.File import java.util.Random import java.util.UUID import java.util.function.Supplier +import kotlinx.coroutines.runBlocking class UploadStorageManagerTest : AbstractIT() { private lateinit var uploadsStorageManager: UploadsStorageManager @@ -65,6 +66,29 @@ class UploadStorageManagerTest : AbstractIT() { ) } + @Test + fun uploadLookupAndFolderCleanupAreIsolatedByAccount() = runBlocking { + val localPath = "/storage/auto-upload-account-isolation.txt" + val remotePath = "/auto-upload-account-isolation.txt" + val first = OCUpload(localPath, remotePath, account.name) + val second = OCUpload(localPath, remotePath, user2.accountName) + val dao = uploadsStorageManager.uploadDao + val firstId = dao.insertOrReplace(first.toUploadEntity()) + val secondId = dao.insertOrReplace(second.toUploadEntity()) + try { + Assert.assertEquals(firstId, dao.getByRemotePath(remotePath, account.name)?.id?.toLong()) + Assert.assertEquals(secondId, dao.getByRemotePath(remotePath, user2.accountName)?.id?.toLong()) + + dao.deleteByLocalRemotePath(localPath, remotePath, account.name) + + Assert.assertNull(dao.getByRemotePath(remotePath, account.name)) + Assert.assertEquals(secondId, dao.getByRemotePath(remotePath, user2.accountName)?.id?.toLong()) + } finally { + uploadsStorageManager.removeUpload(firstId) + uploadsStorageManager.removeUpload(secondId) + } + } + @Test fun testDeleteAllUploads() { // Clean diff --git a/app/src/main/java/com/nextcloud/client/database/dao/UploadDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/UploadDao.kt index 653dc03c9050..9aa8168b09f1 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/UploadDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/UploadDao.kt @@ -32,9 +32,10 @@ interface UploadDao { @Query( "SELECT * FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} " + - "WHERE ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath LIMIT 1" + "WHERE ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath " + + "AND ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName LIMIT 1" ) - fun getByRemotePath(remotePath: String): UploadEntity? + fun getByRemotePath(remotePath: String, accountName: String): UploadEntity? @Query( "DELETE FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} " + @@ -48,9 +49,10 @@ interface UploadDao { DELETE FROM ${ProviderTableMeta.UPLOADS_TABLE_NAME} WHERE ${ProviderTableMeta.UPLOADS_LOCAL_PATH} = :localPath AND ${ProviderTableMeta.UPLOADS_REMOTE_PATH} = :remotePath + AND ${ProviderTableMeta.UPLOADS_ACCOUNT_NAME} = :accountName """ ) - suspend fun deleteByLocalRemotePath(localPath: String, remotePath: String) + suspend fun deleteByLocalRemotePath(localPath: String, remotePath: String, accountName: String) @Query( "SELECT * FROM " + ProviderTableMeta.UPLOADS_TABLE_NAME + diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index f5cfe1aa3c66..0774cc0d64d9 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -199,7 +199,8 @@ class BackgroundJobFactory @Inject constructor( autoUploadHelper = AutoUploadHelper( FileSystemRepository(dao = database.fileSystemDao(), uploadsStorageManager, context) ), - preferences = preferences + preferences = preferences, + clientRepository = clientRepository ) private fun createAutoUploadRescanWorker(context: Context, params: WorkerParameters): AutoUploadRescanWorker = @@ -268,6 +269,7 @@ class BackgroundJobFactory @Inject constructor( syncedFolderProvider, context, uploadFileOperationFactory, + clientRepository, params ) diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt index b2a70f7ed622..df82c5efa8b9 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -25,6 +25,7 @@ import com.nextcloud.client.jobs.upload.UploadDelayPolicy import com.nextcloud.client.jobs.utils.UploadErrorNotificationManager import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences +import com.nextcloud.repository.ClientRepository import com.nextcloud.utils.extensions.getLog import com.nextcloud.utils.extensions.isConflict import com.nextcloud.utils.extensions.isNonRetryable @@ -38,13 +39,12 @@ import com.owncloud.android.datamodel.UploadsStorageManager import com.owncloud.android.db.OCUpload import com.owncloud.android.db.UploadResult import com.owncloud.android.files.services.NameCollisionPolicy -import com.owncloud.android.lib.common.OwnCloudAccount -import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.status.OCCapability import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.operations.upload.UploadFileException import com.owncloud.android.ui.activity.SettingsActivity import com.owncloud.android.utils.theme.CapabilityUtils import com.owncloud.android.utils.theme.ViewThemeUtils @@ -69,7 +69,8 @@ class AutoUploadWorker( val viewThemeUtils: ViewThemeUtils, localBroadcastManager: LocalBroadcastManager, private val autoUploadHelper: AutoUploadHelper, - private val preferences: AppPreferences + private val preferences: AppPreferences, + private val clientRepository: ClientRepository ) : CoroutineWorker(context, params) { companion object { @@ -115,7 +116,7 @@ class AutoUploadWorker( return Result.success() } - uploadFiles(syncedFolder) + if (!uploadFiles(syncedFolder)) return Result.retry() // only update last scan time after uploading files syncedFolder.lastScanTimestampMs = System.currentTimeMillis() @@ -265,12 +266,11 @@ class AutoUploadWorker( } @Suppress("LongMethod", "DEPRECATION", "CyclomaticComplexMethod") - private suspend fun uploadFiles(syncedFolder: SyncedFolder) = withContext(Dispatchers.IO) { - val user = getUserOrReturn(syncedFolder) ?: return@withContext - val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context) - val client = OwnCloudClientManagerFactory.getDefaultSingleton() - .getClientFor(ocAccount, context) + private suspend fun uploadFiles(syncedFolder: SyncedFolder): Boolean = withContext(Dispatchers.IO) { + val user = getUserOrReturn(syncedFolder) ?: return@withContext false + val client = clientRepository.getOwncloudClient(user) ?: return@withContext false val capability = CapabilityUtils.getCapability(user, context) + var completed = true updateNotification() @@ -290,7 +290,7 @@ class AutoUploadWorker( if (preferences.isGlobalUploadPaused) { Log_OC.w(TAG, "⏸️ uploads paused, stopping auto upload") - return@withContext + return@withContext false } delay(retryPolicy.getDelay().milliseconds) @@ -352,9 +352,14 @@ class AutoUploadWorker( ) if (result.isSuccess) { + if (upload.remotePath != operation.remotePath) { + upload.remotePath = operation.remotePath + repository.updateRemotePath(upload, syncedFolder) + } repository.markFileAsHandled(localPath, syncedFolder) Log_OC.d(TAG, "βœ… upload completed: $localPath") } else { + completed = false Log_OC.e( TAG, "❌ upload failed $localPath (${upload.accountName}): ${result.logMessage}" @@ -362,13 +367,13 @@ class AutoUploadWorker( if (result.code == ResultCode.UNAUTHORIZED) { Log_OC.e(TAG, "πŸ”‘ credentials are no longer valid, stopping auto upload") - return@withContext + return@withContext false } if (result.code == ResultCode.QUOTA_EXCEEDED) { Log_OC.w(TAG, "quota exceeded, stopping auto upload") sendUploadFinishEvent(operation, result) - return@withContext + return@withContext false } // Mark CONFLICT files as handled to prevent retries @@ -376,6 +381,11 @@ class AutoUploadWorker( repository.markFileAsHandled(localPath, syncedFolder) Log_OC.w(TAG, "Marked CONFLICT file as handled: $localPath") } + + if (result.exception is UploadFileException.EncryptionKeysUnavailable) { + sendUploadFinishEvent(operation, result) + return@withContext false + } } val isLastInBatch = (batchIndex == filePathsWithIds.size - 1) @@ -383,6 +393,8 @@ class AutoUploadWorker( sendUploadFinishEvent(operation, result) } } catch (e: Exception) { + if (e is CancellationException) throw e + completed = false uploadsStorageManager.updateStatus( uploadEntity, UploadsStorageManager.UploadStatus.UPLOAD_FAILED @@ -400,6 +412,8 @@ class AutoUploadWorker( } } } catch (e: Exception) { + if (e is CancellationException) throw e + completed = false Log_OC.e( TAG, "Exception uploadFiles during creating entity and upload, localPath: $localPath, " + @@ -411,6 +425,7 @@ class AutoUploadWorker( } } } + completed } private suspend fun deleteNonExistingFile(path: String, id: Int, upload: OCUpload) { diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/SyncFolderHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/SyncFolderHelper.kt index 8f4d99ad4067..36343c3eb369 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/SyncFolderHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/SyncFolderHelper.kt @@ -18,6 +18,8 @@ import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.utils.FileStorageUtils import com.owncloud.android.utils.MimeType import com.owncloud.android.utils.theme.CapabilityUtils +import com.nextcloud.client.account.UserAccountManagerImpl +import com.owncloud.android.lib.resources.status.OCCapability import java.io.File import java.text.ParsePosition import java.text.SimpleDateFormat @@ -66,7 +68,8 @@ class SyncFolderHelper(private val context: Context) { subFolderRule ) - val capability = CapabilityUtils.getCapability(context) + val capability = UserAccountManagerImpl.fromContext(context).getUser(syncedFolder.account) + .map { CapabilityUtils.getCapability(it, context) }.orElseGet { OCCapability() } result = AutoRename.rename(result, capability) Log_OC.d(TAG, "auto upload remote path: $result") diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadBroadcastReceiver.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadBroadcastReceiver.kt index 408ebcf9335f..d9cdcbc77c41 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadBroadcastReceiver.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadBroadcastReceiver.kt @@ -63,7 +63,7 @@ class FileUploadBroadcastReceiver : BroadcastReceiver() { FileUploadWorker.cancelUpload(remotePath, accountName) if (remove) { - removeFileIfAlreadyUploaded(context, remotePath) + removeFileIfAlreadyUploaded(context, uploadId, accountName) uploadsStorageManager.removeUpload(uploadId) } else { FileUploadHelper.instance().updateUploadStatus( @@ -79,11 +79,12 @@ class FileUploadBroadcastReceiver : BroadcastReceiver() { } @Suppress("DEPRECATION") - private fun removeFileIfAlreadyUploaded(context: Context, remotePath: String) { + private fun removeFileIfAlreadyUploaded(context: Context, uploadId: Long, accountName: String) { + val upload = uploadsStorageManager.getUploadById(uploadId)?.takeIf { it.accountName == accountName } ?: return val userAccountManager = UserAccountManagerImpl.fromContext(context) - val user = userAccountManager.user + val user = userAccountManager.getUser(accountName).orElse(null) ?: return val storageManager = FileDataStorageManager(user, context.contentResolver) - val ocFile = storageManager.getFileByPath(remotePath) ?: return + val ocFile = storageManager.getFileByEncryptedRemotePath(upload.remotePath) ?: return storageManager.removeFile(ocFile, true, false) } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index 0203dc81dd54..139e9fe57020 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -27,7 +27,7 @@ import com.nextcloud.client.notifications.AppWideNotificationManager import com.nextcloud.model.OCUploadLocalPathData import com.nextcloud.utils.SnackbarUtil import com.nextcloud.utils.extensions.checkWCFRestrictions -import com.nextcloud.utils.extensions.createOwncloudClient +import com.nextcloud.repository.ClientRepository import com.nextcloud.utils.extensions.getUploadIds import com.nextcloud.utils.extensions.isLastResultConflictError import com.nextcloud.utils.extensions.isSame @@ -50,12 +50,14 @@ import com.owncloud.android.lib.resources.files.model.ServerFileInterface import com.owncloud.android.lib.resources.status.OCCapability import com.owncloud.android.operations.RemoveFileOperation import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.operations.factory.UploadFileOperationFactory import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException import java.io.File import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -78,6 +80,12 @@ class FileUploadHelper { @ApplicationScope lateinit var appScope: CoroutineScope + @Inject + lateinit var clientRepository: ClientRepository + + @Inject + lateinit var uploadFileOperationFactory: UploadFileOperationFactory + init { MainApp.getAppComponent().inject(this) } @@ -193,19 +201,16 @@ class FileUploadHelper { val connectivity = connectivityService.connectivity val batteryStatus = powerManagementService.battery - val uploadsToRetry = mutableListOf() - val client = accountManager.createOwncloudClient() + val uploadsToRetry = mutableMapOf>() for (upload in uploads) { + val uploadUser = accountManager.getUser(upload.accountName).orElse(null) ?: continue if (FileUploadWorker.isUploading(upload.remotePath, upload.accountName)) { continue } if (upload.isLastResultConflictError()) { - client?.let { - conflictHandlingResult = - uploadActionHandler.handleConflict(upload, client = it, uploadsStorageManager) - } + conflictHandlingResult = checkConflict(upload) continue } @@ -235,13 +240,13 @@ class FileUploadHelper { // Only uploads that passed checks get marked in progress and are collected for scheduling upload.uploadStatus = UploadStatus.UPLOAD_IN_PROGRESS uploadsStorageManager.updateUpload(upload) - uploadsToRetry.add(upload.uploadId) + uploadsToRetry.getOrPut(uploadUser) { mutableListOf() }.add(upload.uploadId) } - if (uploadsToRetry.isNotEmpty()) { + for ((uploadUser, uploadIds) in uploadsToRetry) { backgroundJobManager.startFilesUploadJob( - accountManager.user, - uploadsToRetry.toLongArray(), + uploadUser, + uploadIds.toLongArray(), false ) } @@ -254,6 +259,27 @@ class FileUploadHelper { return@withContext showNotExistMessage } + suspend fun checkConflict(upload: OCUpload): ConflictHandlingResult = withContext(Dispatchers.IO) { + val user = accountManager.getUser(upload.accountName).orElse(null) + ?: return@withContext ConflictHandlingResult.CannotCheckConflict + val context = MainApp.getAppContext() + val client = clientRepository.getOwncloudClient(user) + ?: return@withContext ConflictHandlingResult.CannotCheckConflict + try { + val operation = uploadFileOperationFactory.create(context, upload, user = user) + val target = if (operation.isAutoUpload) { + operation.findAutoUploadTarget(client) + } else { + operation.storageManager.getFileByEncryptedRemotePath(upload.remotePath) + } + uploadActionHandler.handleConflict(upload, client, uploadsStorageManager, target) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log_OC.e(TAG, "Could not check upload conflict", e) + ConflictHandlingResult.CannotCheckConflict + } + } + @JvmOverloads fun uploadNewFiles(data: OCUploadLocalPathData, showSameFileAlreadyExistsNotification: Boolean = true) { val uploads = getUploadsFromLocalPaths(data) @@ -319,7 +345,7 @@ class FileUploadHelper { remotePath )?.let { return it } - val capability = fileStorageManager.getCapability(accountManager.user) + val capability = fileStorageManager.getCapability(accountName) if (!capability.checkWCFRestrictions()) { // The filesystem should treat files as case-sensitive. For example, "a.TXT" and "a.txt" // are allowed to exist in the same directory as two distinct files. @@ -406,7 +432,7 @@ class FileUploadHelper { } else { dao.getUploadsByStatus(status.value, nameCollisionPolicy?.serialize(), MAX_UPLOADS_PER_QUERY) }.mapNotNull { - it.toOCUpload(capability) + it.toOCUpload(it.accountName?.let(fileStorageManager::getCapability) ?: capability) } } @@ -424,7 +450,7 @@ class FileUploadHelper { } remotePath ?: return false - val upload = uploadsStorageManager.uploadDao.getByRemotePath(remotePath) + val upload = uploadsStorageManager.uploadDao.getByRemotePath(remotePath, accountName) return upload?.status == UploadStatus.UPLOAD_IN_PROGRESS.value || FileUploadWorker.isUploading(remotePath, accountName) } @@ -480,6 +506,8 @@ class FileUploadHelper { if (currentUploadFileOperation == null || currentUploadFileOperation.user == null) return false if (upload == null || upload.accountName != currentUploadFileOperation.user.accountName) return false + if (upload.remotePath == currentUploadFileOperation.uploadRemotePath) return true + return if (currentUploadFileOperation.oldFile != null) { // For file conflicts check old file remote path upload.remotePath == currentUploadFileOperation.remotePath || @@ -654,7 +682,7 @@ class FileUploadHelper { * exists or is intentionally turned off, and AutoUploadWorker to re-queue already handled files * on its next scan via FileSystemRepository.getFilePathsWithIds. */ - suspend fun removeEntityFromUploadEntities(id: Long) { + suspend fun removeEntityFromUploadEntities(id: Long, accountName: String) { uploadsStorageManager.fileSystemDao.getBySyncedFolderId(id.toString()) .filter { it.localPath != null && it.remotePath != null } .forEach { @@ -664,7 +692,8 @@ class FileUploadHelper { ) uploadsStorageManager.uploadDao.deleteByLocalRemotePath( localPath = it.localPath!!, - remotePath = it.remotePath!! + remotePath = it.remotePath!!, + accountName = accountName ) } } diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index 3d7872b7f4cb..21b220d8f003 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -38,14 +38,14 @@ import com.owncloud.android.datamodel.UploadsStorageManager import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus import com.owncloud.android.db.OCUpload import com.owncloud.android.db.UploadResult -import com.owncloud.android.lib.common.OwnCloudAccount import com.owncloud.android.lib.common.OwnCloudClient -import com.owncloud.android.lib.common.OwnCloudClientManagerFactory +import com.nextcloud.repository.ClientRepository import com.owncloud.android.lib.common.network.OnDatatransferProgressListener import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.operations.upload.UploadFileException import com.owncloud.android.operations.factory.UploadFileOperationFactory import com.owncloud.android.ui.notifications.NotificationUtils import com.owncloud.android.utils.theme.ViewThemeUtils @@ -71,6 +71,7 @@ class FileUploadWorker( val syncedFolderProvider: SyncedFolderProvider, val context: Context, val uploadFileOperationFactory: UploadFileOperationFactory, + private val clientRepository: ClientRepository, params: WorkerParameters ) : CoroutineWorker(context, params), OnDatatransferProgressListener { @@ -100,7 +101,10 @@ class FileUploadWorker( @JvmOverloads fun cancelUpload(remotePath: String?, accountName: String?, onCompleted: () -> Unit = {}) { val operation = - activeOperations.values.find { it.remotePath == remotePath && it.user.accountName == accountName } + activeOperations.values.find { + (it.remotePath == remotePath || it.uploadRemotePath == remotePath) && + it.user.accountName == accountName + } operation?.let { Log_OC.d(TAG, "upload operation is cancelled: $remotePath") @@ -130,7 +134,7 @@ class FileUploadWorker( fun getCurrentUpload(id: Long?): UploadFileOperation? = activeOperations[id] fun isUploading(remotePath: String?, accountName: String?): Boolean = activeOperations.values.any { - it.remotePath == remotePath && it.user.accountName == accountName + (it.remotePath == remotePath || it.uploadRemotePath == remotePath) && it.user.accountName == accountName } /** @@ -279,8 +283,7 @@ class FileUploadWorker( val user = optionalUser.get() val previouslyUploadedFileSize = currentBatchIndex * FileUploadHelper.MAX_FILE_COUNT val uploads = uploadsStorageManager.getUploadsByIds(uploadIds, accountName) - val ocAccount = OwnCloudAccount(user.toPlatformAccount(), context) - val client = OwnCloudClientManagerFactory.getDefaultSingleton().getClientFor(ocAccount, context) + val client = clientRepository.getOwncloudClient(user) ?: return@withContext Result.retry() val syncFolderHelper = SyncFolderHelper(context) val syncedFolders = syncedFolderProvider.syncedFolders var hasRetryableFailure = false @@ -368,6 +371,7 @@ class FileUploadWorker( Log_OC.e(TAG, "credentials are no longer valid, stopping uploads") break } + if (result.exception is UploadFileException.EncryptionKeysUnavailable) break } val uploadFilesResult = when { @@ -393,6 +397,7 @@ class FileUploadWorker( if (!filesystemRepository.isBelongToAnyAutoFolder(upload.localPath)) return false return syncedFolders.any { folder -> + if (folder.account != upload.accountName) return@any false val file = upload.localPath.toFile() ?: return false val expectedRemotePath = syncFolderHelper.getAutoUploadRemotePath(folder, file) expectedRemotePath == upload.remotePath @@ -506,7 +511,7 @@ class FileUploadWorker( activeOperations.values.find { it.originalStoragePath == fileAbsoluteName } val accountName = currentUploadFileOperation?.user?.accountName - val remotePath = currentUploadFileOperation?.remotePath + val remotePath = currentUploadFileOperation?.uploadRemotePath updateUploadProgress(percent, currentUploadFileOperation) diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/UploadBroadcastAction.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadBroadcastAction.kt index b1778d23c453..0e5b1184fe35 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/UploadBroadcastAction.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/UploadBroadcastAction.kt @@ -41,7 +41,7 @@ sealed class UploadBroadcastAction { val intent = Intent(context, FileUploadBroadcastReceiver::class.java).apply { putExtra(FileUploadBroadcastReceiver.UPLOAD_ID, operation.ocUploadId) putExtra(FileUploadBroadcastReceiver.ACCOUNT_NAME, operation.user.accountName) - putExtra(FileUploadBroadcastReceiver.REMOTE_PATH, operation.remotePath) + putExtra(FileUploadBroadcastReceiver.REMOTE_PATH, operation.uploadRemotePath) putExtra(FileUploadBroadcastReceiver.REMOVE, remove) action = PauseAndCancel::class.simpleName diff --git a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt index c6622a537b67..fc9c019d3400 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt @@ -82,7 +82,7 @@ object UploadErrorNotificationManager { } // do not show an error notification when uploading the same file again - if (result.code.isConflict()) { + if (result.code.isConflict() && !operation.isEncryptedUpload) { val isSameFile = withContext(Dispatchers.IO) { FileUploadHelper.instance().isSameFileOnRemote( operation.user, diff --git a/app/src/main/java/com/nextcloud/repository/ClientRepository.kt b/app/src/main/java/com/nextcloud/repository/ClientRepository.kt index d513712006a4..217377905939 100644 --- a/app/src/main/java/com/nextcloud/repository/ClientRepository.kt +++ b/app/src/main/java/com/nextcloud/repository/ClientRepository.kt @@ -7,6 +7,7 @@ package com.nextcloud.repository +import com.nextcloud.client.account.User import com.nextcloud.common.NextcloudClient import com.nextcloud.common.PlainClient import com.owncloud.android.lib.common.OwnCloudClient @@ -44,5 +45,7 @@ interface ClientRepository { */ suspend fun getOwncloudClient(): OwnCloudClient? + suspend fun getOwncloudClient(user: User): OwnCloudClient? + fun getPlainClient(): PlainClient } diff --git a/app/src/main/java/com/nextcloud/repository/RemoteClientRepository.kt b/app/src/main/java/com/nextcloud/repository/RemoteClientRepository.kt index b02f0b7439ba..30a67d8023ed 100644 --- a/app/src/main/java/com/nextcloud/repository/RemoteClientRepository.kt +++ b/app/src/main/java/com/nextcloud/repository/RemoteClientRepository.kt @@ -8,6 +8,7 @@ package com.nextcloud.repository import android.content.Context +import com.nextcloud.client.account.User import com.nextcloud.client.account.UserAccountManager import com.nextcloud.client.di.ApplicationScope import com.nextcloud.common.NextcloudClient @@ -16,6 +17,7 @@ import com.owncloud.android.lib.common.OwnCloudClient import com.owncloud.android.lib.common.OwnCloudClientManagerFactory import com.owncloud.android.lib.common.utils.Log_OC import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -73,5 +75,16 @@ class RemoteClientRepository @Inject constructor( } } + override suspend fun getOwncloudClient(user: User): OwnCloudClient? = withContext(Dispatchers.IO) { + try { + clientFactory.getClientFor(user.toOwnCloudAccount(), context) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log_OC.d(TAG, "Exception caught getOwncloudClient(user): $e") + null + } + } + override fun getPlainClient(): PlainClient = PlainClient(context) } diff --git a/app/src/main/java/com/nextcloud/utils/e2ee/E2ECounterHelper.kt b/app/src/main/java/com/nextcloud/utils/e2ee/E2ECounterHelper.kt index cfc62d42744a..ef0f7f12e28b 100644 --- a/app/src/main/java/com/nextcloud/utils/e2ee/E2ECounterHelper.kt +++ b/app/src/main/java/com/nextcloud/utils/e2ee/E2ECounterHelper.kt @@ -10,14 +10,19 @@ package com.nextcloud.utils.e2ee import android.content.Context import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.utils.theme.CapabilityUtils object E2ECounterHelper { private const val NO_METADATA_COUNTER = -1L private const val INCREMENTER = 1L - fun getCounter(context: Context, parentFile: OCFile, metadata: Any?): Long { - if (!E2EVersionHelper.isV2Plus(context)) { + fun getCounter(context: Context, parentFile: OCFile, metadata: Any?): Long = + getCounter(CapabilityUtils.getCapability(context), parentFile, metadata) + + fun getCounter(capability: OCCapability, parentFile: OCFile, metadata: Any?): Long { + if (!E2EVersionHelper.isV2Plus(capability)) { return NO_METADATA_COUNTER } diff --git a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index ae793c845797..6d07d8c03a08 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -2632,6 +2632,14 @@ public String retrieveRemotePathConsideringEncryption(OCFile file) { String remotePath = file.getRemotePath(); if (file.isEncrypted()) { + OCFile physical = getFileByEncryptedRemotePath(remotePath); + if (physical != null) { + return physical.getRemotePath(); + } + OCFile logical = getFileByDecryptedRemotePath(file.getDecryptedRemotePath()); + if (logical != null) { + return logical.getRemotePath(); + } remotePath = getEncryptedRemotePath(file.getRemotePath()); } diff --git a/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt b/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt index 56706af2d321..e5afb6e41bff 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt +++ b/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt @@ -23,6 +23,7 @@ import android.os.Looper import androidx.annotation.VisibleForTesting import com.nextcloud.client.account.CurrentAccountProvider import com.nextcloud.client.account.User +import com.nextcloud.client.account.UserAccountManagerImpl import com.nextcloud.client.database.NextcloudDatabase import com.nextcloud.client.database.dao.UploadDao import com.nextcloud.client.database.entity.UploadEntity @@ -52,18 +53,19 @@ class UploadsStorageManager( private val contentResolver: ContentResolver ) : Observable() { - private var capability: OCCapability? = null - val uploadDao: UploadDao = NextcloudDatabase.instance().uploadDao() val fileSystemDao = NextcloudDatabase.instance().fileSystemDao() val syncedFolderDao = NextcloudDatabase.instance().syncedFolderDao() - private fun initOCCapability() { - try { - this.capability = CapabilityUtils.getCapability(MainApp.getAppContext()) - } catch (e: RuntimeException) { - Log_OC.e(TAG, "Failed to set OCCapability: Dependencies are not yet ready. $e") + private fun getCapability(accountName: String?): OCCapability? = try { + val context = MainApp.getAppContext() + accountName?.let { name -> + UserAccountManagerImpl.fromContext(context).getUser(name) + .map { CapabilityUtils.getCapability(it, context) }.orElse(null) } + } catch (e: RuntimeException) { + Log_OC.e(TAG, "Failed to set OCCapability: Dependencies are not yet ready. $e") + null } @Synchronized @@ -363,20 +365,18 @@ class UploadsStorageManager( private fun createOCUploadFromEntity(entity: UploadEntity?): OCUpload? { if (entity == null) return null - initOCCapability() - return entity.toOCUpload(capability) + return entity.toOCUpload(getCapability(entity.accountName)) } private fun createOCUploadFromCursor(c: Cursor): OCUpload { - initOCCapability() - fun Cursor.str(col: String): String = getString(getColumnIndexOrThrow(col)) fun Cursor.int(col: String): Int = getInt(getColumnIndexOrThrow(col)) fun Cursor.long(col: String): Long = getLong(getColumnIndexOrThrow(col)) var remotePath = c.str(ProviderTableMeta.UPLOADS_REMOTE_PATH) + val capability = getCapability(c.str(ProviderTableMeta.UPLOADS_ACCOUNT_NAME)) if (capability != null) { - remotePath = AutoRename.rename(remotePath, capability!!) + remotePath = AutoRename.rename(remotePath, capability) } return OCUpload( @@ -497,7 +497,7 @@ class UploadsStorageManager( status = UploadStatus.UPLOAD_SUCCEEDED result = if (upload.wasSkipped()) UploadResult.SKIPPED else UploadResult.UPLOADED } else if (code.isConflict()) { - val isSame = FileUploadHelper().isSameFileOnRemote( + val isSame = !upload.isEncryptedUpload && FileUploadHelper().isSameFileOnRemote( upload.user, upload.storagePath, upload.remotePath, @@ -528,7 +528,8 @@ class UploadsStorageManager( ) ) - updateUploadStatus(upload.ocUploadId, status, result, upload.remotePath, localPath) + val remotePath = if (uploadResult.isSuccess) upload.remotePath else upload.uploadRemotePath + updateUploadStatus(upload.ocUploadId, status, result, remotePath, localPath) } fun updateDatabaseUploadStart(upload: UploadFileOperation) { @@ -539,7 +540,7 @@ class UploadsStorageManager( upload.ocUploadId, UploadStatus.UPLOAD_IN_PROGRESS, UploadResult.UNKNOWN, - upload.remotePath, + upload.uploadRemotePath, localPath ) } diff --git a/app/src/main/java/com/owncloud/android/operations/CreateFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/CreateFolderOperation.java index 7a3357cfbf7c..f290e00020ee 100644 --- a/app/src/main/java/com/owncloud/android/operations/CreateFolderOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/CreateFolderOperation.java @@ -65,6 +65,7 @@ public class CreateFolderOperation extends SyncOperation implements OnRemoteOper protected String remotePath; private RemoteFile createdRemoteFolder; private volatile boolean encrypt = false; + private boolean deferFailureToServerRollback; private final User user; private final Context context; @@ -87,6 +88,10 @@ public boolean shouldEncrypt() { return encrypt; } + public void setDeferFailureToServerRollback(boolean value) { + deferFailureToServerRollback = value; + } + @Override protected RemoteOperationResult run(OwnCloudClient client) { String remoteParentPath = new File(getRemotePath()).getParent(); @@ -134,6 +139,7 @@ private RemoteOperationResult encryptedCreateV1(OCFile parent, OwnCloudClient cl String publicKey = arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PUBLIC_KEY); String token = null; + boolean canCommit = true; Boolean metadataExists; DecryptedFolderMetadataFileV1 metadata; String encryptedRemotePath = null; @@ -167,6 +173,7 @@ private RemoteOperationResult encryptedCreateV1(OCFile parent, OwnCloudClient cl String encryptedFileName = createRandomFileName(metadata); encryptedRemotePath = parent.getRemotePath() + encryptedFileName; + canCommit = false; RemoteOperationResult result = new CreateFolderRemoteOperation(encryptedRemotePath, true, token) @@ -196,6 +203,7 @@ private RemoteOperationResult encryptedCreateV1(OCFile parent, OwnCloudClient cl "", arbitraryDataProvider, user); + canCommit = true; // unlock folder if (token != null) { @@ -236,6 +244,9 @@ private RemoteOperationResult encryptedCreateV1(OCFile parent, OwnCloudClient cl return result; } catch (Exception e) { + if (deferFailureToServerRollback) { + return new RemoteOperationResult(e); + } if (!EncryptionUtils.unlockFolderV1(parent, client, token).isSuccess()) { throw new RuntimeException("Could not clean up after failing folder creation!", e); } @@ -259,7 +270,7 @@ private RemoteOperationResult encryptedCreateV1(OCFile parent, OwnCloudClient cl return new RemoteOperationResult(e); } finally { // unlock folder - if (token != null) { + if (token != null && (canCommit || !deferFailureToServerRollback)) { RemoteOperationResult unlockFolderResult = EncryptionUtils.unlockFolderV1(parent, client, token); if (!unlockFolderResult.isSuccess()) { @@ -276,6 +287,7 @@ private RemoteOperationResult encryptedCreateV1(OCFile parent, OwnCloudClient cl ) private RemoteOperationResult encryptedCreateV2(OCFile parent, OwnCloudClient client) { String token = null; + boolean canCommit = true; Boolean metadataExists; DecryptedFolderMetadataFile metadata; String encryptedRemotePath = null; @@ -285,7 +297,7 @@ private RemoteOperationResult encryptedCreateV2(OCFile parent, OwnCloudClient cl long counter = EncryptionUtils.E2E_V2_INITIAL_COUNTER; if (folderDepth != OCFileDepth.Root) { final var metadataObject = EncryptionUtils.downloadFolderMetadata(parent, client, context, user); - counter = E2ECounterHelper.INSTANCE.getCounter(context, parent, metadataObject); + counter = E2ECounterHelper.INSTANCE.getCounter(getStorageManager().getCapability(user), parent, metadataObject); } try { @@ -311,6 +323,7 @@ private RemoteOperationResult encryptedCreateV2(OCFile parent, OwnCloudClient cl String encryptedFileName = createRandomFileName(metadata); encryptedRemotePath = parent.getRemotePath() + encryptedFileName; + canCommit = false; RemoteOperationResult result = new CreateFolderRemoteOperation(encryptedRemotePath, true, token) @@ -349,6 +362,7 @@ private RemoteOperationResult encryptedCreateV2(OCFile parent, OwnCloudClient cl context, user, getStorageManager()); + canCommit = true; // unlock folder final var unlockFolderResult = EncryptionUtils.unlockFolder(parent, client, token); @@ -388,6 +402,9 @@ private RemoteOperationResult encryptedCreateV2(OCFile parent, OwnCloudClient cl return result; } catch (Exception e) { + if (deferFailureToServerRollback) { + return new RemoteOperationResult(e); + } // TODO remove folder if (!EncryptionUtils.unlockFolder(parent, client, token).isSuccess()) { @@ -412,7 +429,7 @@ private RemoteOperationResult encryptedCreateV2(OCFile parent, OwnCloudClient cl return new RemoteOperationResult(e); } finally { // unlock folder - if (token != null) { + if (token != null && (canCommit || !deferFailureToServerRollback)) { RemoteOperationResult unlockFolderResult = EncryptionUtils.unlockFolder(parent, client, token); if (!unlockFolderResult.isSuccess()) { @@ -434,6 +451,11 @@ private boolean isFileExisting(DecryptedFolderMetadataFileV1 metadata, String fi } private boolean isFileExisting(DecryptedFolderMetadataFile metadata, String filename) { + for (String folderName : metadata.getMetadata().getFolders().values()) { + if (filename.equalsIgnoreCase(folderName)) { + return true; + } + } for (DecryptedFile file : metadata.getMetadata().getFiles().values()) { if (filename.equalsIgnoreCase(file.getFilename())) { return true; diff --git a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java index e58e1ffc3c24..387c703a8191 100644 --- a/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/RefreshFolderOperation.java @@ -584,12 +584,11 @@ private void synchronizeData(List folderAndFiles) { mContext); } - final var capability = CapabilityUtils.getCapability(mContext); + final var capability = CapabilityUtils.getCapability(user, mContext); - if (E2EVersionHelper.INSTANCE.isV2Plus(capability)) { - if (encryptedAncestor && object == null) { - throw new IllegalStateException("metadata is null!"); - } + if (encryptedAncestor && object == null && + (E2EVersionHelper.INSTANCE.isV2Plus(capability) || folderAndFiles.size() > 1)) { + throw new IllegalStateException("Cannot resolve encrypted children without metadata"); } // get current data about local contents of the folder to synchronize @@ -666,13 +665,9 @@ private void synchronizeData(List folderAndFiles) { } - // save updated contents in local database - // update file name for encrypted files - if (E2EVersionHelper.INSTANCE.isV1(e2EVersion) && object instanceof DecryptedFolderMetadataFileV1 metadata) { - updateFileNameForEncryptedFileV1(fileDataStorageManager, metadata, mLocalFolder); - } else if (object instanceof DecryptedFolderMetadataFile metadata) { - updateFileNameForEncryptedFile(fileDataStorageManager, metadata, mLocalFolder); - } + // The folder's own name comes from its parent's metadata, not its children's metadata. + remoteFolder.setDecryptedRemotePath(mLocalFolder.getDecryptedRemotePath()); + remoteFolder.setEncrypted(mLocalFolder.isEncrypted()); UnifiedShareSharees.fillBlocking(user, updatedFiles); diff --git a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java index 8ed1907a113f..3fa56ea42117 100644 --- a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java @@ -67,6 +67,8 @@ import com.owncloud.android.operations.upload.UploadFileException; import com.owncloud.android.operations.upload.UploadFileOperationExtensionsKt; import com.owncloud.android.operations.upload.UploadFileResolver; +import com.owncloud.android.operations.upload.UploadFolderException; +import com.owncloud.android.operations.upload.UploadFolderResolver; import com.owncloud.android.utils.EncryptionUtils; import com.owncloud.android.utils.EncryptionUtilsV2; import com.owncloud.android.utils.FileStorageUtils; @@ -314,6 +316,34 @@ public String getRemotePath() { return mFile.getRemotePath(); } + public boolean isAutoUpload() { + return isInstantPicture() || isInstantVideo(); + } + + public boolean isEncryptedUpload() { + return encryptedAncestor; + } + + public String getUploadRemotePath() { + return isAutoUpload() ? mUpload.getRemotePath() : getRemotePath(); + } + + public OCFile findAutoUploadTarget(OwnCloudClient client) throws UploadFolderException { + if (!isAutoUpload()) { + return null; + } + String target = mUpload.getRemotePath(); + int separator = target.lastIndexOf('/'); + OCFile parent = UploadFolderResolver.resolve(this, client, target.substring(0, separator + 1)); + String filename = target.substring(separator + 1); + if (parent.isEncrypted()) { + return uploadFileResolver.findEncryptedTarget(parent, filename); + } + return getStorageManager().getFolderContent(parent, false).stream() + .filter(file -> filename.equals(file.getDecryptedFileName())) + .findFirst().orElse(null); + } + public String getDecryptedRemotePath() { return mFile.getDecryptedRemotePath(); } @@ -436,6 +466,13 @@ protected RemoteOperationResult run(OwnCloudClient client) { } mCancellationRequested.set(false); + if (isAutoUpload()) { + final var conditions = checkConditions(localFile); + if (conditions != null) { + uploadsStorageManager.updateDatabaseUploadResult(conditions, this); + return conditions; + } + } mPaused.set(false); mUploadStarted.set(true); @@ -457,13 +494,30 @@ protected RemoteOperationResult run(OwnCloudClient client) { } remoteParentPath = renamedRemoteParentPath; - OCFile parent = getStorageManager().getFileByPath(remoteParentPath); + OCFile parent; + if (isAutoUpload()) { + try { + parent = UploadFolderResolver.resolve(this, client, remoteParentPath); + String filename = new File(mRemotePath).getName(); + mRemotePath = parent.getRemotePath() + filename; + mFile.setRemotePath(mRemotePath); + mFile.setDecryptedRemotePath(parent.getDecryptedRemotePath() + filename); + } catch (UploadFolderException e) { + final var folderResult = RemoteOperationResultExtensionsKt.isConflict(e.getResult().getCode()) + ? new RemoteOperationResult<>(e) : e.getResult(); + mUploadStarted.set(false); + uploadsStorageManager.updateDatabaseUploadResult(folderResult, this); + return folderResult; + } + } else { + parent = getStorageManager().getFileByPath(remoteParentPath); + } Log_OC.d(TAG, "parent lookup for path: " + remoteParentPath + " β†’ " + (parent == null ? "not found in DB" : "found, id=" + parent.getFileId())); // Create folders only for Nextcloud < 32; 32+ handles this automatically. - if (getCapabilities().getVersion().isOlderThan(NextcloudVersion.nextcloud_32)) { + if (!isAutoUpload() && getCapabilities().getVersion().isOlderThan(NextcloudVersion.nextcloud_32)) { final boolean isResumingEncryptedUpload = (mFolderUnlockToken != null && !mFolderUnlockToken.isEmpty()); if (!isResumingEncryptedUpload && (parent == null || mRemoteFolderToBeCreated)) { Log_OC.d(TAG, "verifying remote parent folder exists: " + remoteParentPath); @@ -506,7 +560,7 @@ protected RemoteOperationResult run(OwnCloudClient client) { result = normalUpload(client); } - if (result.isSuccess() && getCapabilities().getVersion().isNewerOrEqual(NextcloudVersion.nextcloud_32)) { + if (!isAutoUpload() && result.isSuccess() && getCapabilities().getVersion().isNewerOrEqual(NextcloudVersion.nextcloud_32)) { // Add the folders that the server creates automatically (from Nextcloud 32) to local db FileDataStorageManagerExtensionsKt.createDirectoryTree(getStorageManager(), remoteParentPath, null); } @@ -522,6 +576,7 @@ private RemoteOperationResult encryptedUpload(OwnCloudClient client, OCFile pare FileLock fileLock = null; long size; boolean isV1MetadataExists = false; + boolean remoteWriteStarted = false; String token = null; Object object = null; FileChannel channel = null; @@ -532,52 +587,82 @@ private RemoteOperationResult encryptedUpload(OwnCloudClient client, OCFile pare result = checkConditions(e2eFiles.getOriginalFile()); if (result != null) { - return result; + throw new UploadFolderException(result); } - EncryptionUtilsV2 encryptionUtilsV2 = new EncryptionUtilsV2(); - object = EncryptionUtils.downloadFolderMetadata(parentFile, client, mContext, user); - if (object instanceof DecryptedFolderMetadataFileV1 decrypted && decrypted.getMetadata() != null) { - isV1MetadataExists = true; + if (isAutoUpload() && (publicKey.isEmpty() || + arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PRIVATE_KEY).isEmpty())) { + throw new UploadFileException.EncryptionKeysUnavailable(); } - boolean isEndToEndVersionAtLeastV2 = E2EVersionHelper.INSTANCE.isV2Plus(mContext); + EncryptionUtilsV2 encryptionUtilsV2 = new EncryptionUtilsV2(); + final var capability = getCapabilities(); + boolean isEndToEndVersionAtLeastV2 = E2EVersionHelper.INSTANCE.isV2Plus(capability); + if (!isAutoUpload() || isEndToEndVersionAtLeastV2) { + object = EncryptionUtils.downloadFolderMetadata(parentFile, client, mContext, user); + if (object instanceof DecryptedFolderMetadataFileV1 decrypted && decrypted.getMetadata() != null) { + isV1MetadataExists = true; + } - if (isEndToEndVersionAtLeastV2) { - if (object == null) { - return new RemoteOperationResult<>(new IllegalStateException("Metadata does not exist")); + if (isEndToEndVersionAtLeastV2) { + if (object == null) { + throw new UploadFileException.InvalidEncryptionMetadata(); + } + } else { + object = getDecryptedFolderMetadataV1(publicKey, object); } - } else { - object = getDecryptedFolderMetadataV1(publicKey, object); } - long counter = E2ECounterHelper.INSTANCE.getCounter(mContext, parentFile, object); + long counter = E2ECounterHelper.INSTANCE.getCounter(capability, parentFile, object); Log_OC.d(TAG, "counter: " + counter); try { token = getFolderUnlockTokenOrLockFolder(client, parentFile, counter); } catch (Exception e) { Log_OC.e(TAG, "Failed to lock folder", e); - return new RemoteOperationResult<>(e); + throw e; + } + + if (isAutoUpload() && !isEndToEndVersionAtLeastV2) { + // V1 reads under the lock; V2's counter check protects the metadata read before locking. + final var metadata = EncryptionUtils.retrieveMetadataV1(parentFile, + client, + arbitraryDataProvider.getValue(user.getAccountName(), EncryptionUtils.PRIVATE_KEY), + publicKey, + arbitraryDataProvider, + user, + getStorageManager().getE2EEVersion(user)); + if (!metadata.first && !getStorageManager().getFolderContent(parentFile, false).isEmpty()) { + throw new UploadFileException.InvalidEncryptionMetadata(); + } + isV1MetadataExists = metadata.first; + object = metadata.second; } E2EClientData clientData = new E2EClientData(client, token, publicKey); List fileNames = uploadFileResolver.getCollidedFileNames(object); + if (mNameCollisionPolicy == NameCollisionPolicy.OVERWRITE && + uploadFileResolver.isFolder(object, new File(mRemotePath).getName())) { + throw new UploadFolderException(new RemoteOperationResult<>(ResultCode.SYNC_CONFLICT)); + } + final var collisionResult = checkNameCollision(parentFile, client, fileNames, parentFile.isEncrypted()); if (collisionResult != null) { - result = collisionResult; - return collisionResult; + throw new UploadFolderException(collisionResult); } - mFile.setDecryptedRemotePath(parentFile.getDecryptedRemotePath() + e2eFiles.getOriginalFile().getName()); + mFile.setDecryptedRemotePath(parentFile.getDecryptedRemotePath() + new File(mRemotePath).getName()); String expectedPath = FileStorageUtils.getDefaultSavePathFor(user.getAccountName(), mFile); e2eFiles.setExpectedFile(new File(expectedPath)); result = copyFile(e2eFiles.getOriginalFile(), expectedPath); + if (!new File(mFile.getStoragePath()).equals(e2eFiles.getOriginalFile())) { + e2eFiles.setTemporalFile(new File(mFile.getStoragePath())); + } if (!result.isSuccess()) { - return result; + throw new UploadFolderException(result); } long lastModifiedTimestamp = e2eFiles.getOriginalFile().lastModified() / AppConstants.MILLIS_PER_SECOND; @@ -603,11 +688,14 @@ private RemoteOperationResult encryptedUpload(OwnCloudClient client, OCFile pare uploadFileResolver.updateSize(size); setUploadOperationForE2E(token, e2eFiles.getEncryptedTempFile(), e2eData.getEncryptedFileName(), lastModifiedTimestamp, creationTimestamp, size); + remoteWriteStarted = true; result = performE2EUpload(clientData); if (result.isSuccess()) { upsertMetadata(object, e2eData, clientData, e2eFiles, arbitraryDataProvider, encryptionUtilsV2, isV1MetadataExists); } + } catch (UploadFolderException e) { + result = e.getResult(); } catch (FileNotFoundException e) { Log_OC.e(TAG, mFile.getStoragePath() + " does not exist anymore"); result = new RemoteOperationResult<>(ResultCode.LOCAL_FILE_NOT_FOUND); @@ -618,19 +706,22 @@ private RemoteOperationResult encryptedUpload(OwnCloudClient client, OCFile pare Log_OC.e(TAG, "UploadFileOperation exception: " + e.getLocalizedMessage()); result = new RemoteOperationResult<>(e); } finally { - result = releaseLocksAndUnlockE2EFolder(fileLock, channel, e2eFiles, result, object, client, token); + result = releaseLocksAndUnlockE2EFolder(fileLock, channel, e2eFiles, result, object, client, token, + remoteWriteStarted); // update upload status uploadsStorageManager.updateDatabaseUploadResult(result, this); } - handleLocalBehaviourOrSaveConflictAndDeleteTemporalFile(result, e2eFiles, client); + if (!mWasSkipped) { + handleLocalBehaviourOrSaveConflictAndDeleteTemporalFile(result, e2eFiles, client); + } return result; } private String getFolderUnlockTokenOrLockFolder(OwnCloudClient client, OCFile parentFile, long counter) throws UploadException { - if (mFolderUnlockToken != null && !mFolderUnlockToken.isEmpty()) { + if (!isAutoUpload() && mFolderUnlockToken != null && !mFolderUnlockToken.isEmpty()) { Log_OC.d(TAG, "Reusing existing folder unlock token from previous upload attempt"); return mFolderUnlockToken; } @@ -667,6 +758,12 @@ private DecryptedFolderMetadataFileV1 getDecryptedFolderMetadataV1(String public } private String getEncryptedFileName(Object object) { + if (isAutoUpload() && mNameCollisionPolicy == NameCollisionPolicy.OVERWRITE) { + String existing = uploadFileResolver.getEncryptedFileName(object, mFile.getDecryptedFileName()); + if (existing != null) { + return existing; + } + } String encryptedFileName = EncryptionUtils.generateUid(); if (object instanceof DecryptedFolderMetadataFileV1 metadata) { @@ -768,7 +865,8 @@ private long getChannelSize(FileChannel channel) { private RemoteOperationResult performE2EUpload(E2EClientData data) throws OperationCancelledException { for (OnDatatransferProgressListener mDataTransferListener : mDataTransferListeners) { - mUploadOperation.addDataTransferProgressListener(mDataTransferListener); + mUploadOperation.addDataTransferProgressListener((rate, transferred, total, path) -> + mDataTransferListener.onTransferProgress(rate, transferred, total, mOriginalStoragePath)); } if (mCancellationRequested.get()) { @@ -908,7 +1006,7 @@ private void handleLocalBehaviourOrSaveConflictAndDeleteTemporalFile(RemoteOpera e2eFiles.deleteTemporalFile(); } - private RemoteOperationResult releaseLocksAndUnlockE2EFolder(FileLock fileLock, FileChannel channel, E2EFiles e2eFiles, RemoteOperationResult result, Object object, OwnCloudClient client, String token) { + private RemoteOperationResult releaseLocksAndUnlockE2EFolder(FileLock fileLock, FileChannel channel, E2EFiles e2eFiles, RemoteOperationResult result, Object object, OwnCloudClient client, String token, boolean remoteWriteStarted) { mUploadStarted.set(false); if (fileLock != null) { @@ -930,14 +1028,18 @@ private RemoteOperationResult releaseLocksAndUnlockE2EFolder(FileLock fileLock, } } - e2eFiles.deleteTemporalFileWithOriginalFileComparison(); - if (result == null) { result = new RemoteOperationResult<>(ResultCode.UNKNOWN_ERROR); } logResult(result, mFile.getStoragePath(), mFile.getRemotePath()); + if (isAutoUpload() && remoteWriteStarted && !result.isSuccess()) { + // Unlock commits staged files. Leave failed writes locked for the server's existing rollback job. + e2eFiles.deleteEncryptedTempFile(); + return result; + } + if (token == null || token.isEmpty()) { Log_OC.e(TAG, "CRITICAL ERROR: Folder was locked but token is null/empty. Cannot unlock! " + "Folder: " + e2eFiles.getParentFile().getFileName()); @@ -971,8 +1073,11 @@ private RemoteOperationResult releaseLocksAndUnlockE2EFolder(FileLock fileLock, if (unlockFolderResult != null && unlockFolderResult.isSuccess()) { Log_OC.d(TAG, "Folder successfully unlocked: " + e2eFiles.getParentFile().getFileName()); + mFolderUnlockToken = null; + mUpload.setFolderUnlockToken(null); + uploadsStorageManager.updateUpload(mUpload); - if (duplicatedEncryptedFile != null) { + if (duplicatedEncryptedFile != null && !isAutoUpload()) { FileUploadHelper.Companion.instance().removeDuplicatedFile(duplicatedEncryptedFile, client, user, () -> { duplicatedEncryptedFile = null; return Unit.INSTANCE; @@ -1266,6 +1371,9 @@ private RemoteOperationResult checkNameCollision(OCFile parentFile, case SKIP: Log_OC.d(TAG, "user choose to skip upload if same file exists"); mWasSkipped = true; + if (encrypted) { + setEncryptedConflictTarget(parentFile); + } return new RemoteOperationResult<>(ResultCode.OK); case RENAME: mRemotePath = getNewAvailableRemotePath(client, mRemotePath, fileNames, encrypted); @@ -1282,7 +1390,8 @@ private RemoteOperationResult checkNameCollision(OCFile parentFile, break; case OVERWRITE: if (parentFile != null && encrypted) { - duplicatedEncryptedFile = getStorageManager().findDuplicatedFile(parentFile, mFile); + duplicatedEncryptedFile = uploadFileResolver.findEncryptedTarget(parentFile, + mFile.getDecryptedFileName()); } Log_OC.d(TAG, "Overwriting file"); @@ -1292,7 +1401,7 @@ private RemoteOperationResult checkNameCollision(OCFile parentFile, // check if its real SYNC_CONFLICT boolean isSameFileOnRemote = false; - if (mFile != null) { + if (mFile != null && !encrypted) { isSameFileOnRemote = FileUploadHelper.Companion.instance() .isSameFileOnRemote(user, mFile.getStoragePath(), mRemotePath, mContext); } @@ -1300,6 +1409,9 @@ private RemoteOperationResult checkNameCollision(OCFile parentFile, if (isSameFileOnRemote) { return new RemoteOperationResult<>(ResultCode.OK); } else { + if (encrypted) { + setEncryptedConflictTarget(parentFile); + } return new RemoteOperationResult<>(ResultCode.SYNC_CONFLICT); } } @@ -1312,6 +1424,14 @@ private RemoteOperationResult checkNameCollision(OCFile parentFile, return null; } + private void setEncryptedConflictTarget(OCFile parent) { + OCFile existing = uploadFileResolver.findEncryptedTarget(parent, mFile.getDecryptedFileName()); + if (existing != null) { + mFile.setRemotePath(existing.getRemotePath()); + mFile.setDecryptedRemotePath(existing.getDecryptedRemotePath()); + } + } + private void deleteNonExistingFile(File file) { if (file.exists()) { return; @@ -1542,6 +1662,10 @@ public boolean isPaused() { return mPaused.get(); } + public boolean isCancellationRequested() { + return mCancellationRequested.get(); + } + /** * As soon as this method return true, upload can be cancel via cancel(). */ diff --git a/app/src/main/java/com/owncloud/android/operations/factory/UploadFileOperationFactory.kt b/app/src/main/java/com/owncloud/android/operations/factory/UploadFileOperationFactory.kt index 537c66bf5257..1253d17805f0 100644 --- a/app/src/main/java/com/owncloud/android/operations/factory/UploadFileOperationFactory.kt +++ b/app/src/main/java/com/owncloud/android/operations/factory/UploadFileOperationFactory.kt @@ -31,7 +31,9 @@ class UploadFileOperationFactory @Inject constructor( upload: OCUpload, progressListener: OnDatatransferProgressListener? = null, disableRetries: Boolean = true, - user: User = accountManager.user, + user: User = accountManager.getUser(upload.accountName).orElseThrow { + IllegalStateException("Upload account is unavailable") + }, storageManager: FileDataStorageManager = FileDataStorageManager(user, context.contentResolver) ): UploadFileOperation = UploadFileOperation( uploadsStorageManager, diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileException.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileException.kt index 880a5ebbfca1..322ed67df460 100644 --- a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileException.kt +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileException.kt @@ -10,4 +10,7 @@ package com.owncloud.android.operations.upload sealed class UploadFileException(message: String) : Exception(message) { class EmptyOrNullFilePath : UploadFileException("Empty or null file path") class MissingPermission : UploadFileException("Missing storage permission") + class EncryptionKeysUnavailable : UploadFileException("Encryption keys are unavailable for the upload account") + + class InvalidEncryptionMetadata : UploadFileException("Encrypted folder metadata could not be read") } diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileResolver.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileResolver.kt index 8a3df939cdc1..deb7909e2842 100644 --- a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileResolver.kt +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileResolver.kt @@ -39,7 +39,10 @@ class UploadFileResolver(private val operation: UploadFileOperation) { @Suppress("DEPRECATION") fun getCollidedFileNames(metadata: Any?): List = when (metadata) { is DecryptedFolderMetadataFileV1 -> metadata.files.values.map { it.encrypted.filename } - is DecryptedFolderMetadataFile -> metadata.metadata.files.values.map { it.filename } + + is DecryptedFolderMetadataFile -> metadata.metadata.files.values.map { it.filename } + + metadata.metadata.folders.values + else -> emptyList() } @@ -51,6 +54,35 @@ class UploadFileResolver(private val operation: UploadFileOperation) { } } + fun findEncryptedTarget(parent: OCFile, filename: String): OCFile? = + operation.storageManager.getFolderContent(parent, false).orEmpty().firstOrNull { + it.decryptedFileName.equals(filename, ignoreCase = true) + } + + fun getEncryptedFileName(metadata: Any?, filename: String): String? = when (metadata) { + is DecryptedFolderMetadataFileV1 -> metadata.files.entries.firstOrNull { + it.value.encrypted.filename.equals(filename, ignoreCase = true) + }?.key + + is DecryptedFolderMetadataFile -> metadata.metadata.files.entries.firstOrNull { + it.value.filename.equals(filename, ignoreCase = true) + }?.key + + else -> null + } + + fun isFolder(metadata: Any?, filename: String): Boolean = when (metadata) { + is DecryptedFolderMetadataFileV1 -> metadata.files.values.any { + it.encrypted.filename.equals(filename, ignoreCase = true) && it.encrypted.mimetype == MimeType.WEBDAV_FOLDER + } + + is DecryptedFolderMetadataFile -> metadata.metadata.folders.values.any { + it.equals(filename, ignoreCase = true) + } + + else -> false + } + fun createNewOCFile(fileToBeUploaded: OCFile, newRemotePath: String): OCFile = OCFile(newRemotePath).apply { creationTimestamp = fileToBeUploaded.creationTimestamp fileLength = fileToBeUploaded.fileLength @@ -62,6 +94,7 @@ class UploadFileResolver(private val operation: UploadFileOperation) { lastSyncDateForData = fileToBeUploaded.lastSyncDateForData storagePath = fileToBeUploaded.storagePath parentId = fileToBeUploaded.parentId + isEncrypted = fileToBeUploaded.isEncrypted } fun createLocalFolder(remotePath: String): OCFile? { diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFolderBackend.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFolderBackend.kt new file mode 100644 index 000000000000..d007e84c4809 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFolderBackend.kt @@ -0,0 +1,104 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.nextcloud.client.database.NextcloudDatabase +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.CreateFolderOperation +import com.owncloud.android.operations.RefreshFolderOperation +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.FileStorageUtils + +class UploadFolderBackend( + private val operation: UploadFileOperation, + private val client: OwnCloudClient, + private val keys: ArbitraryDataProvider = ArbitraryDataProviderImpl(NextcloudDatabase.instance().arbitraryDataDao()) +) { + private val storage = operation.storageManager + private val user = operation.user + private val context = operation.context + + fun resolve(path: String): OCFile = UploadFolderResolver(::root, ::refresh, ::child, ::createChild).resolve(path) + + private fun root(): OCFile = storage.getFileByEncryptedRemotePath(OCFile.ROOT_PATH) ?: run { + val result = ReadFileRemoteOperation(OCFile.ROOT_PATH).execute(client) + requireSuccess(result) + FileStorageUtils.fillOCFile(result.data.first() as RemoteFile).also { storage.saveFile(it) } + } + + private fun checkKeys() { + if (keys.getValue(user.accountName, EncryptionUtils.PRIVATE_KEY).isEmpty() || + keys.getValue(user.accountName, EncryptionUtils.PUBLIC_KEY).isEmpty() + ) { + throw UploadFolderException(RemoteOperationResult(UploadFileException.EncryptionKeysUnavailable())) + } + } + + private fun refresh(folder: OCFile) { + checkCancellation() + val properties = ReadFileRemoteOperation(folder.remotePath).execute(client) + requireSuccess(properties) + val remote = properties.data.first() as RemoteFile + folder.remoteId = remote.remoteId + val parent = storage.getFileById(folder.parentId) + folder.isEncrypted = remote.isEncrypted || + (parent != null && FileStorageUtils.checkEncryptionStatus(parent, storage)) + if (folder.isEncrypted) { + checkKeys() + } + storage.saveFile(folder) + requireSuccess( + RefreshFolderOperation( + folder, + System.currentTimeMillis(), + false, + true, + true, + storage, + user, + context + ).execute(client) + ) + } + + private fun child(parent: OCFile, name: String): OCFile? = + storage.getFolderContent(parent, false).orEmpty().firstOrNull { + it.isFolder && it.decryptedFileName.equals(name, ignoreCase = parent.isEncrypted) + } + + private fun createChild(parent: OCFile, name: String): OCFile { + checkCancellation() + val target = parent.decryptedRemotePath + name + "/" + if (parent.isEncrypted) checkKeys() + val result = CreateFolderOperation(target, user, context, storage).apply { + setDeferFailureToServerRollback(true) + }.execute(client) + if (!result.isSuccess && result.code != ResultCode.FOLDER_ALREADY_EXISTS) throw UploadFolderException(result) + // A concurrent creator may have chosen a new ciphertext name; adopt it from fresh metadata. + refresh(parent) + return child(parent, name) + ?: throw UploadFolderException(RemoteOperationResult(ResultCode.CANNOT_CREATE_FILE)) + } + + private fun requireSuccess(result: RemoteOperationResult<*>) { + if (!result.isSuccess) throw UploadFolderException(result) + } + + private fun checkCancellation() { + if (operation.isCancellationRequested) { + throw UploadFolderException(RemoteOperationResult(ResultCode.USER_CANCELLED)) + } + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFolderException.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFolderException.kt new file mode 100644 index 000000000000..70cb3b36fef8 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFolderException.kt @@ -0,0 +1,11 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.lib.common.operations.RemoteOperationResult + +class UploadFolderException(val result: RemoteOperationResult<*>) : Exception(result.logMessage) diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFolderResolver.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFolderResolver.kt new file mode 100644 index 000000000000..4469a5149b38 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFolderResolver.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.operations.UploadFileOperation + +class UploadFolderResolver( + private val root: () -> OCFile, + private val refresh: (OCFile) -> Unit, + private val findChild: (OCFile, String) -> OCFile?, + private val createChild: (OCFile, String) -> OCFile +) { + fun resolve(path: String): OCFile { + require(path.startsWith('/')) + val segments = path.split('/').filter { it.isNotEmpty() } + require(segments.none { it == "." || it == ".." }) + var folder = root() + refresh(folder) + for (name in segments) { + folder = findChild(folder, name) ?: createChild(folder, name) + check(folder.isFolder) + refresh(folder) + } + return folder + } + + companion object { + @JvmStatic + @Throws(UploadFolderException::class) + fun resolve(operation: UploadFileOperation, client: OwnCloudClient, path: String): OCFile = + UploadFolderBackend(operation, client).resolve(path) + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt index ec1ad6d800a2..f99db54686b4 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt @@ -38,6 +38,7 @@ import com.owncloud.android.files.services.NameCollisionPolicy import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.UploadFileOperation import com.owncloud.android.ui.dialog.conflict.ConflictResolveDialogFactory import com.owncloud.android.ui.dialog.conflict.ConflictsResolveDialog.Decision import com.owncloud.android.ui.dialog.conflict.ConflictsResolveDialog.OnConflictDecisionMadeListener @@ -167,6 +168,17 @@ class ConflictsResolveActivity : } private fun handleFile(file: OCFile?, upload: OCUpload?, user: User, policy: NameCollisionPolicy) { + if (upload != null && ( + upload.createdBy == UploadFileOperation.CREATED_AS_INSTANT_PICTURE || + upload.createdBy == UploadFileOperation.CREATED_AS_INSTANT_VIDEO + ) + ) { + upload.nameCollisionPolicy = policy + upload.uploadStatus = UploadsStorageManager.UploadStatus.UPLOAD_IN_PROGRESS + uploadsStorageManager.updateUpload(upload) + backgroundJobManager.startFilesUploadJob(user, longArrayOf(upload.uploadId), false, true) + return + } upload?.let { uploadHelper.removeFileUpload(it.remotePath, it.accountName) } uploadHelper.uploadUpdatedFile( user, diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index d3ae4fa89eee..283c6567505d 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -2172,9 +2172,12 @@ class FileDisplayActivity : onlyLocalCopy: Boolean ) { lifecycleScope.launch(Dispatchers.IO) { - entities.mapNotNull { it.id?.toLong() }.forEach { - fileUploadHelper.removeEntityFromUploadEntities(it) - syncedFolderProvider.deleteSyncedFolder(it) + entities.forEach { entity -> + val accountName = entity.account ?: return@forEach + entity.id?.toLong()?.let { id -> + fileUploadHelper.removeEntityFromUploadEntities(id, accountName) + syncedFolderProvider.deleteSyncedFolder(id) + } } connectivityService.isNetworkAndServerAvailable { isAvailable -> diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FolderPickerActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FolderPickerActivity.kt index f71f54bf079c..fabdd18f1890 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FolderPickerActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FolderPickerActivity.kt @@ -143,7 +143,7 @@ open class FolderPickerActivity : private fun setupUIForChooseButton() { captionText = resources.getText(R.string.folder_picker_choose_caption_text).toString() mSearchOnlyFolders = true - isDoNotEnterEncryptedFolder = true + isDoNotEnterEncryptedFolder = !intent.getBooleanExtra(EXTRA_ALLOW_ENCRYPTED_FOLDER, false) if (this is FilePickerActivity) { return @@ -699,6 +699,7 @@ open class FolderPickerActivity : const val MOVE_OR_COPY = "MOVE_OR_COPY" const val CHOOSE_LOCATION = "CHOOSE_LOCATION" + const val EXTRA_ALLOW_ENCRYPTED_FOLDER = "ALLOW_ENCRYPTED_FOLDER" private val TAG = FolderPickerActivity::class.java.simpleName private const val MOVE = "MOVE" private const val COPY = "COPY" diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterAction.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterAction.kt index 0ff6128edca9..0b5a7b5f2598 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterAction.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterAction.kt @@ -8,6 +8,7 @@ package com.owncloud.android.ui.adapter.uploadList.helper import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.datamodel.OCFile import com.owncloud.android.db.OCUpload import com.owncloud.android.lib.common.OwnCloudClient @@ -15,6 +16,7 @@ interface UploadListAdapterAction { suspend fun handleConflict( upload: OCUpload, client: OwnCloudClient, - storageManager: UploadsStorageManager + storageManager: UploadsStorageManager, + existingFile: OCFile? = null ): ConflictHandlingResult } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterActionHandler.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterActionHandler.kt index 66e0923d1684..4fce77ab2c57 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterActionHandler.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterActionHandler.kt @@ -10,6 +10,7 @@ package com.owncloud.android.ui.adapter.uploadList.helper import com.nextcloud.utils.extensions.isSame import com.nextcloud.utils.extensions.updateStatus import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.datamodel.OCFile import com.owncloud.android.db.OCUpload import com.owncloud.android.lib.common.OwnCloudClient import com.owncloud.android.lib.common.operations.RemoteOperationResult @@ -28,9 +29,10 @@ class UploadListAdapterActionHandler : UploadListAdapterAction { override suspend fun handleConflict( upload: OCUpload, client: OwnCloudClient, - storageManager: UploadsStorageManager + storageManager: UploadsStorageManager, + existingFile: OCFile? ): ConflictHandlingResult = withContext(Dispatchers.IO) { - val operationResult = ReadFileRemoteOperation(upload.remotePath).execute(client) + val operationResult = ReadFileRemoteOperation(existingFile?.remotePath ?: upload.remotePath).execute(client) if (!operationResult.isSuccess) { return@withContext when (operationResult.code) { @@ -52,9 +54,9 @@ class UploadListAdapterActionHandler : UploadListAdapterAction { return@withContext ConflictHandlingResult.CannotCheckConflict } - val ocFile = FileStorageUtils.fillOCFile(remoteFile) + val ocFile = existingFile ?: FileStorageUtils.fillOCFile(remoteFile) - if (remoteFile.isSame(ocFile.storagePath)) { + if (!ocFile.isEncrypted && remoteFile.isSame(upload.localPath)) { updateStatus(upload, storageManager, success = true) ConflictHandlingResult.ConflictNotExistsSameFile } else { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt index d90ba0eb1883..ce10f03bc3bc 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt @@ -14,11 +14,13 @@ import androidx.core.content.FileProvider import com.nextcloud.utils.SnackbarUtil import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.db.OCUpload import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.ui.activity.ConflictsResolveActivity import com.owncloud.android.ui.activity.drawer.DrawerActivity import com.owncloud.android.ui.activity.FileDisplayActivity +import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.preview.media.PreviewMediaFragment import com.owncloud.android.utils.MimeType import com.owncloud.android.utils.MimeTypeUtil @@ -56,24 +58,27 @@ class UploadListAdapterHelper(private val activity: DrawerActivity) { } fun onUploadedItemClick(upload: OCUpload) { - val file = activity.storageManager.getFileByEncryptedRemotePath(upload.remotePath) + val user = activity.userAccountManager.getUser(upload.accountName).orElse(null) ?: return + val storage = FileDataStorageManager(user, activity.contentResolver) + val file = storage.getFileByEncryptedRemotePath(upload.remotePath) if (file == null) { SnackbarUtil.show(activity, R.string.error_retrieving_file) Log_OC.i(TAG, "Could not find uploaded file on remote.") return } - val optionalUser = activity.user - if (PreviewMediaFragment.canBePreviewed(file) && optionalUser.isPresent) { + if (PreviewMediaFragment.canBePreviewed(file)) { // show image preview and stay in uploads tab - val intent = FileDisplayActivity.openFileIntent(activity, optionalUser.get(), file) + val intent = FileDisplayActivity.openFileIntent(activity, user, file) activity.startActivity(intent) return } val intent = Intent(activity, FileDisplayActivity::class.java).apply { setAction(Intent.ACTION_VIEW) - putExtra(FileDisplayActivity.KEY_FILE_PATH, upload.remotePath) + putExtra(FileDisplayActivity.KEY_FILE_PATH, file.remotePath) + putExtra(FileActivity.EXTRA_USER, user) + putExtra(FileDisplayActivity.KEY_ACCOUNT, user.accountName) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } activity.startActivity(intent) diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt b/app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt index e88b2622fb62..517805d9f217 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/SyncedFolderPreferencesDialogFragment.kt @@ -27,6 +27,7 @@ import com.nextcloud.utils.text.SpanFormatter import com.owncloud.android.R import com.owncloud.android.databinding.SyncedFoldersSettingsLayoutBinding import com.owncloud.android.datamodel.MediaFolderType +import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.SyncedFolder import com.owncloud.android.datamodel.SyncedFolderDisplayItem import com.owncloud.android.files.services.NameCollisionPolicy @@ -237,9 +238,9 @@ class SyncedFolderPreferencesDialogFragment : setupViews(binding, enabled) } - fun setRemoteFolderSummary(path: String?) { - syncedFolder?.remotePath = path - binding?.remoteFolderSummary?.text = path + fun setRemoteFolderSummary(folder: OCFile?) { + syncedFolder?.remotePath = folder?.decryptedRemotePath + binding?.remoteFolderSummary?.text = folder?.decryptedRemotePath checkAndUpdateSaveButtonState() } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadClickHandler.kt b/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadClickHandler.kt index d6f32116f547..ea7a1db5a4ae 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadClickHandler.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadClickHandler.kt @@ -67,7 +67,7 @@ class AutoUploadClickHandler( Log_OC.d(TAG, "auto-upload configuration sync status is disabled: " + item.remotePath) fragment.lifecycleScope.launch(Dispatchers.IO) { - fileUploadHelper.removeEntityFromUploadEntities(item.id) + fileUploadHelper.removeEntityFromUploadEntities(item.id, item.account) } } @@ -114,7 +114,7 @@ class AutoUploadClickHandler( Log_OC.d(TAG, "deleting auto upload configuration: " + syncedFolder.remotePath) fragment.lifecycleScope.launch(Dispatchers.IO) { - fileUploadHelper.removeEntityFromUploadEntities(syncedFolder.id) + fileUploadHelper.removeEntityFromUploadEntities(syncedFolder.id, syncedFolder.account) persistence.deleteAutoUploadFolder(syncedFolder.id) withContext(Dispatchers.Main) { getAdapter().removeItem(syncedFolder.section) diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadPickers.kt b/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadPickers.kt index 4c8f5bff89dc..aee8559f6e5f 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadPickers.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/autoUpload/handler/AutoUploadPickers.kt @@ -35,6 +35,7 @@ class AutoUploadPickers( fun pickRemoteFolder() { val intent = Intent(fragment.requireContext(), FolderPickerActivity::class.java).apply { putExtra(FolderPickerActivity.EXTRA_ACTION, FolderPickerActivity.CHOOSE_LOCATION) + putExtra(FolderPickerActivity.EXTRA_ALLOW_ENCRYPTED_FOLDER, true) } remoteFolderLauncher.launch(intent) } @@ -57,7 +58,7 @@ class AutoUploadPickers( val dialog = findPreferencesDialog() ?: return val chosenFolder = result.data?.getParcelableArgument(FolderPickerActivity.EXTRA_FOLDER, OCFile::class.java) - dialog.setRemoteFolderSummary(chosenFolder?.remotePath) + dialog.setRemoteFolderSummary(chosenFolder) } private fun onLocalFolderPicked(result: ActivityResult) { diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListConflictHandler.kt b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListConflictHandler.kt index c2e594e842bc..dd6f0bdbca6a 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListConflictHandler.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListConflictHandler.kt @@ -11,18 +11,16 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import com.nextcloud.client.jobs.utils.UploadErrorNotificationManager +import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.utils.extensions.getTypedActivity import com.nextcloud.utils.extensions.webDavParentPath import com.owncloud.android.R -import com.owncloud.android.datamodel.UploadsStorageManager import com.owncloud.android.db.OCUpload import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation import com.owncloud.android.operations.factory.UploadFileOperationFactory import com.owncloud.android.ui.adapter.uploadList.UploadListAdapter import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult -import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterAction -import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterHelper import com.owncloud.android.ui.navigation.NavigatorActivity import kotlinx.coroutines.Dispatchers @@ -31,12 +29,10 @@ import kotlinx.coroutines.withContext class UploadListConflictHandler( private val fragment: Fragment, - private val uploadsStorageManager: UploadsStorageManager, private val uploadFileOperationFactory: UploadFileOperationFactory, private val adapterHelper: UploadListAdapterHelper, private val uploadListAdapter: UploadListAdapter ) { - private val adapterActionHandler: UploadListAdapterAction = UploadListAdapterActionHandler() private var conflictSnackbar: Snackbar? = null fun handleConflict(upload: OCUpload) { @@ -50,8 +46,7 @@ class UploadListConflictHandler( ).apply { show() } fragment.lifecycleScope.launch { - val client = activity.clientRepository.getOwncloudClient() ?: return@launch - val result = adapterActionHandler.handleConflict(upload, client, uploadsStorageManager) + val result = FileUploadHelper.instance().checkConflict(upload) withContext(Dispatchers.Main) { when (result) { @@ -79,12 +74,14 @@ class UploadListConflictHandler( private fun retryUpload(activity: NavigatorActivity, upload: OCUpload) { fragment.lifecycleScope.launch(Dispatchers.IO) { - val client = activity.clientRepository.getOwncloudClient() + val user = activity.userAccountManager.getUser(upload.accountName).orElse(null) ?: return@launch + val client = activity.clientRepository.getOwncloudClient(user) ?: return@launch + val operation = uploadFileOperationFactory.create(activity, upload, user = user) - val file = activity.storageManager.getFileByPath(upload.remotePath) + val file = operation.storageManager.getFileByEncryptedRemotePath(upload.remotePath) val parentPath = (file?.parentRemotePath ?: upload.remotePath?.webDavParentPath()) - parentPath?.let { + parentPath?.takeUnless { operation.isAutoUpload }?.let { val checkResult = ExistenceCheckRemoteOperation(it, false).execute(client) if (!checkResult.isSuccess && @@ -97,9 +94,7 @@ class UploadListConflictHandler( } } - val result = uploadFileOperationFactory - .create(activity, upload) - .execute(client) + val result = operation.execute(client) if (result.isSuccess) { withContext(Dispatchers.Main) { diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt index 74306573276b..5362321187cd 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt @@ -227,7 +227,6 @@ class UploadListFragment : ) conflictHandler = UploadListConflictHandler( this, - uploadsStorageManager, uploadFileOperationFactory, adapterHelper, uploadListAdapter diff --git a/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java b/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java index 941b3346be29..8b85bd148c50 100644 --- a/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java +++ b/app/src/main/java/com/owncloud/android/utils/EncryptionUtils.java @@ -450,7 +450,7 @@ public static DecryptedFolderMetadataFileV1 decryptFolderMetaData(EncryptedFolde user, folder.getLocalId()); - OCCapability capability = CapabilityUtils.getCapability(context); + OCCapability capability = CapabilityUtils.getCapability(user, context); if (E2EVersionHelper.INSTANCE.isV2Plus(capability)) { new EncryptionUtilsV2().migrateV1ToV2andUpload( v1, diff --git a/app/src/main/java/com/owncloud/android/utils/ErrorMessageAdapter.kt b/app/src/main/java/com/owncloud/android/utils/ErrorMessageAdapter.kt index a51c818d5384..3da52c22735a 100644 --- a/app/src/main/java/com/owncloud/android/utils/ErrorMessageAdapter.kt +++ b/app/src/main/java/com/owncloud/android/utils/ErrorMessageAdapter.kt @@ -33,6 +33,7 @@ import com.owncloud.android.operations.UpdateSharePermissionsOperation import com.owncloud.android.operations.UpdateShareViaLinkOperation import com.owncloud.android.operations.UploadFileOperation import com.owncloud.android.operations.albums.CopyFileToAlbumOperation +import com.owncloud.android.operations.upload.UploadFileException import org.apache.commons.httpclient.ConnectTimeoutException import org.apache.commons.httpclient.HttpStatus import java.io.File @@ -203,6 +204,13 @@ object ErrorMessageAdapter { return res.getString(R.string.uploader_upload_succeeded_content_single, operation.fileName) } + if (result.exception is UploadFileException.EncryptionKeysUnavailable) { + return res.getString(R.string.upload_encryption_keys_unavailable) + } + if (result.exception is UploadFileException.InvalidEncryptionMetadata) { + return res.getString(R.string.upload_encryption_metadata_unavailable) + } + return when (result.code) { ResultCode.LOCAL_STORAGE_FULL, ResultCode.LOCAL_STORAGE_NOT_COPIED -> res.getString( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3d6f60bf3e1..e9dd42072ce5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1647,4 +1647,6 @@ Random button Server not allowed Run sync now + Encrypted upload paused. Unlock end-to-end encryption for the account that owns this upload to continue. + Encrypted upload paused because the folder’s encryption information could not be read. The file remains queued. diff --git a/app/src/test/java/com/nextcloud/utils/e2ee/E2ECounterHelperTest.kt b/app/src/test/java/com/nextcloud/utils/e2ee/E2ECounterHelperTest.kt new file mode 100644 index 000000000000..5524b72f9c4b --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/e2ee/E2ECounterHelperTest.kt @@ -0,0 +1,28 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.e2ee + +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.resources.status.E2EVersion +import com.owncloud.android.lib.resources.status.OCCapability +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class E2ECounterHelperTest { + @Test + fun usesTaskAccountCapabilityWithoutReadingForegroundAccount() { + val folder: OCFile = mock { on { e2eCounter } doReturn 5L } + val accountA: OCCapability = mock { on { endToEndEncryptionApiVersion } doReturn E2EVersion.V1_2 } + val accountB: OCCapability = mock { on { endToEndEncryptionApiVersion } doReturn E2EVersion.V2_0 } + + assertEquals(-1L, E2ECounterHelper.getCounter(accountA, folder, null)) + assertEquals(6L, E2ECounterHelper.getCounter(accountB, folder, null)) + assertEquals(-1L, E2ECounterHelper.getCounter(accountA, folder, null)) + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/CreateFolderCollisionTest.kt b/app/src/test/java/com/owncloud/android/operations/CreateFolderCollisionTest.kt new file mode 100644 index 000000000000..660e5b7f7796 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/CreateFolderCollisionTest.kt @@ -0,0 +1,43 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations + +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedMetadata +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +class CreateFolderCollisionTest { + private val operation = CreateFolderOperation("/private/B/", mock(), mock(), mock()) + + @Test + fun concurrentEncryptedFolderCreationRecognizesFolderNamesIgnoringCase() { + assertTrue(exists("b")) + } + + @Test + fun encryptedFolderCreationDoesNotMatchCiphertextAsLogicalName() { + assertFalse(exists("ciphertext")) + } + + private fun exists(name: String): Boolean { + val decrypted: DecryptedMetadata = mock { + on { folders } doReturn mutableMapOf("ciphertext" to "B") + on { files } doReturn mutableMapOf() + } + val metadata: DecryptedFolderMetadataFile = mock { on { this.metadata } doReturn decrypted } + val check = CreateFolderOperation::class.java.getDeclaredMethod( + "isFileExisting", + DecryptedFolderMetadataFile::class.java, + String::class.java + ).apply { isAccessible = true } + return check.invoke(operation, metadata, name) as Boolean + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/RefreshEncryptedFolderTest.kt b/app/src/test/java/com/owncloud/android/operations/RefreshEncryptedFolderTest.kt new file mode 100644 index 000000000000..b935c160ac64 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/RefreshEncryptedFolderTest.kt @@ -0,0 +1,136 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations + +import android.content.Context +import com.nextcloud.client.account.User +import com.nextcloud.utils.extensions.saveFolder +import com.nextcloud.utils.share.UnifiedShareSharees +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1 +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedMetadata +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.lib.resources.status.E2EVersion +import com.owncloud.android.lib.resources.status.OCCapability +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.MimeType +import com.owncloud.android.utils.theme.CapabilityUtils +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.lang.reflect.InvocationTargetException + +class RefreshEncryptedFolderTest { + @Test + fun v1RefreshPreservesFolderNameFromParentMetadata() { + assertFolderMappingPreserved(DecryptedFolderMetadataFileV1(), E2EVersion.V1_2) + } + + @Test + fun v2RefreshPreservesFolderNameAndInheritedEncryption() { + assertFolderMappingPreserved( + DecryptedFolderMetadataFile(metadata = DecryptedMetadata(metadataKey = ByteArray(0)), version = "2.0"), + E2EVersion.V2_0 + ) + } + + private fun assertFolderMappingPreserved(metadata: Any, version: E2EVersion) { + val folder = OCFile("/private/cipher/").apply { + mimeType = MimeType.DIRECTORY + decryptedRemotePath = "/private/photos/" + isEncrypted = true + } + val remoteFolder = OCFile(folder.remotePath).apply { + mimeType = MimeType.DIRECTORY + decryptedRemotePath = remotePath + } + val context: Context = mock() + val user: User = mock() + val storage: FileDataStorageManager = mock() + whenever(storage.getFileByPath(folder.remotePath)).thenReturn(folder) + whenever(storage.getFolderContent(folder, false)).thenReturn(emptyList()) + whenever(storage.getE2EEVersionObject(user)).thenReturn(version) + val capability: OCCapability = mock { on { endToEndEncryptionApiVersion } doReturn version } + val remote: RemoteFile = mock() + val operation = RefreshFolderOperation(folder, storage, user, context) + val synchronize = RefreshFolderOperation::class.java.getDeclaredMethod("synchronizeData", List::class.java) + .apply { isAccessible = true } + + val files = mockStatic(FileStorageUtils::class.java) + val capabilities = mockStatic(CapabilityUtils::class.java) + val encryption = mockStatic(EncryptionUtils::class.java) + val shares = mockStatic(UnifiedShareSharees::class.java) + val persistence = mockStatic(Class.forName("com.nextcloud.utils.extensions.FileDataStorageManagerExtensionsKt")) + try { + files.`when` { FileStorageUtils.fillOCFile(remote) }.thenReturn(remoteFolder) + files.`when` { FileStorageUtils.checkEncryptionStatus(folder, storage) }.thenReturn(true) + capabilities.`when` { CapabilityUtils.getCapability(user, context) }.thenReturn(capability) + encryption.`when` { EncryptionUtils.downloadFolderMetadata(folder, null, context, user) } + .thenReturn(metadata) + synchronize.invoke(operation, listOf(remote)) + + persistence.verify { + eq(storage).saveFolder( + argThat { + remotePath == "/private/cipher/" && + decryptedRemotePath == "/private/photos/" && isEncrypted + }, + any(), + any() + ) + } + } finally { + persistence.close() + shares.close() + encryption.close() + capabilities.close() + files.close() + } + } + + @Test + fun v1MetadataFailureCannotSaveCiphertextChildrenAsLogicalNames() { + val folder = OCFile("/private/").apply { + mimeType = MimeType.DIRECTORY + isEncrypted = true + } + val context: Context = mock() + val user: User = mock() + val storage: FileDataStorageManager = mock() + whenever(storage.getFileByPath(folder.remotePath)).thenReturn(folder) + val capability: OCCapability = mock { on { endToEndEncryptionApiVersion } doReturn E2EVersion.V1_2 } + val remote: RemoteFile = mock() + val operation = RefreshFolderOperation(folder, storage, user, context) + val synchronize = RefreshFolderOperation::class.java.getDeclaredMethod("synchronizeData", List::class.java) + .apply { isAccessible = true } + + mockStatic(FileStorageUtils::class.java).use { files -> + files.`when` { FileStorageUtils.fillOCFile(remote) }.thenReturn(folder) + files.`when` { FileStorageUtils.checkEncryptionStatus(folder, storage) }.thenReturn(true) + mockStatic(CapabilityUtils::class.java).use { capabilities -> + capabilities.`when` { CapabilityUtils.getCapability(user, context) } + .thenReturn(capability) + mockStatic(EncryptionUtils::class.java).use { + val error = assertThrows(InvocationTargetException::class.java) { + synchronize.invoke(operation, listOf(remote, mock())) + } + assertTrue(error.cause is IllegalStateException) + } + } + } + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/AutoUploadIdentityTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/AutoUploadIdentityTest.kt new file mode 100644 index 000000000000..d63b466a0a97 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/AutoUploadIdentityTest.kt @@ -0,0 +1,65 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.OCUpload +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.operations.UploadFileOperation +import com.nextcloud.client.jobs.upload.FileUploadWorker +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import java.io.File + +class AutoUploadIdentityTest { + @Test + fun cancellationAndDuplicateDetectionUseQueueIdentityAndAccountAfterEncryption() { + val uploadUser: com.nextcloud.client.account.User = mock { on { accountName } doReturn "account-A" } + val operation: UploadFileOperation = mock { + on { user } doReturn uploadUser + on { ocUploadId } doReturn 1L + on { remotePath } doReturn "/private/ciphertext" + on { uploadRemotePath } doReturn "/private/photo.jpg" + } + FileUploadWorker.registerActiveUpload(operation) + try { + assertTrue(FileUploadWorker.isUploading("/private/photo.jpg", "account-A")) + assertTrue(FileUploadWorker.isUploading("/private/ciphertext", "account-A")) + assertFalse(FileUploadWorker.isUploading("/private/photo.jpg", "account-B")) + + FileUploadWorker.cancelUpload("/private/photo.jpg", "account-A") + + verify(operation).cancel(ResultCode.USER_CANCELLED) + assertFalse(FileUploadWorker.isUploading("/private/photo.jpg", "account-A")) + } finally { + FileUploadWorker.unregisterActiveUpload(operation.ocUploadId) + } + } + + @Test + fun ciphertextPathChangesDoNotChangeAutoUploadQueueIdentity() { + val file = OCFile("/private/photo.jpg").apply { storagePath = "/storage/photo.jpg" } + val upload = OCUpload(File.separator + "storage/photo.jpg", "/private/photo.jpg", "account-A").apply { + createdBy = UploadFileOperation.CREATED_AS_INSTANT_PICTURE + } + val operation = UploadFileOperation( + mock(), mock(), mock(), mock { on { accountName } doReturn "account-A" }, file, upload, + NameCollisionPolicy.OVERWRITE, 0, mock(), false, false, mock() + ) + + file.remotePath = "/private/ciphertext" + + assertEquals("/private/ciphertext", operation.remotePath) + assertEquals("/private/photo.jpg", operation.uploadRemotePath) + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/AutoUploadRollbackTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/AutoUploadRollbackTest.kt new file mode 100644 index 000000000000..564b9eb660bb --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/AutoUploadRollbackTest.kt @@ -0,0 +1,115 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1 +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.db.OCUpload +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.operations.e2e.E2EFiles +import com.owncloud.android.utils.EncryptionUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions +import java.io.File +import java.nio.channels.FileChannel +import java.nio.channels.FileLock + +class AutoUploadRollbackTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val client: OwnCloudClient = mock() + private val storage: UploadsStorageManager = mock() + private val user: User = mock { on { accountName } doReturn "A" } + private val file = OCFile("/private/photo.jpg").apply { storagePath = "/storage/photo.jpg" } + private val upload = OCUpload(File.separator + "storage/photo.jpg", file.remotePath, "A").apply { + createdBy = UploadFileOperation.CREATED_AS_INSTANT_PICTURE + folderUnlockToken = "previous-token" + } + private val operation = UploadFileOperation( + storage, mock(), mock(), user, file, upload, + NameCollisionPolicy.OVERWRITE, 0, mock(), false, false, mock() + ) + private val parent = OCFile("/private/").apply { remoteId = "folder-id" } + + @Test + fun failedV1WriteLeavesStagedDataForServerRollback() { + assertFailedWriteDoesNotCommit(mock()) + } + + @Test + fun failedV2MetadataUpdateLeavesStagedDataForServerRollback() { + assertFailedWriteDoesNotCommit(mock()) + } + + @Test + fun conflictBeforeWritingStillReleasesCleanLock() { + val conflict = RemoteOperationResult(ResultCode.SYNC_CONFLICT) + mockStatic(EncryptionUtils::class.java).use { encryption -> + encryption.`when`> { + EncryptionUtils.unlockFolderV1(parent, client, "previous-token") + }.thenReturn(RemoteOperationResult(ResultCode.OK)) + + assertSame(conflict, finish(conflict, mock(), false)) + + encryption.verify { EncryptionUtils.unlockFolderV1(parent, client, "previous-token") } + } + } + + @Test + fun retryAcquiresNewLockInsteadOfReusingExpiredSavedToken() { + mockStatic(EncryptionUtils::class.java).use { encryption -> + encryption.`when` { EncryptionUtils.lockFolder(parent, client, -1L) } + .thenReturn("fresh-token") + val lock = UploadFileOperation::class.java.getDeclaredMethod( + "getFolderUnlockTokenOrLockFolder", + OwnCloudClient::class.java, + OCFile::class.java, + Long::class.javaPrimitiveType + ).apply { isAccessible = true } + + assertEquals("fresh-token", lock.invoke(operation, client, parent, -1L)) + assertEquals("fresh-token", upload.folderUnlockToken) + encryption.verify { EncryptionUtils.lockFolder(parent, client, -1L) } + } + } + + private fun assertFailedWriteDoesNotCommit(metadata: Any) { + val failed = RemoteOperationResult(ResultCode.UNKNOWN_ERROR) + mockStatic(EncryptionUtils::class.java).use { encryption -> + assertSame(failed, finish(failed, metadata, true)) + encryption.verifyNoInteractions() + verifyNoInteractions(storage) + assertEquals("previous-token", upload.folderUnlockToken) + } + } + + private fun finish(result: RemoteOperationResult<*>, metadata: Any, writeStarted: Boolean): Any? { + val finish = UploadFileOperation::class.java.getDeclaredMethod( + "releaseLocksAndUnlockE2EFolder", FileLock::class.java, FileChannel::class.java, E2EFiles::class.java, + RemoteOperationResult::class.java, Any::class.java, OwnCloudClient::class.java, String::class.java, + Boolean::class.javaPrimitiveType + ).apply { isAccessible = true } + val original = temporaryFolder.newFile() + val files = E2EFiles(parent, null, original, null, null) + return finish.invoke(operation, null, null, files, result, metadata, client, "previous-token", writeStarted) + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/UploadFileResolverTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/UploadFileResolverTest.kt index f6d77b36e715..8c98807110c3 100644 --- a/app/src/test/java/com/owncloud/android/operations/upload/UploadFileResolverTest.kt +++ b/app/src/test/java/com/owncloud/android/operations/upload/UploadFileResolverTest.kt @@ -108,6 +108,73 @@ class UploadFileResolverTest { assertEquals(FILE_NAMES, resolver.getCollidedFileNames(metadata)) } + @Test + fun conflictTargetUsesDecryptedNamesInsteadOfCiphertextNames() { + val parent = OCFile("/private/") + val existing = OCFile("/private/ciphertext").apply { + decryptedRemotePath = "/private/Photo.jpg" + } + whenever(fileDataStorageManager.getFolderContent(parent, false)).thenReturn(listOf(existing)) + + assertEquals(existing, resolver.findEncryptedTarget(parent, "photo.jpg")) + assertNull(resolver.findEncryptedTarget(parent, "different.jpg")) + } + + @Test + fun overwriteFindsCiphertextIdentityInV2MetadataByPlaintextName() { + val metadata = DecryptedFolderMetadataFile( + metadata = DecryptedMetadata( + files = mutableMapOf( + "ciphertext" to DecryptedFile( + filename = "photo.jpg", + mimetype = "image/jpeg", + nonce = "old", + authenticationTag = "", + key = "old" + ) + ), + metadataKey = ByteArray(0) + ), + version = "2.0" + ) + + assertEquals("ciphertext", resolver.getEncryptedFileName(metadata, "photo.jpg")) + assertNull(resolver.getEncryptedFileName(metadata, "photo (2).jpg")) + assertEquals("ciphertext", resolver.getEncryptedFileName(metadata, "PHOTO.jpg")) + } + + @Test + fun encryptedFolderNamesParticipateInFileCollisionDetection() { + val metadata = DecryptedFolderMetadataFile( + metadata = DecryptedMetadata( + folders = mutableMapOf("cipherFolder" to "photos"), + metadataKey = ByteArray(0) + ), + version = "2.0" + ) + + assertEquals(listOf("photos"), resolver.getCollidedFileNames(metadata)) + assertTrue(resolver.isFolder(metadata, "photos")) + assertTrue(resolver.isFolder(metadata, "PHOTOS")) + assertNull(resolver.getEncryptedFileName(metadata, "photos")) + } + + @Test + fun overwriteFindsCiphertextIdentityInV1MetadataByPlaintextName() { + val metadata = DecryptedFolderMetadataFileV1().apply { + setFiles( + mapOf( + "ciphertext" to DecryptedFileV1().apply { + encrypted = Data().apply { filename = "photo.jpg" } + } + ) + ) + } + + assertEquals("ciphertext", resolver.getEncryptedFileName(metadata, "photo.jpg")) + assertNull(resolver.getEncryptedFileName(metadata, "other.jpg")) + } + @Test fun collidedFileNamesOfUnknownMetadataAreEmpty() { assertTrue(resolver.getCollidedFileNames(null).isEmpty()) @@ -147,6 +214,7 @@ class UploadFileResolverTest { lastSyncDateForData = 6 storagePath = "/storage/photo.jpg" parentId = 7 + isEncrypted = true } val renamed = resolver.createNewOCFile(original, "/photo (2).jpg") @@ -162,6 +230,7 @@ class UploadFileResolverTest { assertEquals(6L, renamed.lastSyncDateForData) assertEquals("/storage/photo.jpg", renamed.storagePath) assertEquals(7L, renamed.parentId) + assertTrue(renamed.isEncrypted) } @Test diff --git a/app/src/test/java/com/owncloud/android/operations/upload/UploadFolderBackendTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/UploadFolderBackendTest.kt new file mode 100644 index 000000000000..5fcef4d5f250 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/UploadFolderBackendTest.kt @@ -0,0 +1,194 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.nextcloud.client.account.User +import com.nextcloud.utils.extensions.isNonRetryable +import com.owncloud.android.datamodel.ArbitraryDataProvider +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.UploadResult +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.e2ee.GetMetadataRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.CreateFolderOperation +import com.owncloud.android.operations.RefreshFolderOperation +import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.utils.MimeType +import com.owncloud.android.utils.EncryptionUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mockConstruction +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class UploadFolderBackendTest { + private val context: android.content.Context = mock() + private val user: User = mock { on { accountName } doReturn "A" } + private val storage: FileDataStorageManager = mock() + private val client: OwnCloudClient = mock() + private val keys: ArbitraryDataProvider = mock { + on { getValue(any(), any()) } doReturn "" + } + private val operation: UploadFileOperation = mock { + on { user } doReturn this@UploadFolderBackendTest.user + on { storageManager } doReturn storage + on { context } doReturn this@UploadFolderBackendTest.context + } + + @Test + fun missingTaskAccountKeysStopsBeforeCreatingChildrenAndRemainsRetryable() { + val root = OCFile("/").apply { mimeType = MimeType.DIRECTORY } + val encrypted = OCFile("/private/").apply { mimeType = MimeType.DIRECTORY } + whenever(storage.getFileByEncryptedRemotePath("/")).thenReturn(root) + whenever(storage.getFolderContent(root, false)).thenReturn(listOf(encrypted)) + val properties: RemoteFile = mock { + on { remoteId } doReturn "encrypted-id" + on { isEncrypted } doReturn true + } + val rootProperties: RemoteFile = mock { on { remoteId } doReturn "root-id" } + val rootResult: RemoteOperationResult = mock { + on { isSuccess } doReturn true + on { data } doReturn arrayListOf(rootProperties) + } + val encryptedResult: RemoteOperationResult = mock { + on { isSuccess } doReturn true + on { data } doReturn arrayListOf(properties) + } + mockConstruction(ReadFileRemoteOperation::class.java) { read, construction -> + doReturn(if (construction.arguments()[0] == "/") rootResult else encryptedResult) + .whenever(read).execute(client) + }.use { + mockConstruction(RefreshFolderOperation::class.java) { refresh, _ -> + doReturn(RemoteOperationResult(ResultCode.OK)).whenever(refresh).execute(client) + }.use { refreshes -> + mockConstruction(CreateFolderOperation::class.java).use { creations -> + val error = assertThrows(UploadFolderException::class.java) { + UploadFolderBackend(operation, client, keys).resolve("/private/new/") + } + + assertTrue(error.result.exception is UploadFileException.EncryptionKeysUnavailable) + assertFalse(UploadResult.fromOperationResult(error.result).isNonRetryable()) + assertEquals(1, refreshes.constructed().size) + assertTrue(creations.constructed().isEmpty()) + } + } + } + verify(keys, never()).getValue(org.mockito.kotlin.eq("B"), any()) + } + + @Test + fun remoteReadFailureDoesNotUseCachedEncryptedFolderOrCreateAnything() { + val root = OCFile("/").apply { mimeType = MimeType.DIRECTORY } + whenever(storage.getFileByEncryptedRemotePath("/")).thenReturn(root) + mockConstruction(ReadFileRemoteOperation::class.java) { read, _ -> + doReturn(RemoteOperationResult(ResultCode.UNAUTHORIZED)).whenever(read).execute(client) + }.use { + mockConstruction(CreateFolderOperation::class.java).use { creations -> + val error = assertThrows(UploadFolderException::class.java) { + UploadFolderBackend(operation, client, keys).resolve("/private/new/") + } + + assertEquals(ResultCode.UNAUTHORIZED, error.result.code) + assertTrue(creations.constructed().isEmpty()) + verify(storage, never()).getFolderContent(any(), any()) + } + } + } + + @Test + fun cancelledPreparationStopsBeforeNetworkAccessOrFolderCreation() { + val root = OCFile("/").apply { mimeType = MimeType.DIRECTORY } + whenever(storage.getFileByEncryptedRemotePath("/")).thenReturn(root) + whenever(operation.isCancellationRequested).thenReturn(true) + mockConstruction(ReadFileRemoteOperation::class.java).use { reads -> + mockConstruction(CreateFolderOperation::class.java).use { creations -> + val error = assertThrows(UploadFolderException::class.java) { + UploadFolderBackend(operation, client, keys).resolve("/private/new/") + } + + assertEquals(ResultCode.USER_CANCELLED, error.result.code) + assertTrue(reads.constructed().isEmpty()) + assertTrue(creations.constructed().isEmpty()) + } + } + } + + @Test + fun deletedEncryptedTargetIsRecreatedAsOrdinaryWithoutEncryptionKeys() { + val root = OCFile("/").apply { mimeType = MimeType.DIRECTORY } + val target = OCFile("/private/").apply { mimeType = MimeType.DIRECTORY } + whenever(storage.getFileByEncryptedRemotePath("/")).thenReturn(root) + whenever(storage.getFolderContent(root, false)).thenReturn(emptyList(), listOf(target)) + val properties: RemoteFile = mock { on { remoteId } doReturn "00000042instance" } + val readResult: RemoteOperationResult = mock { + on { isSuccess } doReturn true + on { data } doReturn arrayListOf(properties) + } + mockConstruction(ReadFileRemoteOperation::class.java) { read, _ -> + doReturn(readResult).whenever(read).execute(client) + }.use { + mockConstruction(RefreshFolderOperation::class.java) { refresh, _ -> + doReturn(RemoteOperationResult(ResultCode.OK)).whenever(refresh).execute(client) + }.use { + mockConstruction(CreateFolderOperation::class.java) { create, construction -> + assertEquals("/private/", construction.arguments()[0]) + doReturn(RemoteOperationResult(ResultCode.OK)).whenever(create).execute(client) + }.use { creations -> + val resolved = UploadFolderBackend(operation, client, keys).resolve("/private/") + + assertEquals(target, resolved) + assertFalse(resolved.isEncrypted) + assertEquals(1, creations.constructed().size) + } + } + } + verify(keys, never()).getValue("A", EncryptionUtils.PRIVATE_KEY) + verify(keys, never()).getValue("A", EncryptionUtils.PUBLIC_KEY) + } + + @Test + fun encryptedFolderRefreshUsesExistingMetadataValidationWithoutDownloadingAgain() { + val root = OCFile("/").apply { mimeType = MimeType.DIRECTORY } + val target = OCFile("/private/").apply { mimeType = MimeType.DIRECTORY } + whenever(storage.getFileByEncryptedRemotePath("/")).thenReturn(root) + whenever(storage.getFolderContent(root, false)).thenReturn(listOf(target)) + whenever(keys.getValue("A", EncryptionUtils.PRIVATE_KEY)).thenReturn("key") + whenever(keys.getValue("A", EncryptionUtils.PUBLIC_KEY)).thenReturn("key") + mockConstruction(ReadFileRemoteOperation::class.java) { read, construction -> + val properties: RemoteFile = mock { + on { remoteId } doReturn "00000042instance" + on { isEncrypted } doReturn (construction.arguments()[0] != "/") + } + val result: RemoteOperationResult = mock { + on { isSuccess } doReturn true + on { data } doReturn arrayListOf(properties) + } + doReturn(result).whenever(read).execute(client) + }.use { + mockConstruction(RefreshFolderOperation::class.java) { refresh, _ -> + doReturn(RemoteOperationResult(ResultCode.OK)).whenever(refresh).execute(client) + }.use { refreshes -> + mockConstruction(GetMetadataRemoteOperation::class.java).use { metadata -> + assertEquals(target, UploadFolderBackend(operation, client, keys).resolve("/private/")) + assertEquals(2, refreshes.constructed().size) + assertTrue(metadata.constructed().isEmpty()) + } + } + } + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/UploadFolderResolverTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/UploadFolderResolverTest.kt new file mode 100644 index 000000000000..e79c5949523f --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/UploadFolderResolverTest.kt @@ -0,0 +1,124 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.OCFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class UploadFolderResolverTest { + private val root = folder("/") + private val children = mutableMapOf, OCFile>() + private val refreshed = mutableListOf() + private val created = mutableListOf>() + + @Test + fun discoversEncryptedChildOfOrdinaryRootFromFreshListing() { + val ordinary = folder("/A/") + val encrypted = folder("/A/B/", encrypted = true) + val resolver = resolver { parent -> + if (parent === root) children[root to "A"] = ordinary + if (parent === ordinary) children[ordinary to "B"] = encrypted + } + + assertSame(encrypted, resolver.resolve("/A/B/")) + assertEquals(listOf(root, ordinary, encrypted), refreshed) + assertTrue(created.isEmpty()) + } + + @Test + fun createsEveryMissingLevelInsideEncryptedParentInOrder() { + val encrypted = folder("/private/") + children[root to "private"] = encrypted + val resolver = resolver() + + resolver.resolve("/private/2026/11/照片/") + + assertEquals(listOf("2026", "11", "照片"), created.map { it.second }) + assertSame(encrypted, created.first().first) + assertSame(refreshed[2], created[1].first) + assertSame(refreshed[3], created[2].first) + } + + @Test + fun usesLogicalNamesForChildrenWithCiphertextPaths() { + val encrypted = folder("/private/") + val child = folder("/private/randomCiphertext/", "/private/ζ—…θ‘Œ/", true) + children[root to "private"] = encrypted + children[encrypted to "ζ—…θ‘Œ"] = child + + assertSame(child, resolver().resolve("/private/ζ—…θ‘Œ/")) + assertTrue(created.isEmpty()) + } + + @Test + fun backslashInAndroidFolderNameIsNotTreatedAsAPathSeparator() { + val child = folder("/cipher/", "/back\\slash/", true) + children[root to "back\\slash"] = child + + assertSame(child, resolver().resolve("/back\\slash/")) + assertTrue(created.isEmpty()) + } + + @Test + fun recreatesDeletedFoldersUsingFreshListingInsteadOfStaleCache() { + val stale = folder("/A/") + children[root to "A"] = stale + val resolver = resolver { if (it === root) children.remove(root to "A") } + + val recreated = resolver.resolve("/A/B/") + + assertEquals(listOf("A", "B"), created.map { it.second }) + assertEquals("/A/B/", recreated.decryptedRemotePath) + } + + @Test + fun keyOrMetadataFailureStopsBeforeCreatingOrResolvingFurtherChildren() { + val encrypted = folder("/private/", encrypted = true) + children[root to "private"] = encrypted + val failure = IllegalStateException("Keys unavailable") + val resolver = resolver { if (it === encrypted) throw failure } + + assertSame(failure, assertThrows(IllegalStateException::class.java) { resolver.resolve("/private/new/") }) + assertTrue(created.isEmpty()) + } + + @Test + fun rejectsInvalidPathsBeforeAnyRemoteOperation() { + listOf("relative/path/", "/a/../b/", "/./").forEach { path -> + assertThrows(IllegalArgumentException::class.java) { resolver().resolve(path) } + } + assertTrue(refreshed.isEmpty()) + assertTrue(created.isEmpty()) + } + + private fun resolver(onRefresh: (OCFile) -> Unit = {}) = UploadFolderResolver( + root = { root }, + refresh = { + refreshed.add(it) + onRefresh(it) + }, + findChild = { parent, name -> children[parent to name] }, + createChild = { parent, name -> + created.add(parent to name) + folder(parent.decryptedRemotePath + name + "/").also { children[parent to name] = it } + } + ) + + private fun folder(path: String, logicalPath: String = path, encrypted: Boolean = false): OCFile = + mock().also { + whenever(it.remotePath).thenReturn(path) + whenever(it.decryptedRemotePath).thenReturn(logicalPath) + whenever(it.isFolder).thenReturn(true) + whenever(it.isEncrypted).thenReturn(encrypted) + } +} From a3b852142a5a4884bfacdfc49fb156c7bfb03593 Mon Sep 17 00:00:00 2001 From: Li Wei Date: Thu, 12 Nov 2026 21:38:08 +0800 Subject: [PATCH 2/2] fix(e2ee): complete encrypted auto-upload conflict and preview flows Verify same-name encrypted files using authenticated plaintext and cache unchanged versions. Reuse verified downloads, complete identical-file records through the existing upload flow, and clean temporary plaintext and ciphertext. Preserve upload names, results and progress; authenticate encrypted downloads before publication; resolve first-open preview events by account and physical path; reuse local originals for upload and encrypted-folder thumbnails. Assisted-by: Codex:GPT-6 Signed-off-by: Li Wei --- .../client/database/dao/FileSystemDao.kt | 14 ++ .../jobs/autoUpload/AutoUploadWorker.kt | 6 +- .../download/DownloadEventFileResolver.kt | 17 ++ .../jobs/download/FileDownloadWorker.kt | 2 +- .../upload/AutoUploadProgressForwarder.kt | 17 ++ .../client/jobs/upload/FileUploadHelper.kt | 73 +++++-- .../thumbnail/AutoUploadThumbnailSource.kt | 35 +++ .../utils/thumbnail/ThumbnailGenerator.kt | 4 + .../utils/thumbnail/UploadThumbnailSource.kt | 86 ++++++++ .../main/java/com/owncloud/android/MainApp.kt | 3 + .../datamodel/ThumbnailsCacheManager.java | 64 +++--- .../datamodel/UploadsStorageManager.kt | 3 +- .../operations/DownloadFileOperation.kt | 116 +++++++--- .../android/operations/EncryptedDownload.kt | 50 +++++ .../operations/UploadFileOperation.java | 19 +- .../upload/EncryptedFileComparison.kt | 11 + .../upload/EncryptedFileComparisonCache.kt | 36 +++ .../operations/upload/EncryptedFileContent.kt | 79 +++++++ .../upload/EncryptedFileMetadata.kt | 45 ++++ .../operations/upload/EncryptedLocalCopy.kt | 27 +++ .../upload/EncryptedUploadConflictChecker.kt | 201 +++++++++++++++++ .../operations/upload/UploadFileLink.kt | 41 ++++ .../operations/upload/UploadFileResolver.kt | 10 +- .../ui/activity/ConflictsResolveActivity.kt | 121 ++++++++-- .../ui/activity/FileDisplayActivity.kt | 16 +- .../UploadProgressListener.kt | 7 + .../adapter/uploadList/UploadListAdapter.kt | 35 ++- .../helper/ConflictHandlingResult.kt | 6 +- .../helper/UploadListAdapterActionHandler.kt | 3 +- .../helper/UploadListAdapterHelper.kt | 36 ++- .../ui/asynctasks/GetRemoteFileTask.kt | 30 +++ .../dialog/conflict/ConflictsResolveDialog.kt | 14 +- .../uploadList/UploadListConflictHandler.kt | 16 +- .../fragment/uploadList/UploadListFragment.kt | 1 - .../download/DownloadEventFileResolverTest.kt | 83 +++++++ .../AutoUploadThumbnailSourceTest.kt | 104 +++++++++ .../utils/thumbnail/ThumbnailGeneratorTest.kt | 80 +++++++ .../thumbnail/UploadThumbnailSourceTest.kt | 206 ++++++++++++++++++ .../UploadsStorageManagerCursorTest.kt | 92 ++++++++ .../operations/EncryptedDownloadTest.kt | 139 ++++++++++++ .../upload/AutoUploadProgressForwarderTest.kt | 51 +++++ .../upload/EncryptedConflictFilesTest.kt | 105 +++++++++ .../EncryptedFileComparisonCacheTest.kt | 71 ++++++ .../upload/EncryptedFileContentTest.kt | 126 +++++++++++ .../upload/EncryptedFileMetadataTest.kt | 68 ++++++ .../upload/EncryptedLocalCopyTest.kt | 66 ++++++ .../EncryptedUploadConflictCheckerTest.kt | 134 ++++++++++++ .../upload/EncryptedUploadedFileOpenTest.kt | 116 ++++++++++ .../operations/upload/UploadFileLinkTest.kt | 127 +++++++++++ .../upload/UploadFileResolverTest.kt | 41 ++++ .../upload/UploadProgressDisplayTest.kt | 31 +++ 51 files changed, 2754 insertions(+), 130 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/client/jobs/download/DownloadEventFileResolver.kt create mode 100644 app/src/main/java/com/nextcloud/client/jobs/upload/AutoUploadProgressForwarder.kt create mode 100644 app/src/main/java/com/nextcloud/utils/thumbnail/AutoUploadThumbnailSource.kt create mode 100644 app/src/main/java/com/nextcloud/utils/thumbnail/UploadThumbnailSource.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/EncryptedDownload.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileComparison.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileComparisonCache.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileContent.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileMetadata.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/upload/EncryptedLocalCopy.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/upload/EncryptedUploadConflictChecker.kt create mode 100644 app/src/main/java/com/owncloud/android/operations/upload/UploadFileLink.kt create mode 100644 app/src/test/java/com/nextcloud/client/jobs/download/DownloadEventFileResolverTest.kt create mode 100644 app/src/test/java/com/nextcloud/utils/thumbnail/AutoUploadThumbnailSourceTest.kt create mode 100644 app/src/test/java/com/nextcloud/utils/thumbnail/ThumbnailGeneratorTest.kt create mode 100644 app/src/test/java/com/nextcloud/utils/thumbnail/UploadThumbnailSourceTest.kt create mode 100644 app/src/test/java/com/owncloud/android/datamodel/UploadsStorageManagerCursorTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/EncryptedDownloadTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/AutoUploadProgressForwarderTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/EncryptedConflictFilesTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/EncryptedFileComparisonCacheTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/EncryptedFileContentTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/EncryptedFileMetadataTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/EncryptedLocalCopyTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/EncryptedUploadConflictCheckerTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/EncryptedUploadedFileOpenTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/UploadFileLinkTest.kt create mode 100644 app/src/test/java/com/owncloud/android/operations/upload/UploadProgressDisplayTest.kt diff --git a/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt b/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt index 068b819e8c4b..b5cdb697f475 100644 --- a/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt +++ b/app/src/main/java/com/nextcloud/client/database/dao/FileSystemDao.kt @@ -17,6 +17,20 @@ import com.owncloud.android.db.ProviderMeta @Dao interface FileSystemDao { + @Query( + """ + SELECT filesystem.* FROM ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} AS filesystem + INNER JOIN ${ProviderMeta.ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME} AS folders + ON filesystem.${ProviderMeta.ProviderTableMeta.FILESYSTEM_SYNCED_FOLDER_ID} + = folders.${ProviderMeta.ProviderTableMeta._ID} + WHERE folders.${ProviderMeta.ProviderTableMeta.SYNCED_FOLDER_ACCOUNT} = :accountName + AND filesystem.${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_REMOTE_PATH} = :remotePath + AND filesystem.${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_SENT_FOR_UPLOAD} = 1 + AND filesystem.${ProviderMeta.ProviderTableMeta.FILESYSTEM_FILE_IS_FOLDER} = 0 + """ + ) + fun getHandledByRemotePath(remotePath: String, accountName: String): List + @Query( """ UPDATE ${ProviderMeta.ProviderTableMeta.FILESYSTEM_TABLE_NAME} diff --git a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt index df82c5efa8b9..e4a3ffe03495 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/autoUpload/AutoUploadWorker.kt @@ -22,6 +22,7 @@ import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.jobs.upload.FileUploadWorker import com.nextcloud.client.jobs.upload.UploadDelayPolicy +import com.nextcloud.client.jobs.upload.AutoUploadProgressForwarder import com.nextcloud.client.jobs.utils.UploadErrorNotificationManager import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.preferences.AppPreferences @@ -334,12 +335,15 @@ class AutoUploadWorker( Log_OC.d(TAG, "πŸ•’ uploading: $localPath, id: $generatedId") FileUploadWorker.registerActiveUpload(operation) + val progress = AutoUploadProgressForwarder(operation) + operation.addDataTransferProgressListener(progress) val result = try { + fileUploadEventBroadcaster.sendUploadStarted(operation, context) operation.execute(client) } finally { + operation.removeDataTransferProgressListener(progress) FileUploadWorker.unregisterActiveUpload(operation.ocUploadId) } - fileUploadEventBroadcaster.sendUploadStarted(operation, context) UploadErrorNotificationManager.handleResult( context, diff --git a/app/src/main/java/com/nextcloud/client/jobs/download/DownloadEventFileResolver.kt b/app/src/main/java/com/nextcloud/client/jobs/download/DownloadEventFileResolver.kt new file mode 100644 index 000000000000..dcd13200a369 --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/download/DownloadEventFileResolver.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.jobs.download + +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile + +object DownloadEventFileResolver { + fun resolve(storage: FileDataStorageManager, accountName: String?, remotePath: String?): OCFile? { + if (accountName == null || accountName != storage.user.accountName || remotePath.isNullOrEmpty()) return null + return storage.getFileByEncryptedRemotePath(remotePath) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadWorker.kt index 96bfe30f5797..ba4c9496c93a 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/download/FileDownloadWorker.kt @@ -301,7 +301,7 @@ class FileDownloadWorker( var file: OCFile? = currentDownload?.file?.fileId?.let { currentUserFileStorageManager?.getFileById(it) } if (file == null) { - file = currentUserFileStorageManager?.getFileByDecryptedRemotePath(currentDownload?.file?.remotePath) + file = currentUserFileStorageManager?.getFileByEncryptedRemotePath(currentDownload?.file?.remotePath) } if (file == null) { diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/AutoUploadProgressForwarder.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/AutoUploadProgressForwarder.kt new file mode 100644 index 000000000000..23697eceefec --- /dev/null +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/AutoUploadProgressForwarder.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.jobs.upload + +import com.owncloud.android.lib.common.network.OnDatatransferProgressListener +import com.owncloud.android.operations.UploadFileOperation + +class AutoUploadProgressForwarder(private val operation: UploadFileOperation) : OnDatatransferProgressListener { + override fun onTransferProgress(rate: Long, transferred: Long, total: Long, filename: String?) { + val key = FileUploadHelper.buildRemoteName(operation.user.accountName, operation.uploadRemotePath) + FileUploadHelper.mBoundListeners[key]?.onTransferProgress(rate, transferred, total, operation.fileName) + } +} diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index 139e9fe57020..4ba206163ade 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -21,6 +21,7 @@ import com.nextcloud.client.device.BatteryStatus import com.nextcloud.client.device.PowerManagementService import com.nextcloud.client.di.ApplicationScope import com.nextcloud.client.jobs.BackgroundJobManager +import com.nextcloud.client.jobs.utils.UploadErrorNotificationManager import com.nextcloud.client.network.Connectivity import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.notifications.AppWideNotificationManager @@ -51,9 +52,14 @@ import com.owncloud.android.lib.resources.status.OCCapability import com.owncloud.android.operations.RemoveFileOperation import com.owncloud.android.operations.UploadFileOperation import com.owncloud.android.operations.factory.UploadFileOperationFactory +import com.owncloud.android.operations.upload.EncryptedUploadConflictChecker +import com.owncloud.android.operations.upload.UploadFileLink +import com.owncloud.android.utils.EncryptionUtils import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -99,7 +105,7 @@ class FileUploadHelper { const val MAX_UPLOADS_PER_QUERY = 500 - val mBoundListeners = HashMap() + val mBoundListeners = java.util.concurrent.ConcurrentHashMap() private val retryInProgress = AtomicBoolean(false) @@ -259,24 +265,63 @@ class FileUploadHelper { return@withContext showNotExistMessage } - suspend fun checkConflict(upload: OCUpload): ConflictHandlingResult = withContext(Dispatchers.IO) { - val user = accountManager.getUser(upload.accountName).orElse(null) - ?: return@withContext ConflictHandlingResult.CannotCheckConflict - val context = MainApp.getAppContext() - val client = clientRepository.getOwncloudClient(user) - ?: return@withContext ConflictHandlingResult.CannotCheckConflict + suspend fun checkConflict(upload: OCUpload, requirePreview: Boolean = false): ConflictHandlingResult { + var preview: OCFile? = null try { - val operation = uploadFileOperationFactory.create(context, upload, user = user) - val target = if (operation.isAutoUpload) { - operation.findAutoUploadTarget(client) - } else { - operation.storageManager.getFileByEncryptedRemotePath(upload.remotePath) + val result = withContext(Dispatchers.IO) { + val user = accountManager.getUser(upload.accountName).orElse(null) + ?: return@withContext ConflictHandlingResult.CannotCheckConflict + val context = MainApp.getAppContext() + val client = clientRepository.getOwncloudClient(user) + ?: return@withContext ConflictHandlingResult.CannotCheckConflict + val operation = uploadFileOperationFactory.create(context, upload, user = user) + val target = if (operation.isAutoUpload) { + operation.findAutoUploadTarget(client) + } else { + operation.storageManager.getFileByEncryptedRemotePath(upload.remotePath) + } + if (target?.isEncrypted != true) { + return@withContext uploadActionHandler.handleConflict(upload, client, uploadsStorageManager, target) + } + val parent = operation.storageManager.getFileByEncryptedRemotePath(target.parentRemotePath) + ?: return@withContext ConflictHandlingResult.CannotCheckConflict + val metadata = EncryptionUtils.downloadFolderMetadata(parent, client, context, user) + ?: return@withContext ConflictHandlingResult.CannotCheckConflict + val comparisonContext = currentCoroutineContext() + val comparison = EncryptedUploadConflictChecker(context, user).compare( + client, + parent, + metadata, + File(upload.localPath), + target.decryptedFileName, + java.util.function.BooleanSupplier { !comparisonContext.isActive }, + requirePreview + ) ?: return@withContext ConflictHandlingResult.CannotCheckConflict + preview = comparison.remoteFile + if (comparison.sameContent) { + UploadFileLink.completeIdentical(upload, comparison.remoteFile, uploadsStorageManager) + return@withContext ConflictHandlingResult.ConflictNotExistsSameFile + } + comparison.remoteFile.fileId = target.fileId + comparison.remoteFile.parentId = target.parentId + ConflictHandlingResult.ShowConflictResolveDialog( + comparison.remoteFile, + upload, + comparison.remoteVersion + ) + } + if (result is ConflictHandlingResult.ConflictNotExistsSameFile) { + UploadErrorNotificationManager.dismissConflictResolveNotification( + MainApp.getAppContext(), + upload.uploadId + ) } - uploadActionHandler.handleConflict(upload, client, uploadsStorageManager, target) + return result } catch (e: Exception) { + EncryptedUploadConflictChecker.releasePreview(MainApp.getAppContext(), preview) if (e is CancellationException) throw e Log_OC.e(TAG, "Could not check upload conflict", e) - ConflictHandlingResult.CannotCheckConflict + return ConflictHandlingResult.CannotCheckConflict } } diff --git a/app/src/main/java/com/nextcloud/utils/thumbnail/AutoUploadThumbnailSource.kt b/app/src/main/java/com/nextcloud/utils/thumbnail/AutoUploadThumbnailSource.kt new file mode 100644 index 000000000000..82a9fa78946e --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/thumbnail/AutoUploadThumbnailSource.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.thumbnail + +import com.nextcloud.client.database.dao.FileSystemDao +import com.nextcloud.client.database.entity.FilesystemEntity +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.model.ServerFileInterface +import java.io.File + +object AutoUploadThumbnailSource { + fun find(remote: OCFile, accountName: String, filesystem: FileSystemDao): File? { + // Pending entries use logical paths; successful encrypted uploads store the actual DAV path. + if (remote.remotePath == remote.decryptedRemotePath) return null + return filesystem.getHandledByRemotePath(remote.remotePath, accountName).firstNotNullOfOrNull { entry -> + val local = entry.localPath?.let(::File) + local?.takeIf { matchesEntry(entry, remote) } + ?.takeIf { it.isFile && it.canRead() && entry.fileModified == it.lastModified() } + ?.takeIf { matchesSize(it, remote) } + }?.also { + Log_OC.d("AutoUploadThumbnailSource", "Using retained auto-upload source: ${remote.fileName}") + } + } + + private fun matchesEntry(entry: FilesystemEntity, remote: ServerFileInterface): Boolean = + entry.remotePath == remote.remotePath && entry.fileSentForUpload == 1 && entry.fileIsFolder == 0 + + private fun matchesSize(local: File, remote: ServerFileInterface): Boolean = remote.fileLength == local.length() || + remote.fileLength == local.length() + UploadThumbnailSource.ENCRYPTED_FILE_TAG_BYTES +} diff --git a/app/src/main/java/com/nextcloud/utils/thumbnail/ThumbnailGenerator.kt b/app/src/main/java/com/nextcloud/utils/thumbnail/ThumbnailGenerator.kt index 558650a08d60..4edd4eec58bb 100644 --- a/app/src/main/java/com/nextcloud/utils/thumbnail/ThumbnailGenerator.kt +++ b/app/src/main/java/com/nextcloud/utils/thumbnail/ThumbnailGenerator.kt @@ -15,6 +15,10 @@ class ThumbnailGenerator @Inject constructor( val fileThumbnailGenerator: FileThumbnailGenerator, val folderThumbnailGenerator: FolderThumbnailGenerator ) { + fun setUploadThumbnail(file: OCFile, view: ImageView) { + fileThumbnailGenerator.setThumbnail(file, view, ThumbnailArguments.none) + } + fun setThumbnail(ocFile: OCFile?, view: ImageView?, arguments: ThumbnailArguments = ThumbnailArguments.none) { if (ocFile == null || view == null) { return diff --git a/app/src/main/java/com/nextcloud/utils/thumbnail/UploadThumbnailSource.kt b/app/src/main/java/com/nextcloud/utils/thumbnail/UploadThumbnailSource.kt new file mode 100644 index 000000000000..bad27b0eb0c2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/thumbnail/UploadThumbnailSource.kt @@ -0,0 +1,86 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.thumbnail + +import com.nextcloud.client.database.dao.UploadDao +import com.nextcloud.client.database.dao.FileSystemDao +import com.nextcloud.client.database.entity.UploadEntity +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus +import com.owncloud.android.db.OCUpload +import com.owncloud.android.db.UploadResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.upload.EncryptedFileComparisonCache +import com.owncloud.android.utils.MimeTypeUtil +import java.io.File + +object UploadThumbnailSource { + internal const val ENCRYPTED_FILE_TAG_BYTES = 16L + private const val TAG = "UploadThumbnailSource" + + fun forUpload(upload: OCUpload, remote: OCFile?): OCFile { + val local = readableFile(upload.localPath) + if (local != null && MimeTypeUtil.isImageOrVideo(upload.mimeType)) { + return OCFile(upload.remotePath).apply { + storagePath = local.absolutePath + mimeType = upload.mimeType + } + } + return remote ?: OCFile(upload.remotePath).apply { mimeType = upload.mimeType } + } + + @JvmStatic + fun forEncryptedFile( + file: OCFile, + accountName: String, + uploads: UploadDao, + filesystem: FileSystemDao? = null + ): File? { + if (!file.isEncrypted || !MimeTypeUtil.isImageOrVideo(file)) return null + val upload = uploads.getByRemotePath(file.remotePath, accountName) + ?: uploads.getByRemotePath(file.decryptedRemotePath, accountName) + ?: run { + Log_OC.d(TAG, "No upload history for encrypted thumbnail: ${file.fileName}") + return filesystem?.let { AutoUploadThumbnailSource.find(file, accountName, it) } + } + if (upload.accountName != accountName || upload.status != UploadStatus.UPLOAD_SUCCEEDED.value) return null + if (upload.lastResult !in completedResults) return null + val local = readableFile(upload.localPath) ?: run { + Log_OC.d(TAG, "Local thumbnail source is unavailable: ${file.fileName}") + return null + } + return local.takeIf { matchesCompletedUpload(it, upload) }.also { + if (it == null) Log_OC.d(TAG, "Local thumbnail source changed since upload: ${file.fileName}") + } + } + + @JvmStatic + fun cacheKey(file: File): String = "tlocal" + EncryptedFileComparisonCache.fingerprint( + file.absolutePath, + file.length().toString(), + file.lastModified().toString() + ) + + private fun readableFile(path: String?): File? = path?.takeIf { it.isNotEmpty() } + ?.let(::File)?.takeIf { it.isFile && it.canRead() } + + private fun matchesCompletedUpload(local: File, upload: UploadEntity): Boolean { + val size = upload.fileSize?.takeIf { it >= 0 } + // Successful transfers record ciphertext length; verified skips record plaintext length. + val ciphertextSize = upload.lastResult == UploadResult.UPLOADED.value && + size == local.length() + ENCRYPTED_FILE_TAG_BYTES + if (size != null && size != local.length() && !ciphertextSize) return false + val completed = upload.uploadEndTimestampLong + return completed == null || completed <= 0 || local.lastModified() <= completed + } + + private val completedResults = setOf( + UploadResult.UPLOADED.value, + UploadResult.SKIPPED.value, + UploadResult.SAME_FILE_CONFLICT.value + ) +} diff --git a/app/src/main/java/com/owncloud/android/MainApp.kt b/app/src/main/java/com/owncloud/android/MainApp.kt index f6bfa309fa53..b461407fc0b0 100644 --- a/app/src/main/java/com/owncloud/android/MainApp.kt +++ b/app/src/main/java/com/owncloud/android/MainApp.kt @@ -6,6 +6,8 @@ */ package com.owncloud.android +import com.owncloud.android.operations.upload.EncryptedUploadConflictChecker + import android.app.Application import android.content.BroadcastReceiver import android.content.ComponentName @@ -184,6 +186,7 @@ open class MainApp : enableStrictMode() preferences.darkThemeMode.setTheme() super.onCreate() + EncryptedUploadConflictChecker.clearOrphanedPreviews(this) ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleEventObserver) insertConscrypt() diff --git a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java index ec0cdc6f10f6..96d6e216d835 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java +++ b/app/src/main/java/com/owncloud/android/datamodel/ThumbnailsCacheManager.java @@ -31,6 +31,7 @@ import com.nextcloud.utils.BitmapExtensionsKt; import com.nextcloud.utils.extensions.FileExtensionsKt; import com.nextcloud.utils.thumbnail.VideoOverlayGenerator; +import com.nextcloud.utils.thumbnail.UploadThumbnailSource; import com.nextcloud.utils.view.ScreenMetrics; import com.owncloud.android.MainApp; import com.owncloud.android.R; @@ -54,7 +55,6 @@ import java.io.InputStream; import java.lang.ref.WeakReference; import java.util.List; -import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -474,24 +474,14 @@ private Bitmap doThumbnailFromOCFileInBackground() { int pxH; pxW = pxH = getThumbnailDimension(); - // Generate thumbnail from local file if available - if (file instanceof OCFile ocFile && ocFile.isDown()) { - Log_OC.d(TAG, "Generating thumbnail from local file: " + ocFile.getFileName()); - - Bitmap bitmap; - if (MimeTypeUtil.isVideo(ocFile)) { - bitmap = ThumbnailUtils.createVideoThumbnail(ocFile.getStoragePath(), - MediaStore.Images.Thumbnails.MINI_KIND); - } else { - bitmap = BitmapUtils.decodeSampledBitmapFromFile(ocFile.getStoragePath(), pxW, pxH); + // Upload sources are thumbnail inputs, not downloaded file associations. + if (file instanceof OCFile ocFile) { + File local = localThumbnailSource(ocFile); + if (local != null) { + thumbnail = localThumbnail(local, pxW, pxH); } - - if (bitmap != null) { - if (PNG_MIMETYPE.equalsIgnoreCase(ocFile.getMimeType())) { - bitmap = BitmapUtils.centerCropOnPngBackground(bitmap, pxW, pxH); - } - - thumbnail = addThumbnailToCache(imageKey, bitmap, ocFile.getStoragePath(), pxW, pxH); + if (thumbnail != null) { + addBitmapToCache(imageKey, thumbnail); ocFile.setUpdateThumbnailNeeded(false); mStorageManager.saveFile(ocFile); } @@ -514,7 +504,8 @@ private Bitmap doThumbnailFromOCFileInBackground() { } } - boolean serverHasPreview = !(file instanceof OCFile) || ((OCFile) file).isPreviewAvailable(); + boolean serverHasPreview = !(file instanceof OCFile ocFile) || + (!ocFile.isEncrypted() && ocFile.isPreviewAvailable()); if (thumbnail == null && !serverHasPreview) { Log_OC.d(TAG, "Server reports no preview for file: " + file.getFileName()); @@ -599,24 +590,33 @@ private int getThumbnailDimension() { private Bitmap doFileInBackground() { File file = (File)mFile; + mImageKey = UploadThumbnailSource.cacheKey(file); + int dimension = getThumbnailDimension(); + return localThumbnail(file, dimension, dimension); + } - final String imageKey = Objects.requireNonNullElseGet(mImageKey, () -> String.valueOf(file.hashCode())); - - // local file should always generate a thumbnail - mImageKey = FileExtensionsKt.getSmallThumbnailKey(file); + private File localThumbnailSource(OCFile file) { + if (file.isDown()) { + return new File(file.getStoragePath()); + } + if (user == null) { + return null; + } + return UploadThumbnailSource.forEncryptedFile(file, user.getAccountName(), mStorageManager.db.uploadDao(), + mStorageManager.db.fileSystemDao()); + } - // Check disk cache in background thread + private Bitmap localThumbnail(File file, int pxW, int pxH) { + String imageKey = UploadThumbnailSource.cacheKey(file); Bitmap thumbnail = getBitmapFromDiskCache(imageKey); - - // Not found in disk cache if (thumbnail == null) { - int pxW; - int pxH; - pxW = pxH = getThumbnailDimension(); - - Bitmap bitmap = BitmapUtils.decodeSampledBitmapFromFile(file.getAbsolutePath(), pxW, pxH); - + Bitmap bitmap = MimeTypeUtil.isVideo(file) + ? ThumbnailUtils.createVideoThumbnail(file.getAbsolutePath(), MediaStore.Images.Thumbnails.MINI_KIND) + : BitmapUtils.decodeSampledBitmapFromFile(file.getAbsolutePath(), pxW, pxH); if (bitmap != null) { + if (PNG_MIMETYPE.equalsIgnoreCase(FileStorageUtils.getMimeTypeFromName(file.getName()))) { + bitmap = BitmapUtils.centerCropOnPngBackground(bitmap, pxW, pxH); + } thumbnail = addThumbnailToCache(imageKey, bitmap, file.getPath(), pxW, pxH); } } diff --git a/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt b/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt index e5afb6e41bff..efb0f2f3f9d7 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt +++ b/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt @@ -402,7 +402,8 @@ class UploadsStorageManager( createdBy = c.int(ProviderTableMeta.UPLOADS_CREATED_BY) isUseWifiOnly = c.int(ProviderTableMeta.UPLOADS_IS_WIFI_ONLY) == 1 isWhileChargingOnly = c.int(ProviderTableMeta.UPLOADS_IS_WHILE_CHARGING_ONLY) == 1 - folderUnlockToken = c.str(ProviderTableMeta.UPLOADS_FOLDER_UNLOCK_TOKEN) + folderUnlockToken = + c.getString(c.getColumnIndexOrThrow(ProviderTableMeta.UPLOADS_FOLDER_UNLOCK_TOKEN)).orEmpty() } } diff --git a/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.kt b/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.kt index 9a586816cc11..f20950a24297 100644 --- a/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.kt +++ b/app/src/main/java/com/owncloud/android/operations/DownloadFileOperation.kt @@ -14,7 +14,6 @@ import com.nextcloud.client.account.User import com.nextcloud.utils.extensions.showToast import com.nextcloud.utils.extensions.toNextcloudClient import com.owncloud.android.R -import com.owncloud.android.datamodel.ArbitraryDataProviderImpl import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1 @@ -36,6 +35,7 @@ import java.nio.file.Files import java.nio.file.StandardCopyOption import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.BooleanSupplier import javax.crypto.Cipher class DownloadFileOperation( @@ -53,6 +53,8 @@ class DownloadFileOperation( private val context = WeakReference(context) private val dataTransferListeners = ConcurrentHashMap.newKeySet() + + @Volatile private var timestampForModification: Long = 0 private val cancellationRequested = AtomicBoolean(false) private val mainThreadHandler = Handler(Looper.getMainLooper()) @@ -115,12 +117,33 @@ class DownloadFileOperation( fun getModificationTimestamp() = if (timestampForModification > 0) timestampForModification else file.modificationTimestamp + fun useVerifiedCopy(verified: OCFile) { + require(file.remotePath == verified.remotePath && verified.isEncrypted) + val source = File(requireNotNull(verified.storagePath)) + val destination = File(savePath) + ensureParentDirectory(destination) + val staged = File.createTempFile("verified-download-", null, destination.parentFile) + try { + Files.copy(source.toPath(), staged.toPath(), StandardCopyOption.REPLACE_EXISTING) + Files.move(staged.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) + if (!destination.setLastModified(verified.modificationTimestamp)) { + Log_OC.w(TAG, "Unable to set verified file modification time") + } + timestampForModification = verified.modificationTimestamp + etag = verified.etag + file.mimeType = verified.mimeType + file.remoteId = verified.remoteId + } finally { + if (staged.exists() && !staged.delete()) { + Log_OC.w(TAG, "Unable to remove temporary verified download") + } + } + } + @Suppress("DEPRECATION") @Deprecated("Deprecated in Java") override fun run(client: OwnCloudClient): RemoteOperationResult { - synchronized(cancellationRequested) { - if (cancellationRequested.get()) return RemoteOperationResult(OperationCancelledException()) - } + if (cancellationRequested.get()) return RemoteOperationResult(OperationCancelledException()) if (file.isFolder) { Log_OC.e(TAG, "Cannot download a folder as a file: ${file.remotePath}") @@ -136,6 +159,21 @@ class DownloadFileOperation( ?: return RemoteOperationResult(RemoteOperationResult.ResultCode.UNKNOWN_ERROR) val tmpFile = File(tmpPath) + try { + return downloadFile(client, operationContext, tmpFile) + } finally { + if (file.isEncrypted && tmpFile.exists() && !tmpFile.delete()) { + Log_OC.w(TAG, "Unable to remove temporary encrypted download") + } + } + } + + @Suppress("DEPRECATION") + private fun downloadFile( + client: OwnCloudClient, + operationContext: Context, + tmpFile: File + ): RemoteOperationResult { val (downloadOp, downloadResult) = executeDownload(client, operationContext) if (!downloadResult.isSuccess) return downloadResult @@ -144,11 +182,11 @@ class DownloadFileOperation( etag = downloadOp.etag if (file.isEncrypted) { - handleDecryption(client, operationContext, tmpFile)?.let { return it } + return handleDecryption(client, operationContext, tmpFile, downloadResult) } val result = when (downloadType) { - DownloadType.DOWNLOAD if !file.isEncrypted -> handleFileMove(tmpFile, downloadResult) + DownloadType.DOWNLOAD -> handleFileMove(tmpFile, downloadResult) DownloadType.EXPORT -> handleExport(operationContext, tmpFile, downloadResult) else -> downloadResult } @@ -175,15 +213,15 @@ class DownloadFileOperation( return DownloadResult(operation, result) } - private data class EncryptionKeys(val key: String?, val nonce: String?, val authTag: String?) + private data class EncryptionKeys(val key: String?, val nonce: String?) private fun extractEncryptionKeys(metadata: Any): EncryptionKeys? = when (metadata) { is DecryptedFolderMetadataFile -> metadata.metadata.files[file.encryptedFileName]?.let { - EncryptionKeys(it.key, it.nonce, it.authenticationTag) + EncryptionKeys(it.key, it.nonce) } is DecryptedFolderMetadataFileV1 -> metadata.files[file.encryptedFileName]?.let { - EncryptionKeys(it.encrypted.key, it.initializationVector, it.authenticationTag) + EncryptionKeys(it.encrypted.key, it.initializationVector) } else -> null @@ -193,33 +231,39 @@ class DownloadFileOperation( private fun handleDecryption( client: OwnCloudClient, operationContext: Context, - tmpFile: File - ): RemoteOperationResult? { - val fileDataStorageManager = FileDataStorageManager(user, operationContext.contentResolver) - val parent = fileDataStorageManager.getFileByEncryptedRemotePath(file.parentRemotePath) - val metadata = EncryptionUtils.downloadFolderMetadata(parent, client, operationContext, user) - ?: return RemoteOperationResult(RemoteOperationResult.ResultCode.METADATA_NOT_FOUND) - - val (keyString, nonceString, authenticationTagString) = extractEncryptionKeys(metadata) - ?: return RemoteOperationResult(RemoteOperationResult.ResultCode.METADATA_NOT_FOUND) - - val key = EncryptionUtils.decodeStringToBase64Bytes(keyString) - val iv = EncryptionUtils.decodeStringToBase64Bytes(nonceString) - - return runCatching { - val cipher = EncryptionUtils.getCipher(Cipher.DECRYPT_MODE, key, iv) - EncryptionUtils.decryptFile( - cipher, - tmpFile, - File(savePath), - authenticationTagString, - ArbitraryDataProviderImpl(operationContext), - user - ) - }.fold( - onSuccess = { null }, - onFailure = { RemoteOperationResult(it as? Exception ?: Exception(it)) } - ) + tmpFile: File, + downloadResult: RemoteOperationResult + ): RemoteOperationResult { + return try { + val fileDataStorageManager = FileDataStorageManager(user, operationContext.contentResolver) + val parent = fileDataStorageManager.getFileByEncryptedRemotePath(file.parentRemotePath) + val metadata = EncryptionUtils.downloadFolderMetadata(parent, client, operationContext, user) + ?: return RemoteOperationResult(RemoteOperationResult.ResultCode.METADATA_NOT_FOUND) + + val (keyString, nonceString) = extractEncryptionKeys(metadata) + ?: return RemoteOperationResult(RemoteOperationResult.ResultCode.METADATA_NOT_FOUND) + + val key = EncryptionUtils.decodeStringToBase64Bytes(keyString) + val iv = EncryptionUtils.decodeStringToBase64Bytes(nonceString) + + val cipher = { EncryptionUtils.getCipher(Cipher.DECRYPT_MODE, key, iv) } + val cancelled = BooleanSupplier { cancellationRequested.get() } + if (downloadType == DownloadType.EXPORT) { + EncryptedDownload.usePlaintext(tmpFile, operationContext.cacheDir, cipher, cancelled) { + handleExport(operationContext, it, downloadResult) + } + } else { + val destination = File(savePath) + EncryptedDownload.save(tmpFile, destination, cipher, cancelled) + if (!destination.setLastModified(getModificationTimestamp())) { + Log_OC.w(TAG, "Unable to set decrypted file modification time") + } + downloadResult + } + } catch (e: Exception) { + Log_OC.e(TAG, "Unable to decrypt downloaded file", e) + RemoteOperationResult(e) + } } private fun ensureParentDirectory(target: File) { diff --git a/app/src/main/java/com/owncloud/android/operations/EncryptedDownload.kt b/app/src/main/java/com/owncloud/android/operations/EncryptedDownload.kt new file mode 100644 index 000000000000..2eab0c273d59 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/EncryptedDownload.kt @@ -0,0 +1,50 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations + +import com.owncloud.android.lib.common.operations.OperationCancelledException +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.upload.EncryptedFileContent +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.function.BooleanSupplier +import javax.crypto.Cipher + +object EncryptedDownload { + fun save(encrypted: File, destination: File, cipher: () -> Cipher, cancelled: BooleanSupplier) { + usePlaintext(encrypted, requireNotNull(destination.absoluteFile.parentFile), cipher, cancelled) { plaintext -> + Files.move(plaintext.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } + + fun usePlaintext( + encrypted: File, + directory: File, + cipher: () -> Cipher, + cancelled: BooleanSupplier, + consume: (File) -> T + ): T { + var plaintext: File? = null + try { + Files.createDirectories(directory.toPath()) + plaintext = File.createTempFile("decrypted-download-", null, directory) + EncryptedFileContent.decrypt(encrypted, plaintext, cipher(), cancelled) + if (cancelled.asBoolean) throw OperationCancelledException() + return consume(plaintext) + } finally { + plaintext?.let { discard(it) } + discard(encrypted) + } + } + + private fun discard(file: File) { + if (file.exists() && !file.delete()) { + Log_OC.w(EncryptedDownload::class.java.simpleName, "Unable to remove temporary download") + } + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java index 3fa56ea42117..14d51373e553 100644 --- a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java @@ -64,6 +64,7 @@ import com.owncloud.android.operations.e2e.E2EData; import com.owncloud.android.operations.e2e.E2EFiles; import com.owncloud.android.operations.upload.RemoteFileExistence; +import com.owncloud.android.operations.upload.EncryptedUploadConflictChecker; import com.owncloud.android.operations.upload.UploadFileException; import com.owncloud.android.operations.upload.UploadFileOperationExtensionsKt; import com.owncloud.android.operations.upload.UploadFileResolver; @@ -648,6 +649,20 @@ private RemoteOperationResult encryptedUpload(OwnCloudClient client, OCFile pare throw new UploadFolderException(new RemoteOperationResult<>(ResultCode.SYNC_CONFLICT)); } + if (mNameCollisionPolicy == NameCollisionPolicy.ASK_USER) { + final var comparison = new EncryptedUploadConflictChecker(mContext, user) + .compare(client, parentFile, object, e2eFiles.getOriginalFile(), mFile.getDecryptedFileName(), + mCancellationRequested::get); + if (comparison != null && comparison.getSameContent()) { + mWasSkipped = true; + mFile.setRemotePath(comparison.getRemoteFile().getRemotePath()); + mFile.setDecryptedRemotePath(comparison.getRemoteFile().getDecryptedRemotePath()); + uploadFileResolver.updateSize(e2eFiles.getOriginalFile().length()); + Log_OC.d(TAG, "Encrypted content is unchanged; skipping duplicate upload"); + throw new UploadFolderException(new RemoteOperationResult<>(ResultCode.OK)); + } + } + final var collisionResult = checkNameCollision(parentFile, client, fileNames, parentFile.isEncrypted()); if (collisionResult != null) { throw new UploadFolderException(collisionResult); @@ -733,7 +748,7 @@ private String getFolderUnlockTokenOrLockFolder(OwnCloudClient client, OCFile pa } mUpload.setFolderUnlockToken(token); - uploadsStorageManager.updateUpload(mUpload); + uploadFileResolver.updateUnlockToken(token); Log_OC.d(TAG, "Folder locked successfully, token saved"); return token; @@ -1075,7 +1090,7 @@ private RemoteOperationResult releaseLocksAndUnlockE2EFolder(FileLock fileLock, Log_OC.d(TAG, "Folder successfully unlocked: " + e2eFiles.getParentFile().getFileName()); mFolderUnlockToken = null; mUpload.setFolderUnlockToken(null); - uploadsStorageManager.updateUpload(mUpload); + uploadFileResolver.updateUnlockToken(null); if (duplicatedEncryptedFile != null && !isAutoUpload()) { FileUploadHelper.Companion.instance().removeDuplicatedFile(duplicatedEncryptedFile, client, user, () -> { diff --git a/app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileComparison.kt b/app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileComparison.kt new file mode 100644 index 000000000000..de483d2fe0a3 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileComparison.kt @@ -0,0 +1,11 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.OCFile + +data class EncryptedFileComparison(val sameContent: Boolean, val remoteFile: OCFile, val remoteVersion: String) diff --git a/app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileComparisonCache.kt b/app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileComparisonCache.kt new file mode 100644 index 000000000000..7ff80a955719 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileComparisonCache.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.ArbitraryDataProvider +import java.security.MessageDigest + +class EncryptedFileComparisonCache(private val provider: ArbitraryDataProvider, private val account: String) { + fun read(identity: String, version: String): Pair? { + val fields = provider.getValue(account, PREFIX + identity).split(':') + if (fields.size != FIELD_COUNT || fields[0] != version) return null + val same = fields[1].toBooleanStrictOrNull() ?: return null + val length = fields[2].toLongOrNull()?.takeIf { it >= 0 } ?: return null + return same to length + } + + fun write(identity: String, version: String, same: Boolean, length: Long) { + provider.storeOrUpdateKeyValue(account, PREFIX + identity, "$version:$same:$length") + } + + companion object { + private const val PREFIX = "encrypted-file-comparison-v1-" + private const val FIELD_COUNT = 3 + + fun version(localLength: Long, localModified: Long, remoteVersion: String): String = + fingerprint(localLength.toString(), localModified.toString(), remoteVersion) + + fun fingerprint(vararg fields: String): String = MessageDigest.getInstance("SHA-256") + .digest(fields.joinToString("\u0000").toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileContent.kt b/app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileContent.kt new file mode 100644 index 000000000000..43c746020f33 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileContent.kt @@ -0,0 +1,79 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.lib.common.operations.OperationCancelledException +import java.io.File +import java.io.InputStream +import java.nio.file.Files +import java.util.function.BooleanSupplier +import javax.crypto.Cipher + +object EncryptedFileContent { + fun decryptAndCompare( + encrypted: File, + plaintext: File, + local: File, + cipher: Cipher, + cancelled: BooleanSupplier + ): Boolean { + try { + decrypt(encrypted, plaintext, cipher, cancelled) + return sameContent(local, plaintext, cancelled) + } catch (e: Exception) { + runCatching { Files.deleteIfExists(plaintext.toPath()) }.onFailure { e.addSuppressed(it) } + throw e + } + } + + fun decrypt(encrypted: File, plaintext: File, cipher: Cipher, cancelled: BooleanSupplier) { + try { + decryptContent(encrypted, plaintext, cipher, cancelled) + } catch (e: Exception) { + runCatching { Files.deleteIfExists(plaintext.toPath()) }.onFailure { e.addSuppressed(it) } + throw e + } + } + + private fun decryptContent(encrypted: File, plaintext: File, cipher: Cipher, cancelled: BooleanSupplier) { + encrypted.inputStream().use { input -> + plaintext.outputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var count = input.read(buffer) + while (count != -1) { + checkCancelled(cancelled) + output.write(cipher.update(buffer, 0, count) ?: byteArrayOf()) + count = input.read(buffer) + } + checkCancelled(cancelled) + output.write(cipher.doFinal()) + } + } + } + + private fun sameContent(left: File, right: File, cancelled: BooleanSupplier): Boolean { + if (left.length() != right.length()) return false + left.inputStream().buffered().use { first -> + right.inputStream().buffered().use { second -> + return sameContent(first, second, cancelled) + } + } + } + + private fun sameContent(first: InputStream, second: InputStream, cancelled: BooleanSupplier): Boolean { + while (true) { + checkCancelled(cancelled) + val value = first.read() + if (value != second.read()) return false + if (value == -1) return true + } + } + + private fun checkCancelled(cancelled: BooleanSupplier) { + if (cancelled.asBoolean) throw OperationCancelledException() + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileMetadata.kt b/app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileMetadata.kt new file mode 100644 index 000000000000..6117f15c543b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/EncryptedFileMetadata.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1 +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.utils.MimeType + +class EncryptedFileMetadata( + val remoteName: String, + val filename: String, + val mimeType: String, + val key: String, + val nonce: String +) { + companion object { + @JvmStatic + fun find(metadata: Any?, filename: String): EncryptedFileMetadata? = when (metadata) { + is DecryptedFolderMetadataFileV1 -> metadata.files.entries.firstOrNull { + it.value.encrypted.filename.equals(filename, ignoreCase = true) && + it.value.encrypted.mimetype != MimeType.WEBDAV_FOLDER + }?.let { (name, file) -> + EncryptedFileMetadata( + name, + file.encrypted.filename, + file.encrypted.mimetype, + file.encrypted.key, + file.initializationVector + ) + } + + is DecryptedFolderMetadataFile -> metadata.metadata.files.entries.firstOrNull { + it.value.filename.equals(filename, ignoreCase = true) + }?.let { (name, file) -> + EncryptedFileMetadata(name, file.filename, file.mimetype, file.key, file.nonce) + } + + else -> null + } + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/EncryptedLocalCopy.kt b/app/src/main/java/com/owncloud/android/operations/upload/EncryptedLocalCopy.kt new file mode 100644 index 000000000000..80b5736fc7b5 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/EncryptedLocalCopy.kt @@ -0,0 +1,27 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.OCFile +import java.io.File +import java.nio.file.Files + +object EncryptedLocalCopy { + fun discardRawCopy(previous: OCFile, refreshed: OCFile, accountDirectory: File) { + if (previous.isEncrypted || previous.isFolder) return + previous.storagePath?.let { path -> + val root = accountDirectory.canonicalFile + val expected = File(root.path + previous.decryptedRemotePath).canonicalFile + val cached = File(path).canonicalFile + if (cached == expected && cached.toPath().startsWith(root.toPath()) && cached.isFile) { + Files.delete(cached.toPath()) + } + } + refreshed.storagePath = null + refreshed.lastSyncDateForData = 0 + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/EncryptedUploadConflictChecker.kt b/app/src/main/java/com/owncloud/android/operations/upload/EncryptedUploadConflictChecker.kt new file mode 100644 index 000000000000..90fcc7cc3da6 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/EncryptedUploadConflictChecker.kt @@ -0,0 +1,201 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import android.content.Context +import com.nextcloud.client.account.User +import com.nextcloud.utils.extensions.toNextcloudClient +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.OperationCancelledException +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.DownloadFileRemoteOperation +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.utils.EncryptionUtils +import com.owncloud.android.utils.FileStorageUtils +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.util.function.BooleanSupplier +import javax.crypto.Cipher + +class EncryptedUploadConflictChecker(private val context: Context, private val user: User) { + @JvmOverloads + @Throws(Exception::class) + fun compare( + client: OwnCloudClient, + parent: OCFile, + metadata: Any?, + local: File, + filename: String, + cancelled: BooleanSupplier = BooleanSupplier { false }, + requirePreview: Boolean = false + ): EncryptedFileComparison? { + val entry = EncryptedFileMetadata.find(metadata, filename) ?: return null + checkCancelled(cancelled) + require(local.isFile) { "Local file is unavailable for encrypted comparison" } + val remote = readRemoteFile(client, parent, entry) + val length = local.length() + val modified = local.lastModified() + val identity = EncryptedFileComparisonCache.fingerprint(user.accountName, remote.remotePath, local.absolutePath) + val remoteVersion = EncryptedFileComparisonCache.fingerprint( + remote.etag.orEmpty(), + remote.remoteId.orEmpty(), + remote.fileLength.toString(), + remote.modificationTimestamp.toString(), + entry.key, + entry.nonce + ) + val version = EncryptedFileComparisonCache.version(length, modified, remoteVersion) + val cache = EncryptedFileComparisonCache(ArbitraryDataProviderImpl(context), user.accountName) + if (!remote.etag.isNullOrEmpty()) { + cache.read(identity, version)?.let { (same, size) -> + if (same || !requirePreview) { + remote.fileLength = size + Log_OC.d(TAG, "Reusing encrypted comparison for unchanged local file and remote version") + return EncryptedFileComparison(same, remote, remoteVersion) + } + } + } + + val same = downloadComparison(client, remote, entry, local, cancelled, requirePreview) + try { + if (length != local.length() || modified != local.lastModified()) { + throw IOException("Local file changed during encrypted comparison") + } + if (!remote.etag.isNullOrEmpty()) { + cache.write(identity, version, same, remote.fileLength) + } + return EncryptedFileComparison(same, remote, remoteVersion) + } catch (e: Exception) { + releasePreview(context, remote) + throw e + } + } + + private fun readRemoteFile(client: OwnCloudClient, parent: OCFile, entry: EncryptedFileMetadata): OCFile { + require(entry.remoteName.isNotBlank() && entry.remoteName != "." && entry.remoteName != "..") + require(!entry.remoteName.contains('/') && !entry.remoteName.contains('\\')) + val result = ReadFileRemoteOperation(parent.remotePath + entry.remoteName).execute(client) + if (!result.isSuccess) throw IOException("Could not read encrypted conflict target: ${result.code}") + return FileStorageUtils.fillOCFile(result.data[0] as RemoteFile).apply { + isEncrypted = true + decryptedRemotePath = parent.decryptedRemotePath + entry.filename + mimeType = entry.mimeType + } + } + + private fun checkCancelled(cancelled: BooleanSupplier) { + if (cancelled.asBoolean) throw OperationCancelledException() + } + + private fun downloadComparison( + client: OwnCloudClient, + remote: OCFile, + entry: EncryptedFileMetadata, + local: File, + cancelled: BooleanSupplier, + requirePreview: Boolean + ): Boolean { + val temporaryDirectory = Files.createTempDirectory(context.cacheDir.toPath(), "encrypted-comparison-").toFile() + var retainedPreview: File? = null + var completed = false + try { + val encrypted = File(temporaryDirectory.absolutePath + remote.remotePath) + require(encrypted.canonicalPath.startsWith(temporaryDirectory.canonicalPath + File.separator)) + val download = + DownloadFileRemoteOperation(remote.remotePath, temporaryDirectory.absolutePath, remote.fileLength) + download.addProgressListener { _, _, _, _ -> if (cancelled.asBoolean) download.cancel() } + val downloaded = download.execute(client.toNextcloudClient(context)) + checkCancelled(cancelled) + if (!downloaded.isSuccess) { + throw IOException( + "Could not download encrypted conflict target: ${downloaded.code}" + ) + } + if (!remote.etag.isNullOrEmpty() && remote.etag != download.etag) { + throw IOException("Encrypted conflict target changed during comparison") + } + val plaintext = File(temporaryDirectory, "plaintext") + val cipher = EncryptionUtils.getCipher( + Cipher.DECRYPT_MODE, + EncryptionUtils.decodeStringToBase64Bytes(entry.key), + EncryptionUtils.decodeStringToBase64Bytes(entry.nonce) + ) + val same = EncryptedFileContent.decryptAndCompare( + encrypted, + plaintext, + local, + cipher, + cancelled + ) + checkCancelled(cancelled) + remote.fileLength = plaintext.length() + if (!same && requirePreview) { + val preview = retainPreview(plaintext, entry.filename) + remote.storagePath = preview.absolutePath + retainedPreview = preview + } + Log_OC.d(TAG, "Authenticated encrypted content comparison completed: identical=$same") + completed = true + return same + } finally { + if (!completed) retainedPreview?.delete() + temporaryDirectory.deleteRecursively() + } + } + + private fun retainPreview(plaintext: File, filename: String): File { + val previewDirectory = File(context.cacheDir, PREVIEW_DIRECTORY).apply { mkdirs() } + val extension = filename.substringAfterLast('.', "bin") + .takeIf { it.matches(Regex("[a-zA-Z0-9]{1,12}")) } ?: "bin" + val preview = File.createTempFile("conflict-", ".$extension", previewDirectory) + try { + Files.move(plaintext.toPath(), preview.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING) + return preview + } catch (e: Exception) { + preview.delete() + throw e + } + } + + companion object { + private const val PREVIEW_DIRECTORY = "encrypted-conflicts" + private const val TAG = "EncryptedUploadConflictChecker" + + fun rememberVerifiedCopy(context: Context, user: User, remote: OCFile, local: File, remoteVersion: String) { + require(local.isFile && local.length() == remote.fileLength && !remote.etag.isNullOrEmpty()) + val identity = EncryptedFileComparisonCache.fingerprint( + user.accountName, + remote.remotePath, + local.absolutePath + ) + val version = EncryptedFileComparisonCache.version(local.length(), local.lastModified(), remoteVersion) + EncryptedFileComparisonCache(ArbitraryDataProviderImpl(context), user.accountName) + .write(identity, version, true, local.length()) + } + + @JvmStatic + fun releasePreview(context: Context, file: OCFile?) { + val path = file?.takeIf { it.isEncrypted }?.storagePath ?: return + val preview = File(path) + if (preview.parentFile?.canonicalFile == File(context.cacheDir, PREVIEW_DIRECTORY).canonicalFile) { + preview.delete() + } + } + + @JvmStatic + fun clearOrphanedPreviews(context: Context) { + File(context.cacheDir, PREVIEW_DIRECTORY).listFiles()?.filter { it.isFile }?.forEach { it.delete() } + context.cacheDir.listFiles()?.filter { + it.isDirectory && it.name.startsWith("encrypted-comparison-") + }?.forEach { it.deleteRecursively() } + } + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileLink.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileLink.kt new file mode 100644 index 000000000000..419146dcf85e --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileLink.kt @@ -0,0 +1,41 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.db.UploadResult +import java.io.File + +object UploadFileLink { + fun completeIdentical(upload: OCUpload, remote: OCFile, uploads: UploadsStorageManager) { + upload.remotePath = remote.remotePath + upload.fileSize = remote.fileLength + upload.uploadStatus = UploadsStorageManager.UploadStatus.UPLOAD_SUCCEEDED + upload.lastResult = UploadResult.SAME_FILE_CONFLICT + uploads.updateUpload(upload) + } + + fun findUploadedFile(storage: FileDataStorageManager, remotePath: String): OCFile? = + storage.getFileByEncryptedRemotePath(remotePath) + ?: storage.getFileByDecryptedRemotePath(remotePath)?.takeIf { it.isEncrypted } + + fun displayPath(storage: FileDataStorageManager, upload: OCUpload, file: OCFile?): String { + if (file?.isEncrypted == true && file.decryptedRemotePath != file.remotePath) { + return file.decryptedRemotePath + } + val parentPath = upload.remotePath.substringBeforeLast('/') + "/" + val parent = storage.getFileByEncryptedRemotePath(parentPath) + return if (parent?.isEncrypted == true) { + parent.decryptedRemotePath + File(upload.localPath).name + } else { + upload.remotePath + } + } +} diff --git a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileResolver.kt b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileResolver.kt index deb7909e2842..f054d2f121ac 100644 --- a/app/src/main/java/com/owncloud/android/operations/upload/UploadFileResolver.kt +++ b/app/src/main/java/com/owncloud/android/operations/upload/UploadFileResolver.kt @@ -54,6 +54,14 @@ class UploadFileResolver(private val operation: UploadFileOperation) { } } + fun updateUnlockToken(token: String?) { + val storageManager = operation.uploadsStorageManager + storageManager.getUploadById(operation.ocUploadId)?.let { + it.folderUnlockToken = token + storageManager.updateUpload(it) + } + } + fun findEncryptedTarget(parent: OCFile, filename: String): OCFile? = operation.storageManager.getFolderContent(parent, false).orEmpty().firstOrNull { it.decryptedFileName.equals(filename, ignoreCase = true) @@ -114,7 +122,7 @@ class UploadFileResolver(private val operation: UploadFileOperation) { file.apply { creationTimestamp = remoteFile.creationTimestamp fileLength = remoteFile.length - mimeType = remoteFile.mimeType + if (!isEncrypted) mimeType = remoteFile.mimeType modificationTimestamp = remoteFile.modifiedTimestamp modificationTimestampAtLastSyncForData = remoteFile.modifiedTimestamp etag = remoteFile.etag diff --git a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt index f99db54686b4..0848ed98214c 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/ConflictsResolveActivity.kt @@ -39,14 +39,23 @@ import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation import com.owncloud.android.lib.resources.files.model.RemoteFile import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.operations.DownloadFileOperation +import com.owncloud.android.operations.upload.EncryptedUploadConflictChecker +import com.owncloud.android.operations.upload.UploadFileLink import com.owncloud.android.ui.dialog.conflict.ConflictResolveDialogFactory import com.owncloud.android.ui.dialog.conflict.ConflictsResolveDialog.Decision import com.owncloud.android.ui.dialog.conflict.ConflictsResolveDialog.OnConflictDecisionMadeListener import com.owncloud.android.utils.FileStorageUtils +import com.owncloud.android.utils.EncryptionUtils +import java.io.File import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject +import java.util.function.BooleanSupplier +import kotlin.coroutines.cancellation.CancellationException class ConflictsResolveActivity : FileActivity(), @@ -61,6 +70,7 @@ class ConflictsResolveActivity : private var conflictUploadId: Long = 0 private var offlineOperationPath: String? = null private var existingFile: OCFile? = null + private var encryptedRemoteVersion: String? = null private var newFile: OCFile? = null private var localBehaviour = FileUploadWorker.LOCAL_BEHAVIOUR_FORGET private lateinit var offlineOperationNotificationManager: OfflineOperationsNotificationManager @@ -87,13 +97,14 @@ class ConflictsResolveActivity : override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + EncryptedUploadConflictChecker.releasePreview(this, existingFile) setIntent(intent) // re-read extras from new intent restoreState(null) // force fresh remote file fetch in onStart - existingFile = null + existingFile = intent.getParcelableArgument(EXTRA_EXISTING_FILE, OCFile::class.java) val upload = uploadsStorageManager.getUploadById(conflictUploadId) newFile = file @@ -101,6 +112,8 @@ class ConflictsResolveActivity : } private fun restoreState(savedInstanceState: Bundle?) { + encryptedRemoteVersion = savedInstanceState?.getString(EXTRA_ENCRYPTED_REMOTE_VERSION) + ?: intent.getStringExtra(EXTRA_ENCRYPTED_REMOTE_VERSION) if (savedInstanceState != null) { conflictUploadId = savedInstanceState.getLong(EXTRA_CONFLICT_UPLOAD_ID) existingFile = savedInstanceState.getParcelableArgument(EXTRA_EXISTING_FILE, OCFile::class.java) @@ -119,6 +132,7 @@ class ConflictsResolveActivity : existingFile.logFileSize(TAG) outState.putLong(EXTRA_CONFLICT_UPLOAD_ID, conflictUploadId) outState.putParcelable(EXTRA_EXISTING_FILE, existingFile) + outState.putString(EXTRA_ENCRYPTED_REMOTE_VERSION, encryptedRemoteVersion) outState.putInt(EXTRA_LOCAL_BEHAVIOUR, localBehaviour) outState.putString( EXTRA_OFFLINE_OPERATION_PATH, @@ -144,7 +158,7 @@ class ConflictsResolveActivity : when (decision) { Decision.KEEP_LOCAL -> handleFile(file, upload, optionalUser.get(), NameCollisionPolicy.OVERWRITE) Decision.KEEP_BOTH -> handleFile(file, upload, optionalUser.get(), NameCollisionPolicy.RENAME) - Decision.KEEP_SERVER -> keepServer(file, upload, optionalUser.get()) + Decision.KEEP_SERVER -> if (!keepServer(file, upload, optionalUser.get())) return@launch Decision.KEEP_OFFLINE_FOLDER -> keepOfflineFolder(file, offlineOperation) Decision.KEEP_SERVER_FOLDER -> keepServerFile(offlineOperation) Decision.KEEP_BOTH_FOLDER -> keepBothFolder(offlineOperation, file) @@ -189,15 +203,9 @@ class ConflictsResolveActivity : ) } - private suspend fun keepServer(file: OCFile?, upload: OCUpload?, user: User) { - if (!shouldDeleteLocal()) { - file?.let { - downloadHelper.downloadFile( - user, - file, - conflictUploadId = conflictUploadId - ) - } + private suspend fun keepServer(file: OCFile?, upload: OCUpload?, user: User): Boolean { + if (!shouldDeleteLocal() && file != null && !keepVerifiedServerFile(file, user)) { + return false } upload?.let { @@ -208,6 +216,41 @@ class ConflictsResolveActivity : UploadNotificationManager(applicationContext, viewThemeUtils, id).dismissNotification(id) } } + return true + } + + private suspend fun keepVerifiedServerFile(file: OCFile, user: User): Boolean { + if (!file.isEncrypted) { + downloadHelper.downloadFile(user, file, conflictUploadId = conflictUploadId) + return true + } + try { + val verified = requireNotNull(existingFile) + val client = clientRepository.getOwncloudClient(user) ?: error("Client unavailable") + val result = ReadFileRemoteOperation(verified.remotePath).execute(client) + val latest = result.data?.firstOrNull() as? RemoteFile + if (!result.isSuccess || latest?.etag != verified.etag || verified.etag.isNullOrEmpty()) { + showErrorAndFinish() + return false + } + val download = DownloadFileOperation(user, file, this) + download.useVerifiedCopy(verified) + downloadHelper.saveFile(file, download, fileDataStorageManager) + encryptedRemoteVersion?.let { version -> + EncryptedUploadConflictChecker.rememberVerifiedCopy( + this, + user, + verified, + File(download.savePath), + version + ) + } + return true + } catch (e: Exception) { + Log_OC.e(TAG, "Could not keep verified server copy", e) + showErrorAndFinish() + return false + } } private suspend fun keepBothFolder(offlineOperation: OfflineOperationEntity?, serverFile: OCFile?) { @@ -272,6 +315,9 @@ class ConflictsResolveActivity : return } + if (existingFile?.isEncrypted == true && existingFile?.storagePath?.let { File(it).isFile } != true) { + existingFile = null + } if (existingFile == null) { fetchRemoteFileAndShowDialog() } else { @@ -305,15 +351,56 @@ class ConflictsResolveActivity : try { val result = operation.execute(account, this@ConflictsResolveActivity) if (result.isSuccess) { - existingFile = FileStorageUtils.fillOCFile(result.data[0] as RemoteFile).also { - it.lastSyncDateForProperties = System.currentTimeMillis() + val localFile = newFile ?: return@launch + existingFile = if (localFile.isEncrypted) { + val currentUser = user.orElse(null) ?: return@launch + val client = clientRepository.getOwncloudClient(currentUser) ?: return@launch + val parent = fileDataStorageManager.getFileByEncryptedRemotePath(localFile.parentRemotePath) + ?: error("Encrypted conflict parent is missing") + val metadata = EncryptionUtils.downloadFolderMetadata( + parent, + client, + this@ConflictsResolveActivity, + currentUser + ) ?: error("Encrypted conflict metadata is missing") + val comparisonContext = currentCoroutineContext() + val comparison = EncryptedUploadConflictChecker(this@ConflictsResolveActivity, currentUser) + .compare( + client, + parent, + metadata, + File(localFile.storagePath), + localFile.decryptedFileName, + BooleanSupplier { !comparisonContext.isActive }, + requirePreview = true + ) ?: error("Encrypted conflict target is missing from metadata") + encryptedRemoteVersion = comparison.remoteVersion + if (comparison.sameContent) { + uploadsStorageManager.getUploadById(conflictUploadId)?.let { upload -> + UploadFileLink.completeIdentical(upload, comparison.remoteFile, uploadsStorageManager) + } + withContext(Dispatchers.Main) { + UploadErrorNotificationManager.dismissConflictResolveNotification( + this@ConflictsResolveActivity, + conflictUploadId + ) + finish() + } + return@launch + } + comparison.remoteFile + } else { + FileStorageUtils.fillOCFile(result.data[0] as RemoteFile) } - showFileConflictDialog(remotePath) + existingFile?.lastSyncDateForProperties = System.currentTimeMillis() + withContext(Dispatchers.Main) { showFileConflictDialog(remotePath) } } else { Log_OC.e(TAG, "ReadFileRemoteOp returned failure with code: ${result.httpCode}") showErrorAndFinish(result.httpCode) } } catch (e: Exception) { + EncryptedUploadConflictChecker.releasePreview(this@ConflictsResolveActivity, existingFile) + if (e is CancellationException) throw e Log_OC.e(TAG, "Error when trying to fetch remote file", e) showErrorAndFinish() } @@ -356,6 +443,11 @@ class ConflictsResolveActivity : listener?.conflictDecisionMade(decision) } + override fun onDestroy() { + if (isFinishing) EncryptedUploadConflictChecker.releasePreview(this, existingFile) + super.onDestroy() + } + private fun updateThumbnailIfNeeded(decision: Decision?, file: OCFile?, oldFile: OCFile?) { if (decision != Decision.KEEP_BOTH && decision != Decision.KEEP_LOCAL) return @@ -386,6 +478,7 @@ class ConflictsResolveActivity : const val EXTRA_CONFLICT_UPLOAD_ID = "CONFLICT_UPLOAD_ID" const val EXTRA_LOCAL_BEHAVIOUR = "LOCAL_BEHAVIOUR" const val EXTRA_EXISTING_FILE = "EXISTING_FILE" + const val EXTRA_ENCRYPTED_REMOTE_VERSION = "ENCRYPTED_REMOTE_VERSION" private const val EXTRA_OFFLINE_OPERATION_PATH = "EXTRA_OFFLINE_OPERATION_PATH" private val TAG = ConflictsResolveActivity::class.java.simpleName diff --git a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt index 283c6567505d..6d66a8ed1798 100644 --- a/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/FileDisplayActivity.kt @@ -60,6 +60,7 @@ import com.nextcloud.client.di.Injectable import com.nextcloud.client.di.ViewModelFactory import com.nextcloud.client.editimage.EditImageActivity import com.nextcloud.client.files.DeepLinkHandler +import com.nextcloud.client.jobs.download.DownloadEventFileResolver import com.nextcloud.client.jobs.download.FileDownloadEventBroadcaster import com.nextcloud.client.jobs.download.FileDownloadHelper import com.nextcloud.client.jobs.upload.FileUploadEventBroadcaster @@ -1813,14 +1814,20 @@ class FileDisplayActivity : } private fun onDownloadStarted(intent: Intent) { + if (!isCurrentAccount(intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_ACCOUNT_NAME))) return Log_OC.d(TAG, "download worker started") getFileOfDownloadEvent(intent)?.let { updateFileDownloadIndicator(FileDownloadIndicator.Downloading, it) } requestDownloadOfWaitingPreview() } private fun onDownloadCompleted(intent: Intent) { + if (!isCurrentAccount(intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_ACCOUNT_NAME))) return Log_OC.d(TAG, "file download completed received") - getFileOfDownloadEvent(intent)?.let { updateFileDownloadIndicator(FileDownloadIndicator.Downloaded, it) } + val success = intent.getBooleanExtra(FileDownloadEventBroadcaster.EXTRA_DOWNLOAD_RESULT, false) + val downloadedFile = getFileOfDownloadEvent(intent) + if (success) { + downloadedFile?.let { updateFileDownloadIndicator(FileDownloadIndicator.Downloaded, it) } + } if (immediatePreviewFileId == NO_IMMEDIATE_PREVIEW_FILE_ID) { Log_OC.d(TAG, "updating ui for file download") @@ -1830,11 +1837,11 @@ class FileDisplayActivity : Log_OC.d(TAG, "updating ui immediate file preview") - val downloadedRemotePath = intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_REMOTE_PATH) - val downloadedFile = storageManager.getFileByDecryptedRemotePath(downloadedRemotePath) ?: return + if (!success || downloadedFile == null) return if (immediatePreviewFileId != downloadedFile.fileId || !downloadedFile.isDown) return immediatePreviewFileId = NO_IMMEDIATE_PREVIEW_FILE_ID + waitingToPreview = null if (PreviewMediaFragment.canBePreviewed(downloadedFile)) { startImagePreview(downloadedFile, downloadedFile.isDown) } else { @@ -1879,8 +1886,9 @@ class FileDisplayActivity : } private fun getFileOfDownloadEvent(intent: Intent): OCFile? { + val accountName = intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_ACCOUNT_NAME) val remotePath = intent.getStringExtra(FileDownloadEventBroadcaster.EXTRA_REMOTE_PATH) - return fileDataStorageManager.getFileByDecryptedRemotePath(remotePath) + return DownloadEventFileResolver.resolve(storageManager, accountName, remotePath) } private fun updateFileDownloadIndicator(indicator: FileDownloadIndicator, file: OCFile) { diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/progressListener/UploadProgressListener.kt b/app/src/main/java/com/owncloud/android/ui/adapter/progressListener/UploadProgressListener.kt index b2e7d5585bcb..41a399f37f13 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/progressListener/UploadProgressListener.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/progressListener/UploadProgressListener.kt @@ -11,6 +11,13 @@ import com.owncloud.android.db.OCUpload class UploadProgressListener(val upload: OCUpload?, progressBar: ProgressBar?) : ProgressListener(progressBar) { + override fun onTransferProgress(rate: Long, transferred: Long, total: Long, filename: String?) { + progressBarRef.get()?.post { + progressBarRef.get()?.isIndeterminate = false + super.onTransferProgress(rate, transferred, total, filename) + } + } + fun isWrapping(progressBar: ProgressBar?): Boolean { val wrappedProgressBar = progressBarRef.get() return wrappedProgressBar != null && wrappedProgressBar === progressBar diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt index 2dcbd49bb9e0..d2aaf9c9a041 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt @@ -33,6 +33,7 @@ import com.nextcloud.utils.extensions.sortedByUploadOrder import com.nextcloud.utils.extensions.toFile import com.nextcloud.utils.text.DisplayTextFormatter import com.nextcloud.utils.thumbnail.ThumbnailGenerator +import com.nextcloud.utils.thumbnail.UploadThumbnailSource import com.owncloud.android.R import com.owncloud.android.databinding.UploadListHeaderBinding import com.owncloud.android.databinding.UploadListItemBinding @@ -42,6 +43,7 @@ import com.owncloud.android.datamodel.UploadsStorageManager import com.owncloud.android.db.OCUpload import com.owncloud.android.db.UploadResult import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.upload.UploadFileLink import com.owncloud.android.ui.activity.drawer.DrawerActivity import com.owncloud.android.ui.adapter.progressListener.UploadProgressListener import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterHelper @@ -64,7 +66,6 @@ import java.util.function.Consumer ) class UploadListAdapter( private val activity: DrawerActivity, - private val fileDataStorageManager: FileDataStorageManager, private val uploadsStorageManager: UploadsStorageManager, private val accountManager: UserAccountManager, private val connectivityService: ConnectivityService, @@ -77,6 +78,8 @@ class UploadListAdapter( private val uploadListSections = UploadListSection.sections() private var latestLoadId = 0 + private var displayedPaths: Map = emptyMap() + private var uploadedFiles: Map = emptyMap() private val showUser: Boolean = accountManager.getAccounts().size > 1 private val uploadHelper = FileUploadHelper.instance() private var uploadProgressListener: UploadProgressListener? = null @@ -261,13 +264,13 @@ class UploadListAdapter( @SuppressLint("SetTextI18n") private fun bindItemText(holder: ItemViewHolder, item: OCUpload) { - val remoteFile = File(item.remotePath) + val remoteFile = File(displayedPaths[item.uploadId] ?: item.remotePath) val fileName = remoteFile.name.takeIf { it.isNotEmpty() } ?: File.separator holder.binding.uploadName.text = fileName - holder.binding.uploadRemotePath.text = File(item.remotePath).parent + holder.binding.uploadRemotePath.text = remoteFile.parent val updateTime = item.uploadEndTimestamp - if (item.fileSize != 0L) { + if (item.fileSize > 0L) { var fileSizeFormat = "%s " // we have valid update time so we can show the upload date @@ -333,7 +336,10 @@ class UploadListAdapter( when (item.uploadStatus) { UploadsStorageManager.UploadStatus.UPLOAD_IN_PROGRESS -> bindItemInProgress(holder, item) - UploadsStorageManager.UploadStatus.UPLOAD_SUCCEEDED, + UploadsStorageManager.UploadStatus.UPLOAD_SUCCEEDED -> { + uploadStatus.setVisibleIf(item.fileSize < 0) + } + UploadsStorageManager.UploadStatus.UPLOAD_CANCELLED -> uploadStatus.visibility = View.GONE else -> {} @@ -358,6 +364,7 @@ class UploadListAdapter( holder.binding.run { viewThemeUtils.platform.themeHorizontalProgressBar(uploadProgressBar) uploadProgressBar.progress = 0 + uploadProgressBar.isIndeterminate = uploadHelper.isUploadingNow(item) uploadProgressBar.visibility = View.VISIBLE uploadFileSize.visibility = View.GONE @@ -500,16 +507,12 @@ class UploadListAdapter( } } - private fun OCUpload.toOCFile(): OCFile = - fileDataStorageManager.getFileByDecryptedRemotePath(remotePath) ?: OCFile(remotePath).apply { - setStoragePath(localPath) - mimeType = this@toOCFile.mimeType - } + private fun OCUpload.toOCFile(): OCFile = UploadThumbnailSource.forUpload(this, uploadedFiles[uploadId]) private fun bindItemThumbnail(holder: ItemViewHolder, ocFile: OCFile) { holder.binding.thumbnail.tag = ocFile.fileId - thumbnailGenerator.setThumbnail(ocFile, holder.binding.thumbnail) + thumbnailGenerator.setUploadThumbnail(ocFile, holder.binding.thumbnail) } // endregion @@ -559,6 +562,7 @@ class UploadListAdapter( } val accountName = optionalUser.get().accountName + val fileDataStorageManager = FileDataStorageManager(optionalUser.get(), activity.contentResolver) val capabilities = optionalCapabilities.get() val loadId = ++latestLoadId @@ -578,6 +582,13 @@ class UploadListAdapter( uploads.forEach { it.setDataFixed(uploadHelper) } sec.withItems(uploads.sortedByUploadOrder()) } + val files = mutableMapOf() + val paths = mutableMapOf() + updatedSections.flatMap { it.items }.forEach { upload -> + val stored = UploadFileLink.findUploadedFile(fileDataStorageManager, upload.remotePath) + if (stored != null) files[upload.uploadId] = stored + paths[upload.uploadId] = UploadFileLink.displayPath(fileDataStorageManager, upload, stored) + } withContext(Dispatchers.Main) { if (loadId != latestLoadId) { @@ -588,6 +599,8 @@ class UploadListAdapter( for (i in uploadListSections.indices) { uploadListSections[i] = updatedSections[i] } + uploadedFiles = files + displayedPaths = paths notifyDataSetChanged() onCompleted.run() } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/ConflictHandlingResult.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/ConflictHandlingResult.kt index a406ca07bc41..78743a8f1bc6 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/ConflictHandlingResult.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/ConflictHandlingResult.kt @@ -14,5 +14,9 @@ sealed class ConflictHandlingResult { data object CannotCheckConflict : ConflictHandlingResult() data object ConflictNotExistsSameFile : ConflictHandlingResult() data object ConflictNotExistsRemoteFileNotFound : ConflictHandlingResult() - data class ShowConflictResolveDialog(val file: OCFile, val upload: OCUpload) : ConflictHandlingResult() + data class ShowConflictResolveDialog( + val file: OCFile, + val upload: OCUpload, + val encryptedRemoteVersion: String? = null + ) : ConflictHandlingResult() } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterActionHandler.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterActionHandler.kt index 4fce77ab2c57..5a9ec037e40e 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterActionHandler.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterActionHandler.kt @@ -17,6 +17,7 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.upload.UploadFileLink import com.owncloud.android.utils.FileStorageUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -57,7 +58,7 @@ class UploadListAdapterActionHandler : UploadListAdapterAction { val ocFile = existingFile ?: FileStorageUtils.fillOCFile(remoteFile) if (!ocFile.isEncrypted && remoteFile.isSame(upload.localPath)) { - updateStatus(upload, storageManager, success = true) + UploadFileLink.completeIdentical(upload, FileStorageUtils.fillOCFile(remoteFile), storageManager) ConflictHandlingResult.ConflictNotExistsSameFile } else { ConflictHandlingResult.ShowConflictResolveDialog(ocFile, upload) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt index ce10f03bc3bc..583b9c2aaffe 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt @@ -17,6 +17,8 @@ import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.db.OCUpload import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.operations.upload.EncryptedUploadConflictChecker +import com.owncloud.android.operations.upload.UploadFileLink import com.owncloud.android.ui.activity.ConflictsResolveActivity import com.owncloud.android.ui.activity.drawer.DrawerActivity import com.owncloud.android.ui.activity.FileDisplayActivity @@ -33,18 +35,42 @@ class UploadListAdapterHelper(private val activity: DrawerActivity) { private const val ANY_MIME_TYPE = "*/*" } - fun openConflictActivity(file: OCFile, upload: OCUpload) { - file.setStoragePath(upload.localPath) + fun openConflictActivity(file: OCFile, upload: OCUpload, encryptedRemoteVersion: String? = null) { val user = activity.userAccountManager.getUser(upload.accountName) user.ifPresent { + val local = if (file.isEncrypted) { + FileDataStorageManager(it, activity.contentResolver).getFileByEncryptedRemotePath(file.remotePath) + ?: OCFile(file.remotePath).apply { + isEncrypted = true + decryptedRemotePath = file.decryptedRemotePath + mimeType = file.mimeType + parentId = file.parentId + } + } else { + file + } + local.storagePath = upload.localPath val intent = ConflictsResolveActivity.createIntent( - file, + local, it, upload.uploadId, Intent.FLAG_ACTIVITY_NEW_TASK, activity ) - activity.startActivity(intent) + if (file.isEncrypted) intent.putExtra(ConflictsResolveActivity.EXTRA_EXISTING_FILE, file) + intent.putExtra(ConflictsResolveActivity.EXTRA_ENCRYPTED_REMOTE_VERSION, encryptedRemoteVersion) + try { + activity.startActivity(intent) + } catch (e: Exception) { + EncryptedUploadConflictChecker.releasePreview(activity, file) + throw e + } + } + if (user.isEmpty) { + EncryptedUploadConflictChecker.releasePreview( + activity, + file + ) } } @@ -60,7 +86,7 @@ class UploadListAdapterHelper(private val activity: DrawerActivity) { fun onUploadedItemClick(upload: OCUpload) { val user = activity.userAccountManager.getUser(upload.accountName).orElse(null) ?: return val storage = FileDataStorageManager(user, activity.contentResolver) - val file = storage.getFileByEncryptedRemotePath(upload.remotePath) + val file = UploadFileLink.findUploadedFile(storage, upload.remotePath) if (file == null) { SnackbarUtil.show(activity, R.string.error_retrieving_file) Log_OC.i(TAG, "Could not find uploaded file on remote.") diff --git a/app/src/main/java/com/owncloud/android/ui/asynctasks/GetRemoteFileTask.kt b/app/src/main/java/com/owncloud/android/ui/asynctasks/GetRemoteFileTask.kt index 6d071f5e24dd..2ea4de3dca07 100644 --- a/app/src/main/java/com/owncloud/android/ui/asynctasks/GetRemoteFileTask.kt +++ b/app/src/main/java/com/owncloud/android/ui/asynctasks/GetRemoteFileTask.kt @@ -8,6 +8,7 @@ package com.owncloud.android.ui.asynctasks import android.content.Context import com.nextcloud.client.account.User +import com.nextcloud.utils.extensions.webDavParentPath import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.common.OwnCloudClient @@ -15,7 +16,10 @@ import com.owncloud.android.lib.common.operations.RemoteOperation import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation import com.owncloud.android.lib.resources.files.model.RemoteFile import com.owncloud.android.operations.RefreshFolderOperation +import com.owncloud.android.operations.upload.EncryptedLocalCopy import com.owncloud.android.utils.FileStorageUtils +import java.io.IOException +import java.io.File class GetRemoteFileTask( private val context: Context, @@ -28,6 +32,32 @@ class GetRemoteFileTask( data class Result(val success: Boolean = false, val file: OCFile = OCFile("/")) override fun invoke(): Result { + val parentPath = fileUrl.webDavParentPath() + val parent = storageManager.getFileByEncryptedRemotePath(parentPath) + if (parent?.isEncrypted == true) { + val previous = storageManager.getFileByEncryptedRemotePath(fileUrl)?.takeUnless { it.isEncrypted } + val refresh = RefreshFolderOperation( + parent, + System.currentTimeMillis(), + false, + true, + storageManager, + user, + context + ).execute(client) + if (!refresh.isSuccess) throw IOException("Could not refresh encrypted file metadata: ${refresh.code}") + val encrypted = storageManager.getFileByEncryptedRemotePath(fileUrl) + ?.takeIf { it.isEncrypted } ?: throw IOException("Encrypted file is missing from refreshed metadata") + if (previous != null) { + EncryptedLocalCopy.discardRawCopy( + previous, + encrypted, + File(FileStorageUtils.getSavePath(user.accountName)) + ) + storageManager.saveFile(encrypted) + } + return Result(true, encrypted) + } val result = ReadFileRemoteOperation(fileUrl).execute(client) if (result.isSuccess) { val remoteFile = result.getData().get(0) as RemoteFile diff --git a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictsResolveDialog.kt b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictsResolveDialog.kt index d1af116438dd..c9299afdd0cd 100644 --- a/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictsResolveDialog.kt +++ b/app/src/main/java/com/owncloud/android/ui/dialog/conflict/ConflictsResolveDialog.kt @@ -14,6 +14,8 @@ import android.view.View import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.DialogFragment +import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.DiskCacheStrategy import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.client.account.User @@ -203,7 +205,17 @@ class ConflictsResolveDialog : viewThemeUtils ) - thumbnailGenerator.setThumbnail(rightDataFile, binding.rightThumbnail) + val serverFile = rightDataFile + val decryptedServerPath = serverFile?.storagePath + if (serverFile?.isEncrypted == true && decryptedServerPath != null) { + Glide.with(binding.rightThumbnail) + .load(File(decryptedServerPath)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .into(binding.rightThumbnail) + } else { + thumbnailGenerator.setThumbnail(serverFile, binding.rightThumbnail) + } } private fun setOnClickListeners() { diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListConflictHandler.kt b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListConflictHandler.kt index dd6f0bdbca6a..2d47ee5f09c5 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListConflictHandler.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListConflictHandler.kt @@ -19,6 +19,7 @@ import com.owncloud.android.db.OCUpload import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.resources.files.ExistenceCheckRemoteOperation import com.owncloud.android.operations.factory.UploadFileOperationFactory +import com.owncloud.android.operations.upload.EncryptedUploadConflictChecker import com.owncloud.android.ui.adapter.uploadList.UploadListAdapter import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterHelper @@ -46,9 +47,10 @@ class UploadListConflictHandler( ).apply { show() } fragment.lifecycleScope.launch { - val result = FileUploadHelper.instance().checkConflict(upload) - - withContext(Dispatchers.Main) { + val result = FileUploadHelper.instance().checkConflict(upload, requirePreview = true) + var handedOff = false + try { + if (!fragment.isAdded || fragment.view !== rootView) return@launch when (result) { is ConflictHandlingResult.ConflictNotExistsRemoteFileNotFound -> { showConflictSnackbar(R.string.upload_sync_conflict_not_exists) @@ -61,13 +63,19 @@ class UploadListConflictHandler( is ConflictHandlingResult.ShowConflictResolveDialog -> { conflictSnackbar?.dismiss() - adapterHelper.openConflictActivity(result.file, result.upload) + adapterHelper.openConflictActivity(result.file, result.upload, result.encryptedRemoteVersion) + handedOff = true } is ConflictHandlingResult.ConflictNotExistsSameFile -> { showConflictSnackbar(R.string.upload_sync_conflict_same_file) + uploadListAdapter.loadUploadItemsFromDb() } } + } finally { + if (!handedOff && result is ConflictHandlingResult.ShowConflictResolveDialog) { + EncryptedUploadConflictChecker.releasePreview(activity, result.file) + } } } } diff --git a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt index 5362321187cd..a0ad5018364e 100644 --- a/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt +++ b/app/src/main/java/com/owncloud/android/ui/fragment/uploadList/UploadListFragment.kt @@ -215,7 +215,6 @@ class UploadListFragment : val adapterHelper = UploadListAdapterHelper(activity) uploadListAdapter = UploadListAdapter( activity, - activity.storageManager, uploadsStorageManager, userAccountManager, connectivityService, diff --git a/app/src/test/java/com/nextcloud/client/jobs/download/DownloadEventFileResolverTest.kt b/app/src/test/java/com/nextcloud/client/jobs/download/DownloadEventFileResolverTest.kt new file mode 100644 index 000000000000..0f6b4785355f --- /dev/null +++ b/app/src/test/java/com/nextcloud/client/jobs/download/DownloadEventFileResolverTest.kt @@ -0,0 +1,83 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.client.jobs.download + +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class DownloadEventFileResolverTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val storage: FileDataStorageManager = mock() + private val user: User = mock() + + @Before + fun setUp() { + whenever(user.accountName).thenReturn("account-A") + whenever(storage.user).thenReturn(user) + } + + @Test + fun encryptedDownloadCompletionResolvesDownloadedPdfByPhysicalPath() { + val file = OCFile("/cipher-folder/cipher-file").apply { + isEncrypted = true + decryptedRemotePath = "/private/document.pdf" + mimeType = "application/pdf" + storagePath = temporaryFolder.newFile("document.pdf").absolutePath + } + whenever(storage.getFileByEncryptedRemotePath(file.remotePath)).thenReturn(file) + + assertSame(file, DownloadEventFileResolver.resolve(storage, "account-A", file.remotePath)) + assertTrue(file.isDown) + verify(storage, never()).getFileByDecryptedRemotePath(any()) + } + + @Test + fun ordinaryDownloadUsesTheSamePhysicalPathLookup() { + val file = OCFile("/document.pdf") + whenever(storage.getFileByEncryptedRemotePath(file.remotePath)).thenReturn(file) + + assertSame(file, DownloadEventFileResolver.resolve(storage, "account-A", file.remotePath)) + } + + @Test + fun anotherAccountsDownloadCannotOpenCurrentAccountsFileAtTheSamePath() { + assertNull(DownloadEventFileResolver.resolve(storage, "account-B", "/document.pdf")) + verify(storage, never()).getFileByEncryptedRemotePath(any()) + } + + @Test + fun missingAccountOrPathCannotResolveAFile() { + assertNull(DownloadEventFileResolver.resolve(storage, null, "/document.pdf")) + assertNull(DownloadEventFileResolver.resolve(storage, "account-A", null)) + assertNull(DownloadEventFileResolver.resolve(storage, "account-A", "")) + verify(storage, never()).getFileByEncryptedRemotePath(any()) + } + + @Test + fun absentPhysicalFileCannotResolveToAnotherFilesDecryptedName() { + whenever(storage.getFileByDecryptedRemotePath("/cipher-file")) + .thenReturn(OCFile("/unrelated-file")) + + assertNull(DownloadEventFileResolver.resolve(storage, "account-A", "/cipher-file")) + verify(storage, never()).getFileByDecryptedRemotePath(any()) + } +} diff --git a/app/src/test/java/com/nextcloud/utils/thumbnail/AutoUploadThumbnailSourceTest.kt b/app/src/test/java/com/nextcloud/utils/thumbnail/AutoUploadThumbnailSourceTest.kt new file mode 100644 index 000000000000..6f8f59c32586 --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/thumbnail/AutoUploadThumbnailSourceTest.kt @@ -0,0 +1,104 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.thumbnail + +import com.nextcloud.client.database.dao.FileSystemDao +import com.nextcloud.client.database.dao.UploadDao +import com.nextcloud.client.database.entity.FilesystemEntity +import com.owncloud.android.datamodel.OCFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File + +@Suppress("MagicNumber") +class AutoUploadThumbnailSourceTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val filesystem: FileSystemDao = mock() + private val uploads: UploadDao = mock() + + @Test + fun clearedUploadHistoryCanUseTheRetainedMappingForTheSameAccountAndPhysicalPath() { + val local = temporaryFolder.newFile().apply { writeText("image bytes") } + val remote = encryptedFile(local) + whenever(filesystem.getHandledByRemotePath(remote.remotePath, "account-A")) + .thenReturn(listOf(entry(local, remote))) + + assertEquals(local, UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads, filesystem)) + verify(filesystem).getHandledByRemotePath("/private/cipher", "account-A") + assertNull(remote.storagePath) + + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-B", uploads, filesystem)) + } + + @Test + fun changedOrDeletedLocalSourceCannotRepresentTheCloudFile() { + val local = temporaryFolder.newFile().apply { writeText("image bytes") } + val remote = encryptedFile(local) + whenever(filesystem.getHandledByRemotePath(remote.remotePath, "account-A")) + .thenReturn(listOf(entry(local, remote))) + assertTrue(local.setLastModified(local.lastModified() + 10_000)) + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads, filesystem)) + assertTrue(local.delete()) + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads, filesystem)) + } + + @Test + fun pendingEntriesAndDifferentPhysicalPathsCannotProvideTheThumbnail() { + val local = temporaryFolder.newFile().apply { writeText("image bytes") } + val remote = encryptedFile(local) + for (invalid in listOf( + entry(local, remote).copy(fileSentForUpload = 0), + entry(local, remote).copy(remotePath = remote.decryptedRemotePath), + entry(local, remote).copy(fileIsFolder = 1) + )) { + whenever(filesystem.getHandledByRemotePath(remote.remotePath, "account-A")) + .thenReturn(listOf(invalid)) + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads, filesystem)) + } + } + + @Test + fun differentCloudSizeIsRejectedButPlaintextAndCiphertextLengthsAreSupported() { + val local = temporaryFolder.newFile().apply { writeText("image bytes") } + val remote = encryptedFile(local) + whenever(filesystem.getHandledByRemotePath(remote.remotePath, "account-A")) + .thenReturn(listOf(entry(local, remote))) + remote.fileLength = local.length() + assertEquals(local, UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads, filesystem)) + remote.fileLength = local.length() + 1 + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads, filesystem)) + } + + private fun encryptedFile(local: File) = OCFile("/private/cipher").apply { + isEncrypted = true + decryptedRemotePath = "/private/photo.jpg" + mimeType = "image/jpeg" + fileLength = local.length() + 16 + remoteId = "server-id" + } + + private fun entry(local: File, remote: OCFile) = FilesystemEntity( + id = 1, + localPath = local.absolutePath, + remotePath = remote.remotePath, + fileIsFolder = 0, + fileFoundRecently = System.currentTimeMillis(), + fileSentForUpload = 1, + syncedFolderId = "3", + crc32 = null, + fileModified = local.lastModified() + ) +} diff --git a/app/src/test/java/com/nextcloud/utils/thumbnail/ThumbnailGeneratorTest.kt b/app/src/test/java/com/nextcloud/utils/thumbnail/ThumbnailGeneratorTest.kt new file mode 100644 index 000000000000..4ce959b2f45e --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/thumbnail/ThumbnailGeneratorTest.kt @@ -0,0 +1,80 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.thumbnail + +import android.widget.ImageView +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus +import com.owncloud.android.db.OCUpload +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.clearInvocations +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions + +class ThumbnailGeneratorTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val files: FileThumbnailGenerator = mock() + private val folders: FolderThumbnailGenerator = mock() + private val view: ImageView = mock() + private val generator = ThumbnailGenerator(files, folders) + + @Test + fun uploadsInEveryStateGenerateLocalThumbnailsDespiteHavingNoServerId() { + val local = temporaryFolder.newFile("photo.jpg") + val remote = OCFile("/encrypted/cipher").apply { remoteId = "server-id" } + for (status in UploadStatus.entries) { + clearInvocations(files) + val upload = mock { + on { localPath } doReturn local.absolutePath + on { remotePath } doReturn remote.remotePath + on { mimeType } doReturn "image/jpeg" + on { uploadStatus } doReturn status + } + val source = UploadThumbnailSource.forUpload(upload, remote) + assertTrue(source.isOfflineOperation) + + generator.setUploadThumbnail(source, view) + + verify(files).setThumbnail(source, view, ThumbnailArguments.none) + verify(files, never()).setOfflineOperationThumbnail(source, view) + } + verifyNoInteractions(folders) + } + + @Test + fun uploadWithoutLocalOriginalUsesTheExistingRemoteThumbnailPath() { + val remote = OCFile("/photo.jpg").apply { remoteId = "server-id" } + val upload = mock { + on { localPath } doReturn temporaryFolder.root.resolve("missing.jpg").absolutePath + } + val source = UploadThumbnailSource.forUpload(upload, remote) + + generator.setUploadThumbnail(source, view) + + verify(files).setThumbnail(remote, view, ThumbnailArguments.none) + verifyNoInteractions(folders) + } + + @Test + fun fileListKeepsTheOfflineOperationThumbnailBehavior() { + val offline = OCFile("/photo.jpg") + + generator.setThumbnail(offline, view) + + verify(files).setOfflineOperationThumbnail(offline, view) + verify(files, never()).setThumbnail(offline, view, ThumbnailArguments.none) + verifyNoInteractions(folders) + } +} diff --git a/app/src/test/java/com/nextcloud/utils/thumbnail/UploadThumbnailSourceTest.kt b/app/src/test/java/com/nextcloud/utils/thumbnail/UploadThumbnailSourceTest.kt new file mode 100644 index 000000000000..17e55163cd42 --- /dev/null +++ b/app/src/test/java/com/nextcloud/utils/thumbnail/UploadThumbnailSourceTest.kt @@ -0,0 +1,206 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.nextcloud.utils.thumbnail + +import com.nextcloud.client.database.dao.UploadDao +import com.nextcloud.client.database.entity.toUploadEntity +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus +import com.owncloud.android.db.OCUpload +import com.owncloud.android.db.UploadResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File + +@Suppress("MagicNumber") +class UploadThumbnailSourceTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val uploads: UploadDao = mock() + + @Test + fun everyUploadStateUsesReadableSourceWithoutChangingRemoteDownloadState() { + val local = localFile() + val remote = encryptedFile() + for (status in UploadStatus.entries) { + val upload = upload(local).apply { whenever(uploadStatus).thenReturn(status) } + + val thumbnail = UploadThumbnailSource.forUpload(upload, remote) + + assertNotSame(remote, thumbnail) + assertEquals(local.absolutePath, thumbnail.storagePath) + assertNull(thumbnail.remoteId) + assertNull(remote.storagePath) + assertEquals("server-id", remote.remoteId) + } + } + + @Test + fun missingOrDirectorySourceFallsBackToExistingRemoteMetadata() { + val remote = encryptedFile() + assertSame(remote, UploadThumbnailSource.forUpload(upload(File(temporaryFolder.root, "missing")), remote)) + assertSame(remote, UploadThumbnailSource.forUpload(upload(temporaryFolder.root), remote)) + } + + @Test + fun missingSourceAndRemoteProducesAnIconWithoutInventingALocalCopy() { + val thumbnail = UploadThumbnailSource.forUpload(upload(File(temporaryFolder.root, "missing")), null) + assertNull(thumbnail.storagePath) + assertEquals("image/jpeg", thumbnail.mimeType) + } + + @Test + fun encryptedFolderUsesTheCompletedUploadsLocalOriginal() { + val local = localFile() + val remote = encryptedFile() + val entity = upload(local).toUploadEntity() + for (result in listOf(UploadResult.UPLOADED, UploadResult.SKIPPED, UploadResult.SAME_FILE_CONFLICT)) { + whenever(uploads.getByRemotePath(remote.remotePath, "account-A")) + .thenReturn(entity.copy(lastResult = result.value)) + assertEquals(local, UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads)) + } + assertNull(remote.storagePath) + } + + @Test + fun encryptedFolderCanResolveOldLogicalUploadPaths() { + val local = localFile() + val remote = encryptedFile() + val entity = upload(local).toUploadEntity().copy(remotePath = remote.decryptedRemotePath) + whenever(uploads.getByRemotePath(remote.decryptedRemotePath, "account-A")) + .thenReturn(entity) + + assertEquals(local, UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads)) + } + + @Test + fun anotherAccountsUploadCannotProvideThePreview() { + val remote = encryptedFile() + val entity = upload(localFile()).toUploadEntity().copy(accountName = "account-B") + whenever(uploads.getByRemotePath(remote.remotePath, "account-A")) + .thenReturn(entity) + + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads)) + verify(uploads, never()).getByRemotePath(any(), org.mockito.kotlin.eq("account-B")) + } + + @Test + fun pendingFailedOrCancelledUploadsCannotStandInForTheEncryptedServerFile() { + val remote = encryptedFile() + val entity = upload(localFile()).toUploadEntity() + for (status in UploadStatus.entries.filter { it != UploadStatus.UPLOAD_SUCCEEDED }) { + whenever(uploads.getByRemotePath(remote.remotePath, "account-A")) + .thenReturn(entity.copy(status = status.value)) + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads)) + } + } + + @Test + fun missingFileResultIsNotACompletedPreviewSource() { + val remote = encryptedFile() + val entity = upload(localFile()).toUploadEntity().copy(lastResult = UploadResult.FILE_NOT_FOUND.value) + whenever(uploads.getByRemotePath(remote.remotePath, "account-A")) + .thenReturn(entity) + + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads)) + } + + @Test + fun encryptedTransferSizeIncludesTheGcmAuthenticationTag() { + val local = localFile() + val remote = encryptedFile() + val entity = upload(local).toUploadEntity().copy(fileSize = local.length() + 16) + whenever(uploads.getByRemotePath(remote.remotePath, "account-A")).thenReturn(entity) + + assertEquals(local, UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads)) + + whenever(uploads.getByRemotePath(remote.remotePath, "account-A")) + .thenReturn(entity.copy(lastResult = UploadResult.SKIPPED.value)) + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads)) + + whenever(uploads.getByRemotePath(remote.remotePath, "account-A")) + .thenReturn(entity.copy(uploadEndTimestampLong = local.lastModified() - 1)) + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads)) + } + + @Test + fun changedLocalFileIsNotUsedAsTheCloudVersionsPreview() { + val local = localFile() + val remote = encryptedFile() + val entity = upload(local).toUploadEntity() + whenever(uploads.getByRemotePath(remote.remotePath, "account-A")).thenReturn(entity) + local.appendText("changed") + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads)) + + whenever(uploads.getByRemotePath(remote.remotePath, "account-A")) + .thenReturn(entity.copy(fileSize = local.length(), uploadEndTimestampLong = local.lastModified() - 1)) + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads)) + } + + @Test + fun deletedSourceAndUnknownCloudFilesHaveNoLocalFallback() { + val remote = encryptedFile() + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads)) + val local = localFile() + val entity = upload(local).toUploadEntity() + whenever(uploads.getByRemotePath(remote.remotePath, "account-A")) + .thenReturn(entity) + assertTrue(local.delete()) + assertNull(UploadThumbnailSource.forEncryptedFile(remote, "account-A", uploads)) + } + + @Test + fun ordinaryCloudPreviewDoesNotConsultUploadHistory() { + assertNull(UploadThumbnailSource.forEncryptedFile(OCFile("/photo.jpg"), "account-A", uploads)) + verify(uploads, never()).getByRemotePath(any(), any()) + } + + @Test + fun localCacheIdentityChangesWhenSizeOrModificationTimeChanges() { + val local = localFile() + val initial = UploadThumbnailSource.cacheKey(local) + assertEquals(initial, UploadThumbnailSource.cacheKey(local)) + local.appendText("changed") + val resized = UploadThumbnailSource.cacheKey(local) + assertNotEquals(initial, resized) + assertTrue(local.setLastModified(local.lastModified() + 10_000)) + assertNotEquals(resized, UploadThumbnailSource.cacheKey(local)) + } + + private fun localFile() = temporaryFolder.newFile().apply { writeText("image bytes") } + + private fun encryptedFile() = OCFile("/private/cipher").apply { + isEncrypted = true + decryptedRemotePath = "/private/photo.jpg" + mimeType = "image/jpeg" + remoteId = "server-id" + } + + private fun upload(local: File): OCUpload = mock().apply { + whenever(localPath).thenReturn(local.absolutePath) + whenever(remotePath).thenReturn("/private/cipher") + whenever(accountName).thenReturn("account-A") + whenever(mimeType).thenReturn("image/jpeg") + whenever(uploadStatus).thenReturn(UploadStatus.UPLOAD_SUCCEEDED) + whenever(lastResult).thenReturn(UploadResult.UPLOADED) + whenever(fileSize).thenReturn(local.length()) + whenever(uploadEndTimestamp).thenReturn(System.currentTimeMillis()) + } +} diff --git a/app/src/test/java/com/owncloud/android/datamodel/UploadsStorageManagerCursorTest.kt b/app/src/test/java/com/owncloud/android/datamodel/UploadsStorageManagerCursorTest.kt new file mode 100644 index 000000000000..2578b986470f --- /dev/null +++ b/app/src/test/java/com/owncloud/android/datamodel/UploadsStorageManagerCursorTest.kt @@ -0,0 +1,92 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.datamodel + +import android.content.ContentResolver +import android.database.Cursor +import com.nextcloud.client.database.NextcloudDatabase +import com.owncloud.android.MainApp +import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus +import com.owncloud.android.db.ProviderMeta.ProviderTableMeta +import com.owncloud.android.db.UploadResult +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.io.File + +class UploadsStorageManagerCursorTest { + private lateinit var manager: UploadsStorageManager + private lateinit var cursor: Cursor + private val columns = mutableMapOf() + private var lockToken: String? = null + + @Before + fun setUp() { + val database = mockk(relaxed = true) + mockkObject(NextcloudDatabase.Companion) + every { NextcloudDatabase.instance() } returns database + mockkObject(MainApp.Companion) + every { MainApp.authority } returns "test.authority" + every { MainApp.getAppContext() } throws IllegalStateException("No capability available") + + cursor = mockk(relaxed = true) + every { cursor.getColumnIndexOrThrow(any()) } answers { + columns.getOrPut(firstArg()) { columns.size } + } + every { cursor.getColumnIndex(any()) } returns -1 + every { cursor.getString(any()) } answers { + when (columns.entries.first { it.value == firstArg() }.key) { + ProviderTableMeta.UPLOADS_LOCAL_PATH -> File.separator + "photo.jpg" + ProviderTableMeta.UPLOADS_REMOTE_PATH -> "/private/photo.jpg" + ProviderTableMeta.UPLOADS_ACCOUNT_NAME -> "test-account" + ProviderTableMeta.UPLOADS_FOLDER_UNLOCK_TOKEN -> lockToken + else -> null + } + } + every { cursor.getLong(any()) } returns 1L + every { cursor.getInt(any()) } answers { + when (columns.entries.first { it.value == firstArg() }.key) { + ProviderTableMeta.UPLOADS_STATUS -> UploadStatus.UPLOAD_FAILED.value + ProviderTableMeta.UPLOADS_LAST_RESULT -> UploadResult.SYNC_CONFLICT.value + else -> 0 + } + } + every { cursor.moveToFirst() } returns true + every { cursor.moveToNext() } returns false + + val resolver = mockk() + every { resolver.query(any(), null, any(), any(), any()) } returnsMany listOf(cursor, null) + manager = UploadsStorageManager(mockk(), resolver) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `cleared encryption lock token does not prevent reading a conflict`() { + val upload = manager.getUploadsForAccount("test-account").single() + + assertEquals("", upload.folderUnlockToken) + assertEquals(UploadStatus.UPLOAD_FAILED, upload.uploadStatus) + assertEquals(UploadResult.SYNC_CONFLICT, upload.lastResult) + assertEquals("/private/photo.jpg", upload.remotePath) + } + + @Test + fun `active encryption lock token is preserved`() { + lockToken = "active-token" + + assertEquals("active-token", manager.getUploadsForAccount("test-account").single().folderUnlockToken) + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/EncryptedDownloadTest.kt b/app/src/test/java/com/owncloud/android/operations/EncryptedDownloadTest.kt new file mode 100644 index 000000000000..0ee6095fc066 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/EncryptedDownloadTest.kt @@ -0,0 +1,139 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations + +import com.owncloud.android.lib.common.operations.OperationCancelledException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.IOException +import java.util.function.BooleanSupplier +import javax.crypto.AEADBadTagException +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Suppress("MagicNumber") +class EncryptedDownloadTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val key = SecretKeySpec(ByteArray(16) { it.toByte() }, "AES") + private val nonce = ByteArray(12) { (it + 1).toByte() } + private val cancelled = BooleanSupplier { false } + private val decrypt = { cipher(Cipher.DECRYPT_MODE) } + + @Test + fun firstDownloadCreatesMissingDirectoriesAndPublishesOnlyPlaintext() { + val content = ByteArray(32_783) { (it % 251).toByte() } + val encrypted = encrypted(content) + val destination = File(temporaryFolder.root, "not/browsed/before/document.pdf") + + EncryptedDownload.save(encrypted, destination, decrypt, cancelled) + + assertArrayEquals(content, destination.readBytes()) + assertFalse(encrypted.exists()) + assertEquals(listOf(destination), destination.parentFile!!.listFiles()!!.toList()) + } + + @Test + fun invalidTagPreservesExistingCopyAndCleansBothTemporaryFiles() { + val destination = temporaryFolder.newFile().apply { writeText("previous valid copy") } + val encrypted = encrypted(ByteArray(20_000)).apply { + writeBytes(readBytes().also { it[it.lastIndex] = (it.last().toInt() xor 1).toByte() }) + } + + assertThrows(AEADBadTagException::class.java) { + EncryptedDownload.save(encrypted, destination, decrypt, cancelled) + } + + assertEquals("previous valid copy", destination.readText()) + assertEquals(listOf(destination), temporaryFolder.root.listFiles()!!.toList()) + } + + @Test + fun cancellationPreservesExistingCopyAndCleansDownloads() { + val destination = temporaryFolder.newFile().apply { writeText("previous valid copy") } + val encrypted = encrypted(ByteArray(20_000)) + + assertThrows(OperationCancelledException::class.java) { + EncryptedDownload.save(encrypted, destination, decrypt, BooleanSupplier { true }) + } + + assertEquals("previous valid copy", destination.readText()) + assertEquals(listOf(destination), temporaryFolder.root.listFiles()!!.toList()) + } + + @Test + fun invalidEncryptionParametersCleanDownloadsWithoutPublishingAFile() { + val encrypted = encrypted(byteArrayOf(1, 2, 3)) + val destination = File(temporaryFolder.root, "document.pdf") + + assertThrows(IllegalStateException::class.java) { + EncryptedDownload.save(encrypted, destination, { error("Missing key") }, cancelled) + } + + assertFalse(destination.exists()) + assertTrue(temporaryFolder.root.listFiles()!!.isEmpty()) + } + + @Test + fun exportReceivesAuthenticatedPlaintextAndLeavesNoLocalDownloadCopy() { + val encrypted = encrypted(byteArrayOf(1, 2, 3)) + val cache = temporaryFolder.newFolder("cache") + val exported = temporaryFolder.newFile("exported.pdf") + + EncryptedDownload.usePlaintext(encrypted, cache, decrypt, cancelled) { plaintext -> + assertArrayEquals(byteArrayOf(1, 2, 3), plaintext.readBytes()) + plaintext.copyTo(exported, overwrite = true) + } + + assertArrayEquals(byteArrayOf(1, 2, 3), exported.readBytes()) + assertFalse(encrypted.exists()) + assertTrue(cache.listFiles()!!.isEmpty()) + } + + @Test + fun failingExportStillRemovesPlaintextAndCiphertext() { + val encrypted = encrypted(byteArrayOf(1, 2, 3)) + + assertThrows(IOException::class.java) { + EncryptedDownload.usePlaintext(encrypted, temporaryFolder.root, decrypt, cancelled) { + throw IOException("Export failed") + } + } + + assertTrue(temporaryFolder.root.listFiles()!!.isEmpty()) + } + + @Test + fun blockedDestinationPreservesUnrelatedFileAndRemovesCiphertext() { + val blocker = temporaryFolder.newFile().apply { writeText("preserve") } + val encrypted = encrypted(byteArrayOf(1, 2, 3)) + + assertThrows(IOException::class.java) { + EncryptedDownload.save(encrypted, File(blocker, "document.pdf"), decrypt, cancelled) + } + + assertEquals("preserve", blocker.readText()) + assertFalse(encrypted.exists()) + } + + private fun encrypted(bytes: ByteArray): File = temporaryFolder.newFile().apply { + writeBytes(cipher(Cipher.ENCRYPT_MODE).doFinal(bytes)) + } + + private fun cipher(mode: Int): Cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(mode, key, GCMParameterSpec(128, nonce)) + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/AutoUploadProgressForwarderTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/AutoUploadProgressForwarderTest.kt new file mode 100644 index 000000000000..91e3b0365c50 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/AutoUploadProgressForwarderTest.kt @@ -0,0 +1,51 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.nextcloud.client.account.User +import com.nextcloud.client.jobs.upload.AutoUploadProgressForwarder +import com.nextcloud.client.jobs.upload.FileUploadHelper +import com.owncloud.android.lib.common.network.OnDatatransferProgressListener +import com.owncloud.android.operations.UploadFileOperation +import org.junit.After +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever + +class AutoUploadProgressForwarderTest { + @After + fun tearDown() { + FileUploadHelper.mBoundListeners.clear() + } + + @Test + fun encryptedUploadForwardsBytesToLogicalQueuePathAndCorrectAccount() { + val user: User = mock() + val operation: UploadFileOperation = mock() + val listener: OnDatatransferProgressListener = mock() + val otherAccount: OnDatatransferProgressListener = mock() + whenever(user.accountName).thenReturn("account-A") + whenever(operation.user).thenReturn(user) + whenever(operation.uploadRemotePath).thenReturn("/private/photo.jpg") + whenever(operation.remotePath).thenReturn("/cipher-folder/cipher") + whenever(operation.fileName).thenReturn("photo.jpg") + FileUploadHelper.mBoundListeners["account-A/private/photo.jpg"] = listener + FileUploadHelper.mBoundListeners["account-B/private/photo.jpg"] = otherAccount + + val progress = AutoUploadProgressForwarder(operation) + progress.onTransferProgress(1, 50, 100, "/cache/encrypted") + + verify(listener).onTransferProgress(1, 50, 100, "photo.jpg") + verifyNoInteractions(otherAccount) + val replacement: OnDatatransferProgressListener = mock() + FileUploadHelper.mBoundListeners["account-A/private/photo.jpg"] = replacement + progress.onTransferProgress(1, 100, 100, "/cache/encrypted") + verify(replacement).onTransferProgress(1, 100, 100, "photo.jpg") + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/EncryptedConflictFilesTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedConflictFilesTest.kt new file mode 100644 index 000000000000..eaf2125904fc --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedConflictFilesTest.kt @@ -0,0 +1,105 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import android.content.Context +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.operations.DownloadFileOperation +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import java.io.File +import java.io.IOException + +class EncryptedConflictFilesTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun verifiedServerCopyBecomesLocalCopyWithoutAnotherDownload() { + val local = temporaryFolder.newFile("local.jpg").apply { writeText("older local content") } + val snapshot = temporaryFolder.newFile("snapshot.jpg").apply { writeText("new server content") } + val file = OCFile("/private/ciphertext").apply { storagePath = local.absolutePath } + val verified = OCFile(file.remotePath).apply { + isEncrypted = true + storagePath = snapshot.absolutePath + modificationTimestamp = 1_700_000_000_000 + etag = "verified-etag" + mimeType = "image/jpeg" + remoteId = "remote-id" + } + val user: User = mock { on { accountName } doReturn "account-A" } + val operation = DownloadFileOperation(user, file, mock()) + + operation.useVerifiedCopy(verified) + + assertArrayEquals(snapshot.readBytes(), local.readBytes()) + assertEquals(verified.modificationTimestamp, local.lastModified()) + assertEquals(verified.modificationTimestamp, operation.getModificationTimestamp()) + assertEquals(verified.etag, operation.etag) + assertEquals(verified.mimeType, operation.mimeType) + assertEquals(verified.remoteId, file.remoteId) + assertFalse(temporaryFolder.root.listFiles()!!.any { it.name.startsWith("verified-download-") }) + } + + @Test + fun missingSnapshotCannotReplaceExistingLocalFileAndStagingIsCleaned() { + val local = temporaryFolder.newFile().apply { writeText("preserve this") } + val file = OCFile("/private/ciphertext").apply { storagePath = local.absolutePath } + val verified = OCFile(file.remotePath).apply { + isEncrypted = true + storagePath = File(temporaryFolder.root, "missing").absolutePath + } + val operation = DownloadFileOperation(mock(), file, mock()) + + assertThrows(IOException::class.java) { operation.useVerifiedCopy(verified) } + assertEquals("preserve this", local.readText()) + assertFalse(temporaryFolder.root.listFiles()!!.any { it.name.startsWith("verified-download-") }) + } + + @Test + fun previewReleaseDeletesOnlyOwnedTemporaryFiles() { + val context: Context = mock { on { cacheDir } doReturn temporaryFolder.root } + val preview = File(temporaryFolder.newFolder("encrypted-conflicts"), "conflict.jpg").apply { + writeText("private") + } + val personal = temporaryFolder.newFile("personal.jpg").apply { writeText("preserve") } + val file = OCFile("/ciphertext").apply { isEncrypted = true } + + file.storagePath = personal.absolutePath + EncryptedUploadConflictChecker.releasePreview(context, file) + assertTrue(personal.exists()) + file.storagePath = preview.absolutePath + EncryptedUploadConflictChecker.releasePreview(context, file) + assertFalse(preview.exists()) + } + + @Test + fun processRestartCleansOrphansAndPreservesOtherCacheFiles() { + val context: Context = mock { on { cacheDir } doReturn temporaryFolder.root } + val preview = File(temporaryFolder.newFolder("encrypted-conflicts"), "conflict.jpg").apply { + writeText("private") + } + val abandoned = temporaryFolder.newFolder("encrypted-comparison-abandoned") + File(abandoned, "partial").writeText("partial download") + val unrelated = temporaryFolder.newFile("other-cache") + + EncryptedUploadConflictChecker.clearOrphanedPreviews(context) + + assertFalse(preview.exists()) + assertFalse(abandoned.exists()) + assertTrue(unrelated.exists()) + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/EncryptedFileComparisonCacheTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedFileComparisonCacheTest.kt new file mode 100644 index 000000000000..6ce02116f633 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedFileComparisonCacheTest.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.ArbitraryDataProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@Suppress("SpreadOperator") +class EncryptedFileComparisonCacheTest { + private val values = mutableMapOf, String>() + private val provider: ArbitraryDataProvider = mock() + private val cache = EncryptedFileComparisonCache(provider, "account-A") + + @Before + fun setUp() { + whenever(provider.getValue(any(), any())).thenAnswer { + values[it.getArgument(0) to it.getArgument(1)].orEmpty() + } + doAnswer { + values[it.getArgument(0) to it.getArgument(1)] = it.getArgument(2) + null + }.whenever(provider).storeOrUpdateKeyValue(any(), any(), any()) + } + + @Test + fun unchangedVersionReusesVerifiedEquality() { + cache.write("file", "version-A", true, 42) + assertEquals(true to 42L, cache.read("file", "version-A")) + } + + @Test + fun changedRemoteOrLocalVersionRequiresNewVerification() { + cache.write("file", "version-A", true, 42) + assertNull(cache.read("file", "version-B")) + } + + @Test + fun verifiedDifferenceIsAlsoReused() { + cache.write("file", "version-A", false, 42) + assertEquals(false to 42L, cache.read("file", "version-A")) + } + + @Test + fun recordsCannotBeReusedAcrossAccountsOrPaths() { + cache.write("file", "version-A", true, 42) + assertNull(EncryptedFileComparisonCache(provider, "account-B").read("file", "version-A")) + assertNull(cache.read("another-file", "version-A")) + } + + @Test + fun everyLocalAndRemoteVersionFieldInvalidatesTheFingerprint() { + val fields = arrayOf("size", "mtime", "etag", "remote-id", "cipher-size", "remote-mtime", "key", "nonce") + val original = EncryptedFileComparisonCache.fingerprint(*fields) + fields.indices.forEach { index -> + val changed = fields.copyOf().also { it[index] += "-changed" } + assertNotEquals(original, EncryptedFileComparisonCache.fingerprint(*changed)) + } + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/EncryptedFileContentTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedFileContentTest.kt new file mode 100644 index 000000000000..fc65c8104c28 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedFileContentTest.kt @@ -0,0 +1,126 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.lib.common.operations.OperationCancelledException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.function.BooleanSupplier +import javax.crypto.AEADBadTagException +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Suppress("MagicNumber") +class EncryptedFileContentTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val key = SecretKeySpec(ByteArray(16) { it.toByte() }, "AES") + private val nonce = ByteArray(12) { (it + 1).toByte() } + + @Test + fun identicalPlaintextMatchesDespiteCiphertextSizeAndDifferentTimestamp() { + val content = ByteArray(32_783) { (it % 251).toByte() } + val encrypted = encrypted(content) + val local = temporaryFolder.newFile().apply { + writeBytes(content) + setLastModified(1_000) + } + val plaintext = temporaryFolder.newFile() + + assertTrue(compare(encrypted, plaintext, local)) + assertArrayEquals(content, plaintext.readBytes()) + assertTrue(encrypted.length() != local.length()) + } + + @Test + fun differentContentOfSameSizeIsAConflict() { + val original = ByteArray(16_400) { 7 } + val changed = original.copyOf().apply { this[lastIndex] = 8 } + val local = temporaryFolder.newFile().apply { writeBytes(changed) } + val plaintext = temporaryFolder.newFile() + + assertFalse(compare(encrypted(original), plaintext, local)) + assertArrayEquals(changed, local.readBytes()) + assertArrayEquals(original, plaintext.readBytes()) + } + + @Test + fun emptyPlaintextMatches() { + assertTrue(compare(encrypted(byteArrayOf()), temporaryFolder.newFile(), temporaryFolder.newFile())) + } + + @Test + fun differentPlaintextLengthIsAConflict() { + val local = temporaryFolder.newFile().apply { writeBytes(byteArrayOf(1)) } + assertFalse(compare(encrypted(byteArrayOf(1, 2)), temporaryFolder.newFile(), local)) + } + + @Test + fun corruptedAuthenticationTagCannotProduceAnEqualityResultOrPreview() { + val local = temporaryFolder.newFile().apply { writeBytes(ByteArray(9_000)) } + val encrypted = encrypted(local.readBytes()).apply { + writeBytes(readBytes().also { it[it.lastIndex] = (it.last().toInt() xor 1).toByte() }) + } + val plaintext = temporaryFolder.newFile() + + assertThrows(AEADBadTagException::class.java) { compare(encrypted, plaintext, local) } + assertFalse(plaintext.exists()) + assertTrue(local.exists()) + } + + @Test + fun truncatedCiphertextIsRejectedAndPlaintextRemoved() { + val encrypted = encrypted(byteArrayOf(1, 2, 3)).apply { writeBytes(readBytes().dropLast(1).toByteArray()) } + val plaintext = temporaryFolder.newFile() + + assertThrows(AEADBadTagException::class.java) { compare(encrypted, plaintext, temporaryFolder.newFile()) } + assertFalse(plaintext.exists()) + } + + @Test + fun cancellationRemovesTemporaryPlaintextAndPreservesLocalFile() { + val local = temporaryFolder.newFile().apply { writeBytes(byteArrayOf(1, 2, 3)) } + val plaintext = temporaryFolder.newFile() + + assertThrows(OperationCancelledException::class.java) { + EncryptedFileContent.decryptAndCompare( + encrypted(local.readBytes()), + plaintext, + local, + cipher(Cipher.DECRYPT_MODE), + BooleanSupplier { true } + ) + } + assertFalse(plaintext.exists()) + assertArrayEquals(byteArrayOf(1, 2, 3), local.readBytes()) + } + + private fun encrypted(bytes: ByteArray): File = temporaryFolder.newFile().apply { + writeBytes(cipher(Cipher.ENCRYPT_MODE).doFinal(bytes)) + } + + private fun compare(encrypted: File, plaintext: File, local: File): Boolean = + EncryptedFileContent.decryptAndCompare( + encrypted, + plaintext, + local, + cipher(Cipher.DECRYPT_MODE), + BooleanSupplier { false } + ) + + private fun cipher(mode: Int): Cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(mode, key, GCMParameterSpec(128, nonce)) + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/EncryptedFileMetadataTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedFileMetadataTest.kt new file mode 100644 index 000000000000..0d75a073c928 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedFileMetadataTest.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.e2e.v1.decrypted.Data +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFolderMetadataFileV1 +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedMetadata +import com.owncloud.android.utils.MimeType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import com.owncloud.android.datamodel.e2e.v1.decrypted.DecryptedFile as DecryptedFileV1 + +class EncryptedFileMetadataTest { + @Test + fun v1ResolvesCiphertextIdentityAndPlaintextAttributes() { + val metadata = DecryptedFolderMetadataFileV1().apply { + setFiles( + mapOf( + "cipher" to DecryptedFileV1().apply { + encrypted = Data().apply { + filename = "photo.jpg" + mimetype = "image/jpeg" + key = "key" + } + initializationVector = "nonce" + } + ) + ) + } + + val file = requireNotNull(EncryptedFileMetadata.find(metadata, "PHOTO.jpg")) + assertEquals("cipher", file.remoteName) + assertEquals("photo.jpg", file.filename) + assertEquals("image/jpeg", file.mimeType) + assertEquals("key", file.key) + assertEquals("nonce", file.nonce) + assertNull(EncryptedFileMetadata.find(metadata, "other.jpg")) + metadata.files["cipher"]!!.encrypted.mimetype = MimeType.WEBDAV_FOLDER + assertNull(EncryptedFileMetadata.find(metadata, "photo.jpg")) + } + + @Test + fun v2ResolvesFilesAndDoesNotTreatFolderAsFile() { + val metadata = DecryptedFolderMetadataFile( + metadata = DecryptedMetadata( + files = mutableMapOf("cipher" to DecryptedFile("photo.jpg", "image/jpeg", "nonce", "tag", "key")), + folders = mutableMapOf("cipher-folder" to "photos"), + metadataKey = ByteArray(0) + ), + version = "2.0" + ) + + val file = requireNotNull(EncryptedFileMetadata.find(metadata, "photo.jpg")) + assertEquals("cipher", file.remoteName) + assertEquals("image/jpeg", file.mimeType) + assertEquals("key", file.key) + assertEquals("nonce", file.nonce) + assertNull(EncryptedFileMetadata.find(metadata, "photos")) + assertNull(EncryptedFileMetadata.find(null, "photo.jpg")) + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/EncryptedLocalCopyTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedLocalCopyTest.kt new file mode 100644 index 000000000000..dc91f77834a0 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedLocalCopyTest.kt @@ -0,0 +1,66 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.OCFile +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class EncryptedLocalCopyTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun repairingRawCiphertextRecordDeletesOnlyItsDefaultAccountCopy() { + val root = temporaryFolder.newFolder() + val raw = File(root, "cipher").apply { writeText("ciphertext") } + val previous = OCFile("/cipher").apply { storagePath = raw.absolutePath } + val refreshed = OCFile("/cipher").apply { isEncrypted = true } + + EncryptedLocalCopy.discardRawCopy(previous, refreshed, root) + + assertFalse(raw.exists()) + assertNull(refreshed.storagePath) + } + + @Test + fun repairingRecordPreservesExternalSourceAndClearsUnverifiedAssociation() { + val root = temporaryFolder.newFolder() + val source = temporaryFolder.newFile().apply { writeText("source") } + val previous = OCFile("/cipher").apply { storagePath = source.absolutePath } + val refreshed = OCFile("/cipher").apply { + isEncrypted = true + storagePath = source.absolutePath + } + + EncryptedLocalCopy.discardRawCopy(previous, refreshed, root) + + assertTrue(source.exists()) + assertNull(refreshed.storagePath) + } + + @Test + fun validEncryptedLocalCopyIsPreserved() { + val root = temporaryFolder.newFolder() + val local = File(root, "photo.jpg").apply { writeText("plaintext") } + val previous = OCFile("/cipher").apply { isEncrypted = true } + val refreshed = OCFile("/cipher").apply { + isEncrypted = true + storagePath = local.absolutePath + } + + EncryptedLocalCopy.discardRawCopy(previous, refreshed, root) + + assertTrue(local.exists()) + assertTrue(refreshed.storagePath == local.absolutePath) + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/EncryptedUploadConflictCheckerTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedUploadConflictCheckerTest.kt new file mode 100644 index 000000000000..4210749ecafe --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedUploadConflictCheckerTest.kt @@ -0,0 +1,134 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import android.content.Context +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.ArbitraryDataProviderImpl +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedFolderMetadataFile +import com.owncloud.android.datamodel.e2e.v2.decrypted.DecryptedMetadata +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.files.DownloadFileRemoteOperation +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.mockito.Mockito.mockConstruction +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class EncryptedUploadConflictCheckerTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + @Test + fun unchangedVerifiedFileDoesNotDownloadCiphertextAgain() { + verifyCachedComparison(true) + } + + @Test + fun unchangedKnownConflictDoesNotDownloadDuringBackgroundRetry() { + verifyCachedComparison(false) + } + + @Test + fun acceptingVerifiedServerCopyDoesNotDownloadOnNextScan() { + verifyCachedComparison(false, adoptCopy = true) + } + + @Suppress("NestedBlockDepth") + private fun verifyCachedComparison(same: Boolean, adoptCopy: Boolean = false) { + val local = temporaryFolder.newFile().apply { writeText("abc") } + val context: Context = mock { on { cacheDir } doReturn temporaryFolder.root } + val user: User = mock { on { accountName } doReturn "account-A" } + val client: OwnCloudClient = mock() + val parent = OCFile("/private/").apply { + isEncrypted = true + decryptedRemotePath = "/private/" + } + val metadata = DecryptedFolderMetadataFile( + metadata = DecryptedMetadata( + files = mutableMapOf("cipher" to DecryptedFile("photo.jpg", "image/jpeg", "nonce", "tag", "key")), + metadataKey = ByteArray(0) + ), + version = "2.0" + ) + val remote = RemoteFile("/private/cipher").apply { + length = 19 + etag = "version-A" + remoteId = "00000042instance" + mimeType = "application/octet-stream" + modifiedTimestamp = 1_700_000_000_000 + } + val remoteVersion = EncryptedFileComparisonCache.fingerprint( + remote.etag.orEmpty(), + remote.remoteId.orEmpty(), + remote.length.toString(), + remote.modifiedTimestamp.toString(), + "key", + "nonce" + ) + val version = EncryptedFileComparisonCache.version(local.length(), local.lastModified(), remoteVersion) + var record = "$version:$same:3" + val result: RemoteOperationResult = mock { + on { isSuccess } doReturn true + on { data } doReturn arrayListOf(remote) + } + mockConstruction(ReadFileRemoteOperation::class.java) { read, _ -> + doReturn(result).whenever(read).execute(client) + }.use { + mockConstruction(ArbitraryDataProviderImpl::class.java) { provider, _ -> + whenever(provider.getValue(any(), any())).thenAnswer { record } + doAnswer { + record = it.getArgument(2) + null + }.whenever(provider).storeOrUpdateKeyValue(any(), any(), any()) + }.use { + mockConstruction(DownloadFileRemoteOperation::class.java).use { downloads -> + val compared = requireNotNull( + EncryptedUploadConflictChecker( + context, + user + ).compare(client, parent, metadata, local, "photo.jpg") + ) + + assertEquals(same, compared.sameContent) + assertEquals("/private/photo.jpg", compared.remoteFile.decryptedRemotePath) + assertEquals("image/jpeg", compared.remoteFile.mimeType) + assertEquals(3L, compared.remoteFile.fileLength) + if (adoptCopy) { + local.writeText("new") + local.setLastModified(local.lastModified() + 1_000) + EncryptedUploadConflictChecker.rememberVerifiedCopy( + context, + user, + compared.remoteFile, + local, + compared.remoteVersion + ) + val afterCopy = requireNotNull( + EncryptedUploadConflictChecker(context, user) + .compare(client, parent, metadata, local, "photo.jpg") + ) + assertTrue(afterCopy.sameContent) + } + assertTrue(downloads.constructed().isEmpty()) + assertTrue(temporaryFolder.root.listFiles()!!.none { it.isDirectory }) + } + } + } + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/EncryptedUploadedFileOpenTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedUploadedFileOpenTest.kt new file mode 100644 index 000000000000..a77e08be2789 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/EncryptedUploadedFileOpenTest.kt @@ -0,0 +1,116 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import android.content.Context +import com.nextcloud.client.account.User +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.operations.RefreshFolderOperation +import com.owncloud.android.ui.asynctasks.GetRemoteFileTask +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito.mockConstruction +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.IOException + +class EncryptedUploadedFileOpenTest { + private val context: Context = mock() + private val client: OwnCloudClient = mock() + private val storage: FileDataStorageManager = mock() + private val user: User = mock() + private val parent = OCFile("/cipher-folder/").apply { + isEncrypted = true + decryptedRemotePath = "/private/" + } + + @Test + fun openingEncryptedFileUsesRefreshedMetadataInsteadOfSavingCiphertextProperties() { + val encrypted = OCFile("/cipher-folder/cipher").apply { + isEncrypted = true + decryptedRemotePath = "/private/document.pdf" + mimeType = "application/pdf" + } + whenever(storage.getFileByEncryptedRemotePath(parent.remotePath)).thenReturn(parent) + whenever(storage.getFileByEncryptedRemotePath(encrypted.remotePath)).thenReturn(encrypted) + val refreshed: RemoteOperationResult = mock { on { isSuccess } doReturn true } + mockConstruction(RefreshFolderOperation::class.java) { refresh, arguments -> + assertSame(parent, arguments.arguments()[0]) + doReturn(refreshed).whenever(refresh).execute(client) + }.use { refreshes -> + mockConstruction(ReadFileRemoteOperation::class.java).use { reads -> + val opened = GetRemoteFileTask(context, encrypted.remotePath, client, storage, user).invoke() + + assertTrue(opened.success) + assertSame(encrypted, opened.file) + assertEquals("application/pdf", opened.file.mimeType) + assertEquals("/private/document.pdf", opened.file.decryptedRemotePath) + assertEquals(1, refreshes.constructed().size) + assertTrue(reads.constructed().isEmpty()) + verify(storage, never()).saveFileWithParent(any(), any()) + } + } + } + + @Test + fun metadataRefreshFailureCannotDowngradeEncryptedFileToRawCiphertext() { + whenever(storage.getFileByEncryptedRemotePath(parent.remotePath)).thenReturn(parent) + val failed: RemoteOperationResult = mock { on { isSuccess } doReturn false } + mockConstruction(RefreshFolderOperation::class.java) { refresh, _ -> + doReturn(failed).whenever(refresh).execute(client) + }.use { + mockConstruction(ReadFileRemoteOperation::class.java).use { reads -> + assertThrows(IOException::class.java) { + GetRemoteFileTask(context, "/cipher-folder/cipher", client, storage, user).invoke() + } + assertTrue(reads.constructed().isEmpty()) + verify(storage, never()).saveFileWithParent(any(), any()) + } + } + } + + @Test + fun ordinaryFileStillReadsAndSavesDavProperties() { + val remote = RemoteFile("/public/document.pdf").apply { mimeType = "application/pdf" } + val stored = OCFile(remote.remotePath).apply { mimeType = remote.mimeType } + val readResult: RemoteOperationResult = mock { + on { isSuccess } doReturn true + on { data } doReturn arrayListOf(remote) + } + whenever(storage.saveFileWithParent(any(), any())).thenReturn(stored) + mockConstruction(ReadFileRemoteOperation::class.java) { read, _ -> + doReturn(readResult).whenever(read).execute(client) + }.use { + mockConstruction(RefreshFolderOperation::class.java).use { refreshes -> + val opened = GetRemoteFileTask( + context, + requireNotNull(remote.remotePath), + client, + storage, + user + ).invoke() + + assertTrue(opened.success) + assertSame(stored, opened.file) + verify(storage).saveFileWithParent(any(), any()) + assertTrue(refreshes.constructed().isEmpty()) + } + } + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/UploadFileLinkTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/UploadFileLinkTest.kt new file mode 100644 index 000000000000..641d8778b78f --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/UploadFileLinkTest.kt @@ -0,0 +1,127 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.datamodel.UploadsStorageManager.UploadStatus +import com.owncloud.android.db.OCUpload +import com.owncloud.android.db.UploadResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.io.File + +class UploadFileLinkTest { + private val storage: FileDataStorageManager = mock() + + @Test + fun identicalEncryptedConflictBecomesCompletedWithPhysicalRemoteLink() { + verifyCompletion(encrypted = true) + } + + @Test + fun identicalOrdinaryConflictUsesTheSameCompletionFlow() { + verifyCompletion(encrypted = false) + } + + private fun verifyCompletion(encrypted: Boolean) { + val uploads: UploadsStorageManager = mock() + val localPath = File.separator + "photo.jpg" + val upload = OCUpload(localPath, "/private/photo.jpg", "account-A").apply { + uploadId = 42 + uploadStatus = UploadStatus.UPLOAD_FAILED + lastResult = UploadResult.SYNC_CONFLICT + } + val remote = OCFile(if (encrypted) "/private/cipher" else "/private/photo.jpg").apply { + isEncrypted = encrypted + decryptedRemotePath = "/private/photo.jpg" + fileLength = 3 + } + whenever(storage.getFileByEncryptedRemotePath(remote.remotePath)).thenReturn(remote) + + UploadFileLink.completeIdentical(upload, remote, uploads) + + verify(uploads).updateUpload( + argThat { + uploadId == 42L && accountName == "account-A" && this.localPath == localPath && + remotePath == remote.remotePath && fileSize == 3L && + uploadStatus == UploadStatus.UPLOAD_SUCCEEDED && lastResult == UploadResult.SAME_FILE_CONFLICT + } + ) + assertSame(remote, UploadFileLink.findUploadedFile(storage, upload.remotePath)) + assertNull(remote.storagePath) + } + + @Test + fun oldCompletedPlaintextLinkResolvesToEncryptedFile() { + val remote = OCFile("/private/cipher").apply { isEncrypted = true } + whenever(storage.getFileByDecryptedRemotePath("/private/photo.jpg")).thenReturn(remote) + + assertSame(remote, UploadFileLink.findUploadedFile(storage, "/private/photo.jpg")) + } + + @Test + fun physicalPathTakesPrecedenceOverDecryptedNameCollision() { + val remote = OCFile("/private/cipher") + whenever(storage.getFileByEncryptedRemotePath(remote.remotePath)).thenReturn(remote) + + assertSame(remote, UploadFileLink.findUploadedFile(storage, remote.remotePath)) + verify(storage, never()).getFileByDecryptedRemotePath(any()) + } + + @Test + fun decryptedFallbackCannotSelectAnUnencryptedFile() { + whenever(storage.getFileByDecryptedRemotePath("/private/photo.jpg")) + .thenReturn(OCFile("/other/photo.jpg")) + + assertNull(UploadFileLink.findUploadedFile(storage, "/private/photo.jpg")) + } + + @Test + fun missingRemoteLinkRemainsAnError() { + assertEquals(null, UploadFileLink.findUploadedFile(storage, "/private/missing")) + } + + @Test + fun encryptedListUsesDecryptedNameAndParentEvenAfterRemoteRename() { + val upload = OCUpload(File.separator + "original.jpg", "/cipher-folder/cipher", "account-A") + val remote = OCFile(upload.remotePath).apply { + isEncrypted = true + decryptedRemotePath = "/private/renamed.jpg" + } + + assertEquals("/private/renamed.jpg", UploadFileLink.displayPath(storage, upload, remote)) + } + + @Test + fun rawCiphertextPropertiesUseSourceNameAndDecryptedParentForDisplay() { + val upload = OCUpload(File.separator + "photo.jpg", "/cipher-folder/cipher", "account-A") + val parent = OCFile("/cipher-folder/").apply { + isEncrypted = true + decryptedRemotePath = "/private/" + } + whenever(storage.getFileByEncryptedRemotePath(parent.remotePath)).thenReturn(parent) + + assertEquals("/private/photo.jpg", UploadFileLink.displayPath(storage, upload, OCFile(upload.remotePath))) + } + + @Test + fun ordinaryUploadDisplaysRemoteRenameInsteadOfSourceName() { + val upload = OCUpload(File.separator + "original.jpg", "/photos/renamed.jpg", "account-A") + + assertEquals(upload.remotePath, UploadFileLink.displayPath(storage, upload, null)) + } +} diff --git a/app/src/test/java/com/owncloud/android/operations/upload/UploadFileResolverTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/UploadFileResolverTest.kt index 8c98807110c3..570315dbc284 100644 --- a/app/src/test/java/com/owncloud/android/operations/upload/UploadFileResolverTest.kt +++ b/app/src/test/java/com/owncloud/android/operations/upload/UploadFileResolverTest.kt @@ -201,6 +201,27 @@ class UploadFileResolverTest { verify(uploadsStorageManager, never()).updateUpload(any()) } + @Test + fun clearingUnlockTokenPreservesLatestSizePathAndCompletionResult() { + val upload = OCUpload(java.io.File.separator + "photo.jpg", "/private/cipher", "account-A").apply { + fileSize = 1024 + uploadStatus = UploadsStorageManager.UploadStatus.UPLOAD_SUCCEEDED + lastResult = com.owncloud.android.db.UploadResult.UPLOADED + folderUnlockToken = "old-token" + } + whenever(uploadsStorageManager.getUploadById(UPLOAD_ID)).thenReturn(upload) + + resolver.updateUnlockToken(null) + + verify(uploadsStorageManager).updateUpload( + argThat { + fileSize == 1024L && remotePath == "/private/cipher" && + uploadStatus == UploadsStorageManager.UploadStatus.UPLOAD_SUCCEEDED && + lastResult == com.owncloud.android.db.UploadResult.UPLOADED && folderUnlockToken == null + } + ) + } + @Test fun createNewOCFileCopiesPropertiesToNewRemotePath() { val original = OCFile("/photo.jpg").apply { @@ -306,6 +327,26 @@ class UploadFileResolverTest { assertEquals(geoLocation, file.geoLocation) } + @Test + fun ciphertextPropertiesDoNotReplaceEncryptedFilesPlaintextMimeType() { + val file = OCFile("/private/cipher").apply { + isEncrypted = true + decryptedRemotePath = "/private/photo.jpg" + mimeType = "image/jpeg" + } + val remote = RemoteFile(file.remotePath).apply { + mimeType = "application/octet-stream" + etag = "version-B" + } + + resolver.updateOCFile(file, remote) + + assertTrue(file.isEncrypted) + assertEquals("image/jpeg", file.mimeType) + assertEquals("/private/photo.jpg", file.decryptedRemotePath) + assertEquals("version-B", file.etag) + } + private companion object { const val UPLOAD_ID = 5L val FILE_NAMES = listOf("first.txt", "second.txt") diff --git a/app/src/test/java/com/owncloud/android/operations/upload/UploadProgressDisplayTest.kt b/app/src/test/java/com/owncloud/android/operations/upload/UploadProgressDisplayTest.kt new file mode 100644 index 000000000000..4b61259e2b47 --- /dev/null +++ b/app/src/test/java/com/owncloud/android/operations/upload/UploadProgressDisplayTest.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package com.owncloud.android.operations.upload + +import android.widget.ProgressBar +import com.owncloud.android.ui.adapter.progressListener.UploadProgressListener +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class UploadProgressDisplayTest { + @Test + fun firstTransferReplacesPreparationSpinnerWithActualByteProgress() { + val bar: ProgressBar = mock() + whenever(bar.post(any())).thenAnswer { + it.getArgument(0).run() + true + } + + UploadProgressListener(null, bar).onTransferProgress(1, 75, 100, "photo.jpg") + + verify(bar).isIndeterminate = false + verify(bar).progress = 75 + } +}