Skip to content

Commit b7bae7d

Browse files
committed
feat: Implement TUS resumable upload protocol and fix unit tests
- Add TUS upload operations (create, chunk, offset check, support detection) - Implement chunked upload with resume capability - Add file list adapter, thumbnail requester, and file details improvements - 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
1 parent 6e3b4fc commit b7bae7d

22 files changed

Lines changed: 237 additions & 153 deletions

File tree

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
android.defaults.buildfeatures.buildconfig=true
1+
22
android.enableJetifier=true
33
android.nonFinalResIds=false
44
android.nonTransitiveRClass=false

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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,7 @@ static def getGitOriginRemote() {
250250
def found = values.find { it.startsWith("origin") && it.endsWith("(push)") }
251251
return found.replace("origin", "").replace("(push)", "").replace(".git", "").trim()
252252
}
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/workers/TusUploadHelper.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ class TusUploadHelper(
133133
Timber.e("TUS: upload loop exited but offset=%d != fileSize=%d", offset, fileSize)
134134
throw java.io.IOException("TUS: upload incomplete - offset $offset does not match file size $fileSize")
135135
}
136-
137136
transferRepository.updateTusState(
138137
id = uploadId,
139138
tusUploadUrl = null,
@@ -301,11 +300,11 @@ class TusUploadHelper(
301300
Timber.w("TUS: invalid recovered offset %d (total=%d)", newOffset, totalSize)
302301
null
303302
}
303+
} catch (e: java.io.IOException) {
304+
Timber.w(e, "TUS: recover offset failed")
305+
throw e
304306
} catch (recoverError: Throwable) {
305307
Timber.w(recoverError, "TUS: recover offset failed")
306-
if (recoverError is java.io.IOException) {
307-
throw recoverError
308-
}
309308
null
310309
}
311310

opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@ import eu.opencloud.android.lib.common.network.OnDatatransferProgressListener
5151
import eu.opencloud.android.lib.common.operations.RemoteOperationResult.ResultCode
5252
import eu.opencloud.android.lib.resources.files.CheckPathExistenceRemoteOperation
5353
import eu.opencloud.android.lib.resources.files.CreateRemoteFolderOperation
54-
import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation
5554
import eu.opencloud.android.presentation.authentication.AccountUtils
5655
import eu.opencloud.android.utils.NotificationUtils
57-
import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath
5856
import eu.opencloud.android.utils.UPLOAD_NOTIFICATION_CHANNEL_ID
57+
import eu.opencloud.android.utils.RemoteFileUtils.getAvailableRemotePath
58+
import eu.opencloud.android.lib.resources.files.UploadFileFromFileSystemOperation
5959
import kotlinx.coroutines.CoroutineScope
6060
import kotlinx.coroutines.Dispatchers
6161
import kotlinx.coroutines.launch
@@ -97,23 +97,21 @@ class UploadFileFromContentUriWorker(
9797
private val getWebdavUrlForSpaceUseCase: GetWebDavUrlForSpaceUseCase by inject()
9898
private val getStoredCapabilitiesUseCase: GetStoredCapabilitiesUseCase by inject()
9999

100-
override suspend fun doWork(): Result {
101-
return try {
102-
prepareFile()
103-
val clientForThisUpload = getClientForThisUpload()
104-
checkParentFolderExistence(clientForThisUpload)
105-
checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload)
106-
uploadDocument(clientForThisUpload)
107-
updateUploadsDatabaseWithResult(null)
108-
Result.success()
109-
} catch (throwable: Throwable) {
110-
Timber.e(throwable)
111-
112-
if (shouldRetry(throwable)) {
113-
Timber.i("Retrying upload %d after transient failure", uploadIdInStorageManager)
114-
return Result.retry()
115-
}
116-
100+
override suspend fun doWork(): Result = try {
101+
prepareFile()
102+
val clientForThisUpload = getClientForThisUpload()
103+
checkParentFolderExistence(clientForThisUpload)
104+
checkNameCollisionAndGetAnAvailableOneInCase(clientForThisUpload)
105+
uploadDocument(clientForThisUpload)
106+
updateUploadsDatabaseWithResult(null)
107+
Result.success()
108+
}catch (throwable: Throwable) {
109+
Timber.e(throwable)
110+
111+
if (shouldRetry(throwable)) {
112+
Timber.i("Retrying upload %d after transient failure", uploadIdInStorageManager)
113+
Result.retry()
114+
}else {
117115
showNotification(throwable)
118116
updateUploadsDatabaseWithResult(throwable)
119117
Result.failure()
@@ -163,7 +161,7 @@ class UploadFileFromContentUriWorker(
163161
if (it != null) {
164162
Timber.d("Upload with id ($uploadIdInStorageManager) has been found in database.")
165163
Timber.d("Upload info: $it")
166-
} else {
164+
}else {
167165
Timber.w("Upload with id ($uploadIdInStorageManager) has not been found in database.")
168166
Timber.w("$uploadPath won't be uploaded")
169167
}
@@ -304,7 +302,7 @@ class UploadFileFromContentUriWorker(
304302
spaceWebDavUrl = spaceWebDavUrl,
305303
)
306304
true
307-
} catch (throwable: Throwable) {
305+
}catch (throwable: Throwable) {
308306
Timber.w(throwable, "TUS upload failed, falling back to single PUT")
309307
if (shouldRetry(throwable)) {
310308
throw throwable
@@ -317,7 +315,7 @@ class UploadFileFromContentUriWorker(
317315
Timber.d("TUS upload completed for %s", uploadPath)
318316
return
319317
}
320-
} else {
318+
}else {
321319
Timber.d(
322320
"Skipping TUS: file too small or unsupported (size=%d, threshold=%d, supportsTus=%s)",
323321
fileSize,
@@ -400,7 +398,7 @@ class UploadFileFromContentUriWorker(
400398
private fun getUploadStatusForThrowable(throwable: Throwable?): TransferStatus =
401399
if (throwable == null) {
402400
TransferStatus.TRANSFER_SUCCEEDED
403-
} else {
401+
}else {
404402
TransferStatus.TRANSFER_FAILED
405403
}
406404

@@ -413,7 +411,7 @@ class UploadFileFromContentUriWorker(
413411

414412
val pendingIntent = if (needsToUpdateCredentials) {
415413
NotificationUtils.composePendingIntentToRefreshCredentials(appContext, account)
416-
} else {
414+
}else {
417415
NotificationUtils.composePendingIntentToUploadList(appContext)
418416
}
419417

0 commit comments

Comments
 (0)