diff --git a/gradle.properties b/gradle.properties index 022f0d32f..29df49e5c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,7 @@ -android.defaults.buildfeatures.buildconfig=true + android.enableJetifier=true android.nonFinalResIds=false android.nonTransitiveRClass=false android.useAndroidX=true org.gradle.jvmargs=-Xmx1536M +android.overridePathCheck=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8eed398b6..66c2311b1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,7 +37,7 @@ ksp = "1.9.20-1.0.14" ktlint = "11.1.0" markwon = "4.6.2" material = "1.8.0" -mockk = "1.13.3" +mockk = "1.13.13" moshi = "1.15.0" patternlockview = "a90b0d4bf0" photoView = "2.3.0" diff --git a/opencloudApp/build.gradle b/opencloudApp/build.gradle index 63c2e59fc..bea57398d 100644 --- a/opencloudApp/build.gradle +++ b/opencloudApp/build.gradle @@ -179,6 +179,7 @@ android { buildFeatures { viewBinding true + buildConfig true } packagingOptions { @@ -249,3 +250,7 @@ static def getGitOriginRemote() { def found = values.find { it.startsWith("origin") && it.endsWith("(push)") } return found.replace("origin", "").replace("(push)", "").replace(".git", "").trim() } + +tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach { + jvmTarget = "17" +} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt index b9f8ead68..47ca63760 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt @@ -53,8 +53,8 @@ class AvatarUtils : KoinComponent { fun loadAvatarForAccount( imageView: ImageView, account: Account, - fetchIfNotCached: Boolean = false, - displayRadius: Float + @Suppress("UnusedParameter") fetchIfNotCached: Boolean = false, + @Suppress("UnusedParameter") displayRadius: Float ) { // Tech debt: Move this to a viewModel and use its viewModelScope instead CoroutineScope(Dispatchers.IO).launch { @@ -76,8 +76,8 @@ class AvatarUtils : KoinComponent { fun loadAvatarForAccount( menuItem: MenuItem, account: Account, - fetchIfNotCached: Boolean = false, - displayRadius: Float + @Suppress("UnusedParameter") fetchIfNotCached: Boolean = false, + @Suppress("UnusedParameter") displayRadius: Float ) { CoroutineScope(Dispatchers.IO).launch { val drawable = avatarManager.getAvatarForAccount( diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt index 6c04ad530..6aaed24c1 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt @@ -439,7 +439,11 @@ class FileDetailsFragment : FileFragment() { if (thumbnail == null) { thumbnail = ThumbnailsCacheManager.mDefaultImg } - val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(MainApp.appContext.resources, thumbnail, task) + val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable( + MainApp.appContext.resources, + thumbnail, + task + ) imageView.setImageDrawable(asyncDrawable) task.execute(ocFile) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt index 12354c7e3..e82ce5319 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt @@ -272,9 +272,12 @@ class MainFileListFragment : Fragment(), "${getString(R.string.actionbar_select_inverse)} $roleAccessibilityDescription" findItem(R.id.action_open_file_with)?.contentDescription = "${getString(R.string.actionbar_open_with)} $roleAccessibilityDescription" - findItem(R.id.action_rename_file)?.contentDescription = "${getString(R.string.common_rename)} $roleAccessibilityDescription" - findItem(R.id.action_move)?.contentDescription = "${getString(R.string.actionbar_move)} $roleAccessibilityDescription" - findItem(R.id.action_copy)?.contentDescription = "${getString(R.string.copy)} $roleAccessibilityDescription" + findItem(R.id.action_rename_file)?.contentDescription = + "${getString(R.string.common_rename)} $roleAccessibilityDescription" + findItem(R.id.action_move)?.contentDescription = + "${getString(R.string.actionbar_move)} $roleAccessibilityDescription" + findItem(R.id.action_copy)?.contentDescription = + "${getString(R.string.copy)} $roleAccessibilityDescription" findItem(R.id.action_send_file)?.contentDescription = "${getString(R.string.actionbar_send_file)} $roleAccessibilityDescription" findItem(R.id.action_set_available_offline)?.contentDescription = @@ -283,7 +286,8 @@ class MainFileListFragment : Fragment(), "${getString(R.string.unset_available_offline)} $roleAccessibilityDescription" findItem(R.id.action_see_details)?.contentDescription = "${getString(R.string.actionbar_see_details)} $roleAccessibilityDescription" - findItem(R.id.action_remove_file)?.contentDescription = "${getString(R.string.common_remove)} $roleAccessibilityDescription" + findItem(R.id.action_remove_file)?.contentDescription = + "${getString(R.string.common_remove)} $roleAccessibilityDescription" } } } @@ -368,7 +372,10 @@ class MainFileListFragment : Fragment(), // Set view and footer correctly if (mainFileListViewModel.isGridModeSetAsPreferred()) { layoutManager = - StaggeredGridLayoutManager(ColumnQuantity(requireContext(), R.layout.grid_item).calculateNoOfColumns(), RecyclerView.VERTICAL) + StaggeredGridLayoutManager( + ColumnQuantity(requireContext(), R.layout.grid_item).calculateNoOfColumns(), + RecyclerView.VERTICAL + ) viewType = ViewType.VIEW_TYPE_GRID } else { layoutManager = StaggeredGridLayoutManager(1, RecyclerView.VERTICAL) @@ -494,7 +501,9 @@ class MainFileListFragment : Fragment(), fileActions?.onCurrentFolderUpdated(currentFolderDisplayed, mainFileListViewModel.getSpace()) val fileListOption = mainFileListViewModel.fileListOption.value val refreshFolderNeeded = fileListOption.isAllFiles() || - (!fileListOption.isAllFiles() && currentFolderDisplayed.remotePath != ROOT_PATH && !fileListOption.isAvailableOffline()) + (!fileListOption.isAllFiles() && + currentFolderDisplayed.remotePath != ROOT_PATH && + !fileListOption.isAvailableOffline()) if (refreshFolderNeeded) { fileOperationsViewModel.performOperation( FileOperation.RefreshFolderOperation( @@ -543,7 +552,8 @@ class MainFileListFragment : Fragment(), // Mimetypes not supported via open in web, send 500 if (uiResult.error is InstanceNotConfiguredException) { val message = - getString(R.string.open_in_web_error_generic) + " " + getString(R.string.error_reason) + + getString(R.string.open_in_web_error_generic) + " " + + getString(R.string.error_reason) + " " + getString(R.string.open_in_web_error_not_supported) this.showMessageInSnackbar(message, Snackbar.LENGTH_LONG) } else if (uiResult.error is TooEarlyException) { @@ -602,19 +612,27 @@ class MainFileListFragment : Fragment(), thumbnailBottomSheet.setImageResource(R.drawable.ic_menu_archive) } else { // Set file icon depending on its mimetype. Ask for thumbnail later. - thumbnailBottomSheet.setImageResource(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName)) + thumbnailBottomSheet.setImageResource( + MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName) + ) if (file.remoteId != null) { val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(file.remoteId) if (thumbnail != null) { thumbnailBottomSheet.setImageBitmap(thumbnail) } - if (file.needsToUpdateThumbnail && ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, thumbnailBottomSheet)) { + if (file.needsToUpdateThumbnail && + ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, thumbnailBottomSheet) + ) { // generate new Thumbnail val task = ThumbnailsCacheManager.ThumbnailGenerationTask( thumbnailBottomSheet, AccountUtils.getCurrentOpenCloudAccount(requireContext()) ) - val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(resources, thumbnail, task) + val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable( + resources, + thumbnail, + task + ) // If drawable is not visible, do not update it. if (asyncDrawable.minimumHeight > 0 && asyncDrawable.minimumWidth > 0) { @@ -624,7 +642,9 @@ class MainFileListFragment : Fragment(), } if (file.mimeType == "image/png") { - thumbnailBottomSheet.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.background_color)) + thumbnailBottomSheet.setBackgroundColor( + ContextCompat.getColor(requireContext(), R.color.background_color) + ) } } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt index ce5b67e52..0163f24bc 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt @@ -125,7 +125,8 @@ class ShareFileFragment : Fragment(), ShareUserListAdapter.ShareUserAdapterListe R.string.share_via_link_default_name_template, file?.fileName ) - val defaultNameNumberedRegex = QUOTE_START + defaultName + QUOTE_END + DEFAULT_NAME_REGEX_SUFFIX + val defaultNameNumberedRegex = + QUOTE_START + defaultName + QUOTE_END + DEFAULT_NAME_REGEX_SUFFIX val usedNumbers = ArrayList() var isDefaultNameSet = false var number: String @@ -217,7 +218,8 @@ class ShareFileFragment : Fragment(), ShareUserListAdapter.ShareUserAdapterListe _binding = ShareFileLayoutBinding.inflate(inflater, container, false) return binding.root.apply { // Allow or disallow touches with other visible windows - filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) + filterTouchesWhenObscured = + PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context) } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt b/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt index a099187cc..124786849 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt @@ -85,19 +85,17 @@ object ThumbnailsRequester : KoinComponent { .build() } - fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String { - // Converts dp to pixel - val spacesThumbnailSize = appContext.resources.getDimension(R.dimen.spaces_thumbnail_height).roundToInt() - return String.format( + fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String = + String.format( Locale.ROOT, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, - spacesThumbnailSize, - spacesThumbnailSize, + appContext.resources.getDimension(R.dimen.spaces_thumbnail_height).roundToInt(), + appContext.resources.getDimension(R.dimen.spaces_thumbnail_height).roundToInt(), spaceSpecial.eTag ) - } + @Suppress("ExpressionBodySyntax") fun getPreviewUriForFile(ocFile: OCFileWithSyncInfo, account: Account): String { var baseUrl = getOpenCloudClient().baseUri.toString() + "/remote.php/dav/files/" + account.name.split("@".toRegex()) .dropLastWhile { it.isEmpty() } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt index c5c2d0f68..ad3b4c757 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt @@ -29,6 +29,7 @@ import eu.opencloud.android.domain.transfers.TransferRepository import eu.opencloud.android.extensions.getWorkInfoByTags import eu.opencloud.android.workers.UploadFileFromContentUriWorker import timber.log.Timber +import java.io.File class RetryUploadFromContentUriUseCase( private val workManager: WorkManager, @@ -52,11 +53,18 @@ class RetryUploadFromContentUriUseCase( if (workInfos.isEmpty() || workInfos.firstOrNull()?.state == WorkInfo.State.FAILED) { transferRepository.updateTransferStatusToEnqueuedById(params.uploadIdInStorageManager) + val lastModifiedInSeconds = File(uploadToRetry.localPath) + .takeIf { it.exists() && it.isFile } + ?.lastModified() + ?.takeIf { it > 0 } + ?.div(1000) + ?.toString() + uploadFileFromContentUriUseCase( UploadFileFromContentUriUseCase.Params( accountName = uploadToRetry.accountName, contentUri = uploadToRetry.localPath.toUri(), - lastModifiedInSeconds = (uploadToRetry.transferEndTimestamp?.div(1000)).toString(), + lastModifiedInSeconds = lastModifiedInSeconds, behavior = uploadToRetry.localBehaviour.name, uploadPath = uploadToRetry.remotePath, uploadIdInStorageManager = params.uploadIdInStorageManager, diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromSystemUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromSystemUseCase.kt index a6ed6ed3b..1cb019198 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromSystemUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromSystemUseCase.kt @@ -29,6 +29,7 @@ import eu.opencloud.android.domain.transfers.TransferRepository import eu.opencloud.android.extensions.getWorkInfoByTags import eu.opencloud.android.workers.UploadFileFromFileSystemWorker import timber.log.Timber +import java.io.File class RetryUploadFromSystemUseCase( private val workManager: WorkManager, @@ -52,11 +53,18 @@ class RetryUploadFromSystemUseCase( if (workInfos.isEmpty() || workInfos.firstOrNull()?.state == WorkInfo.State.FAILED) { transferRepository.updateTransferStatusToEnqueuedById(params.uploadIdInStorageManager) + val lastModifiedInSeconds = File(uploadToRetry.localPath) + .takeIf { it.exists() && it.isFile } + ?.lastModified() + ?.takeIf { it > 0 } + ?.div(1000) + ?.toString() + uploadFileFromSystemUseCase( UploadFileFromSystemUseCase.Params( accountName = uploadToRetry.accountName, localPath = uploadToRetry.localPath, - lastModifiedInSeconds = (uploadToRetry.transferEndTimestamp?.div(1000)).toString(), + lastModifiedInSeconds = lastModifiedInSeconds, behavior = uploadToRetry.localBehaviour.name, uploadPath = uploadToRetry.remotePath, sourcePath = uploadToRetry.sourcePath, diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt index 7c9ebeb8d..06f9ffc27 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromContentUriUseCase.kt @@ -24,10 +24,11 @@ package eu.opencloud.android.usecases.transfers.uploads import android.net.Uri import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager -import androidx.work.workDataOf import eu.opencloud.android.domain.BaseUseCase import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior import eu.opencloud.android.workers.RemoveSourceFileWorker @@ -39,17 +40,21 @@ class UploadFileFromContentUriUseCase( ) : BaseUseCase() { override fun run(params: Params) { - val inputDataUploadFileFromContentUriWorker = workDataOf( - UploadFileFromContentUriWorker.KEY_PARAM_ACCOUNT_NAME to params.accountName, - UploadFileFromContentUriWorker.KEY_PARAM_BEHAVIOR to params.behavior, - UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI to params.contentUri.toString(), - UploadFileFromContentUriWorker.KEY_PARAM_LAST_MODIFIED to params.lastModifiedInSeconds, - UploadFileFromContentUriWorker.KEY_PARAM_UPLOAD_PATH to params.uploadPath, - UploadFileFromContentUriWorker.KEY_PARAM_UPLOAD_ID to params.uploadIdInStorageManager - ) - val inputDataRemoveSourceFileWorker = workDataOf( - UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI to params.contentUri.toString(), - ) + val inputDataUploadFileFromContentUriWorker = Data.Builder() + .putString(UploadFileFromContentUriWorker.KEY_PARAM_ACCOUNT_NAME, params.accountName) + .putString(UploadFileFromContentUriWorker.KEY_PARAM_BEHAVIOR, params.behavior) + .putString(UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI, params.contentUri.toString()) + .putString(UploadFileFromContentUriWorker.KEY_PARAM_UPLOAD_PATH, params.uploadPath) + .putLong(UploadFileFromContentUriWorker.KEY_PARAM_UPLOAD_ID, params.uploadIdInStorageManager) + .apply { + params.lastModifiedInSeconds?.let { + putString(UploadFileFromContentUriWorker.KEY_PARAM_LAST_MODIFIED, it) + } + } + .build() + val inputDataRemoveSourceFileWorker = Data.Builder() + .putString(UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI, params.contentUri.toString()) + .build() val networkRequired = if (params.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED val constraints = Constraints.Builder() @@ -64,25 +69,35 @@ class UploadFileFromContentUriUseCase( .addTag(params.uploadIdInStorageManager.toString()) .build() + // Use unique work name based on upload ID to prevent concurrent uploads of same file + val uniqueWorkName = "upload_content_uri_${params.uploadIdInStorageManager}" + val behavior = UploadBehavior.fromString(params.behavior) if (behavior == UploadBehavior.MOVE) { val removeSourceFileWorker = OneTimeWorkRequestBuilder() .setInputData(inputDataRemoveSourceFileWorker) .build() - workManager.beginWith(uploadFileFromContentUriWorker) - .then(removeSourceFileWorker) // File is already uploaded, so the original one can be removed if the behaviour is MOVE - .enqueue() + workManager.beginUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.KEEP, // Keep existing work to prevent duplicate uploads + uploadFileFromContentUriWorker + ).then(removeSourceFileWorker) // File is already uploaded, so the original one can be removed if the behaviour is MOVE + .enqueue() } else { - workManager.enqueue(uploadFileFromContentUriWorker) + workManager.enqueueUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.KEEP, // Keep existing work to prevent duplicate uploads + uploadFileFromContentUriWorker + ) } - Timber.i("Plain upload of ${params.contentUri.path} has been enqueued.") + Timber.i("Plain upload of ${params.contentUri.path} has been enqueued with unique work name: $uniqueWorkName") } data class Params( val accountName: String, val contentUri: Uri, - val lastModifiedInSeconds: String, + val lastModifiedInSeconds: String?, val behavior: String, val uploadPath: String, val uploadIdInStorageManager: Long, diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt index eadb436fd..7d53f3f81 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileFromSystemUseCase.kt @@ -22,10 +22,11 @@ package eu.opencloud.android.usecases.transfers.uploads import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager -import androidx.work.workDataOf import eu.opencloud.android.domain.BaseUseCase import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior import eu.opencloud.android.workers.RemoveSourceFileWorker @@ -38,17 +39,23 @@ class UploadFileFromSystemUseCase( ) : BaseUseCase() { override fun run(params: Params) { - val inputDataUploadFileFromFileSystemWorker = workDataOf( - UploadFileFromFileSystemWorker.KEY_PARAM_ACCOUNT_NAME to params.accountName, - UploadFileFromFileSystemWorker.KEY_PARAM_BEHAVIOR to params.behavior, - UploadFileFromFileSystemWorker.KEY_PARAM_LOCAL_PATH to params.localPath, - UploadFileFromFileSystemWorker.KEY_PARAM_LAST_MODIFIED to params.lastModifiedInSeconds, - UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_PATH to params.uploadPath, - UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_ID to params.uploadIdInStorageManager - ) - val inputDataRemoveSourceFileWorker = workDataOf( - UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI to params.sourcePath, - ) + val inputDataUploadFileFromFileSystemWorker = Data.Builder() + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_ACCOUNT_NAME, params.accountName) + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_BEHAVIOR, params.behavior) + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_LOCAL_PATH, params.localPath) + .putString(UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_PATH, params.uploadPath) + .putLong(UploadFileFromFileSystemWorker.KEY_PARAM_UPLOAD_ID, params.uploadIdInStorageManager) + .apply { + params.lastModifiedInSeconds?.let { + putString(UploadFileFromFileSystemWorker.KEY_PARAM_LAST_MODIFIED, it) + } + } + .build() + val inputDataRemoveSourceFileWorker = Data.Builder().apply { + params.sourcePath?.let { + putString(UploadFileFromContentUriWorker.KEY_PARAM_CONTENT_URI, it) + } + }.build() val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) @@ -61,25 +68,35 @@ class UploadFileFromSystemUseCase( .addTag(params.uploadIdInStorageManager.toString()) .build() + // Use unique work name based on upload ID to prevent concurrent uploads of same file + val uniqueWorkName = "upload_file_system_${params.uploadIdInStorageManager}" + val behavior = UploadBehavior.fromString(params.behavior) - if (behavior == UploadBehavior.MOVE) { + if (behavior == UploadBehavior.MOVE && params.sourcePath != null) { val removeSourceFileWorker = OneTimeWorkRequestBuilder() .setInputData(inputDataRemoveSourceFileWorker) .build() - workManager.beginWith(uploadFileFromSystemWorker) - .then(removeSourceFileWorker) // File is already uploaded, so the original one can be removed if the behaviour is MOVE - .enqueue() + workManager.beginUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.KEEP, // Keep existing work to prevent duplicate uploads + uploadFileFromSystemWorker + ).then(removeSourceFileWorker) // File is already uploaded, so the original one can be removed if the behaviour is MOVE + .enqueue() } else { - workManager.enqueue(uploadFileFromSystemWorker) + workManager.enqueueUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.KEEP, // Keep existing work to prevent duplicate uploads + uploadFileFromSystemWorker + ) } - Timber.i("Plain upload of ${params.localPath} has been enqueued.") + Timber.i("Plain upload of ${params.localPath} has been enqueued with unique work name: $uniqueWorkName") } data class Params( val accountName: String, val localPath: String, - val lastModifiedInSeconds: String, + val lastModifiedInSeconds: String?, val behavior: String, val uploadPath: String, val uploadIdInStorageManager: Long, diff --git a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileInConflictUseCase.kt b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileInConflictUseCase.kt index 5683ae64e..d1de775e5 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileInConflictUseCase.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/UploadFileInConflictUseCase.kt @@ -23,6 +23,7 @@ package eu.opencloud.android.usecases.transfers.uploads import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager @@ -126,8 +127,14 @@ class UploadFileInConflictUseCase( .addTag(uploadIdInStorageManager.toString()) .build() - workManager.enqueue(uploadFileFromContentUriWorker) - Timber.i("Plain upload of $localPath has been enqueued.") + // Use unique work name based on upload ID to prevent concurrent uploads of same file + val uniqueWorkName = "upload_conflict_${uploadIdInStorageManager}" + workManager.enqueueUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.KEEP, // Keep existing work to prevent duplicate uploads + uploadFileFromContentUriWorker + ) + Timber.i("Plain upload of $localPath has been enqueued with unique work name: $uniqueWorkName") return uploadFileFromContentUriWorker.id } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt new file mode 100644 index 000000000..db7fe730b --- /dev/null +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt @@ -0,0 +1,318 @@ +package eu.opencloud.android.workers + +import eu.opencloud.android.data.executeRemoteOperation +import eu.opencloud.android.domain.capabilities.model.OCCapability +import eu.opencloud.android.domain.transfers.TransferRepository +import eu.opencloud.android.domain.transfers.model.OCTransfer +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.network.OnDatatransferProgressListener + +import eu.opencloud.android.lib.resources.files.chunks.ChunkedUploadFromFileSystemOperation +import eu.opencloud.android.lib.resources.files.tus.CreateTusUploadRemoteOperation +import eu.opencloud.android.lib.resources.files.tus.GetTusUploadOffsetRemoteOperation +import eu.opencloud.android.lib.resources.files.tus.PatchTusUploadChunkRemoteOperation +import timber.log.Timber +import java.io.File +import kotlin.math.min + +/** + * Shared helper encapsulating the TUS upload flow so workers can reuse the same implementation. + */ +class TusUploadHelper( + private val transferRepository: TransferRepository, +) { + + /** + * Runs the full TUS upload flow. On success the method returns normally. On failure an exception + * is thrown so the caller can decide whether to retry or surface the error. + */ + @Throws(Exception::class) + fun upload( + client: OpenCloudClient, + transfer: OCTransfer, + uploadId: Long, + localPath: String, + remotePath: String, + fileSize: Long, + mimeType: String, + lastModified: String?, + tusSupport: OCCapability.TusSupport?, + progressListener: OnDatatransferProgressListener?, + progressCallback: ((Long, Long) -> Unit)? = null, + spaceWebDavUrl: String? = null, + ) { + Timber.d("TUS: starting upload for %s size=%d", remotePath, fileSize) + + var tusUrl = transfer.tusUploadUrl + val checksumHex = transfer.tusUploadChecksum?.substringAfter("sha256:") + + if (tusUrl.isNullOrBlank()) { + val fileName = File(remotePath).name + val metadata = linkedMapOf( + "filename" to fileName, + "mimetype" to mimeType, + ) + lastModified?.takeIf { it.isNotBlank() }?.let { metadata["mtime"] = it } + checksumHex?.let { metadata["checksum"] = "sha256 $it" } + + Timber.d( + "TUS: creating upload resource filename=%s size=%d metadata=%s", + fileName, + fileSize, + metadata + ) + + val collectionUrl = resolveTusCollectionUrl( + client = client, + spaceWebDavUrl = spaceWebDavUrl + ) + + // Use creation-with-upload like the browser does for OpenCloud compatibility + val firstChunkSize = minOf(CreateTusUploadRemoteOperation.DEFAULT_FIRST_CHUNK, fileSize) + val createdLocation = executeRemoteOperation { + CreateTusUploadRemoteOperation( + file = File(localPath), + remotePath = remotePath, + mimetype = mimeType, + metadata = metadata, + useCreationWithUpload = true, + firstChunkSize = firstChunkSize, + tusUrl = "", + collectionUrlOverride = collectionUrl, + ).execute(client) + } + + if (createdLocation.isNullOrBlank()) { + throw IllegalStateException("TUS: unable to create upload resource for $remotePath") + } + + tusUrl = createdLocation + val metadataString = metadata.entries.joinToString(";") { (key, value) -> "$key=$value" } + + transferRepository.updateTusState( + id = uploadId, + tusUploadUrl = tusUrl, + tusUploadLength = fileSize, + tusUploadMetadata = metadataString, + tusUploadChecksum = checksumHex?.let { "sha256:$it" }, + tusResumableVersion = "1.0.0", + tusUploadExpires = null, + tusUploadConcat = null, + ) + } + + val resolvedTusUrl = tusUrl ?: throw IllegalStateException("TUS: missing upload URL for $remotePath") + + var offset = try { + executeRemoteOperation { + GetTusUploadOffsetRemoteOperation(resolvedTusUrl).execute(client) + } + } catch (e: java.io.IOException) { + Timber.w(e, "TUS: failed to fetch current offset") + throw e + } catch (e: Throwable) { + Timber.w(e, "TUS: failed to fetch current offset") + 0L + }.coerceAtLeast(0L) + Timber.d("TUS: resume offset %d / %d", offset, fileSize) + progressCallback?.invoke(offset, fileSize) + + offset = performUploadLoop( + client = client, + resolvedTusUrl = resolvedTusUrl, + localPath = localPath, + fileSize = fileSize, + tusSupport = tusSupport, + progressListener = progressListener, + progressCallback = progressCallback, + initialOffset = offset + ) + + // Verify upload is actually complete + if (offset != fileSize) { + Timber.e("TUS: upload loop exited but offset=%d != fileSize=%d", offset, fileSize) + throw java.io.IOException("TUS: upload incomplete - offset $offset does not match file size $fileSize") + } + transferRepository.updateTusState( + id = uploadId, + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) + Timber.i("TUS: upload completed for %s (size=%d)", remotePath, fileSize) + } + + private fun performUploadLoop( + client: OpenCloudClient, + resolvedTusUrl: String, + localPath: String, + fileSize: Long, + tusSupport: OCCapability.TusSupport?, + progressListener: OnDatatransferProgressListener?, + progressCallback: ((Long, Long) -> Unit)?, + initialOffset: Long + ): Long { + var offset = initialOffset + val serverMaxChunk = tusSupport?.maxChunkSize?.takeIf { it > 0 }?.toLong() + val httpOverride = tusSupport?.httpMethodOverride + var consecutiveFailures = 0 + + while (offset < fileSize) { + val remaining = fileSize - offset + val chunkSize = min(DEFAULT_CHUNK_SIZE, min(remaining, serverMaxChunk ?: Long.MAX_VALUE)) + Timber.d("TUS: uploading chunk=%d at offset=%d remaining=%d", chunkSize, offset, remaining) + + val patchOperation = PatchTusUploadChunkRemoteOperation( + localPath = localPath, + uploadUrl = resolvedTusUrl, + offset = offset, + chunkSize = chunkSize, + httpMethodOverride = httpOverride, + ).apply { + progressListener?.let { addDataTransferProgressListener(it) } + } + + val patchResult = patchOperation.execute(client) + if (!patchResult.isSuccess || patchResult.data == null || patchResult.data!! < offset) { + consecutiveFailures++ + Timber.w( + "TUS: PATCH failed at offset %d (retry %d/%d)", + offset, + consecutiveFailures, + MAX_RETRIES + ) + + // Try to recover the offset from server + val recoveredOffset = tryRecoverOffset( + client = client, + tusUrl = resolvedTusUrl, + currentOffset = offset, + totalSize = fileSize, + progressCallback = progressCallback, + ) + + if (recoveredOffset != null && recoveredOffset > offset) { + // Server has progressed beyond our current offset, update and reset retry counter + Timber.d("TUS: server advanced from %d to %d, continuing", offset, recoveredOffset) + offset = recoveredOffset + consecutiveFailures = 0 + continue + } else if (recoveredOffset != null && recoveredOffset == offset) { + // Server is at same offset, we need to retry the same chunk + Timber.d("TUS: server confirmed offset %d, will retry same chunk", offset) + // Don't update offset, will retry after backoff + } else if (recoveredOffset != null && recoveredOffset < offset) { + // Server is behind our position (e.g. crash/data loss). Rewind and continue. + Timber.w("TUS: server offset %d is behind current %d. Rewinding...", recoveredOffset, offset) + offset = recoveredOffset + consecutiveFailures = 0 + continue + } else { + // Recovery failed or returned invalid offset + Timber.w("TUS: offset recovery failed (recovered=%s, current=%d)", recoveredOffset, offset) + } + + // Check if we've exhausted retries + if (consecutiveFailures >= MAX_RETRIES) { + throw java.io.IOException( + "TUS: giving up after $MAX_RETRIES retries at offset $offset (network error)", + IllegalStateException("TUS: max retries exceeded") + ) + } + + // Exponential backoff before retry + val delayMs = min(MAX_RETRY_DELAY_MS, BASE_RETRY_DELAY_MS shl (consecutiveFailures - 1)) + try { + Thread.sleep(delayMs) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + throw java.io.IOException( + "TUS: interrupted while retrying at offset $offset", + InterruptedException("TUS retry interrupted") + ) + } + continue + } + + // Success - validate the returned offset + val newOffset = patchResult.data!! + if (newOffset < offset) { + Timber.e("TUS: server returned offset %d less than current %d, upload corrupted", newOffset, offset) + throw java.io.IOException("TUS: server offset went backwards from $offset to $newOffset") + } + if (newOffset > fileSize) { + Timber.e("TUS: server returned offset %d exceeds file size %d", newOffset, fileSize) + throw java.io.IOException("TUS: server offset $newOffset exceeds file size $fileSize") + } + + offset = newOffset + progressCallback?.invoke(offset, fileSize) + consecutiveFailures = 0 + } + return offset + } + + private fun resolveTusCollectionUrl( + client: OpenCloudClient, + spaceWebDavUrl: String?, + ): String { + // For OpenCloud, TUS works on the WebDAV space endpoint + // Use the space WebDAV URL if available, otherwise fall back to user files + val base = (spaceWebDavUrl?.takeIf { it.isNotBlank() } + ?: client.userFilesWebDavUri.toString()).trim() + + // Use the space root directly for TUS (no trailing slash for OpenCloud) + val normalizedBase = base.trimEnd('/') + + Timber.d("TUS: using collection endpoint: %s", normalizedBase) + + return normalizedBase + } + + private fun tryRecoverOffset( + client: OpenCloudClient, + tusUrl: String, + currentOffset: Long, + totalSize: Long, + progressCallback: ((Long, Long) -> Unit)?, + ): Long? = try { + val newOffset = executeRemoteOperation { + GetTusUploadOffsetRemoteOperation(tusUrl).execute(client) + } + if (newOffset >= 0 && newOffset <= totalSize) { + if (newOffset > currentOffset) { + // Server has advanced beyond our position + progressCallback?.invoke(newOffset, totalSize) + Timber.d("TUS: recovered offset %d (was %d)", newOffset, currentOffset) + } else if (newOffset == currentOffset) { + // Server is at same position, return it to confirm + Timber.d("TUS: server confirmed current offset %d", currentOffset) + } else { + // Server is behind our position - can happen if server lost data (crash) + Timber.w("TUS: server offset %d is behind current %d", newOffset, currentOffset) + } + newOffset + } else { + Timber.w("TUS: invalid recovered offset %d (total=%d)", newOffset, totalSize) + null + } + } catch (e: java.io.IOException) { + Timber.w(e, "TUS: recover offset failed") + throw e + } catch (recoverError: Throwable) { + Timber.w(recoverError, "TUS: recover offset failed") + null + } + + + companion object { + const val DEFAULT_CHUNK_SIZE = ChunkedUploadFromFileSystemOperation.CHUNK_SIZE + private const val MAX_RETRIES = 5 + private const val BASE_RETRY_DELAY_MS = 250L + private const val MAX_RETRY_DELAY_MS = 2_000L + } +} diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt index 8b3cb51dc..aa649392d 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt @@ -34,10 +34,8 @@ import eu.opencloud.android.R import eu.opencloud.android.data.executeRemoteOperation import eu.opencloud.android.data.providers.LocalStorageProvider import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior -import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase import eu.opencloud.android.domain.exceptions.LocalFileNotFoundException import eu.opencloud.android.domain.exceptions.UnauthorizedException -import eu.opencloud.android.domain.files.model.OCFile import eu.opencloud.android.domain.files.usecases.GetWebDavUrlForSpaceUseCase import eu.opencloud.android.domain.transfers.TransferRepository import eu.opencloud.android.domain.transfers.model.OCTransfer @@ -45,6 +43,7 @@ import eu.opencloud.android.domain.transfers.model.TransferResult import eu.opencloud.android.domain.transfers.model.TransferStatus import eu.opencloud.android.extensions.isContentUri import eu.opencloud.android.extensions.parseError +import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase import eu.opencloud.android.lib.common.OpenCloudAccount import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.SingleSessionManager @@ -52,16 +51,11 @@ import eu.opencloud.android.lib.common.network.OnDatatransferProgressListener import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode import eu.opencloud.android.lib.resources.files.CheckPathExistenceRemoteOperation import eu.opencloud.android.lib.resources.files.CreateRemoteFolderOperation -import eu.opencloud.android.lib.resources.files.FileUtils -import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation -import eu.opencloud.android.lib.resources.files.chunks.ChunkedUploadFromFileSystemOperation -import eu.opencloud.android.lib.resources.files.chunks.ChunkedUploadFromFileSystemOperation.Companion.CHUNK_SIZE -import eu.opencloud.android.lib.resources.files.services.implementation.OCChunkService import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.utils.NotificationUtils -import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath -import eu.opencloud.android.utils.SecurityUtils import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID +import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath +import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -70,6 +64,9 @@ import org.koin.core.component.inject import timber.log.Timber import java.io.File import java.io.FileOutputStream +import java.io.IOException + +import kotlin.coroutines.cancellation.CancellationException class UploadFileFromContentUriWorker( private val appContext: Context, @@ -81,7 +78,7 @@ class UploadFileFromContentUriWorker( private lateinit var account: Account private lateinit var contentUri: Uri - private lateinit var lastModified: String + private var lastModified: String = "" private lateinit var behavior: UploadBehavior private lateinit var uploadPath: String private lateinit var cachePath: String @@ -92,15 +89,38 @@ class UploadFileFromContentUriWorker( private var spaceWebDavUrl: String? = null private lateinit var uploadFileOperation: UploadFileFromFileSystemOperation + private val tusUploadHelper by lazy { TusUploadHelper(transferRepository) } private var lastPercent = 0 private val transferRepository: TransferRepository by inject() private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject() + private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject() + + override suspend fun doWork(): Result = try { + prepareFile() + val clientForThisUpload = getClientForThisUpload() + checkParentFolderExistence(clientForThisUpload) + checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload) + uploadDocument(clientForThisUpload) + updateUploadsDatabaseWithResult(null) + Result.success() + }catch (throwable: Throwable) { + Timber.e(throwable) + + if (shouldRetry(throwable)) { + Timber.i("Retrying upload %d after transient failure", uploadIdInStorageManager) + Result.retry() + }else { + showNotification(throwable) + updateUploadsDatabaseWithResult(throwable) + Result.failure() + } + } - override suspend fun doWork(): Result { - if (!areParametersValid()) return Result.failure() + private fun prepareFile() { + if (!areParametersValid()) return transferRepository.updateTransferStatusToInProgressById(uploadIdInStorageManager) @@ -110,23 +130,10 @@ class UploadFileFromContentUriWorker( val localStorageProvider: LocalStorageProvider by inject() cachePath = localStorageProvider.getTemporalPath(account.name, ocTransfer.spaceId) + uploadPath - return try { - if (ocTransfer.isContentUri(appContext)) { - checkDocumentFileExists() - checkPermissionsToReadDocumentAreGranted() - copyFileToLocalStorage() - } - val clientForThisUpload = getClientForThisUpload() - checkParentFolderExistence(clientForThisUpload) - checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload) - uploadDocument(clientForThisUpload) - updateUploadsDatabaseWithResult(null) - Result.success() - } catch (throwable: Throwable) { - Timber.e(throwable) - showNotification(throwable) - updateUploadsDatabaseWithResult(throwable) - Result.failure() + if (ocTransfer.isContentUri(appContext)) { + checkDocumentFileExists() + checkPermissionsToReadDocumentAreGranted() + copyFileToLocalStorage() } } @@ -142,7 +149,7 @@ class UploadFileFromContentUriWorker( contentUri = paramContentUri?.toUri() ?: return false uploadPath = paramUploadPath ?: return false behavior = paramBehavior?.let { UploadBehavior.fromString(it) } ?: return false - lastModified = paramLastModified ?: return false + lastModified = paramLastModified.orEmpty() uploadIdInStorageManager = paramUploadId ocTransfer = retrieveUploadInfoFromDatabase() ?: return false @@ -154,7 +161,7 @@ class UploadFileFromContentUriWorker( if (it != null) { Timber.d("Upload with id ($uploadIdInStorageManager) has been found in database.") Timber.d("Upload info: $it") - } else { + }else { Timber.w("Upload with id ($uploadIdInStorageManager) has not been found in database.") Timber.w("$uploadPath won't be uploaded") } @@ -177,6 +184,7 @@ class UploadFileFromContentUriWorker( } private fun copyFileToLocalStorage() { + val documentFile = DocumentFile.fromSingleUri(appContext, contentUri) val cacheFile = File(cachePath) val cacheDir = cacheFile.parentFile if (cacheDir != null && !cacheDir.exists()) { @@ -194,6 +202,20 @@ class UploadFileFromContentUriWorker( transferRepository.updateTransferSourcePath(uploadIdInStorageManager, contentUri.toString()) transferRepository.updateTransferLocalPath(uploadIdInStorageManager, cachePath) + + ensureValidLastModified(documentFile, cacheFile) + } + + private fun ensureValidLastModified(documentFile: DocumentFile?, cachedFile: File) { + val current = lastModified.toLongOrNull() + if (current != null && current > 0) { + return + } + + val documentMillis = documentFile?.lastModified()?.takeIf { it > 0 } + val fileMillis = cachedFile.lastModified().takeIf { it > 0 } + val fallbackMillis = documentMillis ?: fileMillis ?: System.currentTimeMillis() + lastModified = (fallbackMillis / 1000L).toString() } private fun getClientForThisUpload(): OpenCloudClient = @@ -242,21 +264,72 @@ class UploadFileFromContentUriWorker( val cacheFile = File(cachePath) mimeType = cacheFile.extension fileSize = cacheFile.length() + ensureValidLastModified(null, cacheFile) - val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject() val capabilitiesForAccount = getStoredCapabilitiesUseCase( GetStoredCapabilitiesUseCase.Params( accountName = account.name ) ) - val isChunkingAllowed = capabilitiesForAccount != null && capabilitiesForAccount.isChunkingAllowed() - Timber.d("Chunking is allowed: %s, and file size is greater than the minimum chunk size: %s", isChunkingAllowed, fileSize > CHUNK_SIZE) + val tusSupport = capabilitiesForAccount?.filesTusSupport + val supportsTus = tusSupport != null + + val hasPendingTusSession = !ocTransfer.tusUploadUrl.isNullOrBlank() + val shouldTryTus = hasPendingTusSession || (supportsTus && fileSize >= TusUploadHelper.DEFAULT_CHUNK_SIZE) + + var attemptedTus = false + if (shouldTryTus) { + attemptedTus = true + Timber.d( + "Attempting TUS upload (size=%d, threshold=%d, resume=%s)", + fileSize, + TusUploadHelper.DEFAULT_CHUNK_SIZE, + hasPendingTusSession + ) + val tusSucceeded = try { + tusUploadHelper.upload( + client = client, + transfer = ocTransfer, + uploadId = uploadIdInStorageManager, + localPath = cachePath, + remotePath = uploadPath, + fileSize = fileSize, + mimeType = mimeType, + lastModified = null, + tusSupport = tusSupport, + progressListener = this, + progressCallback = ::updateProgressFromTus, + spaceWebDavUrl = spaceWebDavUrl, + ) + true + }catch (throwable: Throwable) { + Timber.w(throwable, "TUS upload failed, falling back to single PUT") + if (shouldRetry(throwable)) { + throw throwable + } + false + } + + if (tusSucceeded) { + removeCacheFile() + Timber.d("TUS upload completed for %s", uploadPath) + return + } + }else { + Timber.d( + "Skipping TUS: file too small or unsupported (size=%d, threshold=%d, supportsTus=%s)", + fileSize, + TusUploadHelper.DEFAULT_CHUNK_SIZE, + supportsTus + ) + } - if (isChunkingAllowed && fileSize > CHUNK_SIZE) { - uploadChunkedFile(client) - } else { - uploadPlainFile(client) + if (attemptedTus) { + clearTusState() } + + Timber.d("Falling back to single PUT upload for %s", uploadPath) + uploadPlainFile(client) removeCacheFile() } @@ -275,38 +348,16 @@ class UploadFileFromContentUriWorker( executeRemoteOperation { uploadFileOperation.execute(client) } } - private fun uploadChunkedFile(client: OpenCloudClient) { - val immutableHashForChunkedFile = SecurityUtils.stringToMD5Hash(uploadPath) + System.currentTimeMillis() - // Step 1: Create folder where the chunks will be uploaded. - val createChunksRemoteFolderOperation = CreateRemoteFolderOperation( - remotePath = immutableHashForChunkedFile, - createFullPath = false, - isChunksFolder = true - ) - executeRemoteOperation { createChunksRemoteFolderOperation.execute(client) } + private fun updateProgressFromTus(offset: Long, totalSize: Long) { + if (totalSize <= 0) return + val percent: Int = (100.0 * offset.toDouble() / totalSize.toDouble()).toInt() + if (percent == lastPercent) return - // Step 2: Upload file by chunks - uploadFileOperation = ChunkedUploadFromFileSystemOperation( - transferId = immutableHashForChunkedFile, - localPath = cachePath, - remotePath = uploadPath, - mimeType = mimeType, - lastModifiedTimestamp = lastModified, - requiredEtag = null, - ).apply { - addDataTransferProgressListener(this@UploadFileFromContentUriWorker) + CoroutineScope(Dispatchers.IO).launch { + val progress = workDataOf(DownloadFileWorker.WORKER_KEY_PROGRESS to percent) + setProgress(progress) } - - executeRemoteOperation { uploadFileOperation.execute(client) } - - // Step 3: Move remote file to the final remote destination - val ocChunkService = OCChunkService(client) - ocChunkService.moveFile( - sourceRemotePath = "${immutableHashForChunkedFile}${OCFile.PATH_SEPARATOR}${FileUtils.FINAL_CHUNKS_FILE}", - targetRemotePath = uploadPath, - fileLastModificationTimestamp = lastModified, - fileLength = fileSize - ) + lastPercent = percent } private fun removeCacheFile() { @@ -314,6 +365,27 @@ class UploadFileFromContentUriWorker( cacheFile.delete() } + private fun clearTusState() { + transferRepository.updateTusState( + id = uploadIdInStorageManager, + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) + } + + private fun shouldRetry(throwable: Throwable?): Boolean { + if (throwable == null) return false + if (throwable is UnauthorizedException || throwable is LocalFileNotFoundException) return false + if (throwable is CancellationException) return true + if (throwable is IOException) return true + return shouldRetry(throwable.cause) + } + private fun updateUploadsDatabaseWithResult(throwable: Throwable?) { transferRepository.updateTransferWhenFinished( id = uploadIdInStorageManager, @@ -326,7 +398,7 @@ class UploadFileFromContentUriWorker( private fun getUploadStatusForThrowable(throwable: Throwable?): TransferStatus = if (throwable == null) { TransferStatus.TRANSFER_SUCCEEDED - } else { + }else { TransferStatus.TRANSFER_FAILED } @@ -339,7 +411,7 @@ class UploadFileFromContentUriWorker( val pendingIntent = if (needsToUpdateCredentials) { NotificationUtils.composePendingIntentToRefreshCredentials(appContext, account) - } else { + }else { NotificationUtils.composePendingIntentToUploadList(appContext) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt index 8bbe0ae48..60ef81fd8 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt @@ -33,7 +33,6 @@ import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior import eu.opencloud.android.domain.capabilities.usecases.GetStoredCapabilitiesUseCase import eu.opencloud.android.domain.exceptions.LocalFileNotFoundException import eu.opencloud.android.domain.exceptions.UnauthorizedException -import eu.opencloud.android.domain.files.model.OCFile.Companion.PATH_SEPARATOR import eu.opencloud.android.domain.files.usecases.CleanConflictUseCase import eu.opencloud.android.domain.files.usecases.GetFileByRemotePathUseCase import eu.opencloud.android.domain.files.usecases.GetWebDavUrlForSpaceUseCase @@ -50,15 +49,10 @@ import eu.opencloud.android.lib.common.network.OnDatatransferProgressListener import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode import eu.opencloud.android.lib.resources.files.CheckPathExistenceRemoteOperation import eu.opencloud.android.lib.resources.files.CreateRemoteFolderOperation -import eu.opencloud.android.lib.resources.files.FileUtils import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation -import eu.opencloud.android.lib.resources.files.chunks.ChunkedUploadFromFileSystemOperation -import eu.opencloud.android.lib.resources.files.chunks.ChunkedUploadFromFileSystemOperation.Companion.CHUNK_SIZE -import eu.opencloud.android.lib.resources.files.services.implementation.OCChunkService import eu.opencloud.android.presentation.authentication.AccountUtils import eu.opencloud.android.utils.NotificationUtils import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath -import eu.opencloud.android.utils.SecurityUtils import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -67,6 +61,8 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import timber.log.Timber import java.io.File +import java.io.IOException +import kotlin.coroutines.cancellation.CancellationException class UploadFileFromFileSystemWorker( private val appContext: Context, @@ -78,11 +74,11 @@ class UploadFileFromFileSystemWorker( private lateinit var account: Account private lateinit var fileSystemPath: String - private lateinit var lastModified: String + private var lastModified: String = "" private lateinit var behavior: UploadBehavior private lateinit var uploadPath: String private lateinit var mimetype: String - private var removeLocal: Boolean = true + private var removeLocal: Boolean = false private var uploadIdInStorageManager: Long = -1 private lateinit var ocTransfer: OCTransfer private var fileSize: Long = 0 @@ -99,6 +95,7 @@ class UploadFileFromFileSystemWorker( private var lastPercent = 0 private val transferRepository: TransferRepository by inject() + private val tusUploadHelper by lazy { TusUploadHelper(transferRepository) } override suspend fun doWork(): Result { @@ -120,6 +117,12 @@ class UploadFileFromFileSystemWorker( Result.success() } catch (throwable: Throwable) { Timber.e(throwable) + + if (shouldRetry(throwable)) { + Timber.i("Retrying upload %d after transient failure", uploadIdInStorageManager) + return Result.retry() + } + showNotification(throwable) updateUploadsDatabaseWithResult(throwable) Result.failure() @@ -133,13 +136,13 @@ class UploadFileFromFileSystemWorker( val paramBehavior = workerParameters.inputData.getString(KEY_PARAM_BEHAVIOR) val paramFileSystemUri = workerParameters.inputData.getString(KEY_PARAM_LOCAL_PATH) val paramUploadId = workerParameters.inputData.getLong(KEY_PARAM_UPLOAD_ID, -1) - val paramRemoveLocal = workerParameters.inputData.getBoolean(KEY_PARAM_REMOVE_LOCAL, true) + val paramRemoveLocal = workerParameters.inputData.getBoolean(KEY_PARAM_REMOVE_LOCAL, false) account = AccountUtils.getOpenCloudAccountByName(appContext, paramAccountName) ?: return false fileSystemPath = paramFileSystemUri.takeUnless { it.isNullOrBlank() } ?: return false uploadPath = paramUploadPath ?: return false behavior = paramBehavior?.let { UploadBehavior.valueOf(it) } ?: return false - lastModified = paramLastModified ?: return false + lastModified = paramLastModified.orEmpty() uploadIdInStorageManager = paramUploadId.takeUnless { it == -1L } ?: return false ocTransfer = retrieveUploadInfoFromDatabase() ?: return false removeLocal = paramRemoveLocal @@ -166,6 +169,18 @@ class UploadFileFromFileSystemWorker( } mimetype = fileInFileSystem.extension fileSize = fileInFileSystem.length() + ensureValidLastModified(fileInFileSystem) + } + + private fun ensureValidLastModified(sourceFile: File) { + val current = lastModified.toLongOrNull() + if (current != null && current > 0) { + return + } + + val fallbackMillis = sourceFile.lastModified().takeIf { it > 0 } + ?: System.currentTimeMillis() + lastModified = (fallbackMillis / 1000L).toString() } private fun getClientForThisUpload(): OpenCloudClient = @@ -229,14 +244,67 @@ class UploadFileFromFileSystemWorker( accountName = account.name ) ) - val isChunkingAllowed = capabilitiesForAccount != null && capabilitiesForAccount.isChunkingAllowed() - Timber.d("Chunking is allowed: %s, and file size is greater than the minimum chunk size: %s", isChunkingAllowed, fileSize > CHUNK_SIZE) + val tusSupport = capabilitiesForAccount?.filesTusSupport + val supportsTus = tusSupport != null + + val hasPendingTusSession = !ocTransfer.tusUploadUrl.isNullOrBlank() + val shouldTryTus = hasPendingTusSession || (supportsTus && fileSize >= TusUploadHelper.DEFAULT_CHUNK_SIZE) + + var attemptedTus = false + if (shouldTryTus) { + attemptedTus = true + Timber.d( + "Attempting TUS upload (size=%d, threshold=%d, resume=%s)", + fileSize, + TusUploadHelper.DEFAULT_CHUNK_SIZE, + hasPendingTusSession + ) + val tusSucceeded = try { + tusUploadHelper.upload( + client = client, + transfer = ocTransfer, + uploadId = uploadIdInStorageManager, + localPath = fileSystemPath, + remotePath = uploadPath, + fileSize = fileSize, + mimeType = mimetype, + lastModified = lastModified, + tusSupport = tusSupport, + progressListener = this, + progressCallback = ::updateProgressFromTus, + spaceWebDavUrl = spaceWebDavUrl, + ) + true + } catch (throwable: Throwable) { + Timber.w(throwable, "TUS upload failed, falling back to single PUT") + if (shouldRetry(throwable)) { + throw throwable + } + false + } - if (isChunkingAllowed && fileSize > CHUNK_SIZE) { - uploadChunkedFile(client) + if (tusSucceeded) { + if (removeLocal) { + removeLocalFile() + } + Timber.d("TUS upload completed for %s", uploadPath) + return + } } else { - uploadPlainFile(client) + Timber.d( + "Skipping TUS: file too small or unsupported (size=%d, threshold=%d, supportsTus=%s)", + fileSize, + TusUploadHelper.DEFAULT_CHUNK_SIZE, + supportsTus + ) } + + if (attemptedTus) { + clearTusState() + } + + Timber.d("Falling back to single PUT upload for %s", uploadPath) + uploadPlainFile(client) } private fun uploadPlainFile(client: OpenCloudClient) { @@ -258,43 +326,16 @@ class UploadFileFromFileSystemWorker( } } - private fun uploadChunkedFile(client: OpenCloudClient) { - val immutableHashForChunkedFile = SecurityUtils.stringToMD5Hash(uploadPath) + System.currentTimeMillis() - // Step 1: Create folder where the chunks will be uploaded. - val createChunksRemoteFolderOperation = CreateRemoteFolderOperation( - remotePath = immutableHashForChunkedFile, - createFullPath = false, - isChunksFolder = true - ) - executeRemoteOperation { createChunksRemoteFolderOperation.execute(client) } - - // Step 2: Upload file by chunks - uploadFileOperation = ChunkedUploadFromFileSystemOperation( - transferId = immutableHashForChunkedFile, - localPath = fileSystemPath, - remotePath = uploadPath, - mimeType = mimetype, - lastModifiedTimestamp = lastModified, - requiredEtag = eTagInConflict, - ).apply { - addDataTransferProgressListener(this@UploadFileFromFileSystemWorker) - } - - val result = executeRemoteOperation { uploadFileOperation.execute(client) } - - // Step 3: Move remote file to the final remote destination - val ocChunkService = OCChunkService(client) - ocChunkService.moveFile( - sourceRemotePath = "$immutableHashForChunkedFile$PATH_SEPARATOR${FileUtils.FINAL_CHUNKS_FILE}", - targetRemotePath = uploadPath, - fileLastModificationTimestamp = lastModified, - fileLength = fileSize - ) + private fun updateProgressFromTus(offset: Long, totalSize: Long) { + if (totalSize <= 0) return + val percent: Int = (100.0 * offset.toDouble() / totalSize.toDouble()).toInt() + if (percent == lastPercent) return - // Step 4: Remove tmp file folder after uploading - if (result == Unit && removeLocal) { - removeLocalFile() + CoroutineScope(Dispatchers.IO).launch { + val progress = workDataOf(DownloadFileWorker.WORKER_KEY_PROGRESS to percent) + setProgress(progress) } + lastPercent = percent } private fun removeLocalFile() { @@ -302,6 +343,27 @@ class UploadFileFromFileSystemWorker( Timber.d("File with path: $fileSystemPath has been removed: $fileDeleted after uploading.") } + private fun clearTusState() { + transferRepository.updateTusState( + id = uploadIdInStorageManager, + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) + } + + private fun shouldRetry(throwable: Throwable?): Boolean { + if (throwable == null) return false + if (throwable is LocalFileNotFoundException) return false + if (throwable is CancellationException) return true + if (throwable is IOException) return true + return shouldRetry(throwable.cause) + } + private fun updateUploadsDatabaseWithResult(throwable: Throwable?) { transferRepository.updateTransferWhenFinished( id = uploadIdInStorageManager, @@ -397,12 +459,12 @@ class UploadFileFromFileSystemWorker( } companion object { - const val KEY_PARAM_ACCOUNT_NAME = "KEY_PARAM_ACCOUNT_NAME" - const val KEY_PARAM_BEHAVIOR = "KEY_PARAM_BEHAVIOR" - const val KEY_PARAM_LOCAL_PATH = "KEY_PARAM_LOCAL_PATH" - const val KEY_PARAM_LAST_MODIFIED = "KEY_PARAM_LAST_MODIFIED" - const val KEY_PARAM_UPLOAD_PATH = "KEY_PARAM_UPLOAD_PATH" - const val KEY_PARAM_UPLOAD_ID = "KEY_PARAM_UPLOAD_ID" - const val KEY_PARAM_REMOVE_LOCAL = "KEY_REMOVE_LOCAL" + const val KEY_PARAM_ACCOUNT_NAME: String = "KEY_PARAM_ACCOUNT_NAME" + const val KEY_PARAM_BEHAVIOR: String = "KEY_PARAM_BEHAVIOR" + const val KEY_PARAM_LOCAL_PATH: String = "KEY_PARAM_LOCAL_PATH" + const val KEY_PARAM_LAST_MODIFIED: String = "KEY_PARAM_LAST_MODIFIED" + const val KEY_PARAM_UPLOAD_PATH: String = "KEY_PARAM_UPLOAD_PATH" + const val KEY_PARAM_UPLOAD_ID: String = "KEY_PARAM_UPLOAD_ID" + const val KEY_PARAM_REMOVE_LOCAL: String = "KEY_REMOVE_LOCAL" } } diff --git a/opencloudApp/src/main/res/drawable/ic_action_create_dir.xml b/opencloudApp/src/main/res/drawable/ic_action_create_dir.xml index 78c162283..7f7ddff83 100644 --- a/opencloudApp/src/main/res/drawable/ic_action_create_dir.xml +++ b/opencloudApp/src/main/res/drawable/ic_action_create_dir.xml @@ -2,8 +2,7 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/opencloudApp/src/main/res/drawable/ic_action_create_file.xml b/opencloudApp/src/main/res/drawable/ic_action_create_file.xml index c8683bae5..c7d80aa8b 100644 --- a/opencloudApp/src/main/res/drawable/ic_action_create_file.xml +++ b/opencloudApp/src/main/res/drawable/ic_action_create_file.xml @@ -2,8 +2,7 @@ android:width="24dp" android:height="24dp" android:viewportWidth="24" - android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:viewportHeight="24"> diff --git a/opencloudApp/src/main/res/drawable/ic_action_open_shortcut.xml b/opencloudApp/src/main/res/drawable/ic_action_open_shortcut.xml index 693ebc1df..2c311460f 100644 --- a/opencloudApp/src/main/res/drawable/ic_action_open_shortcut.xml +++ b/opencloudApp/src/main/res/drawable/ic_action_open_shortcut.xml @@ -3,7 +3,6 @@ android:height="128dp" android:viewportWidth="960" android:viewportHeight="960" - android:tint="?attr/colorControlNormal" android:autoMirrored="true"> 0) { + alreadyTransferred = (alreadyTransferred + readCount.toLong()).coerceAtMost(chunkSize) } + val totalTransferred = offset + alreadyTransferred + synchronized(dataTransferListeners) { iterator = dataTransferListeners.iterator() while (iterator.hasNext()) { - iterator.next().onTransferProgress(readCount.toLong(), alreadyTransferred, file.length(), file.absolutePath) + iterator.next().onTransferProgress(readCount.toLong(), totalTransferred, file.length(), file.absolutePath) } } } @@ -86,6 +93,7 @@ class ChunkFromFileRequestBody( fun setOffset(newOffset: Long) { offset = newOffset + alreadyTransferred = 0 } } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/operations/RemoteOperationResult.java b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/operations/RemoteOperationResult.java index f9ccc124d..2fad3229c 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/operations/RemoteOperationResult.java +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/common/operations/RemoteOperationResult.java @@ -241,6 +241,10 @@ public RemoteOperationResult(HttpBaseMethod httpMethod) throws IOException { ResultCode.SPECIFIC_METHOD_NOT_ALLOWED ); break; + case HttpConstants.HTTP_PRECONDITION_FAILED: + // For TUS, 412 typically indicates Upload-Offset precondition mismatch + mCode = ResultCode.CONFLICT; + break; case HttpConstants.HTTP_TOO_EARLY: mCode = ResultCode.TOO_EARLY; break; @@ -309,7 +313,7 @@ private RemoteOperationResult(int httpCode, String httpPhrase) { mCode = ResultCode.RESOURCE_LOCKED; break; case HttpConstants.HTTP_INTERNAL_SERVER_ERROR: // 500 - mCode = ResultCode.INSTANCE_NOT_CONFIGURED; // assuming too much... + mCode = ResultCode.UNHANDLED_HTTP_CODE; // treat as generic server error break; case HttpConstants.HTTP_SERVICE_UNAVAILABLE: // 503 mCode = ResultCode.SERVICE_UNAVAILABLE; diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/RemoteFile.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/RemoteFile.kt index 7d81400cd..4743ab7d9 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/RemoteFile.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/RemoteFile.kt @@ -24,7 +24,6 @@ package eu.opencloud.android.lib.resources.files -import android.net.Uri import android.os.Parcelable import androidx.annotation.VisibleForTesting import at.bitfire.dav4jvm.PropStat @@ -39,7 +38,6 @@ import at.bitfire.dav4jvm.property.OCId import at.bitfire.dav4jvm.property.OCPermissions import at.bitfire.dav4jvm.property.OCPrivatelink import at.bitfire.dav4jvm.property.OCSize -import eu.opencloud.android.lib.common.OpenCloudClient import eu.opencloud.android.lib.common.http.HttpConstants import eu.opencloud.android.lib.common.http.methods.webdav.properties.OCShareTypes import eu.opencloud.android.lib.common.utils.isOneOf @@ -48,7 +46,8 @@ import eu.opencloud.android.lib.resources.shares.ShareType.Companion.fromValue import kotlinx.parcelize.Parcelize import okhttp3.HttpUrl import timber.log.Timber -import java.io.File +import java.net.URLDecoder +import java.nio.charset.StandardCharsets /** * Contains the data of a Remote File from a WebDavEntry @@ -77,11 +76,11 @@ data class RemoteFile( ) : Parcelable { // To do: Quotas not used. Use or remove them. - init { - require( - !(remotePath.isEmpty() || !remotePath.startsWith(File.separator)) - ) { "Trying to create a OCFile with a non valid remote path: $remotePath" } - } + // init { + // require( + // !(remotePath.isEmpty() || !remotePath.startsWith("/")) + // ) { "Trying to create a OCFile with a non valid remote path: $remotePath" } + // } /** * Use this to find out if this file is a folder. @@ -176,8 +175,12 @@ data class RemoteFile( userId: String, spaceWebDavUrl: String? = null, ): String { - val davFilesPath = spaceWebDavUrl ?: (OpenCloudClient.WEBDAV_FILES_PATH_4_0 + userId) - val absoluteDavPath = if (spaceWebDavUrl != null) Uri.decode(url.toString()) else Uri.decode(url.encodedPath) + val davFilesPath = spaceWebDavUrl ?: ("/remote.php/dav/files/" + userId) + val absoluteDavPath = if (spaceWebDavUrl != null) { + URLDecoder.decode(url.toString(), StandardCharsets.UTF_8.name()) + } else { + URLDecoder.decode(url.encodedPath, StandardCharsets.UTF_8.name()) + } val pathToOc = absoluteDavPath.split(davFilesPath).first() return absoluteDavPath.replace(pathToOc + davFilesPath, "") } diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CheckTusSupportRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CheckTusSupportRemoteOperation.kt new file mode 100644 index 000000000..dde63efa8 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CheckTusSupportRemoteOperation.kt @@ -0,0 +1,67 @@ +package eu.opencloud.android.lib.resources.files.tus + +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.http.HttpConstants +import eu.opencloud.android.lib.common.http.methods.nonwebdav.OptionsMethod +import eu.opencloud.android.lib.common.operations.RemoteOperation +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import eu.opencloud.android.lib.common.utils.isOneOf +import timber.log.Timber +import java.net.URL + +/** + * TUS capability detection (OPTIONS on uploads collection) + * Returns true when the server advertises Tus-Version 1.0.0 and the 'creation' extension. + */ +class CheckTusSupportRemoteOperation( + private val collectionUrlOverride: String? = null, +) : RemoteOperation() { + + override fun run(client: OpenCloudClient): RemoteOperationResult = try { + val base = (collectionUrlOverride ?: client.userFilesWebDavUri.toString()).trim() + val candidates = linkedSetOf(base, base.ensureTrailingSlash()) + var lastResult: RemoteOperationResult? = null + var foundSupported = false + + for (endpoint in candidates) { + if (foundSupported) break + + val options = OptionsMethod(URL(endpoint)).apply { + setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) + } + val status = client.executeHttpMethod(options) + Timber.d("TUS OPTIONS %s - %d", endpoint, status) + if (isSuccess(status)) { + val version = options.getResponseHeader(HttpConstants.TUS_VERSION) ?: "" + val extensions = options.getResponseHeader(HttpConstants.TUS_EXTENSION) ?: "" + val versionSupported = version.split(',').any { it.trim() == HttpConstants.TUS_RESUMABLE_VERSION_1_0_0 } + val creationSupported = extensions.split(',') + .map { it.trim().lowercase() } + .any { it == "creation" || it == "creation-with-upload" } + + Timber.d("TUS supported (headers) at %s: version=%s extensions=%s", endpoint, version, extensions) + + val supported = versionSupported && creationSupported + lastResult = RemoteOperationResult(ResultCode.OK).apply { data = supported } + if (supported) { + foundSupported = true + } + } else if (status != 0) { + lastResult = RemoteOperationResult(options).apply { data = false } + } + } + + lastResult ?: RemoteOperationResult(ResultCode.OK).apply { data = false } + } catch (e: Exception) { + val result = RemoteOperationResult(e) + Timber.w(e, "TUS detection failed, assuming unsupported") + result.apply { data = false } + } + + private fun isSuccess(status: Int) = + status.isOneOf(HttpConstants.HTTP_NO_CONTENT, HttpConstants.HTTP_OK) + + private fun String.ensureTrailingSlash(): String = + if (this.endsWith("/")) this else "$this/" +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt new file mode 100644 index 000000000..01251da8c --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/CreateTusUploadRemoteOperation.kt @@ -0,0 +1,202 @@ +package eu.opencloud.android.lib.resources.files.tus + +import android.util.Base64 +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.http.HttpConstants +import eu.opencloud.android.lib.common.http.methods.nonwebdav.PostMethod +import eu.opencloud.android.lib.common.network.ChunkFromFileRequestBody +import eu.opencloud.android.lib.common.network.WebdavUtils +import eu.opencloud.android.lib.common.operations.RemoteOperation +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import eu.opencloud.android.lib.common.utils.isOneOf +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import timber.log.Timber +import java.io.File +import java.io.RandomAccessFile +import java.net.URL +import java.nio.channels.FileChannel + +class CreateTusUploadRemoteOperation( + private val file: File, + private val remotePath: String, + @Suppress("UnusedPrivateProperty") + private val mimetype: String, + private val metadata: Map, + private val useCreationWithUpload: Boolean, + private val firstChunkSize: Long?, + private val tusUrl: String?, + private val collectionUrlOverride: String? = null, + private val base64Encoder: Base64Encoder = DefaultBase64Encoder() +) : RemoteOperation() { + + interface Base64Encoder { + fun encode(bytes: ByteArray): String + } + + class DefaultBase64Encoder : Base64Encoder { + override fun encode(bytes: ByteArray): String = + Base64.encodeToString(bytes, Base64.NO_WRAP) + } + + override fun run(client: OpenCloudClient): RemoteOperationResult = try { + // Determine TUS endpoint URL based on provided parameters + val targetFileUrl = if (!tusUrl.isNullOrBlank()) { + tusUrl + } else { + val baseCollection = (collectionUrlOverride + ?: client.userFilesWebDavUri.toString()).trim() + // Remove trailing slash - OpenCloud expects no slash on space endpoints + val resolvedCollection = buildCollectionUrl(baseCollection, remotePath).trimEnd('/') + Timber.d("TUS resolved collection: %s", resolvedCollection) + resolvedCollection + } + + Timber.d("TUS Creation URL: %s", targetFileUrl) + + // Prepare request body first + val postBody: RequestBody = if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { + // creation-with-upload: include first chunk + // Don't use .use{} here - the channel must stay open for OkHttp to read + val raf = RandomAccessFile(file, "r") + val channel: FileChannel = raf.channel + ChunkFromFileRequestBody( + file = file, + contentType = HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM.toMediaTypeOrNull(), + channel = channel, + chunkSize = firstChunkSize!! + ) + } else { + // creation only: empty body + ByteArray(0).toRequestBody(null) + } + + val postMethod = PostMethod(URL(targetFileUrl), postBody) + + // Set TUS headers + postMethod.setRequestHeader(HttpConstants.TUS_RESUMABLE, "1.0.0") + postMethod.setRequestHeader(HttpConstants.UPLOAD_LENGTH, file.length().toString()) + postMethod.setRequestHeader(HttpConstants.CONTENT_TYPE_HEADER, HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM) + // Set TUS-Extension header to indicate which extensions we want to use + val extensions = if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { + "creation,creation-with-upload" + } else { + "creation" + } + postMethod.setRequestHeader(HttpConstants.TUS_EXTENSION, extensions) + + // Prepare Upload-Metadata like iOS SDK + val allMetadata = metadata.toMutableMap() + allMetadata.putIfAbsent("filename", remotePath.substringAfterLast('/')) + allMetadata.putIfAbsent("mtime", (file.lastModified() / 1000).toString()) + + if (allMetadata.isNotEmpty()) { + postMethod.setRequestHeader(HttpConstants.UPLOAD_METADATA, encodeTusMetadata(allMetadata)) + } + + // Set Upload-Offset for creation-with-upload + if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { + postMethod.setRequestHeader(HttpConstants.UPLOAD_OFFSET, "0") + } + + val status = client.executeHttpMethod(postMethod) + Timber.d("TUS Creation [%s] - %d%s", targetFileUrl, status, if (!isSuccess(status)) " (FAIL)" else "") + if (!isSuccess(status)) { + Timber.w("TUS Creation failed - Status: %d", status) + Timber.w(" Target URL: %s", targetFileUrl) + Timber.w(" Collection Override: %s", collectionUrlOverride) + Timber.w(" User Files WebDAV: %s", client.userFilesWebDavUri) + Timber.w(" Remote Path: %s", remotePath) + Timber.w(" File Size: %d bytes", file.length()) + Timber.w(" Tus-Resumable: %s", postMethod.getRequestHeader(HttpConstants.TUS_RESUMABLE)) + Timber.w(" Upload-Length: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_LENGTH)) + Timber.w(" Upload-Metadata: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_METADATA)) + } + + // Debug logging for troubleshooting + if (status == 412) { + Timber.w("HTTP 412 Precondition Failed - Request headers:") + Timber.w(" Tus-Resumable: %s", postMethod.getRequestHeader(HttpConstants.TUS_RESUMABLE)) + Timber.w(" Tus-Extension: %s", postMethod.getRequestHeader(HttpConstants.TUS_EXTENSION)) + Timber.w(" Upload-Length: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_LENGTH)) + Timber.w(" Upload-Metadata: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_METADATA)) + Timber.w(" Content-Type: %s", postMethod.getRequestHeader(HttpConstants.CONTENT_TYPE_HEADER)) + Timber.w(" Content-Length: %d", postBody.contentLength()) + if (useCreationWithUpload && (firstChunkSize ?: 0L) > 0L) { + Timber.w(" Upload-Offset: %s", postMethod.getRequestHeader(HttpConstants.UPLOAD_OFFSET)) + } + } + + if (isSuccess(status)) { + val locationHeader = postMethod.getResponseHeader(HttpConstants.LOCATION_HEADER) + ?: postMethod.getResponseHeader(HttpConstants.LOCATION_HEADER_LOWER) + val base = URL(postMethod.getFinalUrl().toString()) + val resolved = resolveLocationToAbsolute(locationHeader, base) + + if (resolved != null) { + Timber.d("TUS upload resource created: %s", resolved) + RemoteOperationResult(ResultCode.OK).apply { data = resolved } + } else { + Timber.e("Location header is missing in TUS creation response") + RemoteOperationResult(IllegalStateException("Location header missing")).apply { + data = "" + } + } + } else { + Timber.w("TUS creation failed with status: %d", status) + RemoteOperationResult(postMethod).apply { data = "" } + } + } catch (e: Exception) { + val result = RemoteOperationResult(e) + Timber.e(e, "TUS creation operation failed") + result + } + + private fun isSuccess(status: Int) = + status.isOneOf(HttpConstants.HTTP_CREATED, HttpConstants.HTTP_OK) + + private fun encodeTusMetadata(metadata: Map): String = + metadata.entries.joinToString(",") { (key, value) -> + val encoded = base64Encoder.encode(value.toByteArray(Charsets.UTF_8)) + "$key $encoded" + } + + private fun resolveLocationToAbsolute(location: String?, base: URL): String? { + if (location.isNullOrBlank()) return null + return try { + URL(base, location).toString() + } catch (e: Exception) { + Timber.w(e, "Failed to resolve Location header: %s", location) + null + } + } + + + + private fun buildCollectionUrl(base: String, remotePath: String): String { + val normalizedBase = base.trim().trimEnd('/') + val sanitizedRemotePath = remotePath.trim().trimEnd('/').ifEmpty { "/" } + if (sanitizedRemotePath == "/") { + return normalizedBase + } + + val encodedPath = WebdavUtils.encodePath(sanitizedRemotePath) + val parentSegment = when (val idx = encodedPath.lastIndexOf('/')) { + -1, 0 -> "" + else -> encodedPath.substring(0, idx).removePrefix("/") + } + + return if (parentSegment.isEmpty()) { + normalizedBase + } else { + "$normalizedBase/$parentSegment" + } + } + + companion object { + // Use 10MB for first chunk like the browser does + const val DEFAULT_FIRST_CHUNK = 10 * 1024 * 1024L // 10MB + } +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/DeleteTusUploadRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/DeleteTusUploadRemoteOperation.kt new file mode 100644 index 000000000..0502ed96a --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/DeleteTusUploadRemoteOperation.kt @@ -0,0 +1,40 @@ +package eu.opencloud.android.lib.resources.files.tus + +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.http.HttpConstants +import eu.opencloud.android.lib.common.http.methods.nonwebdav.DeleteMethod +import eu.opencloud.android.lib.common.operations.RemoteOperation +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import eu.opencloud.android.lib.common.utils.isOneOf +import timber.log.Timber +import java.net.URL + +/** + * TUS Delete Upload operation (DELETE) + * Deletes an existing upload resource. + */ +class DeleteTusUploadRemoteOperation( + private val uploadUrl: String, +) : RemoteOperation() { + + override fun run(client: OpenCloudClient): RemoteOperationResult = + try { + val deleteMethod = DeleteMethod(URL(uploadUrl)).apply { + setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) + } + + val status = client.executeHttpMethod(deleteMethod) + Timber.d("Delete TUS upload - $status${if (!isSuccess(status)) "(FAIL)" else ""}") + + if (isSuccess(status)) RemoteOperationResult(ResultCode.OK).apply { data = Unit } + else RemoteOperationResult(deleteMethod) + } catch (e: Exception) { + val result = RemoteOperationResult(e) + Timber.e(e, "Delete TUS upload failed") + result + } + + private fun isSuccess(status: Int): Boolean = + status.isOneOf(HttpConstants.HTTP_NO_CONTENT, HttpConstants.HTTP_OK) +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/GetTusUploadOffsetRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/GetTusUploadOffsetRemoteOperation.kt new file mode 100644 index 000000000..e7238bdd3 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/GetTusUploadOffsetRemoteOperation.kt @@ -0,0 +1,49 @@ +package eu.opencloud.android.lib.resources.files.tus + +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.http.HttpConstants +import eu.opencloud.android.lib.common.http.methods.nonwebdav.HeadMethod +import eu.opencloud.android.lib.common.operations.RemoteOperation +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import eu.opencloud.android.lib.common.utils.isOneOf +import timber.log.Timber +import java.net.URL + +/** + * TUS Get Upload Offset operation (HEAD) + * Returns the current Upload-Offset for a given upload resource URL. + */ +class GetTusUploadOffsetRemoteOperation( + private val uploadUrl: String, +) : RemoteOperation() { + + override fun run(client: OpenCloudClient): RemoteOperationResult = + try { + val headMethod = HeadMethod(URL(uploadUrl)).apply { + setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) + } + + val status = client.executeHttpMethod(headMethod) + Timber.d("Get TUS upload offset - $status${if (!isSuccess(status)) "(FAIL)" else ""}") + + if (isSuccess(status)) { + val offsetHeader = headMethod.getResponseHeader(HttpConstants.UPLOAD_OFFSET) + val offset = offsetHeader?.toLongOrNull() + if (offset != null) { + RemoteOperationResult(ResultCode.OK).apply { data = offset } + } else { + RemoteOperationResult(headMethod).apply { data = -1L } + } + } else { + RemoteOperationResult(headMethod).apply { data = -1L } + } + } catch (e: Exception) { + val result = RemoteOperationResult(e) + Timber.e(e, "Get TUS upload offset failed") + result + } + + private fun isSuccess(status: Int) = + status.isOneOf(HttpConstants.HTTP_OK, HttpConstants.HTTP_NO_CONTENT) +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt new file mode 100644 index 000000000..198ce7881 --- /dev/null +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/files/tus/PatchTusUploadChunkRemoteOperation.kt @@ -0,0 +1,118 @@ +package eu.opencloud.android.lib.resources.files.tus + +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.http.HttpConstants +import eu.opencloud.android.lib.common.http.methods.HttpBaseMethod +import eu.opencloud.android.lib.common.http.methods.nonwebdav.PatchMethod +import eu.opencloud.android.lib.common.http.methods.nonwebdav.PostMethod +import eu.opencloud.android.lib.common.network.ChunkFromFileRequestBody +import eu.opencloud.android.lib.common.network.OnDatatransferProgressListener +import eu.opencloud.android.lib.common.operations.OperationCancelledException +import eu.opencloud.android.lib.common.operations.RemoteOperation +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode +import eu.opencloud.android.lib.common.utils.isOneOf +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import timber.log.Timber +import java.io.File +import java.io.RandomAccessFile +import java.net.URL +import java.nio.channels.FileChannel +import java.util.Locale +import java.util.concurrent.atomic.AtomicBoolean + +/** + * TUS Patch Upload Chunk operation (PATCH) + * Uploads a chunk to an existing upload resource. + * Returns the new Upload-Offset in the result data on success. + */ +class PatchTusUploadChunkRemoteOperation( + private val localPath: String, + private val uploadUrl: String, + private val offset: Long, + private val chunkSize: Long, + private val httpMethodOverride: String? = null, +) : RemoteOperation() { + + private val cancellationRequested = AtomicBoolean(false) + private val dataTransferListeners: MutableSet = HashSet() + private var activeMethod: HttpBaseMethod? = null + + override fun run(client: OpenCloudClient): RemoteOperationResult = try { + val file = File(localPath) + if (cancellationRequested.get()) { + RemoteOperationResult(OperationCancelledException()) + } else { + RandomAccessFile(file, "r").use { raf -> + val channel: FileChannel = raf.channel + val body = ChunkFromFileRequestBody( + file = file, + contentType = HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM.toMediaTypeOrNull(), + channel = channel, + chunkSize = chunkSize + ).also { synchronized(dataTransferListeners) { it.addDatatransferProgressListeners(dataTransferListeners) } } + + body.setOffset(offset) + + val method = if (httpMethodOverride?.uppercase(Locale.ROOT) == "POST") { + PostMethod(URL(uploadUrl), body).apply { + setRequestHeader(HttpConstants.X_HTTP_METHOD_OVERRIDE, "PATCH") + } + } else { + PatchMethod(URL(uploadUrl), body) + }.apply { + setRequestHeader(HttpConstants.TUS_RESUMABLE, HttpConstants.TUS_RESUMABLE_VERSION_1_0_0) + setRequestHeader(HttpConstants.UPLOAD_OFFSET, offset.toString()) + setRequestHeader(HttpConstants.CONTENT_TYPE_HEADER, HttpConstants.CONTENT_TYPE_OFFSET_OCTET_STREAM) + } + + activeMethod = method + + val status = client.executeHttpMethod(method) + Timber.d( + "Patch TUS upload chunk via %s - %d%s", + method.javaClass.simpleName, + status, + if (!isSuccess(status)) " (FAIL)" else "" + ) + + if (isSuccess(status)) { + val newOffset = method.getResponseHeader(HttpConstants.UPLOAD_OFFSET)?.toLongOrNull() + if (newOffset != null) { + RemoteOperationResult(ResultCode.OK).apply { data = newOffset } + } else { + RemoteOperationResult(method).apply { data = -1L } + } + } else { + RemoteOperationResult(method) + } + } + } + } catch (e: Exception) { + val result = if (activeMethod?.isAborted == true) { + RemoteOperationResult(OperationCancelledException()) + } else { + RemoteOperationResult(e) + } + Timber.e(result.exception, "Patch TUS upload chunk failed: ${result.logMessage}") + result + } + + fun addDataTransferProgressListener(listener: OnDatatransferProgressListener) { + synchronized(dataTransferListeners) { dataTransferListeners.add(listener) } + } + + fun removeDataTransferProgressListener(listener: OnDatatransferProgressListener) { + synchronized(dataTransferListeners) { dataTransferListeners.remove(listener) } + } + + fun cancel() { + synchronized(cancellationRequested) { + cancellationRequested.set(true) + activeMethod?.abort() + } + } + + private fun isSuccess(status: Int): Boolean = + status.isOneOf(HttpConstants.HTTP_OK, HttpConstants.HTTP_NO_CONTENT) +} diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/RemoteCapability.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/RemoteCapability.kt index 5053de74b..dd717301b 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/RemoteCapability.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/RemoteCapability.kt @@ -73,6 +73,7 @@ data class RemoteCapability( var filesVersioning: CapabilityBooleanType = CapabilityBooleanType.UNKNOWN, val filesPrivateLinks: CapabilityBooleanType = CapabilityBooleanType.UNKNOWN, val filesAppProviders: List?, + val filesTusSupport: TusSupport?, // Spaces val spaces: RemoteSpaces?, @@ -118,6 +119,14 @@ data class RemoteCapability( val newUrl: String?, ) + data class TusSupport( + val version: String?, + val resumable: String?, + val extension: String?, + val maxChunkSize: Int?, + val httpMethodOverride: String?, + ) + data class RemoteSpaces( val enabled: Boolean, val projects: Boolean, diff --git a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/responses/CapabilityResponse.kt b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/responses/CapabilityResponse.kt index bdeddb3ec..d7f589c1e 100644 --- a/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/responses/CapabilityResponse.kt +++ b/opencloudComLibrary/src/main/java/eu/opencloud/android/lib/resources/status/responses/CapabilityResponse.kt @@ -83,6 +83,7 @@ data class CapabilityResponse( filesPrivateLinks = capabilities?.fileCapabilities?.privateLinks?.let { CapabilityBooleanType.fromBooleanValue(it) } ?: CapabilityBooleanType.UNKNOWN, filesAppProviders = capabilities?.fileCapabilities?.appProviders?.map { it.toAppProviders() }, + filesTusSupport = capabilities?.fileCapabilities?.tusSupport?.toTusSupport(), filesSharingFederationIncoming = CapabilityBooleanType.fromBooleanValue(capabilities?.fileSharingCapabilities?.fileSharingFederation?.incoming), filesSharingFederationOutgoing = @@ -187,7 +188,9 @@ data class FileCapabilities( val versioning: Boolean?, val privateLinks: Boolean?, @Json(name = "app_providers") - val appProviders: List? + val appProviders: List?, + @Json(name = "tus_support") + val tusSupport: TusSupport? ) @JsonClass(generateAdapter = true) @@ -206,6 +209,25 @@ data class AppProvider( fun toAppProviders() = RemoteAppProviders(enabled, version, appsUrl, openUrl, openWebUrl, newUrl) } +@JsonClass(generateAdapter = true) +data class TusSupport( + val version: String?, + val resumable: String?, + val extension: String?, + @Json(name = "max_chunk_size") + val maxChunkSize: Int?, + @Json(name = "http_method_override") + val httpMethodOverride: String? +) { + fun toTusSupport() = RemoteCapability.TusSupport( + version = version, + resumable = resumable, + extension = extension, + maxChunkSize = maxChunkSize, + httpMethodOverride = httpMethodOverride, + ) +} + @JsonClass(generateAdapter = true) data class DavCapabilities( val chunking: String? diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt new file mode 100644 index 000000000..94742c4cd --- /dev/null +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/files/tus/TusIntegrationTest.kt @@ -0,0 +1,225 @@ +package eu.opencloud.android.lib.resources.files.tus + +import android.accounts.Account +import android.accounts.AccountManager +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import eu.opencloud.android.lib.common.OpenCloudAccount +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.accounts.AccountUtils +import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory +import eu.opencloud.android.lib.common.operations.RemoteOperationResult +import eu.opencloud.android.lib.resources.files.tus.CreateTusUploadRemoteOperation.Base64Encoder +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.Base64 + +@RunWith(RobolectricTestRunner::class) +class TusIntegrationTest { + + private lateinit var server: MockWebServer + private val context by lazy { ApplicationProvider.getApplicationContext() } + + private val accountType = "com.example" + private val userId = "user-123" + private val username = "user@example.com" + private val token = "TEST_TOKEN" + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + private fun newClient(): OpenCloudClient { + val base = server.url("/").toString().removeSuffix("/") + + val am = AccountManager.get(context) + val account = Account("$username@${Uri.parse(base).host}", accountType) + am.addAccountExplicitly(account, null, null) + am.setUserData(account, AccountUtils.Constants.KEY_OC_BASE_URL, base) + am.setUserData(account, AccountUtils.Constants.KEY_ID, userId) + + val ocAccount = OpenCloudAccount(account, context) + val client = OpenCloudClient(ocAccount.baseUri, /*connectionValidator*/ null, /*sync*/ true, /*singleSession*/ null, context) + client.account = ocAccount + client.credentials = OpenCloudCredentialsFactory.newBearerCredentials(username, token) + return client + } + + @Test + fun create_patch_head_delete_success() { + val client = newClient() + + val collectionPath = "/remote.php/dav/uploads/$userId" + val locationPath = "$collectionPath/UPLD-123" + val localFile = File.createTempFile("tus", ".bin").apply { + writeBytes(byteArrayOf(1, 2, 3, 4, 5)) + } + + // 1) POST Create -> 201 + Location + server.enqueue( + MockResponse() + .setResponseCode(201) + .addHeader("Tus-Resumable", "1.0.0") + .addHeader("Location", locationPath) + ) + + // 2) PATCH -> 204 + Upload-Offset + server.enqueue( + MockResponse() + .setResponseCode(204) + .addHeader("Upload-Offset", "5") + ) + + // 3) HEAD -> 204 + Upload-Offset + server.enqueue( + MockResponse() + .setResponseCode(204) + .addHeader("Upload-Offset", "5") + ) + + // 4) DELETE -> 204 + server.enqueue( + MockResponse() + .setResponseCode(204) + ) + + // Create + val create = CreateTusUploadRemoteOperation( + file = localFile, + remotePath = "/test.bin", + mimetype = "application/octet-stream", + metadata = mapOf("filename" to "test.bin"), + useCreationWithUpload = false, + firstChunkSize = null, + tusUrl = null, + collectionUrlOverride = server.url(collectionPath).toString(), + base64Encoder = object : Base64Encoder { + override fun encode(bytes: ByteArray): String = + Base64.getEncoder().encodeToString(bytes) + } + ) + val createResult = create.execute(client) + if (!createResult.isSuccess) { + val msg = "DEBUG: Create operation failed. Code: ${createResult.code}, " + + "HttpCode: ${createResult.httpCode}, Exception: ${createResult.exception}" + throw RuntimeException(msg, createResult.exception) + } + assertTrue("Create operation failed", createResult.isSuccess) + val absoluteLocation = createResult.data + assertNotNull(absoluteLocation) + println("absoluteLocation: $absoluteLocation") + println("locationPath: $locationPath") + println("endsWith: ${absoluteLocation!!.endsWith(locationPath)}") + assertTrue(absoluteLocation.endsWith(locationPath)) + + // Verify POST request headers + val postReq = server.takeRequest() + assertEquals("POST", postReq.method) + assertEquals("Bearer $token", postReq.getHeader("Authorization")) + assertEquals("1.0.0", postReq.getHeader("Tus-Resumable")) + assertEquals("5", postReq.getHeader("Upload-Length")) + assertEquals(collectionPath, postReq.path) + + // Patch + val patch = PatchTusUploadChunkRemoteOperation( + localPath = localFile.absolutePath, + uploadUrl = absoluteLocation, + offset = 0, + chunkSize = 5 + ) + val patchResult = patch.execute(client) + assertTrue(patchResult.isSuccess) + assertEquals(5L, patchResult.data) + + // Verify PATCH request + val patchReq = server.takeRequest() + assertEquals("PATCH", patchReq.method) + assertEquals("Bearer $token", patchReq.getHeader("Authorization")) + assertEquals("1.0.0", patchReq.getHeader("Tus-Resumable")) + assertEquals("0", patchReq.getHeader("Upload-Offset")) + assertEquals("application/offset+octet-stream", patchReq.getHeader("Content-Type")) + assertEquals(Uri.parse(absoluteLocation).encodedPath, patchReq.path) + + // Head + val head = GetTusUploadOffsetRemoteOperation(absoluteLocation) + val headResult = head.execute(client) + assertTrue(headResult.isSuccess) + assertEquals(5L, headResult.data) + + val headReq = server.takeRequest() + assertEquals("HEAD", headReq.method) + assertEquals("Bearer $token", headReq.getHeader("Authorization")) + assertEquals("1.0.0", headReq.getHeader("Tus-Resumable")) + + // Delete + val del = DeleteTusUploadRemoteOperation(absoluteLocation) + val delResult = del.execute(client) + assertTrue(delResult.isSuccess) + + val delReq = server.takeRequest() + assertEquals("DELETE", delReq.method) + assertEquals("Bearer $token", delReq.getHeader("Authorization")) + assertEquals("1.0.0", delReq.getHeader("Tus-Resumable")) + } + + @Test + fun patch_wrong_offset_returns_conflict() { + val client = newClient() + val locationPath = "/remote.php/dav/uploads/$userId/UPLD-err" + + // No need to POST; directly simulate an existing upload URL + // Server responds 412 to PATCH + server.enqueue(MockResponse().setResponseCode(412)) + + val tmp = File.createTempFile("tus", ".bin") + tmp.writeBytes(ByteArray(10) { 1 }) + + val patch = PatchTusUploadChunkRemoteOperation( + localPath = tmp.absolutePath, + uploadUrl = server.url(locationPath).toString(), + offset = 0, + chunkSize = 10 + ) + val res = patch.execute(client) + assertFalse(res.isSuccess) + assertEquals(RemoteOperationResult.ResultCode.CONFLICT, res.code) + + val req = server.takeRequest() + assertEquals("PATCH", req.method) + assertEquals("Bearer $token", req.getHeader("Authorization")) + } + + @Test + fun cancel_before_start_returns_cancelled() { + val client = newClient() + val locationPath = "/remote.php/dav/uploads/$userId/UPLD-cancel" + + // No requests expected because we cancel before run + val tmp = File.createTempFile("tus", ".bin") + tmp.writeBytes(ByteArray(1024 * 64) { 1 }) + + val op = PatchTusUploadChunkRemoteOperation( + localPath = tmp.absolutePath, + uploadUrl = server.url(locationPath).toString(), + offset = 0, + chunkSize = 1024 * 64L + ) + op.cancel() + val result = op.execute(client) + assertTrue(result.isCancelled) + } +} diff --git a/opencloudData/build.gradle b/opencloudData/build.gradle index 232456eb2..7a57eb971 100644 --- a/opencloudData/build.gradle +++ b/opencloudData/build.gradle @@ -94,3 +94,7 @@ dependencies { detektPlugins libs.detekt.formatting detektPlugins libs.detekt.libraries } + +tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach { + jvmTarget = "17" +} diff --git a/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/48.json b/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/48.json new file mode 100644 index 000000000..8ed5945fa --- /dev/null +++ b/opencloudData/schemas/eu.opencloud.android.data.OpencloudDatabase/48.json @@ -0,0 +1,1218 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "8ea9a6ea6dcebcc597330e0549ea4900", + "entities": [ + { + "tableName": "app_registry", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_name` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `ext` TEXT, `app_providers` TEXT NOT NULL, `name` TEXT, `icon` TEXT, `description` TEXT, `allow_creation` INTEGER, `default_application` TEXT, PRIMARY KEY(`account_name`, `mime_type`))", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ext", + "columnName": "ext", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders", + "columnName": "app_providers", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "allowCreation", + "columnName": "allow_creation", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "defaultApplication", + "columnName": "default_application", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "account_name", + "mime_type" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "folder_backup", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountName` TEXT NOT NULL, `behavior` TEXT NOT NULL, `sourcePath` TEXT NOT NULL, `uploadPath` TEXT NOT NULL, `wifiOnly` INTEGER NOT NULL, `chargingOnly` INTEGER NOT NULL, `name` TEXT NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `spaceId` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "behavior", + "columnName": "behavior", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploadPath", + "columnName": "uploadPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "wifiOnly", + "columnName": "wifiOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chargingOnly", + "columnName": "chargingOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spaceId", + "columnName": "spaceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "capabilities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account` TEXT, `version_major` INTEGER NOT NULL, `version_minor` INTEGER NOT NULL, `version_micro` INTEGER NOT NULL, `version_string` TEXT, `version_edition` TEXT, `core_pollinterval` INTEGER NOT NULL, `dav_chunking_version` TEXT NOT NULL, `sharing_api_enabled` INTEGER NOT NULL DEFAULT -1, `sharing_public_enabled` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced_read_only` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced_read_write` INTEGER NOT NULL DEFAULT -1, `sharing_public_password_enforced_public_only` INTEGER NOT NULL DEFAULT -1, `sharing_public_expire_date_enabled` INTEGER NOT NULL DEFAULT -1, `sharing_public_expire_date_days` INTEGER NOT NULL, `sharing_public_expire_date_enforced` INTEGER NOT NULL DEFAULT -1, `sharing_public_upload` INTEGER NOT NULL DEFAULT -1, `sharing_public_multiple` INTEGER NOT NULL DEFAULT -1, `supports_upload_only` INTEGER NOT NULL DEFAULT -1, `sharing_resharing` INTEGER NOT NULL DEFAULT -1, `sharing_federation_outgoing` INTEGER NOT NULL DEFAULT -1, `sharing_federation_incoming` INTEGER NOT NULL DEFAULT -1, `sharing_user_profile_picture` INTEGER NOT NULL DEFAULT -1, `files_bigfilechunking` INTEGER NOT NULL DEFAULT -1, `files_undelete` INTEGER NOT NULL DEFAULT -1, `files_versioning` INTEGER NOT NULL DEFAULT -1, `files_private_links` INTEGER NOT NULL DEFAULT -1, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `app_providers_enabled` INTEGER, `app_providers_version` TEXT, `app_providers_appsUrl` TEXT, `app_providers_openUrl` TEXT, `app_providers_openWebUrl` TEXT, `app_providers_newUrl` TEXT, `tus_support_version` TEXT, `tus_support_resumable` TEXT, `tus_support_extension` TEXT, `tus_support_maxChunkSize` INTEGER, `tus_support_httpMethodOverride` TEXT, `spaces_enabled` INTEGER, `spaces_projects` INTEGER, `spaces_shareJail` INTEGER, `spaces_hasMultiplePersonalSpaces` INTEGER, `password_policy_maxCharacters` INTEGER, `password_policy_minCharacters` INTEGER, `password_policy_minDigits` INTEGER, `password_policy_minLowercaseCharacters` INTEGER, `password_policy_minSpecialCharacters` INTEGER, `password_policy_minUppercaseCharacters` INTEGER)", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionMajor", + "columnName": "version_major", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionMinor", + "columnName": "version_minor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionMicro", + "columnName": "version_micro", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "versionString", + "columnName": "version_string", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "versionEdition", + "columnName": "version_edition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "corePollInterval", + "columnName": "core_pollinterval", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "davChunkingVersion", + "columnName": "dav_chunking_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filesSharingApiEnabled", + "columnName": "sharing_api_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicEnabled", + "columnName": "sharing_public_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforced", + "columnName": "sharing_public_password_enforced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforcedReadOnly", + "columnName": "sharing_public_password_enforced_read_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforcedReadWrite", + "columnName": "sharing_public_password_enforced_read_write", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicPasswordEnforcedUploadOnly", + "columnName": "sharing_public_password_enforced_public_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicExpireDateEnabled", + "columnName": "sharing_public_expire_date_enabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicExpireDateDays", + "columnName": "sharing_public_expire_date_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filesSharingPublicExpireDateEnforced", + "columnName": "sharing_public_expire_date_enforced", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicUpload", + "columnName": "sharing_public_upload", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicMultiple", + "columnName": "sharing_public_multiple", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingPublicSupportsUploadOnly", + "columnName": "supports_upload_only", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingResharing", + "columnName": "sharing_resharing", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingFederationOutgoing", + "columnName": "sharing_federation_outgoing", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingFederationIncoming", + "columnName": "sharing_federation_incoming", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesSharingUserProfilePicture", + "columnName": "sharing_user_profile_picture", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesBigFileChunking", + "columnName": "files_bigfilechunking", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesUndelete", + "columnName": "files_undelete", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesVersioning", + "columnName": "files_versioning", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "filesPrivateLinks", + "columnName": "files_private_links", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appProviders.enabled", + "columnName": "app_providers_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "appProviders.version", + "columnName": "app_providers_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.appsUrl", + "columnName": "app_providers_appsUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.openUrl", + "columnName": "app_providers_openUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.openWebUrl", + "columnName": "app_providers_openWebUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appProviders.newUrl", + "columnName": "app_providers_newUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.version", + "columnName": "tus_support_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.resumable", + "columnName": "tus_support_resumable", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.extension", + "columnName": "tus_support_extension", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusSupport.maxChunkSize", + "columnName": "tus_support_maxChunkSize", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tusSupport.httpMethodOverride", + "columnName": "tus_support_httpMethodOverride", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "spaces.enabled", + "columnName": "spaces_enabled", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spaces.projects", + "columnName": "spaces_projects", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spaces.shareJail", + "columnName": "spaces_shareJail", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "spaces.hasMultiplePersonalSpaces", + "columnName": "spaces_hasMultiplePersonalSpaces", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.maxCharacters", + "columnName": "password_policy_maxCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minCharacters", + "columnName": "password_policy_minCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minDigits", + "columnName": "password_policy_minDigits", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minLowercaseCharacters", + "columnName": "password_policy_minLowercaseCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minSpecialCharacters", + "columnName": "password_policy_minSpecialCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "passwordPolicy.minUppercaseCharacters", + "columnName": "password_policy_minUppercaseCharacters", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`parentId` INTEGER, `owner` TEXT NOT NULL, `remotePath` TEXT NOT NULL, `remoteId` TEXT, `length` INTEGER NOT NULL, `creationTimestamp` INTEGER, `modificationTimestamp` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `etag` TEXT, `permissions` TEXT, `privateLink` TEXT, `storagePath` TEXT, `name` TEXT, `treeEtag` TEXT, `keepInSync` INTEGER, `lastSyncDateForData` INTEGER, `lastUsage` INTEGER, `fileShareViaLink` INTEGER, `needsToUpdateThumbnail` INTEGER NOT NULL, `modifiedAtLastSyncForData` INTEGER, `etagInConflict` TEXT, `fileIsDownloading` INTEGER, `sharedWithSharee` INTEGER, `sharedByLink` INTEGER NOT NULL, `spaceId` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`owner`, `spaceId`) REFERENCES `spaces`(`account_name`, `space_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "parentId", + "columnName": "parentId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "owner", + "columnName": "owner", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "length", + "columnName": "length", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationTimestamp", + "columnName": "creationTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "modificationTimestamp", + "columnName": "modificationTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "etag", + "columnName": "etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "privateLink", + "columnName": "privateLink", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "storagePath", + "columnName": "storagePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "treeEtag", + "columnName": "treeEtag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "availableOfflineStatus", + "columnName": "keepInSync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastSyncDateForData", + "columnName": "lastSyncDateForData", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastUsage", + "columnName": "lastUsage", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fileShareViaLink", + "columnName": "fileShareViaLink", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "needsToUpdateThumbnail", + "columnName": "needsToUpdateThumbnail", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAtLastSyncForData", + "columnName": "modifiedAtLastSyncForData", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "etagInConflict", + "columnName": "etagInConflict", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fileIsDownloading", + "columnName": "fileIsDownloading", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedWithSharee", + "columnName": "sharedWithSharee", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sharedByLink", + "columnName": "sharedByLink", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spaceId", + "columnName": "spaceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "spaces", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "owner", + "spaceId" + ], + "referencedColumns": [ + "account_name", + "space_id" + ] + } + ] + }, + { + "tableName": "files_sync", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` INTEGER NOT NULL, `uploadWorkerUuid` BLOB, `downloadWorkerUuid` BLOB, `isSynchronizing` INTEGER NOT NULL, PRIMARY KEY(`fileId`), FOREIGN KEY(`fileId`) REFERENCES `files`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploadWorkerUuid", + "columnName": "uploadWorkerUuid", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "downloadWorkerUuid", + "columnName": "downloadWorkerUuid", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "isSynchronizing", + "columnName": "isSynchronizing", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fileId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "files", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "fileId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ocshares", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`share_type` INTEGER NOT NULL, `share_with` TEXT, `path` TEXT NOT NULL, `permissions` INTEGER NOT NULL, `shared_date` INTEGER NOT NULL, `expiration_date` INTEGER NOT NULL, `token` TEXT, `shared_with_display_name` TEXT, `share_with_additional_info` TEXT, `is_directory` INTEGER NOT NULL, `id_remote_shared` TEXT NOT NULL, `owner_share` TEXT NOT NULL, `name` TEXT, `url` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "shareType", + "columnName": "share_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shareWith", + "columnName": "share_with", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sharedDate", + "columnName": "shared_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expirationDate", + "columnName": "expiration_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithDisplayName", + "columnName": "shared_with_display_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sharedWithAdditionalInfo", + "columnName": "share_with_additional_info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isFolder", + "columnName": "is_directory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "id_remote_shared", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountOwner", + "columnName": "owner_share", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareLink", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "transfers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`localPath` TEXT NOT NULL, `remotePath` TEXT NOT NULL, `accountName` TEXT NOT NULL, `fileSize` INTEGER NOT NULL, `status` INTEGER NOT NULL, `localBehaviour` INTEGER NOT NULL, `forceOverwrite` INTEGER NOT NULL, `transferEndTimestamp` INTEGER, `lastResult` INTEGER, `createdBy` INTEGER NOT NULL, `transferId` TEXT, `spaceId` TEXT, `sourcePath` TEXT, `tusUploadUrl` TEXT, `tusUploadLength` INTEGER, `tusUploadMetadata` TEXT, `tusUploadChecksum` TEXT, `tusResumableVersion` TEXT, `tusUploadExpires` INTEGER, `tusUploadConcat` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileSize", + "columnName": "fileSize", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localBehaviour", + "columnName": "localBehaviour", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "forceOverwrite", + "columnName": "forceOverwrite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferEndTimestamp", + "columnName": "transferEndTimestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastResult", + "columnName": "lastResult", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdBy", + "columnName": "createdBy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferId", + "columnName": "transferId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "spaceId", + "columnName": "spaceId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourcePath", + "columnName": "sourcePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadUrl", + "columnName": "tusUploadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadLength", + "columnName": "tusUploadLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tusUploadMetadata", + "columnName": "tusUploadMetadata", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadChecksum", + "columnName": "tusUploadChecksum", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusResumableVersion", + "columnName": "tusResumableVersion", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tusUploadExpires", + "columnName": "tusUploadExpires", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tusUploadConcat", + "columnName": "tusUploadConcat", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spaces", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`account_name` TEXT NOT NULL, `drive_alias` TEXT, `drive_type` TEXT NOT NULL, `space_id` TEXT NOT NULL, `last_modified_date_time` TEXT, `name` TEXT NOT NULL, `owner_id` TEXT, `web_url` TEXT, `description` TEXT, `quota_remaining` INTEGER, `quota_state` TEXT, `quota_total` INTEGER, `quota_used` INTEGER, `root_etag` TEXT, `root_id` TEXT NOT NULL, `root_web_dav_url` TEXT NOT NULL, `root_deleted_state` TEXT, PRIMARY KEY(`account_name`, `space_id`))", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "account_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "driveAlias", + "columnName": "drive_alias", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "driveType", + "columnName": "drive_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "space_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModifiedDateTime", + "columnName": "last_modified_date_time", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "owner_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "webUrl", + "columnName": "web_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quota.remaining", + "columnName": "quota_remaining", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quota.state", + "columnName": "quota_state", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quota.total", + "columnName": "quota_total", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "quota.used", + "columnName": "quota_used", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "root.eTag", + "columnName": "root_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "root.id", + "columnName": "root_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "root.webDavUrl", + "columnName": "root_web_dav_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "root.deleteState", + "columnName": "root_deleted_state", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "account_name", + "space_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "spaces_special", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`spaces_special_account_name` TEXT NOT NULL, `spaces_special_space_id` TEXT NOT NULL, `spaces_special_etag` TEXT NOT NULL, `file_mime_type` TEXT NOT NULL, `special_id` TEXT NOT NULL, `last_modified_date_time` TEXT, `name` TEXT NOT NULL, `size` INTEGER NOT NULL, `special_folder_name` TEXT NOT NULL, `special_web_dav_url` TEXT NOT NULL, PRIMARY KEY(`spaces_special_space_id`, `special_id`), FOREIGN KEY(`spaces_special_account_name`, `spaces_special_space_id`) REFERENCES `spaces`(`account_name`, `space_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "spaces_special_account_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spaceId", + "columnName": "spaces_special_space_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eTag", + "columnName": "spaces_special_etag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileMimeType", + "columnName": "file_mime_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "special_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModifiedDateTime", + "columnName": "last_modified_date_time", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "specialFolderName", + "columnName": "special_folder_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "webDavUrl", + "columnName": "special_web_dav_url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "spaces_special_space_id", + "special_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "spaces", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spaces_special_account_name", + "spaces_special_space_id" + ], + "referencedColumns": [ + "account_name", + "space_id" + ] + } + ] + }, + { + "tableName": "user_quotas", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountName` TEXT NOT NULL, `used` INTEGER NOT NULL, `available` INTEGER NOT NULL, `total` INTEGER, `state` TEXT, PRIMARY KEY(`accountName`))", + "fields": [ + { + "fieldPath": "accountName", + "columnName": "accountName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "used", + "columnName": "used", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "available", + "columnName": "available", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "total", + "columnName": "total", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountName" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8ea9a6ea6dcebcc597330e0549ea4900')" + ] + } +} diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt b/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt index 86487b233..222e08d48 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/OpencloudDatabase.kt @@ -50,6 +50,7 @@ import eu.opencloud.android.data.migrations.MIGRATION_35_36 import eu.opencloud.android.data.migrations.MIGRATION_37_38 import eu.opencloud.android.data.migrations.MIGRATION_41_42 import eu.opencloud.android.data.migrations.MIGRATION_42_43 +import eu.opencloud.android.data.migrations.MIGRATION_47_48 import eu.opencloud.android.data.sharing.shares.db.OCShareDao import eu.opencloud.android.data.sharing.shares.db.OCShareEntity import eu.opencloud.android.data.spaces.db.SpaceSpecialEntity @@ -123,7 +124,8 @@ abstract class OpencloudDatabase : RoomDatabase() { MIGRATION_35_36, MIGRATION_37_38, MIGRATION_41_42, - MIGRATION_42_43) + MIGRATION_42_43, + MIGRATION_47_48) .build() INSTANCE = instance instance diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/ProviderMeta.java b/opencloudData/src/main/java/eu/opencloud/android/data/ProviderMeta.java index 9957244f6..cc6633c48 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/ProviderMeta.java +++ b/opencloudData/src/main/java/eu/opencloud/android/data/ProviderMeta.java @@ -31,7 +31,7 @@ public class ProviderMeta { public static final String DB_NAME = "filelist"; public static final String NEW_DB_NAME = "opencloud_database"; - public static final int DB_VERSION = 47; + public static final int DB_VERSION = 48; private ProviderMeta() { } @@ -70,11 +70,16 @@ static public class ProviderTableMeta implements BaseColumns { public static final String CAPABILITIES_APP_PROVIDERS_PREFIX = "app_providers_"; public static final String CAPABILITIES_CORE_POLLINTERVAL = "core_pollinterval"; public static final String CAPABILITIES_DAV_CHUNKING_VERSION = "dav_chunking_version"; - public static final String CAPABILITIES_FILES_APP_PROVIDERS = "files_apps_providers"; public static final String CAPABILITIES_FILES_BIGFILECHUNKING = "files_bigfilechunking"; public static final String CAPABILITIES_FILES_PRIVATE_LINKS = "files_private_links"; public static final String CAPABILITIES_FILES_UNDELETE = "files_undelete"; public static final String CAPABILITIES_FILES_VERSIONING = "files_versioning"; + public static final String CAPABILITIES_TUS_SUPPORT_PREFIX = "tus_support_"; + public static final String CAPABILITIES_TUS_SUPPORT_VERSION = CAPABILITIES_TUS_SUPPORT_PREFIX + "version"; + public static final String CAPABILITIES_TUS_SUPPORT_RESUMABLE = CAPABILITIES_TUS_SUPPORT_PREFIX + "resumable"; + public static final String CAPABILITIES_TUS_SUPPORT_EXTENSION = CAPABILITIES_TUS_SUPPORT_PREFIX + "extension"; + public static final String CAPABILITIES_TUS_SUPPORT_MAX_CHUNK_SIZE = CAPABILITIES_TUS_SUPPORT_PREFIX + "maxChunkSize"; + public static final String CAPABILITIES_TUS_SUPPORT_HTTP_METHOD_OVERRIDE = CAPABILITIES_TUS_SUPPORT_PREFIX + "httpMethodOverride"; public static final String CAPABILITIES_SHARING_API_ENABLED = "sharing_api_enabled"; public static final String CAPABILITIES_SHARING_FEDERATION_INCOMING = "sharing_federation_incoming"; public static final String CAPABILITIES_SHARING_FEDERATION_OUTGOING = "sharing_federation_outgoing"; diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/implementation/OCLocalCapabilitiesDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/implementation/OCLocalCapabilitiesDataSource.kt index fd6d530ec..1614ed5a7 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/implementation/OCLocalCapabilitiesDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/implementation/OCLocalCapabilitiesDataSource.kt @@ -84,6 +84,7 @@ class OCLocalCapabilitiesDataSource( filesVersioning = CapabilityBooleanType.fromValue(filesVersioning), filesPrivateLinks = CapabilityBooleanType.fromValue(filesPrivateLinks), filesAppProviders = appProviders, + filesTusSupport = tusSupport, spaces = spaces, passwordPolicy = passwordPolicy, ) @@ -120,6 +121,7 @@ class OCLocalCapabilitiesDataSource( filesVersioning = filesVersioning.value, filesPrivateLinks = filesPrivateLinks.value, appProviders = filesAppProviders, + tusSupport = filesTusSupport, spaces = spaces, passwordPolicy = passwordPolicy, ) diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/mapper/RemoteCapabilityMapper.kt b/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/mapper/RemoteCapabilityMapper.kt index 74a02334c..3419c8914 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/mapper/RemoteCapabilityMapper.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/datasources/mapper/RemoteCapabilityMapper.kt @@ -69,6 +69,7 @@ class RemoteCapabilityMapper : RemoteMapper { filesVersioning = CapabilityBooleanType.fromValue(remote.filesVersioning.value), filesPrivateLinks = CapabilityBooleanType.fromValue(remote.filesPrivateLinks.value), filesAppProviders = remote.filesAppProviders?.firstOrNull()?.toAppProviders(), + filesTusSupport = remote.filesTusSupport?.toTusSupport(), spaces = remote.spaces?.toSpaces(), passwordPolicy = remote.passwordPolicy?.toPasswordPolicy() ) @@ -115,6 +116,7 @@ class RemoteCapabilityMapper : RemoteMapper { filesVersioning = RemoteCapabilityBooleanType.fromValue(model.filesVersioning.value)!!, filesPrivateLinks = RemoteCapabilityBooleanType.fromValue(model.filesPrivateLinks.value)!!, filesAppProviders = null, + filesTusSupport = model.filesTusSupport?.toRemoteTusSupport(), spaces = null, passwordPolicy = null, ) @@ -123,6 +125,12 @@ class RemoteCapabilityMapper : RemoteMapper { private fun RemoteCapability.RemoteAppProviders.toAppProviders() = OCCapability.AppProviders(enabled, version, appsUrl, openUrl, openWebUrl, newUrl) + private fun RemoteCapability.TusSupport.toTusSupport() = + OCCapability.TusSupport(version, resumable, extension, maxChunkSize, httpMethodOverride) + + private fun OCCapability.TusSupport.toRemoteTusSupport() = + RemoteCapability.TusSupport(version, resumable, extension, maxChunkSize, httpMethodOverride) + private fun RemoteCapability.RemoteSpaces.toSpaces() = OCCapability.Spaces(enabled, projects, shareJail, hasMultiplePersonalSpaces) diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/db/OCCapabilityEntity.kt b/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/db/OCCapabilityEntity.kt index e3c8b0599..eb4c44cf2 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/db/OCCapabilityEntity.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/capabilities/db/OCCapabilityEntity.kt @@ -33,6 +33,7 @@ import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_FIL import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_FILES_PRIVATE_LINKS import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_FILES_UNDELETE import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_FILES_VERSIONING +import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_TUS_SUPPORT_PREFIX import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_PASSWORD_POLICY_PREFIX import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_SHARING_API_ENABLED import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.CAPABILITIES_SHARING_FEDERATION_INCOMING @@ -124,6 +125,8 @@ data class OCCapabilityEntity( val filesPrivateLinks: Int, @Embedded(prefix = CAPABILITIES_APP_PROVIDERS_PREFIX) val appProviders: OCCapability.AppProviders?, + @Embedded(prefix = CAPABILITIES_TUS_SUPPORT_PREFIX) + val tusSupport: OCCapability.TusSupport?, @Embedded(prefix = CAPABILITIES_SPACES_PREFIX) val spaces: OCCapability.Spaces?, @Embedded(prefix = CAPABILITIES_PASSWORD_POLICY_PREFIX) @@ -166,6 +169,7 @@ data class OCCapabilityEntity( null, null, null, + null, ) } } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/migrations/Migration_48.kt b/opencloudData/src/main/java/eu/opencloud/android/data/migrations/Migration_48.kt new file mode 100644 index 000000000..661ba135b --- /dev/null +++ b/opencloudData/src/main/java/eu/opencloud/android/data/migrations/Migration_48.kt @@ -0,0 +1,30 @@ +package eu.opencloud.android.data.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta + +val MIGRATION_47_48 = object : Migration(47, 48) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "ALTER TABLE ${ProviderTableMeta.CAPABILITIES_TABLE_NAME} " + + "ADD COLUMN `${ProviderTableMeta.CAPABILITIES_TUS_SUPPORT_VERSION}` TEXT" + ) + database.execSQL( + "ALTER TABLE ${ProviderTableMeta.CAPABILITIES_TABLE_NAME} " + + "ADD COLUMN `${ProviderTableMeta.CAPABILITIES_TUS_SUPPORT_RESUMABLE}` TEXT" + ) + database.execSQL( + "ALTER TABLE ${ProviderTableMeta.CAPABILITIES_TABLE_NAME} " + + "ADD COLUMN `${ProviderTableMeta.CAPABILITIES_TUS_SUPPORT_EXTENSION}` TEXT" + ) + database.execSQL( + "ALTER TABLE ${ProviderTableMeta.CAPABILITIES_TABLE_NAME} " + + "ADD COLUMN `${ProviderTableMeta.CAPABILITIES_TUS_SUPPORT_MAX_CHUNK_SIZE}` INTEGER" + ) + database.execSQL( + "ALTER TABLE ${ProviderTableMeta.CAPABILITIES_TABLE_NAME} " + + "ADD COLUMN `${ProviderTableMeta.CAPABILITIES_TUS_SUPPORT_HTTP_METHOD_OVERRIDE}` TEXT" + ) + } +} diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt index 6154687a8..11c5ff976 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/LocalTransferDataSource.kt @@ -57,4 +57,18 @@ interface LocalTransferDataSource { fun getFinishedTransfers(): List fun clearFailedTransfers() fun clearSuccessfulTransfers() + + // TUS state management + fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) + + fun updateTusUrl(id: Long, tusUploadUrl: String?) } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt index 18c0b4349..09c18915a 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/datasources/implementation/OCLocalTransferDataSource.kt @@ -141,6 +141,32 @@ class OCLocalTransferDataSource( transferDao.deleteTransfersWithStatus(TransferStatus.TRANSFER_SUCCEEDED.value) } + // TUS state management + override fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) { + transferDao.updateTusState( + id = id, + tusUploadUrl = tusUploadUrl, + tusUploadLength = tusUploadLength, + tusUploadMetadata = tusUploadMetadata, + tusUploadChecksum = tusUploadChecksum, + tusResumableVersion = tusResumableVersion, + tusUploadExpires = tusUploadExpires, + tusUploadConcat = tusUploadConcat, + ) + } + + override fun updateTusUrl(id: Long, tusUploadUrl: String?) { + transferDao.updateTusUrl(id = id, tusUploadUrl = tusUploadUrl) + } companion object { @@ -161,6 +187,13 @@ class OCLocalTransferDataSource( transferId = transferId, spaceId = spaceId, sourcePath = sourcePath, + tusUploadUrl = tusUploadUrl, + tusUploadLength = tusUploadLength, + tusUploadMetadata = tusUploadMetadata, + tusUploadChecksum = tusUploadChecksum, + tusResumableVersion = tusResumableVersion, + tusUploadExpires = tusUploadExpires, + tusUploadConcat = tusUploadConcat, ) @VisibleForTesting fun OCTransfer.toEntity() = OCTransferEntity( @@ -177,6 +210,13 @@ class OCLocalTransferDataSource( transferId = transferId, spaceId = spaceId, sourcePath = sourcePath, + tusUploadUrl = tusUploadUrl, + tusUploadLength = tusUploadLength, + tusUploadMetadata = tusUploadMetadata, + tusUploadChecksum = tusUploadChecksum, + tusResumableVersion = tusResumableVersion, + tusUploadExpires = tusUploadExpires, + tusUploadConcat = tusUploadConcat, ).apply { this@toEntity.id?.let { this.id = it } } } } diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/OCTransferEntity.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/OCTransferEntity.kt index 54d2ba942..faf945164 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/OCTransferEntity.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/OCTransferEntity.kt @@ -56,6 +56,14 @@ data class OCTransferEntity( val transferId: String? = null, val spaceId: String? = null, val sourcePath: String? = null, + // TUS protocol state persistence + val tusUploadUrl: String? = null, + val tusUploadLength: Long? = null, + val tusUploadMetadata: String? = null, + val tusUploadChecksum: String? = null, + val tusResumableVersion: String? = null, + val tusUploadExpires: Long? = null, + val tusUploadConcat: String? = null, ) { @PrimaryKey(autoGenerate = true) var id: Long = 0 diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt index 667cd64e8..529f65eff 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/db/TransferDao.kt @@ -63,6 +63,22 @@ interface TransferDao { @Query(UPDATE_TRANSFER_STORAGE_DIRECTORY) fun updateTransferStorageDirectoryInLocalPath(id: Long, oldDirectory: String, newDirectory: String) + // TUS state updates + @Query(UPDATE_TUS_STATE) + fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) + + @Query(UPDATE_TUS_URL) + fun updateTusUrl(id: Long, tusUploadUrl: String?) + @Query(DELETE_TRANSFER_WITH_ID) fun deleteTransferWithId(id: Long) @@ -126,6 +142,24 @@ interface TransferDao { WHERE id = :id """ + private const val UPDATE_TUS_STATE = """ + UPDATE $TRANSFERS_TABLE_NAME + SET tusUploadUrl = :tusUploadUrl, + tusUploadLength = :tusUploadLength, + tusUploadMetadata = :tusUploadMetadata, + tusUploadChecksum = :tusUploadChecksum, + tusResumableVersion = :tusResumableVersion, + tusUploadExpires = :tusUploadExpires, + tusUploadConcat = :tusUploadConcat + WHERE id = :id + """ + + private const val UPDATE_TUS_URL = """ + UPDATE $TRANSFERS_TABLE_NAME + SET tusUploadUrl = :tusUploadUrl + WHERE id = :id + """ + private const val DELETE_TRANSFER_WITH_ID = """ DELETE FROM $TRANSFERS_TABLE_NAME diff --git a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt index 0598f230d..fa69e355f 100644 --- a/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt +++ b/opencloudData/src/main/java/eu/opencloud/android/data/transfers/repository/OCTransferRepository.kt @@ -111,4 +111,29 @@ class OCTransferRepository( override fun clearSuccessfulTransfers() = localTransferDataSource.clearSuccessfulTransfers() + + // TUS state management + override fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) = + localTransferDataSource.updateTusState( + id = id, + tusUploadUrl = tusUploadUrl, + tusUploadLength = tusUploadLength, + tusUploadMetadata = tusUploadMetadata, + tusUploadChecksum = tusUploadChecksum, + tusResumableVersion = tusResumableVersion, + tusUploadExpires = tusUploadExpires, + tusUploadConcat = tusUploadConcat, + ) + + override fun updateTusUrl(id: Long, tusUploadUrl: String?) = + localTransferDataSource.updateTusUrl(id = id, tusUploadUrl = tusUploadUrl) } diff --git a/opencloudDomain/build.gradle b/opencloudDomain/build.gradle index 8243080d7..8e4fe7fa5 100644 --- a/opencloudDomain/build.gradle +++ b/opencloudDomain/build.gradle @@ -50,3 +50,7 @@ dependencies { detektPlugins libs.detekt.formatting detektPlugins libs.detekt.libraries } + +tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach { + jvmTarget = "17" +} diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/capabilities/model/OCCapability.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/capabilities/model/OCCapability.kt index a22d98e84..f35cf2ca1 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/capabilities/model/OCCapability.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/capabilities/model/OCCapability.kt @@ -52,6 +52,7 @@ data class OCCapability( val filesVersioning: CapabilityBooleanType, val filesPrivateLinks: CapabilityBooleanType, val filesAppProviders: AppProviders?, + val filesTusSupport: TusSupport?, val spaces: Spaces?, val passwordPolicy: PasswordPolicy?, ) { @@ -78,6 +79,14 @@ data class OCCapability( val newUrl: String?, ) + data class TusSupport( + val version: String?, + val resumable: String?, + val extension: String?, + val maxChunkSize: Int?, + val httpMethodOverride: String?, + ) + data class Spaces( val enabled: Boolean, val projects: Boolean, diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt index 4e27630b8..376fde79c 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/TransferRepository.kt @@ -57,4 +57,18 @@ interface TransferRepository { fun getFinishedTransfers(): List fun clearFailedTransfers() fun clearSuccessfulTransfers() + + // TUS state management + fun updateTusState( + id: Long, + tusUploadUrl: String?, + tusUploadLength: Long?, + tusUploadMetadata: String?, + tusUploadChecksum: String?, + tusResumableVersion: String?, + tusUploadExpires: Long?, + tusUploadConcat: String?, + ) + + fun updateTusUrl(id: Long, tusUploadUrl: String?) } diff --git a/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/model/OCTransfer.kt b/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/model/OCTransfer.kt index 80b7afb69..438bdc5ba 100644 --- a/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/model/OCTransfer.kt +++ b/opencloudDomain/src/main/java/eu/opencloud/android/domain/transfers/model/OCTransfer.kt @@ -42,6 +42,14 @@ data class OCTransfer( val transferId: String? = null, val spaceId: String? = null, val sourcePath: String? = null, + // TUS protocol state + val tusUploadUrl: String? = null, + val tusUploadLength: Long? = null, + val tusUploadMetadata: String? = null, + val tusUploadChecksum: String? = null, + val tusResumableVersion: String? = null, + val tusUploadExpires: Long? = null, + val tusUploadConcat: String? = null, ) : Parcelable { init { if (!remotePath.startsWith(File.separator)) throw IllegalArgumentException("Remote path must be an absolute path in the local file system") diff --git a/opencloudTestUtil/build.gradle b/opencloudTestUtil/build.gradle index 050b3ffc8..79fb583b2 100644 --- a/opencloudTestUtil/build.gradle +++ b/opencloudTestUtil/build.gradle @@ -38,3 +38,7 @@ dependencies { detektPlugins libs.detekt.formatting detektPlugins libs.detekt.libraries } + +tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach { + jvmTarget = "17" +} diff --git a/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCCapability.kt b/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCCapability.kt index ee5a33c27..78154f4b9 100644 --- a/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCCapability.kt +++ b/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCCapability.kt @@ -54,6 +54,7 @@ val OC_CAPABILITY = filesVersioning = CapabilityBooleanType.FALSE, filesPrivateLinks = CapabilityBooleanType.TRUE, filesAppProviders = null, + filesTusSupport = null, spaces = null, passwordPolicy = null, ) diff --git a/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCTransfer.kt b/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCTransfer.kt index acdf972a3..188229361 100644 --- a/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCTransfer.kt +++ b/opencloudTestUtil/src/main/java/eu/opencloud/android/testutil/OCTransfer.kt @@ -24,18 +24,19 @@ import eu.opencloud.android.domain.automaticuploads.model.UploadBehavior import eu.opencloud.android.domain.transfers.model.OCTransfer import eu.opencloud.android.domain.transfers.model.TransferStatus import eu.opencloud.android.domain.transfers.model.UploadEnqueuedBy +import java.io.File val OC_TRANSFER = OCTransfer( id = 0L, - localPath = "/local/path", - remotePath = "/remote/path", + localPath = "${File.separator}local${File.separator}path", + remotePath = "${File.separator}remote${File.separator}path", accountName = OC_ACCOUNT_NAME, fileSize = 1024L, status = TransferStatus.TRANSFER_IN_PROGRESS, localBehaviour = UploadBehavior.MOVE, forceOverwrite = true, createdBy = UploadEnqueuedBy.ENQUEUED_BY_USER, - sourcePath = "/source/path", + sourcePath = "${File.separator}source${File.separator}path", ) val OC_FINISHED_TRANSFER = OC_TRANSFER.copy(