diff --git a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt index 5e573b1c4cd0..b9145d7e8769 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/BackgroundJobFactory.kt @@ -36,6 +36,7 @@ import com.nextcloud.client.preferences.AppPreferences import com.owncloud.android.datamodel.ArbitraryDataProvider import com.owncloud.android.datamodel.SyncedFolderProvider import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.operations.factory.UploadFileOperationFactory import com.owncloud.android.utils.theme.ViewThemeUtils import org.greenrobot.eventbus.EventBus import javax.inject.Inject @@ -66,7 +67,8 @@ class BackgroundJobFactory @Inject constructor( private val localBroadcastManager: Provider, private val generatePdfUseCase: GeneratePDFUseCase, private val syncedFolderProvider: SyncedFolderProvider, - private val database: NextcloudDatabase + private val database: NextcloudDatabase, + private val uploadFileOperationFactory: UploadFileOperationFactory ) : WorkerFactory() { @SuppressLint("NewApi") @@ -247,6 +249,7 @@ class BackgroundJobFactory @Inject constructor( FileSystemRepository(dao = database.fileSystemDao(), uploadsStorageManager, context), syncedFolderProvider, context, + uploadFileOperationFactory, params ) diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt index b0ec486822e3..b4dea7a2b062 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadHelper.kt @@ -25,7 +25,9 @@ import com.nextcloud.client.network.ConnectivityService import com.nextcloud.client.notifications.AppWideNotificationManager import com.nextcloud.utils.extensions.checkWCFRestrictions import com.nextcloud.utils.extensions.getUploadIds +import com.nextcloud.utils.extensions.isAnonymous import com.nextcloud.utils.extensions.isLastResultConflictError +import com.nextcloud.utils.extensions.isSame import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.datamodel.FileDataStorageManager @@ -36,6 +38,7 @@ import com.owncloud.android.db.OCUpload import com.owncloud.android.db.UploadResult import com.owncloud.android.files.services.NameCollisionPolicy import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.OwnCloudClientFactory import com.owncloud.android.lib.common.network.OnDatatransferProgressListener import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC @@ -45,8 +48,9 @@ import com.owncloud.android.lib.resources.files.model.ServerFileInterface import com.owncloud.android.lib.resources.status.OCCapability import com.owncloud.android.operations.RemoveFileOperation import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler import com.owncloud.android.utils.DisplayUtils -import com.owncloud.android.utils.FileUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -167,25 +171,36 @@ class FileUploadHelper { } @Suppress("ComplexCondition") - private fun retryUploads( + private suspend fun retryUploads( uploadsStorageManager: UploadsStorageManager, connectivityService: ConnectivityService, accountManager: UserAccountManager, powerManagementService: PowerManagementService, uploads: List - ): Boolean { + ): Boolean = withContext(Dispatchers.IO) { var showNotExistMessage = false - var showSyncConflictNotification = false + var conflictHandlingResult: ConflictHandlingResult? = null val isOnline = checkConnectivity(connectivityService) val connectivity = connectivityService.connectivity val batteryStatus = powerManagementService.battery val uploadsToRetry = mutableListOf() + val currentAccount = accountManager.currentAccount + val context = MainApp.getAppContext() + var ownCloudClient: OwnCloudClient? = null + if (!currentAccount.isAnonymous(context)) { + ownCloudClient = + OwnCloudClientFactory.createOwnCloudClient(accountManager.currentAccount, MainApp.getAppContext()) + } + val uploadActionHandler = UploadListAdapterActionHandler() + for (upload in uploads) { if (upload.isLastResultConflictError()) { - Log_OC.d(TAG, "retry upload skipped, sync conflict: ${upload.remotePath}") - showSyncConflictNotification = true + ownCloudClient?.let { + conflictHandlingResult = + uploadActionHandler.handleConflict(upload, ownCloudClient, uploadsStorageManager) + } continue } @@ -226,11 +241,12 @@ class FileUploadHelper { ) } - if (showSyncConflictNotification) { + if (conflictHandlingResult is ConflictHandlingResult.ShowConflictResolveDialog) { + Log_OC.d(TAG, "retry upload skipped, sync conflict: ${conflictHandlingResult.file.remotePath}") AppWideNotificationManager.showSyncConflictNotification(MainApp.getAppContext()) } - return showNotExistMessage + return@withContext showNotExistMessage } @JvmOverloads @@ -563,25 +579,17 @@ class FileUploadHelper { } @Suppress("MagicNumber", "ReturnCount", "ComplexCondition") - fun isSameFileOnRemote(user: User?, localFile: File?, remotePath: String?, context: Context?): Boolean { - if (user == null || localFile == null || remotePath == null || context == null) { + fun isSameFileOnRemote(user: User?, localPath: String?, remotePath: String?, context: Context?): Boolean { + if (user == null || localPath == null || remotePath == null || context == null) { Log_OC.e(TAG, "cannot compare remote and local file") return false } - // Compare remote file to local file - val localLastModifiedTimestamp = localFile.lastModified() / 1000 // remote file timestamp in milli not micro sec - val localCreationTimestamp = FileUtil.getCreationTimestamp(localFile) - val localSize: Long = localFile.length() - val operation = ReadFileRemoteOperation(remotePath) val result: RemoteOperationResult<*> = operation.execute(user, context) if (result.isSuccess) { val remoteFile = result.data[0] as RemoteFile - return remoteFile.size == localSize && - localCreationTimestamp != null && - localCreationTimestamp == remoteFile.creationTimestamp && - remoteFile.modifiedTimestamp == localLastModifiedTimestamp * 1000 + return remoteFile.isSame(localPath) } return false } @@ -648,7 +656,6 @@ class FileUploadHelper { files: List, accountName: String ): Pair, List> { - val autoUploadFolders = mutableListOf() val nonAutoUploadFiles = mutableListOf() diff --git a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt index 4b4450f21434..359bacdd05a2 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/upload/FileUploadWorker.kt @@ -29,7 +29,6 @@ import com.nextcloud.utils.extensions.getPercent import com.nextcloud.utils.extensions.toFile import com.nextcloud.utils.extensions.updateStatus import com.owncloud.android.R -import com.owncloud.android.datamodel.FileDataStorageManager import com.owncloud.android.datamodel.ForegroundServiceType import com.owncloud.android.datamodel.SyncedFolder import com.owncloud.android.datamodel.SyncedFolderProvider @@ -44,6 +43,7 @@ import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.operations.RemoteOperationResult.ResultCode import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.operations.UploadFileOperation +import com.owncloud.android.operations.factory.UploadFileOperationFactory import com.owncloud.android.ui.notifications.NotificationUtils import com.owncloud.android.utils.theme.ViewThemeUtils import kotlinx.coroutines.Dispatchers @@ -66,6 +66,7 @@ class FileUploadWorker( val filesystemRepository: FileSystemRepository, val syncedFolderProvider: SyncedFolderProvider, val context: Context, + val uploadFileOperationFactory: UploadFileOperationFactory, params: WorkerParameters ) : CoroutineWorker(context, params), OnDatatransferProgressListener { @@ -270,7 +271,7 @@ class FileUploadWorker( } fileUploadEventBroadcaster.sendUploadEnqueued(context) - val operation = createUploadFileOperation(upload, user) + val operation = uploadFileOperationFactory.create(upload, this@FileUploadWorker) activeOperations[upload.uploadId] = operation val currentIndex = (index + 1) @@ -348,24 +349,6 @@ class FileUploadWorker( return result } - private fun createUploadFileOperation(upload: OCUpload, user: User): UploadFileOperation = UploadFileOperation( - uploadsStorageManager, - connectivityService, - powerManagementService, - user, - null, - upload, - upload.nameCollisionPolicy, - upload.localAction, - context, - upload.isUseWifiOnly, - upload.isWhileChargingOnly, - true, - FileDataStorageManager(user, context.contentResolver) - ).apply { - addDataTransferProgressListener(this@FileUploadWorker) - } - @Suppress("TooGenericExceptionCaught", "DEPRECATION") private suspend fun upload( upload: OCUpload, diff --git a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt index c2e24785798d..409e66c0c9fd 100644 --- a/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt +++ b/app/src/main/java/com/nextcloud/client/jobs/utils/UploadErrorNotificationManager.kt @@ -28,7 +28,6 @@ import com.owncloud.android.ui.activity.ConflictsResolveActivity import com.owncloud.android.utils.ErrorMessageAdapter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.io.File object UploadErrorNotificationManager { private const val TAG = "UploadErrorNotificationManager" @@ -76,7 +75,7 @@ object UploadErrorNotificationManager { val isSameFile = withContext(Dispatchers.IO) { FileUploadHelper.instance().isSameFileOnRemote( operation.user, - File(operation.storagePath), + operation.storagePath, operation.remotePath, context ) diff --git a/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt b/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt index cdcc3506e018..d16ea8eef159 100644 --- a/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt +++ b/app/src/main/java/com/nextcloud/utils/OCFileUtils.kt @@ -11,7 +11,8 @@ import android.graphics.drawable.BitmapDrawable import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toDrawable -import androidx.exifinterface.media.ExifInterface +import com.nextcloud.utils.extensions.getBitmapSize +import com.nextcloud.utils.extensions.getExifSize import com.owncloud.android.MainApp import com.owncloud.android.R import com.owncloud.android.datamodel.OCFile @@ -41,8 +42,8 @@ object OCFileUtils { // Local file val path = ocFile.storagePath if (!path.isNullOrEmpty() && ocFile.exists()) { - getExifSize(path)?.let { return it } - getBitmapSize(path)?.let { return it } + path.getExifSize()?.let { return it } + path.getBitmapSize()?.let { return it } } // 3 Fallback @@ -55,41 +56,6 @@ object OCFileUtils { return fallbackPair } - private fun getExifSize(path: String): Pair? = try { - val exif = ExifInterface(path) - var w = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0) - var h = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0) - - val orientation = exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL - ) - if (orientation == ExifInterface.ORIENTATION_ROTATE_90 || - orientation == ExifInterface.ORIENTATION_ROTATE_270 - ) { - val tmp = w - w = h - h = tmp - } - - Log_OC.d(TAG, "Using exif imageDimension: $w x $h") - if (w > 0 && h > 0) w to h else null - } catch (_: Exception) { - null - } - - private fun getBitmapSize(path: String): Pair? = try { - val options = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } - android.graphics.BitmapFactory.decodeFile(path, options) - val w = options.outWidth - val h = options.outHeight - - Log_OC.d(TAG, "Using bitmap factory imageDimension: $w x $h") - if (w > 0 && h > 0) w to h else null - } catch (_: Exception) { - null - } - fun getMediaPlaceholder(file: OCFile, imageDimension: Pair): BitmapDrawable { val context = MainApp.getAppContext() diff --git a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt index 5bae77a44d61..1804550bff03 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/FileExtensions.kt @@ -7,6 +7,7 @@ package com.nextcloud.utils.extensions +import androidx.exifinterface.media.ExifInterface import com.owncloud.android.datamodel.OCFile import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.utils.DisplayUtils @@ -50,3 +51,38 @@ fun String.toFile(): File? { return file } + +fun String.getExifSize(): Pair? = try { + val exif = ExifInterface(this) + var w = exif.getAttributeInt(ExifInterface.TAG_IMAGE_WIDTH, 0) + var h = exif.getAttributeInt(ExifInterface.TAG_IMAGE_LENGTH, 0) + + val orientation = exif.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + if (orientation == ExifInterface.ORIENTATION_ROTATE_90 || + orientation == ExifInterface.ORIENTATION_ROTATE_270 + ) { + val tmp = w + w = h + h = tmp + } + + Log_OC.d(TAG, "Using exif imageDimension: $w x $h") + if (w > 0 && h > 0) w to h else null +} catch (_: Exception) { + null +} + +fun String.getBitmapSize(): Pair? = try { + val options = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } + android.graphics.BitmapFactory.decodeFile(this, options) + val w = options.outWidth + val h = options.outHeight + + Log_OC.d(TAG, "Using bitmap factory imageDimension: $w x $h") + if (w > 0 && h > 0) w to h else null +} catch (_: Exception) { + null +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/RemoteFileExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/RemoteFileExtensions.kt new file mode 100644 index 000000000000..114849b5a831 --- /dev/null +++ b/app/src/main/java/com/nextcloud/utils/extensions/RemoteFileExtensions.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.nextcloud.utils.extensions + +import com.nextcloud.utils.TimeConstants +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.utils.FileUtil +import com.owncloud.android.utils.MimeTypeUtil + +fun RemoteFile.isSame(path: String?): Boolean { + val localFile = path?.toFile() ?: return false + + // remote file timestamp in millisecond not microsecond + val localLastModifiedTimestamp = localFile.lastModified() / TimeConstants.MILLIS_PER_SECOND + val localCreationTimestamp = FileUtil.getCreationTimestamp(localFile) + val localSize: Long = localFile.length() + + return size == localSize && + localCreationTimestamp != null && + localCreationTimestamp == creationTimestamp && + modifiedTimestamp == localLastModifiedTimestamp * TimeConstants.MILLIS_PER_SECOND && + this.areImageDimensionsSame(path) +} + +@Suppress("ReturnCount") +private fun RemoteFile.areImageDimensionsSame(path: String): Boolean { + if (!MimeTypeUtil.isImage(mimeType)) { + // can't compare it's not image + return true + } + + val localFileImageDimension = path.getExifSize() ?: path.getBitmapSize() + if (localFileImageDimension == null) { + // can't compare local file image dimension is not determined + return true + } + + return localFileImageDimension.first.toFloat() == imageDimension?.width && + localFileImageDimension.second.toFloat() == imageDimension?.height +} diff --git a/app/src/main/java/com/nextcloud/utils/extensions/UploadResultExtensions.kt b/app/src/main/java/com/nextcloud/utils/extensions/UploadResultExtensions.kt index 38614ed7dc45..4ca297751522 100644 --- a/app/src/main/java/com/nextcloud/utils/extensions/UploadResultExtensions.kt +++ b/app/src/main/java/com/nextcloud/utils/extensions/UploadResultExtensions.kt @@ -94,7 +94,7 @@ fun UploadResult.getFailedStatusText(context: Context): String = when (this) { UploadResult.OLD_ANDROID_API -> context.getString(R.string.upload_old_android) - UploadResult.SYNC_CONFLICT -> context.getString(R.string.upload_sync_conflict) + UploadResult.SYNC_CONFLICT -> context.getString(R.string.upload_sync_conflict_check) UploadResult.CANNOT_CREATE_FILE -> context.getString(R.string.upload_cannot_create_file) diff --git a/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt b/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt index cd969f884689..73be317b47b5 100644 --- a/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt +++ b/app/src/main/java/com/owncloud/android/datamodel/UploadsStorageManager.kt @@ -41,7 +41,6 @@ import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.lib.resources.status.OCCapability import com.owncloud.android.operations.UploadFileOperation import com.owncloud.android.utils.theme.CapabilityUtils -import java.io.File import java.util.Calendar import java.util.Locale import java.util.Observable @@ -485,7 +484,7 @@ class UploadsStorageManager( } else if (code.isConflict()) { val isSame = FileUploadHelper().isSameFileOnRemote( upload.user, - File(upload.storagePath), + upload.storagePath, upload.remotePath, upload.context ) diff --git a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java index f966de291d7d..3e5cfcfa101d 100644 --- a/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java +++ b/app/src/main/java/com/owncloud/android/operations/UploadFileOperation.java @@ -1298,13 +1298,8 @@ private RemoteOperationResult checkNameCollision(OCFile parentFile, // check if its real SYNC_CONFLICT boolean isSameFileOnRemote = false; if (mFile != null) { - String localPath = mFile.getStoragePath(); - - if (localPath != null) { - File localFile = new File(localPath); - isSameFileOnRemote = FileUploadHelper.Companion.instance() - .isSameFileOnRemote(user, localFile, mRemotePath, mContext); - } + isSameFileOnRemote = FileUploadHelper.Companion.instance() + .isSameFileOnRemote(user, mFile.getStoragePath(), mRemotePath, mContext); } if (isSameFileOnRemote) { diff --git a/app/src/main/java/com/owncloud/android/operations/factory/UploadFileOperationFactory.kt b/app/src/main/java/com/owncloud/android/operations/factory/UploadFileOperationFactory.kt new file mode 100644 index 000000000000..9fc0d558710b --- /dev/null +++ b/app/src/main/java/com/owncloud/android/operations/factory/UploadFileOperationFactory.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.operations.factory + +import android.content.Context +import com.nextcloud.client.account.UserAccountManager +import com.nextcloud.client.device.PowerManagementService +import com.nextcloud.client.network.ConnectivityService +import com.owncloud.android.datamodel.FileDataStorageManager +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.files.services.NameCollisionPolicy +import com.owncloud.android.lib.common.network.OnDatatransferProgressListener +import com.owncloud.android.operations.UploadFileOperation +import javax.inject.Inject + +@Suppress("LongParameterList") +class UploadFileOperationFactory @Inject constructor( + private val uploadsStorageManager: UploadsStorageManager, + private val connectivityService: ConnectivityService, + private val powerManagementService: PowerManagementService, + private val context: Context, + private val accountManager: UserAccountManager, + private val fileDataStorageManager: FileDataStorageManager +) { + + fun create( + upload: OCUpload, + progressListener: OnDatatransferProgressListener? = null, + disableRetries: Boolean = true + ): UploadFileOperation = UploadFileOperation( + uploadsStorageManager, + connectivityService, + powerManagementService, + accountManager.user, + null, + upload, + upload.nameCollisionPolicy ?: NameCollisionPolicy.ASK_USER, + upload.localAction, + context, + upload.isUseWifiOnly, + upload.isWhileChargingOnly, + disableRetries, + fileDataStorageManager + ).apply { + progressListener?.let { addDataTransferProgressListener(it) } + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt b/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt index 9e6db3e327a9..92bbc3c59ff1 100755 --- a/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt +++ b/app/src/main/java/com/owncloud/android/ui/activity/UploadListActivity.kt @@ -16,9 +16,11 @@ import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View +import androidx.lifecycle.lifecycleScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.GridLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.material.snackbar.Snackbar import com.nextcloud.client.account.User import com.nextcloud.client.core.Clock import com.nextcloud.client.device.PowerManagementService @@ -30,17 +32,29 @@ import com.owncloud.android.databinding.UploadListLayoutBinding import com.owncloud.android.datamodel.OCFile import com.owncloud.android.datamodel.SyncedFolderProvider import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload import com.owncloud.android.lib.common.operations.RemoteOperation import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC import com.owncloud.android.operations.CheckCurrentCredentialsOperation +import com.owncloud.android.operations.factory.UploadFileOperationFactory import com.owncloud.android.ui.adapter.uploadList.UploadListAdapter +import com.owncloud.android.ui.adapter.uploadList.helper.ConflictHandlingResult +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterAction +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterActionHandler +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterHelper +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListItemOnClick import com.owncloud.android.ui.decoration.MediaGridItemDecoration import com.owncloud.android.utils.FilesSyncHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject @Suppress("MagicNumber") -class UploadListActivity : FileActivity() { +class UploadListActivity : + FileActivity(), + UploadListItemOnClick { @Inject lateinit var uploadsStorageManager: UploadsStorageManager @Inject lateinit var powerManagementService: PowerManagementService @@ -53,10 +67,15 @@ class UploadListActivity : FileActivity() { @Inject lateinit var throttler: Throttler + @Inject lateinit var uploadFileOperationFactory: UploadFileOperationFactory + private var swipeListRefreshLayout: SwipeRefreshLayout? = null private var binding: UploadListLayoutBinding? = null - private var uploadListAdapter: UploadListAdapter? = null + private var uploadFinishReceiver: UploadFinishReceiver? = null + private lateinit var uploadListAdapter: UploadListAdapter + private lateinit var adapterActionHandler: UploadListAdapterAction + private lateinit var adapterHelper: UploadListAdapterHelper override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -82,19 +101,21 @@ class UploadListActivity : FileActivity() { private fun setupContent() { setupEmptyList() + adapterActionHandler = UploadListAdapterActionHandler() + adapterHelper = UploadListAdapterHelper(this) uploadListAdapter = UploadListAdapter( this, uploadsStorageManager, - storageManager, userAccountManager, connectivityService, powerManagementService, - clock, - viewThemeUtils + viewThemeUtils, + this, + adapterHelper ) val lm = GridLayoutManager(this, 1) - uploadListAdapter?.setLayoutManager(lm) + uploadListAdapter.setLayoutManager(lm) val spacing = getResources().getDimensionPixelSize(R.dimen.media_grid_spacing) binding?.list?.run { @@ -133,7 +154,7 @@ class UploadListActivity : FileActivity() { private fun loadItems() { swipeListRefreshLayout?.isRefreshing = true - uploadListAdapter?.loadUploadItemsFromDb { swipeListRefreshLayout?.isRefreshing = false } + uploadListAdapter.loadUploadItemsFromDb { swipeListRefreshLayout?.isRefreshing = false } } private fun refresh() { @@ -145,7 +166,7 @@ class UploadListActivity : FileActivity() { ) if (!isUploadStarted) { - uploadListAdapter?.loadUploadItemsFromDb { swipeListRefreshLayout?.isRefreshing = false } + uploadListAdapter.loadUploadItemsFromDb { swipeListRefreshLayout?.isRefreshing = false } } } @@ -203,7 +224,7 @@ class UploadListActivity : FileActivity() { val ids = uploadsStorageManager.getCurrentUploadIds(user.accountName) uploadHelper.cancelAndRestartUploadJob(user, ids) } - uploadListAdapter?.notifyDataSetChanged() + uploadListAdapter.notifyDataSetChanged() } override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { @@ -240,7 +261,7 @@ class UploadListActivity : FileActivity() { fileOperationsHelper.opIdWaitingFor = Long.MAX_VALUE dismissLoadingDialog() - val account = result.getData()[0] as? Account + val account = result.data[0] as? Account if (!result.isSuccess) { requestCredentialsUpdate(account) } else { @@ -253,9 +274,71 @@ class UploadListActivity : FileActivity() { } } + private var conflictSnackbar: Snackbar? = null + + override fun onLastUploadResultConflictClick(upload: OCUpload) { + val rootView = binding?.root ?: return + + conflictSnackbar = Snackbar.make( + rootView, + R.string.upload_sync_conflict_checking, + Snackbar.LENGTH_INDEFINITE + ).apply { show() } + + lifecycleScope.launch { + val client = clientRepository.getOwncloudClient() ?: return@launch + val result = adapterActionHandler.handleConflict(upload, client, uploadsStorageManager) + + withContext(Dispatchers.Main) { + when (result) { + is ConflictHandlingResult.ConflictNotExists -> { + showConflictNotExists(upload) + } + + is ConflictHandlingResult.CannotCheckConflict -> { + showConflictError() + } + + is ConflictHandlingResult.ShowConflictResolveDialog -> { + conflictSnackbar?.dismiss() + adapterHelper.openConflictActivity(result.file, result.upload) + } + } + } + } + } + + private fun showConflictNotExists(upload: OCUpload) { + conflictSnackbar?.apply { + setText(R.string.upload_sync_conflict_not_exists) + setDuration(Snackbar.LENGTH_LONG) + setAction(R.string.retry) { + lifecycleScope.launch(Dispatchers.IO) { + val client = clientRepository.getOwncloudClient() + val operation = uploadFileOperationFactory.create(upload).execute(client) + if (operation.isSuccess) { + withContext(Dispatchers.Main) { + uploadListAdapter.loadUploadItemsFromDb() + } + } + } + } + show() + } + } + + private fun showConflictError() { + conflictSnackbar?.apply { + setText(R.string.upload_sync_conflict_check_error) + setDuration(Snackbar.LENGTH_LONG) + setAction(null, null) + show() + } + } + private inner class UploadFinishReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - throttler.run("update_upload_list") { uploadListAdapter?.loadUploadItemsFromDb() } + throttler.run("update_upload_list") { uploadListAdapter.loadUploadItemsFromDb() } } } diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt index ef9347912ddd..33b0a835cad1 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapter.kt @@ -21,7 +21,6 @@ import com.afollestad.sectionedrecyclerview.SectionedRecyclerViewAdapter import com.afollestad.sectionedrecyclerview.SectionedViewHolder import com.nextcloud.client.account.User import com.nextcloud.client.account.UserAccountManager -import com.nextcloud.client.core.Clock import com.nextcloud.client.device.PowerManagementService import com.nextcloud.client.jobs.upload.FileUploadHelper import com.nextcloud.client.jobs.upload.FileUploadWorker @@ -40,13 +39,13 @@ import com.owncloud.android.datamodel.ThumbnailsCacheManager import com.owncloud.android.datamodel.UploadsStorageManager import com.owncloud.android.db.OCUpload import com.owncloud.android.db.UploadResult -import com.owncloud.android.lib.common.operations.OnRemoteOperationListener -import com.owncloud.android.lib.common.operations.RemoteOperation -import com.owncloud.android.lib.common.operations.RemoteOperationResult import com.owncloud.android.lib.common.utils.Log_OC -import com.owncloud.android.operations.RefreshFolderOperation import com.owncloud.android.ui.activity.FileActivity import com.owncloud.android.ui.adapter.progressListener.UploadProgressListener +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListAdapterHelper +import com.owncloud.android.ui.adapter.uploadList.helper.UploadListItemOnClick +import com.owncloud.android.ui.adapter.uploadList.model.UploadListSection +import com.owncloud.android.ui.adapter.uploadList.model.UploadListType import com.owncloud.android.utils.DisplayUtils import com.owncloud.android.utils.MimeTypeUtil import com.owncloud.android.utils.theme.ViewThemeUtils @@ -69,12 +68,12 @@ import java.util.function.Consumer class UploadListAdapter( private val activity: FileActivity, private val uploadsStorageManager: UploadsStorageManager, - private val storageManager: FileDataStorageManager, private val accountManager: UserAccountManager, private val connectivityService: ConnectivityService, private val powerManagementService: PowerManagementService, - private val clock: Clock, - private val viewThemeUtils: ViewThemeUtils + private val viewThemeUtils: ViewThemeUtils, + private val itemOnClick: UploadListItemOnClick, + private val helper: UploadListAdapterHelper ) : SectionedRecyclerViewAdapter() { private val uploadListSections = UploadListSection.sections() @@ -82,7 +81,6 @@ class UploadListAdapter( private val uploadHelper = FileUploadHelper.instance() private var uploadProgressListener: UploadProgressListener? = null private var notificationManager: NotificationManager? = null - private val helper = UploadListAdapterHelper(activity) init { Log_OC.d(TAG, "UploadListAdapter") @@ -390,11 +388,6 @@ class UploadListAdapter( private fun bindItemActions(holder: ItemViewHolder, item: OCUpload) { holder.binding.run { val optionalUser = accountManager.getUser(item.accountName) - val status = item.getStatusText( - activity, - activity.appPreferences.isGlobalUploadPaused, - uploadHelper.isUploadingNow(item) - ) // Right-side button when (item.uploadStatus) { @@ -422,9 +415,7 @@ class UploadListAdapter( if (item.isLastResultConflictError()) { setImageResource(R.drawable.ic_dots_vertical) setOnClickListener { view -> - optionalUser.ifPresent { user -> - showItemConflictPopup(user, holder, item, status, view) - } + showItemConflictPopup(item, view) } } else { setImageResource(R.drawable.ic_action_delete_grey) @@ -444,7 +435,7 @@ class UploadListAdapter( UploadsStorageManager.UploadStatus.UPLOAD_FAILED, UploadsStorageManager.UploadStatus.UPLOAD_CANCELLED -> setOnClickListener { - onFailedOrCancelledItemClick(item, optionalUser, holder, status) + onFailedOrCancelledItemClick(item, optionalUser) } UploadsStorageManager.UploadStatus.UPLOAD_SUCCEEDED -> @@ -461,12 +452,7 @@ class UploadListAdapter( } } - private fun onFailedOrCancelledItemClick( - item: OCUpload, - optionalUser: Optional, - holder: ItemViewHolder, - status: String - ) { + private fun onFailedOrCancelledItemClick(item: OCUpload, optionalUser: Optional) { if (optionalUser.isEmpty) { return } @@ -475,10 +461,7 @@ class UploadListAdapter( if (item.lastResult == UploadResult.CREDENTIAL_ERROR) { activity.fileOperationsHelper.checkCurrentCredentials(user) } else if (item.isLastResultConflictError()) { - if (checkAndOpenConflictResolutionDialog(user, holder, item, status)) { - return - } - retryOrShowError(item) + itemOnClick.onLastUploadResultConflictClick(item) } else { retryOrShowError(item) } @@ -611,78 +594,12 @@ class UploadListAdapter( itemViewHolder.binding.thumbnail.setImageDrawable(drawable) } - private fun checkAndOpenConflictResolutionDialog( - user: User?, - itemViewHolder: ItemViewHolder, - item: OCUpload, - status: String? - ): Boolean { - val remotePath = item.remotePath - val localFile = storageManager.getFileByEncryptedRemotePath(remotePath) - - if (localFile == null) { - // Remote file doesn't exist, try to refresh folder - val folder = storageManager.getFileByEncryptedRemotePath(File(remotePath).getParent() + "/") - - if (folder != null && folder.isFolder) { - refreshFolderAndUpdateUI(itemViewHolder, user, folder, remotePath, item, status) - return true - } - - // Destination folder doesn't exist anymore - } - - if (localFile != null) { - helper.openConflictActivity(localFile, item) - return true - } - - // Remote file doesn't exist anymore = there is no more conflict - return false - } - - private fun refreshFolderAndUpdateUI( - holder: ItemViewHolder, - user: User?, - folder: OCFile?, - remotePath: String?, - item: OCUpload, - status: String? - ) { - refreshFolder( - holder, - user, - folder - ) { _: RemoteOperation<*>?, result: RemoteOperationResult<*>? -> - holder.binding.uploadStatus.text = status - if (result?.isSuccess == true) { - val fileOnServer = storageManager.getFileByEncryptedRemotePath(remotePath) - if (fileOnServer != null) { - helper.openConflictActivity(fileOnServer, item) - } else { - displayFileNotFoundError(holder.itemView, activity) - } - } - } - } - - private fun displayFileNotFoundError(itemView: View?, context: Context) { - val message = context.getString(R.string.uploader_file_not_found_message) - DisplayUtils.showSnackMessage(itemView, message) - } - - private fun showItemConflictPopup( - user: User?, - holder: ItemViewHolder, - item: OCUpload, - status: String?, - view: View? - ) { + private fun showItemConflictPopup(item: OCUpload, view: View) { PopupMenu(activity, view).apply { inflate(R.menu.upload_list_item_file_conflict) setOnMenuItemClickListener { menuItem -> if (menuItem.itemId == R.id.action_upload_list_resolve_conflict) { - checkAndOpenConflictResolutionDialog(user, holder, item, status) + itemOnClick.onLastUploadResultConflictClick(item) } else { removeUpload(item) } @@ -698,25 +615,6 @@ class UploadListAdapter( loadUploadItemsFromDb() } - private fun refreshFolder(view: ItemViewHolder, user: User?, folder: OCFile?, listener: OnRemoteOperationListener) { - view.binding.uploadListItemLayout.isClickable = false - view.binding.uploadStatus.setText(R.string.uploads_view_upload_status_fetching_server_version) - RefreshFolderOperation( - folder, - clock.currentTime, - false, - false, - true, - storageManager, - user, - activity - ) - .execute(user, activity, { caller, result -> - view.binding.uploadListItemLayout.isClickable = true - listener.onRemoteOperationFinish(caller, result) - }, activity.handler) - } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SectionedViewHolder = if (viewType == VIEW_TYPE_HEADER) { HeaderViewHolder( diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/ConflictHandlingResult.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/ConflictHandlingResult.kt new file mode 100644 index 000000000000..ff0d07d7c99a --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/ConflictHandlingResult.kt @@ -0,0 +1,17 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter.uploadList.helper + +import com.owncloud.android.datamodel.OCFile +import com.owncloud.android.db.OCUpload + +sealed class ConflictHandlingResult { + data object CannotCheckConflict : ConflictHandlingResult() + data object ConflictNotExists : ConflictHandlingResult() + data class ShowConflictResolveDialog(val file: OCFile, val upload: OCUpload) : ConflictHandlingResult() +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterAction.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterAction.kt new file mode 100644 index 000000000000..0ff6128edca9 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterAction.kt @@ -0,0 +1,20 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter.uploadList.helper + +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.lib.common.OwnCloudClient + +interface UploadListAdapterAction { + suspend fun handleConflict( + upload: OCUpload, + client: OwnCloudClient, + storageManager: UploadsStorageManager + ): ConflictHandlingResult +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterActionHandler.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterActionHandler.kt new file mode 100644 index 000000000000..b92d29b005d2 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterActionHandler.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter.uploadList.helper + +import com.nextcloud.utils.extensions.isSame +import com.nextcloud.utils.extensions.updateStatus +import com.owncloud.android.datamodel.UploadsStorageManager +import com.owncloud.android.db.OCUpload +import com.owncloud.android.lib.common.OwnCloudClient +import com.owncloud.android.lib.common.operations.RemoteOperationResult +import com.owncloud.android.lib.common.utils.Log_OC +import com.owncloud.android.lib.resources.files.ReadFileRemoteOperation +import com.owncloud.android.lib.resources.files.model.RemoteFile +import com.owncloud.android.utils.FileStorageUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class UploadListAdapterActionHandler : UploadListAdapterAction { + companion object { + private const val TAG = "UploadListAdapterActionHandler" + } + + override suspend fun handleConflict( + upload: OCUpload, + client: OwnCloudClient, + storageManager: UploadsStorageManager + ): ConflictHandlingResult = withContext(Dispatchers.IO) { + val operationResult = ReadFileRemoteOperation(upload.remotePath).execute(client) + + if (!operationResult.isSuccess) { + return@withContext when (operationResult.code) { + RemoteOperationResult.ResultCode.FILE_NOT_FOUND -> { + onConflictNotExists(upload, storageManager) + } + + else -> { + Log_OC.e(TAG, "cannot check conflict, operation result is not success") + ConflictHandlingResult.CannotCheckConflict + } + } + } + + val remoteFile = operationResult.data[0] as? RemoteFile + ?: run { + Log_OC.e(TAG, "cannot check conflict, operation result cannot be cast to RemoteFile") + return@withContext ConflictHandlingResult.CannotCheckConflict + } + + val ocFile = FileStorageUtils.fillOCFile(remoteFile) + + if (remoteFile.isSame(ocFile.storagePath)) { + onConflictNotExists(upload, storageManager) + } else { + ConflictHandlingResult.ShowConflictResolveDialog(ocFile, upload) + } + } + + private fun onConflictNotExists( + upload: OCUpload, + storageManager: UploadsStorageManager + ): ConflictHandlingResult.ConflictNotExists { + val entity = storageManager.uploadDao.getUploadById(upload.uploadId, upload.accountName) + storageManager.updateStatus(entity, UploadsStorageManager.UploadStatus.UPLOAD_FAILED) + return ConflictHandlingResult.ConflictNotExists + } +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapterHelper.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt similarity index 88% rename from app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapterHelper.kt rename to app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt index 64cd8edb56c0..63af435a21fe 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListAdapterHelper.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListAdapterHelper.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.owncloud.android.ui.adapter.uploadList +package com.owncloud.android.ui.adapter.uploadList.helper import android.content.ActivityNotFoundException import android.content.Intent @@ -32,7 +32,7 @@ class UploadListAdapterHelper(private val activity: FileActivity) { file.setStoragePath(upload.localPath) val user = activity.accountManager.getUser(upload.accountName) if (user.isPresent) { - val intent = ConflictsResolveActivity.createIntent( + val intent = ConflictsResolveActivity.Companion.createIntent( file, user.get(), upload.uploadId, @@ -61,16 +61,16 @@ class UploadListAdapterHelper(private val activity: FileActivity) { } val optionalUser = activity.user - if (PreviewImageFragment.canBePreviewed(file) && optionalUser.isPresent) { + if (PreviewImageFragment.Companion.canBePreviewed(file) && optionalUser.isPresent) { // show image preview and stay in uploads tab - val intent = FileDisplayActivity.openFileIntent(activity, optionalUser.get(), file) + val intent = FileDisplayActivity.Companion.openFileIntent(activity, optionalUser.get(), file) activity.startActivity(intent) return } val intent = Intent(activity, FileDisplayActivity::class.java).apply { setAction(Intent.ACTION_VIEW) - putExtra(FileDisplayActivity.KEY_FILE_PATH, upload.remotePath) + putExtra(FileDisplayActivity.Companion.KEY_FILE_PATH, upload.remotePath) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) } activity.startActivity(intent) diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListItemOnClick.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListItemOnClick.kt new file mode 100644 index 000000000000..4243adb86006 --- /dev/null +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/helper/UploadListItemOnClick.kt @@ -0,0 +1,14 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Alper Ozturk + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +package com.owncloud.android.ui.adapter.uploadList.helper + +import com.owncloud.android.db.OCUpload + +interface UploadListItemOnClick { + fun onLastUploadResultConflictClick(upload: OCUpload) +} diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListSection.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/model/UploadListSection.kt similarity index 97% rename from app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListSection.kt rename to app/src/main/java/com/owncloud/android/ui/adapter/uploadList/model/UploadListSection.kt index 6cb1bcc96995..6513a19d4fd9 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListSection.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/model/UploadListSection.kt @@ -5,7 +5,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.owncloud.android.ui.adapter.uploadList +package com.owncloud.android.ui.adapter.uploadList.model import com.owncloud.android.R import com.owncloud.android.datamodel.UploadsStorageManager diff --git a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListType.kt b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/model/UploadListType.kt similarity index 80% rename from app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListType.kt rename to app/src/main/java/com/owncloud/android/ui/adapter/uploadList/model/UploadListType.kt index aa2d818f9378..0b6d415fc7fd 100644 --- a/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/UploadListType.kt +++ b/app/src/main/java/com/owncloud/android/ui/adapter/uploadList/model/UploadListType.kt @@ -5,6 +5,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -package com.owncloud.android.ui.adapter.uploadList +package com.owncloud.android.ui.adapter.uploadList.model enum class UploadListType { CURRENT, COMPLETED, FAILED, CANCELLED, SKIPPED } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 843c2eb35d79..3cbe9a1195e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -310,7 +310,6 @@ Unknown error Waiting for non-metered Wi-Fi Waiting to exit power save mode - Fetching server version… Waiting to upload %1$s (%2$d) @@ -1095,7 +1094,10 @@ Locking folder failed Local storage full Encryption is only possible with >= Android 5.0 - Sync conflict, please resolve manually + Checking for upload conflicts… + Conflict resolved. + Could not check for upload conflicts. Please try again. + There might be a conflict from your last upload. Tap to check. Cannot create local file File could not be copied to local storage All uploads are paused @@ -1188,7 +1190,6 @@ Failed to start editor Add folder description We couldnt locate the file on server. Another user may have deleted the file - File not found. Are you sure that this file exists or has a previous conflict not been resolved? File upload conflict Pick which version to keep of %1$s Resolve conflict