Skip to content

Commit 36bd7a7

Browse files
Merge pull request #16858 from nextcloud/backport/16819/stable-33.1.0
[stable-33.1.0] fix(upload-list): handle conflict actions
2 parents 60066e9 + 36f34cd commit 36bd7a7

23 files changed

Lines changed: 537 additions & 225 deletions
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Nextcloud - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Alper Ozturk <alper.ozturk@nextcloud.com>
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.utils
9+
10+
import com.nextcloud.utils.extensions.webDavParentPath
11+
import org.junit.Assert.assertEquals
12+
import org.junit.Test
13+
14+
@Suppress("TooManyFunctions")
15+
class WebDavParentPathTests {
16+
17+
@Test
18+
fun testWebDavParentPathWhenGivenCorrectParentShouldReturnOneLevelAbove() {
19+
assertEquals("/Photos/Vacation/", "/Photos/Vacation/beach.jpg".webDavParentPath())
20+
assertEquals("/work/docs/", "/work/docs/notes.txt".webDavParentPath())
21+
}
22+
23+
@Test
24+
fun testWebDavParentPathWhenGivenDeepNestingShouldReturnDirectParent() {
25+
assertEquals("/a/b/c/d/", "/a/b/c/d/e.txt".webDavParentPath())
26+
}
27+
28+
@Test
29+
fun testWebDavParentPathWhenGivenRootFileShouldReturnRoot() {
30+
assertEquals("/", "/image.png".webDavParentPath())
31+
}
32+
33+
@Test
34+
fun testWebDavParentPathWhenGivenSlashShouldReturnRoot() {
35+
assertEquals("/", "/".webDavParentPath())
36+
}
37+
38+
@Test
39+
fun testWebDavParentPathWhenGivenEmptyStringShouldReturnRoot() {
40+
assertEquals("/", "".webDavParentPath())
41+
}
42+
43+
@Test
44+
fun testWebDavParentPathWhenGivenOnlySlashesShouldReturnRoot() {
45+
assertEquals("/", "///".webDavParentPath())
46+
}
47+
48+
@Test
49+
fun testWebDavParentPathWhenGivenRelativePathShouldReturnOneLevelAbove() {
50+
assertEquals("Documents/", "Documents/file.pdf".webDavParentPath())
51+
}
52+
53+
@Test
54+
fun testWebDavParentPathWhenGivenSingleWordPathShouldReturnRoot() {
55+
assertEquals("/", "readme.md".webDavParentPath())
56+
}
57+
58+
@Test
59+
fun testWebDavParentPathWhenGivenTrailingSlashShouldReturnOneLevelAbove() {
60+
assertEquals("/Photos/", "/Photos/Vacation/".webDavParentPath())
61+
}
62+
63+
@Test
64+
fun testWebDavParentPathWhenGivenMultipleTrailingSlashesShouldReturnOneLevelAbove() {
65+
assertEquals("/Photos/", "/Photos/Vacation///".webDavParentPath())
66+
}
67+
68+
@Test
69+
fun testWebDavParentPathWhenGivenEncodedSpacesShouldPreserveEncoding() {
70+
assertEquals("/My%20Photos/", "/My%20Photos/beach%20photo.jpg".webDavParentPath())
71+
}
72+
73+
@Test
74+
fun testWebDavParentPathWhenGivenEncodedSpecialCharsShouldPreserveEncoding() {
75+
assertEquals("/files/%23reports/", "/files/%23reports/q1%262.pdf".webDavParentPath())
76+
}
77+
78+
@Test
79+
fun testWebDavParentPathWhenGivenUnicodeCharsShouldReturnOneLevelAbove() {
80+
assertEquals("/照片/假期/", "/照片/假期/海滩.jpg".webDavParentPath())
81+
}
82+
83+
@Test
84+
fun testWebDavParentPathWhenGivenSingleCharFileAtRootShouldReturnRoot() {
85+
assertEquals("/", "/a".webDavParentPath())
86+
}
87+
88+
@Test
89+
fun testWebDavParentPathWhenGivenSingleCharDirShouldReturnOneLevelAbove() {
90+
assertEquals("/a/", "/a/b".webDavParentPath())
91+
}
92+
}

