Skip to content

Commit 0db2c87

Browse files
zerox80zerox80
andauthored
TUS resumable upload protocol (#36)
- Add TUS 1.0.0 protocol support for resumable file uploads - Implement chunked uploads with automatic retry logic - Add TUS upload operations (create, chunk, offset check, support detection) - Add SHA-256 checksum computation for upload integrity - Persist TUS session state in database for resume capability - Add TUS capability detection via OPTIONS request - Implement offset recovery after connection interruptions - Add fallback to standard PUT upload if TUS unavailable - Support both file system and content URI uploads - Include comprehensive retry handling for transient failures - Add TUS-specific database schema migration - Implement proper cleanup of TUS state after completion - Rethrow IOException during offset fetch to enable worker-level retry - chore: update Gradle build configuration settings - Add android.overridePathCheck=true to gradle.properties for path validation - Fix unit test failures on Windows (path separator compatibility) - Upgrade MockK to 1.13.13 for Java 21 support - Add Gradle Version Catalog for dependency management - Fix Detekt issues and build warnings --------- Co-authored-by: zerox80 <[email protected]>
1 parent 1854741 commit 0db2c87

File tree

53 files changed

+3034
-223
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+3034
-223
lines changed

gradle.properties

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
android.defaults.buildfeatures.buildconfig=true
1+
22
android.enableJetifier=true
33
android.nonFinalResIds=false
44
android.nonTransitiveRClass=false
55
android.useAndroidX=true
66
org.gradle.jvmargs=-Xmx1536M
7+
android.overridePathCheck=true

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ ksp = "1.9.20-1.0.14"
3737
ktlint = "11.1.0"
3838
markwon = "4.6.2"
3939
material = "1.8.0"
40-
mockk = "1.13.3"
40+
mockk = "1.13.13"
4141
moshi = "1.15.0"
4242
patternlockview = "a90b0d4bf0"
4343
photoView = "2.3.0"

opencloudApp/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ android {
179179

180180
buildFeatures {
181181
viewBinding true
182+
buildConfig true
182183
}
183184

184185
packagingOptions {
@@ -249,3 +250,7 @@ static def getGitOriginRemote() {
249250
def found = values.find { it.startsWith("origin") && it.endsWith("(push)") }
250251
return found.replace("origin", "").replace("(push)", "").replace(".git", "").trim()
251252
}
253+
254+
tasks.withType(io.gitlab.arturbosch.detekt.Detekt).configureEach {
255+
jvmTarget = "17"
256+
}

opencloudApp/src/main/java/eu/opencloud/android/presentation/avatar/AvatarUtils.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ class AvatarUtils : KoinComponent {
5353
fun loadAvatarForAccount(
5454
imageView: ImageView,
5555
account: Account,
56-
fetchIfNotCached: Boolean = false,
57-
displayRadius: Float
56+
@Suppress("UnusedParameter") fetchIfNotCached: Boolean = false,
57+
@Suppress("UnusedParameter") displayRadius: Float
5858
) {
5959
// Tech debt: Move this to a viewModel and use its viewModelScope instead
6060
CoroutineScope(Dispatchers.IO).launch {
@@ -76,8 +76,8 @@ class AvatarUtils : KoinComponent {
7676
fun loadAvatarForAccount(
7777
menuItem: MenuItem,
7878
account: Account,
79-
fetchIfNotCached: Boolean = false,
80-
displayRadius: Float
79+
@Suppress("UnusedParameter") fetchIfNotCached: Boolean = false,
80+
@Suppress("UnusedParameter") displayRadius: Float
8181
) {
8282
CoroutineScope(Dispatchers.IO).launch {
8383
val drawable = avatarManager.getAvatarForAccount(

opencloudApp/src/main/java/eu/opencloud/android/presentation/files/details/FileDetailsFragment.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,11 @@ class FileDetailsFragment : FileFragment() {
439439
if (thumbnail == null) {
440440
thumbnail = ThumbnailsCacheManager.mDefaultImg
441441
}
442-
val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(MainApp.appContext.resources, thumbnail, task)
442+
val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(
443+
MainApp.appContext.resources,
444+
thumbnail,
445+
task
446+
)
443447
imageView.setImageDrawable(asyncDrawable)
444448
task.execute(ocFile)
445449
}

opencloudApp/src/main/java/eu/opencloud/android/presentation/files/filelist/MainFileListFragment.kt

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -272,9 +272,12 @@ class MainFileListFragment : Fragment(),
272272
"${getString(R.string.actionbar_select_inverse)} $roleAccessibilityDescription"
273273
findItem(R.id.action_open_file_with)?.contentDescription =
274274
"${getString(R.string.actionbar_open_with)} $roleAccessibilityDescription"
275-
findItem(R.id.action_rename_file)?.contentDescription = "${getString(R.string.common_rename)} $roleAccessibilityDescription"
276-
findItem(R.id.action_move)?.contentDescription = "${getString(R.string.actionbar_move)} $roleAccessibilityDescription"
277-
findItem(R.id.action_copy)?.contentDescription = "${getString(R.string.copy)} $roleAccessibilityDescription"
275+
findItem(R.id.action_rename_file)?.contentDescription =
276+
"${getString(R.string.common_rename)} $roleAccessibilityDescription"
277+
findItem(R.id.action_move)?.contentDescription =
278+
"${getString(R.string.actionbar_move)} $roleAccessibilityDescription"
279+
findItem(R.id.action_copy)?.contentDescription =
280+
"${getString(R.string.copy)} $roleAccessibilityDescription"
278281
findItem(R.id.action_send_file)?.contentDescription =
279282
"${getString(R.string.actionbar_send_file)} $roleAccessibilityDescription"
280283
findItem(R.id.action_set_available_offline)?.contentDescription =
@@ -283,7 +286,8 @@ class MainFileListFragment : Fragment(),
283286
"${getString(R.string.unset_available_offline)} $roleAccessibilityDescription"
284287
findItem(R.id.action_see_details)?.contentDescription =
285288
"${getString(R.string.actionbar_see_details)} $roleAccessibilityDescription"
286-
findItem(R.id.action_remove_file)?.contentDescription = "${getString(R.string.common_remove)} $roleAccessibilityDescription"
289+
findItem(R.id.action_remove_file)?.contentDescription =
290+
"${getString(R.string.common_remove)} $roleAccessibilityDescription"
287291
}
288292
}
289293
}
@@ -368,7 +372,10 @@ class MainFileListFragment : Fragment(),
368372
// Set view and footer correctly
369373
if (mainFileListViewModel.isGridModeSetAsPreferred()) {
370374
layoutManager =
371-
StaggeredGridLayoutManager(ColumnQuantity(requireContext(), R.layout.grid_item).calculateNoOfColumns(), RecyclerView.VERTICAL)
375+
StaggeredGridLayoutManager(
376+
ColumnQuantity(requireContext(), R.layout.grid_item).calculateNoOfColumns(),
377+
RecyclerView.VERTICAL
378+
)
372379
viewType = ViewType.VIEW_TYPE_GRID
373380
} else {
374381
layoutManager = StaggeredGridLayoutManager(1, RecyclerView.VERTICAL)
@@ -494,7 +501,9 @@ class MainFileListFragment : Fragment(),
494501
fileActions?.onCurrentFolderUpdated(currentFolderDisplayed, mainFileListViewModel.getSpace())
495502
val fileListOption = mainFileListViewModel.fileListOption.value
496503
val refreshFolderNeeded = fileListOption.isAllFiles() ||
497-
(!fileListOption.isAllFiles() && currentFolderDisplayed.remotePath != ROOT_PATH && !fileListOption.isAvailableOffline())
504+
(!fileListOption.isAllFiles() &&
505+
currentFolderDisplayed.remotePath != ROOT_PATH &&
506+
!fileListOption.isAvailableOffline())
498507
if (refreshFolderNeeded) {
499508
fileOperationsViewModel.performOperation(
500509
FileOperation.RefreshFolderOperation(
@@ -543,7 +552,8 @@ class MainFileListFragment : Fragment(),
543552
// Mimetypes not supported via open in web, send 500
544553
if (uiResult.error is InstanceNotConfiguredException) {
545554
val message =
546-
getString(R.string.open_in_web_error_generic) + " " + getString(R.string.error_reason) +
555+
getString(R.string.open_in_web_error_generic) + " " +
556+
getString(R.string.error_reason) +
547557
" " + getString(R.string.open_in_web_error_not_supported)
548558
this.showMessageInSnackbar(message, Snackbar.LENGTH_LONG)
549559
} else if (uiResult.error is TooEarlyException) {
@@ -602,19 +612,27 @@ class MainFileListFragment : Fragment(),
602612
thumbnailBottomSheet.setImageResource(R.drawable.ic_menu_archive)
603613
} else {
604614
// Set file icon depending on its mimetype. Ask for thumbnail later.
605-
thumbnailBottomSheet.setImageResource(MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName))
615+
thumbnailBottomSheet.setImageResource(
616+
MimetypeIconUtil.getFileTypeIconId(file.mimeType, file.fileName)
617+
)
606618
if (file.remoteId != null) {
607619
val thumbnail = ThumbnailsCacheManager.getBitmapFromDiskCache(file.remoteId)
608620
if (thumbnail != null) {
609621
thumbnailBottomSheet.setImageBitmap(thumbnail)
610622
}
611-
if (file.needsToUpdateThumbnail && ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, thumbnailBottomSheet)) {
623+
if (file.needsToUpdateThumbnail &&
624+
ThumbnailsCacheManager.cancelPotentialThumbnailWork(file, thumbnailBottomSheet)
625+
) {
612626
// generate new Thumbnail
613627
val task = ThumbnailsCacheManager.ThumbnailGenerationTask(
614628
thumbnailBottomSheet,
615629
AccountUtils.getCurrentOpenCloudAccount(requireContext())
616630
)
617-
val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(resources, thumbnail, task)
631+
val asyncDrawable = ThumbnailsCacheManager.AsyncThumbnailDrawable(
632+
resources,
633+
thumbnail,
634+
task
635+
)
618636

619637
// If drawable is not visible, do not update it.
620638
if (asyncDrawable.minimumHeight > 0 && asyncDrawable.minimumWidth > 0) {
@@ -624,7 +642,9 @@ class MainFileListFragment : Fragment(),
624642
}
625643

626644
if (file.mimeType == "image/png") {
627-
thumbnailBottomSheet.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.background_color))
645+
thumbnailBottomSheet.setBackgroundColor(
646+
ContextCompat.getColor(requireContext(), R.color.background_color)
647+
)
628648
}
629649
}
630650
}

opencloudApp/src/main/java/eu/opencloud/android/presentation/sharing/ShareFileFragment.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,8 @@ class ShareFileFragment : Fragment(), ShareUserListAdapter.ShareUserAdapterListe
125125
R.string.share_via_link_default_name_template,
126126
file?.fileName
127127
)
128-
val defaultNameNumberedRegex = QUOTE_START + defaultName + QUOTE_END + DEFAULT_NAME_REGEX_SUFFIX
128+
val defaultNameNumberedRegex =
129+
QUOTE_START + defaultName + QUOTE_END + DEFAULT_NAME_REGEX_SUFFIX
129130
val usedNumbers = ArrayList<Int>()
130131
var isDefaultNameSet = false
131132
var number: String
@@ -217,7 +218,8 @@ class ShareFileFragment : Fragment(), ShareUserListAdapter.ShareUserAdapterListe
217218
_binding = ShareFileLayoutBinding.inflate(inflater, container, false)
218219
return binding.root.apply {
219220
// Allow or disallow touches with other visible windows
220-
filterTouchesWhenObscured = PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context)
221+
filterTouchesWhenObscured =
222+
PreferenceUtils.shouldDisallowTouchesWithOtherVisibleWindows(context)
221223
}
222224
}
223225

opencloudApp/src/main/java/eu/opencloud/android/presentation/thumbnails/ThumbnailsRequester.kt

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,19 +85,17 @@ object ThumbnailsRequester : KoinComponent {
8585
.build()
8686
}
8787

88-
fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String {
89-
// Converts dp to pixel
90-
val spacesThumbnailSize = appContext.resources.getDimension(R.dimen.spaces_thumbnail_height).roundToInt()
91-
return String.format(
88+
fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String =
89+
String.format(
9290
Locale.ROOT,
9391
SPACE_SPECIAL_PREVIEW_URI,
9492
spaceSpecial.webDavUrl,
95-
spacesThumbnailSize,
96-
spacesThumbnailSize,
93+
appContext.resources.getDimension(R.dimen.spaces_thumbnail_height).roundToInt(),
94+
appContext.resources.getDimension(R.dimen.spaces_thumbnail_height).roundToInt(),
9795
spaceSpecial.eTag
9896
)
99-
}
10097

98+
@Suppress("ExpressionBodySyntax")
10199
fun getPreviewUriForFile(ocFile: OCFileWithSyncInfo, account: Account): String {
102100
var baseUrl = getOpenCloudClient().baseUri.toString() + "/remote.php/dav/files/" + account.name.split("@".toRegex())
103101
.dropLastWhile { it.isEmpty() }

opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromContentUriUseCase.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import eu.opencloud.android.domain.transfers.TransferRepository
2929
import eu.opencloud.android.extensions.getWorkInfoByTags
3030
import eu.opencloud.android.workers.UploadFileFromContentUriWorker
3131
import timber.log.Timber
32+
import java.io.File
3233

3334
class RetryUploadFromContentUriUseCase(
3435
private val workManager: WorkManager,
@@ -52,11 +53,18 @@ class RetryUploadFromContentUriUseCase(
5253
if (workInfos.isEmpty() || workInfos.firstOrNull()?.state == WorkInfo.State.FAILED) {
5354
transferRepository.updateTransferStatusToEnqueuedById(params.uploadIdInStorageManager)
5455

56+
val lastModifiedInSeconds = File(uploadToRetry.localPath)
57+
.takeIf { it.exists() && it.isFile }
58+
?.lastModified()
59+
?.takeIf { it > 0 }
60+
?.div(1000)
61+
?.toString()
62+
5563
uploadFileFromContentUriUseCase(
5664
UploadFileFromContentUriUseCase.Params(
5765
accountName = uploadToRetry.accountName,
5866
contentUri = uploadToRetry.localPath.toUri(),
59-
lastModifiedInSeconds = (uploadToRetry.transferEndTimestamp?.div(1000)).toString(),
67+
lastModifiedInSeconds = lastModifiedInSeconds,
6068
behavior = uploadToRetry.localBehaviour.name,
6169
uploadPath = uploadToRetry.remotePath,
6270
uploadIdInStorageManager = params.uploadIdInStorageManager,

opencloudApp/src/main/java/eu/opencloud/android/usecases/transfers/uploads/RetryUploadFromSystemUseCase.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import eu.opencloud.android.domain.transfers.TransferRepository
2929
import eu.opencloud.android.extensions.getWorkInfoByTags
3030
import eu.opencloud.android.workers.UploadFileFromFileSystemWorker
3131
import timber.log.Timber
32+
import java.io.File
3233

3334
class RetryUploadFromSystemUseCase(
3435
private val workManager: WorkManager,
@@ -52,11 +53,18 @@ class RetryUploadFromSystemUseCase(
5253
if (workInfos.isEmpty() || workInfos.firstOrNull()?.state == WorkInfo.State.FAILED) {
5354
transferRepository.updateTransferStatusToEnqueuedById(params.uploadIdInStorageManager)
5455

56+
val lastModifiedInSeconds = File(uploadToRetry.localPath)
57+
.takeIf { it.exists() && it.isFile }
58+
?.lastModified()
59+
?.takeIf { it > 0 }
60+
?.div(1000)
61+
?.toString()
62+
5563
uploadFileFromSystemUseCase(
5664
UploadFileFromSystemUseCase.Params(
5765
accountName = uploadToRetry.accountName,
5866
localPath = uploadToRetry.localPath,
59-
lastModifiedInSeconds = (uploadToRetry.transferEndTimestamp?.div(1000)).toString(),
67+
lastModifiedInSeconds = lastModifiedInSeconds,
6068
behavior = uploadToRetry.localBehaviour.name,
6169
uploadPath = uploadToRetry.remotePath,
6270
sourcePath = uploadToRetry.sourcePath,

0 commit comments

Comments
 (0)