app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import com.nextcloud.client.preferences.AppPreferences
3636
import com.owncloud.android.datamodel.ArbitraryDataProvider
3737
import com.owncloud.android.datamodel.SyncedFolderProvider
3838
import com.owncloud.android.datamodel.UploadsStorageManager
39+
import com.owncloud.android.operations.factory.UploadFileOperationFactory
3940
import com.owncloud.android.utils.theme.ViewThemeUtils
4041
import org.greenrobot.eventbus.EventBus
4142
import javax.inject.Inject
@@ -66,7 +67,8 @@ class BackgroundJobFactory @Inject constructor(
6667
private val localBroadcastManager: Provider<LocalBroadcastManager>,
6768
private val generatePdfUseCase: GeneratePDFUseCase,
6869
private val syncedFolderProvider: SyncedFolderProvider,
69-
private val database: NextcloudDatabase
70+
private val database: NextcloudDatabase,
71+
private val uploadFileOperationFactory: UploadFileOperationFactory
7072
) : WorkerFactory() {
7173

7274
@SuppressLint("NewApi")
@@ -247,6 +249,7 @@ class BackgroundJobFactory @Inject constructor(
247249
FileSystemRepository(dao = database.fileSystemDao(), uploadsStorageManager, context),
248250
syncedFolderProvider,
249251
context,
252+
uploadFileOperationFactory,
250253
params
251254
)
252255

app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import com.nextcloud.client.network.ConnectivityService
2525
import com.nextcloud.client.notifications.AppWideNotificationManager
2626
import com.nextcloud.utils.extensions.checkWCFRestrictions
2727
import com.nextcloud.utils.extensions.getUploadIds
28+
import com.nextcloud.utils.extensions.isAnonymous
2829
import com.nextcloud.utils.extensions.isLastResultConflictError
30+
import com.nextcloud.utils.extensions.isSame
2931
import com.owncloud.android.MainApp
3032
import com.owncloud.android.R
3133
import com.owncloud.android.datamodel.FileDataStorageManager
@@ -36,6 +38,7 @@ import com.owncloud.android.db.OCUpload
3638
import com.owncloud.android.db.UploadResult
3739
import com.owncloud.android.files.services.NameCollisionPolicy
3840
import com.owncloud.android.lib.common.OwnCloudClient
41+
import com.owncloud.android.lib.common.OwnCloudClientFactory
3942
import com.owncloud.android.lib.common.network.OnDatatransferProgressListener
4043
import com.owncloud.android.lib.common.operations.RemoteOperationResult
4144
import com.owncloud.android.lib.common.utils.Log_OC
@@ -45,8 +48,9 @@ import com.owncloud.android.lib.resources.files.model.ServerFileInterface
4548
import com.owncloud.android.lib.resources.status.OCCapability
4649
import com.owncloud.android.operations.RemoveFileOperation
4750
import com.owncloud.android.operations.UploadFileOperation
51+
import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult
52+
import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler
4853
import com.owncloud.android.utils.DisplayUtils
49-
import com.owncloud.android.utils.FileUtil
5054
import kotlinx.coroutines.CoroutineScope
5155
import kotlinx.coroutines.Dispatchers
5256
import kotlinx.coroutines.launch
@@ -167,25 +171,36 @@ class FileUploadHelper {
167171
}
168172

169173
@Suppress("ComplexCondition")
170-
private fun retryUploads(
174+
private suspend fun retryUploads(
171175
uploadsStorageManager: UploadsStorageManager,
172176
connectivityService: ConnectivityService,
173177
accountManager: UserAccountManager,
174178
powerManagementService: PowerManagementService,
175179
uploads: List<OCUpload>
176-
): Boolean {
180+
): Boolean = withContext(Dispatchers.IO) {
177181
var showNotExistMessage = false
178-
var showSyncConflictNotification = false
182+
var conflictHandlingResult: ConflictHandlingResult? = null
179183
val isOnline = checkConnectivity(connectivityService)
180184
val connectivity = connectivityService.connectivity
181185
val batteryStatus = powerManagementService.battery
182186

183187
val uploadsToRetry = mutableListOf<Long>()
184188

189+
val currentAccount = accountManager.currentAccount
190+
val context = MainApp.getAppContext()
191+
var ownCloudClient: OwnCloudClient? = null
192+
if (!currentAccount.isAnonymous(context)) {
193+
ownCloudClient =
194+
OwnCloudClientFactory.createOwnCloudClient(accountManager.currentAccount, MainApp.getAppContext())
195+
}
196+
val uploadActionHandler = UploadListAdapterActionHandler()
197+
185198
for (upload in uploads) {
186199
if (upload.isLastResultConflictError()) {
187-
Log_OC.d(TAG, "retry upload skipped, sync conflict: ${upload.remotePath}")
188-
showSyncConflictNotification = true
200+
ownCloudClient?.let {
201+
conflictHandlingResult =
202+
uploadActionHandler.handleConflict(upload, ownCloudClient, uploadsStorageManager)
203+
}
189204
continue
190205
}
191206

@@ -226,11 +241,12 @@ class FileUploadHelper {
226241
)
227242
}
228243

229-
if (showSyncConflictNotification) {
244+
if (conflictHandlingResult is ConflictHandlingResult.ShowConflictResolveDialog) {
245+
Log_OC.d(TAG, "retry upload skipped, sync conflict: ${conflictHandlingResult.file.remotePath}")
230246
AppWideNotificationManager.showSyncConflictNotification(MainApp.getAppContext())
231247
}
232248

233-
return showNotExistMessage
249+
return@withContext showNotExistMessage
234250
}
235251

236252
@JvmOverloads
@@ -563,25 +579,17 @@ class FileUploadHelper {
563579
}
564580

565581
@Suppress("MagicNumber", "ReturnCount", "ComplexCondition")
566-
fun isSameFileOnRemote(user: User?, localFile: File?, remotePath: String?, context: Context?): Boolean {
567-
if (user == null || localFile == null || remotePath == null || context == null) {
582+
fun isSameFileOnRemote(user: User?, localPath: String?, remotePath: String?, context: Context?): Boolean {
583+
if (user == null || localPath == null || remotePath == null || context == null) {
568584
Log_OC.e(TAG, "cannot compare remote and local file")
569585
return false
570586
}
571587

572-
// Compare remote file to local file
573-
val localLastModifiedTimestamp = localFile.lastModified() / 1000 // remote file timestamp in milli not micro sec
574-
val localCreationTimestamp = FileUtil.getCreationTimestamp(localFile)
575-
val localSize: Long = localFile.length()
576-
577588
val operation = ReadFileRemoteOperation(remotePath)
578589
val result: RemoteOperationResult<*> = operation.execute(user, context)
579590
if (result.isSuccess) {
580591
val remoteFile = result.data[0] as RemoteFile
581-
return remoteFile.size == localSize &&
582-
localCreationTimestamp != null &&
583-
localCreationTimestamp == remoteFile.creationTimestamp &&
584-
remoteFile.modifiedTimestamp == localLastModifiedTimestamp * 1000
592+
return remoteFile.isSame(localPath)
585593
}
586594
return false
587595
}

app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import com.nextcloud.utils.extensions.getPercent
2929
import com.nextcloud.utils.extensions.toFile
3030
import com.nextcloud.utils.extensions.updateStatus
3131
import com.owncloud.android.R
32-
import com.owncloud.android.datamodel.FileDataStorageManager
3332
import com.owncloud.android.datamodel.ForegroundServiceType
3433
import com.owncloud.android.datamodel.SyncedFolder
3534
import com.owncloud.android.datamodel.SyncedFolderProvider
@@ -44,6 +43,7 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult
4443
import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode
4544
import com.owncloud.android.lib.common.utils.Log_OC
4645
import com.owncloud.android.operations.UploadFileOperation
46+
import com.owncloud.android.operations.factory.UploadFileOperationFactory
4747
import com.owncloud.android.ui.notifications.NotificationUtils
4848
import com.owncloud.android.utils.theme.ViewThemeUtils
4949
import kotlinx.coroutines.Dispatchers
@@ -66,6 +66,7 @@ class FileUploadWorker(
6666
val filesystemRepository: FileSystemRepository,
6767
val syncedFolderProvider: SyncedFolderProvider,
6868
val context: Context,
69+
val uploadFileOperationFactory: UploadFileOperationFactory,
6970
params: WorkerParameters
7071
) : CoroutineWorker(context, params),
7172
OnDatatransferProgressListener {
@@ -270,7 +271,7 @@ class FileUploadWorker(
270271
}
271272

272273
fileUploadEventBroadcaster.sendUploadEnqueued(context)
273-
val operation = createUploadFileOperation(upload, user)
274+
val operation = uploadFileOperationFactory.create(upload, this@FileUploadWorker)
274275
activeOperations[upload.uploadId] = operation
275276

276277
val currentIndex = (index + 1)
@@ -348,24 +349,6 @@ class FileUploadWorker(
348349
return result
349350
}
350351

351-
private fun createUploadFileOperation(upload: OCUpload, user: User): UploadFileOperation = UploadFileOperation(
352-
uploadsStorageManager,
353-
connectivityService,
354-
powerManagementService,
355-
user,
356-
null,
357-
upload,
358-
upload.nameCollisionPolicy,
359-
upload.localAction,
360-
context,
361-
upload.isUseWifiOnly,
362-
upload.isWhileChargingOnly,
363-
true,
364-
FileDataStorageManager(user, context.contentResolver)
365-
).apply {
366-
addDataTransferProgressListener(this@FileUploadWorker)
367-
}
368-
369352
@Suppress("TooGenericExceptionCaught", "DEPRECATION")
370353
private suspend fun upload(
371354
upload: OCUpload,

app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import com.owncloud.android.ui.activity.ConflictsResolveActivity
2828
import com.owncloud.android.utils.ErrorMessageAdapter
2929
import kotlinx.coroutines.Dispatchers
3030
import kotlinx.coroutines.withContext
31-
import java.io.File
3231

3332
object UploadErrorNotificationManager {
3433
private const val TAG = "UploadErrorNotificationManager"
@@ -76,7 +75,7 @@ object UploadErrorNotificationManager {
7675
val isSameFile = withContext(Dispatchers.IO) {
7776
FileUploadHelper.instance().isSameFileOnRemote(
7877
operation.user,
79-
File(operation.storagePath),
78+
operation.storagePath,
8079
operation.remotePath,
8180
context
8281
)

app/src/main/java/com/nextcloud/utils/OCFileUtils.kt

Lines changed: 4 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import android.graphics.drawable.BitmapDrawable
1111
import androidx.core.content.ContextCompat
1212
import androidx.core.graphics.drawable.toBitmap
1313
import androidx.core.graphics.drawable.toDrawable
14-
import androidx.exifinterface.media.ExifInterface
14+
import com.nextcloud.utils.extensions.getBitmapSize
15+
import com.nextcloud.utils.extensions.getExifSize
1516
import com.owncloud.android.MainApp
1617
import com.owncloud.android.R
1718
import com.owncloud.android.datamodel.OCFile
@@ -41,8 +42,8 @@ object OCFileUtils {
4142
// Local file
4243
val path = ocFile.storagePath
4344
if (!path.isNullOrEmpty() && ocFile.exists()) {
44-
getExifSize(path)?.let { return it }
45-
getBitmapSize(path)?.let { return it }
45+
path.getExifSize()?.let { return it }
46+
path.getBitmapSize()?.let { return it }
4647
}
4748

4849
// 3 Fallback
@@ -55,41 +56,6 @@ object OCFileUtils {
5556
return fallbackPair
5657
}
5758

58-
private fun getExifSize(path: String): Pair<Int, Int>? = try {
59-
val exif = ExifInterface(path)
60-
var w = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0)
61-
var h = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0)
62-
63-
val orientation = exif.getAttributeInt(
64-
ExifInterface.TAG_ORIENTATION,
65-
ExifInterface.ORIENTATION_NORMAL
66-
)
67-
if (orientation == ExifInterface.ORIENTATION_ROTATE_90 ||
68-
orientation == ExifInterface.ORIENTATION_ROTATE_270
69-
) {
70-
val tmp = w
71-
w = h
72-
h = tmp
73-
}
74-
75-
Log_OC.d(TAG, "Using exif imageDimension: $w x $h")
76-
if (w > 0 && h > 0) w to h else null
77-
} catch (_: Exception) {
78-
null
79-
}
80-
81-
private fun getBitmapSize(path: String): Pair<Int, Int>? = try {
82-
val options = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
83-
android.graphics.BitmapFactory.decodeFile(path, options)
84-
val w = options.outWidth
85-
val h = options.outHeight
86-
87-
Log_OC.d(TAG, "Using bitmap factory imageDimension: $w x $h")
88-
if (w > 0 && h > 0) w to h else null
89-
} catch (_: Exception) {
90-
null
91-
}
92-
9359
fun getMediaPlaceholder(file: OCFile, imageDimension: Pair<Int, Int>): BitmapDrawable {
9460
val context = MainApp.getAppContext()
9561

0 commit comments

Comments
 (0)