diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4c27ccc2c8..f7a501eb23 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,6 +48,7 @@ + @@ -327,6 +328,14 @@ + + + + // uploadReferenceToWorkId is session-local - after an app restart it's empty, then the work is + // found by its reference id. That lookup blocks, so it runs off the main thread. + // The placeholder below is removed whether or not a work is found. + // Both jobs run in appScope: leaving the chat right after the tap must not drop the cancellation. + val workId = uploadReferenceToWorkId.remove(referenceId) + appScope.launch(Dispatchers.IO) { UploadAndShareFilesWorker.cancelUpload(referenceId, workId) } - viewModelScope.launch { + appScope.launch { chatRepository.deleteTempMessageByReferenceId(referenceId) } _uploadProgressMap.update { it - referenceId } @@ -2573,7 +2573,11 @@ class ChatViewModel @AssistedInject constructor( if (progress >= 0) { _uploadProgressMap.update { it + (referenceId to progress) } } - if (workInfo.state == WorkInfo.State.FAILED) { + // The worker ends a failed upload as SUCCEEDED (so the uploads queued behind it still run) and reports + // the failure in its output data; FAILED stays for a worker that crashed. + val failed = workInfo.state == WorkInfo.State.FAILED || + workInfo.outputData.getBoolean(UploadAndShareFilesWorker.KEY_UPLOAD_FAILED, false) + if (failed) { viewModelScope.launch { chatRepository.markUploadPlaceholderFailed(referenceId) } } if (workInfo.state.isFinished) { diff --git a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt index 0fdda7cb24..cdd9282cbc 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorker.kt @@ -12,6 +12,7 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.NotificationManager import android.content.Context +import android.content.pm.ServiceInfo import android.net.Uri import android.os.Build import android.os.SystemClock @@ -22,12 +23,14 @@ import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkRequest import androidx.work.Worker import androidx.work.WorkerParameters +import androidx.work.workDataOf import autodagger.AutoInjector import com.nextcloud.talk.R import com.nextcloud.talk.api.NcApi @@ -41,6 +44,12 @@ import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachm import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ProbeConversationAttachmentRequestDto import com.nextcloud.talk.upload.chunked.ChunkedFileUploader import com.nextcloud.talk.upload.chunked.OnDataTransferProgressListener +import com.nextcloud.talk.upload.UploadNotification +import com.nextcloud.talk.upload.UploadProgressThrottle +import com.nextcloud.talk.upload.UploadRetryPolicy +import com.nextcloud.talk.upload.Unavailable +import com.nextcloud.talk.upload.UploadWorkspace +import com.nextcloud.talk.upload.PreparedUpload import com.nextcloud.talk.upload.normal.FileUploader import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils @@ -59,17 +68,17 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.runBlocking +import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import java.io.File import java.io.IOException -import java.util.Collections import java.util.UUID -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import javax.inject.Inject +@Suppress("TooManyFunctions") @AutoInjector(NextcloudTalkApplication::class) class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerParameters) : Worker(context, workerParameters), @@ -107,20 +116,27 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa lateinit var currentUser: User private var isChunkedUploading = false private var file: File? = null + + @Volatile private var chunkedFileUploader: ChunkedFileUploader? = null + private var referenceId: String? = null private var internalConversationId: String? = null private var uploadDisposable: Disposable? = null private var uploadLatch: CountDownLatch? = null + private lateinit var workspace: UploadWorkspace + private val progressThrottle = UploadProgressThrottle() + private var isForeground = false + private var keepWorkspace = false + private val notificationId = id.hashCode() /** - * WorkManager's own `isStopped`/`onStopped()` only becomes true/fires after - * cancelUniqueWork() round-trips through WorkManager's internal executor and Room DB - for a - * small/compressed image the whole upload+share can finish faster than that round-trip, so - * isStopped alone arrives too late. [cancelledReferenceIds] is set synchronously by the UI - * before cancelUniqueWork() is even called, so it's visible to doWork() immediately. + * The user cancelled this upload. The cancel is a flag file in the workspace, not a cancellation in + * WorkManager: a cancelled work left in the queue of the conversation makes the APPEND_OR_REPLACE of the next + * upload delete the other, still living works of that queue. The worker looks at the flag at the start, between + * its stages and in the progress callback, and then aborts itself, see [cancelledResult]. */ - private fun isCancelled(): Boolean = referenceId?.let { cancelledReferenceIds.contains(it) } == true + private fun isCancelled(): Boolean = ::workspace.isInitialized && workspace.isCancelled() override fun doWork(): Result { // Invariant: dependencies that are already set (a test worker factory) are not overwritten. @@ -128,10 +144,20 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) } + referenceId = inputData.getString(KEY_REFERENCE_ID) + workspace = UploadWorkspace(workspaceDir(context, id)) + // WorkManager does not interrupt the thread of a stopped worker, so an old run can still be busy. + if (!workspace.tryLock()) { + Log.w(TAG, "Another run of this upload is still active, trying again later") + return Result.retry() + } try { return doUpload() } finally { - referenceId?.let { cancelledReferenceIds.remove(it) } + if (!keepWorkspace) { + workspace.delete() + } + workspace.unlock() } } @@ -144,14 +170,18 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private fun doUpload(): Result { return try { // Read first, so a failure below can still mark the placeholder message as failed. - referenceId = inputData.getString(KEY_REFERENCE_ID) internalConversationId = inputData.getString(KEY_INTERNAL_CONVERSATION_ID) + // The user comes first: a cancelled upload that waited for a retry has to remove its parts from the + // server, and that needs the account. val userId = inputData.getLong(KEY_INTERNAL_USER_ID, 0L) currentUser = runBlocking { userManager.getUserWithId(userId) } ?: run { // E.g. the account was removed while the upload waited for a retry. Log.e(TAG, "No user found for id $userId") - return failUpload() + return if (isCancelled()) cancelledResult() else failUpload() + } + if (isCancelled()) { + return cancelledResult() } val sourceFile = inputData.getString(DEVICE_SOURCE_FILE) roomToken = inputData.getString(ROOM_TOKEN)!! @@ -162,78 +192,336 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa require(sourceFile.isNotEmpty()) checkNotNull(roomToken) - var sourceFileUri = sourceFile.toUri() + val sourceFileUri = sourceFile.toUri() fileName = FileUtils.getFileName(sourceFileUri, context) - file = FileUtils.getFileFromUri(context, sourceFileUri) + deleteFinishedWorkspaces() - if (inputData.getBoolean(COMPRESS_IMAGES, false)) { - sourceFileUri = compressMediaIfPossible(sourceFileUri) + // The stages come before the preparation: after the share nothing is needed any more, after the + // upload only the path, and the prepared file may be gone (the system cleaned the cache). + if (workspace.isShared()) { + return successResult() + } + if (needsForeground(sourceFileUri)) { + startForeground() } - val remotePath = getRemotePath(currentUser) + val prepared = if (workspace.uploadedPath() != null) { + workspace.prepared() + } else { + prepareOnce(sourceFileUri) ?: return failUpload() + } + file = prepared?.file + fileName = prepared?.fileName ?: workspace.uploadedName() ?: fileName + if (isStopped || isCancelled()) { + return stoppedResult() + } val useConversationSubfolders = CapabilitiesUtil.hasConversationSubfoldersForAttachments( currentUser.capabilities!!.spreedCapability!! ) val allowUpdate = inputData.getBoolean(ALLOW_UPDATE, false) file?.let { isChunkedUploading = it.length() > CHUNK_UPLOAD_THRESHOLD_SIZE } - val uploadSuccess: Boolean = uploadFile( - sourceFileUri = sourceFileUri, - metaData = metaData, - remotePath = remotePath, - useConversationSubfolders = useConversationSubfolders, - allowUpdate = allowUpdate - ) + val uploadUri = prepared?.let { Uri.fromFile(it.file) } - if (uploadSuccess && (isStopped || isCancelled())) { - // Cancelled right as the upload finished - don't share a cancelled upload. - return Result.failure() + val shared = if (useConversationSubfolders) { + // The conversation subfolder path shares as part of its last step, postConversationAttachment. + uploadUsingConversationSubfolders(uploadUri, metaData, allowUpdate) + } else { + uploadAndShare(uploadUri, metaData) } - if (uploadSuccess) { - // useConversationSubfolders already shares as part of uploadFile() via - // postConversationAttachment, so only share explicitly for the plain upload path. - val shareSuccess = useConversationSubfolders || shareFile(remotePath, metaData) - if (shareSuccess) { - updatePlaceholderStatus(SendStatus.SENT_PENDING_ACK) - _uploadCompletedFlow.tryEmit(roomToken) - return Result.success() - } - Log.e(TAG, "Share operation failed after upload") - return failUpload() + if (shared) { + workspace.markShared() + return successResult() } else if (isStopped || isCancelled()) { - // since work is cancelled the result would be ignored anyways - return Result.failure() + return stoppedResult() } Log.e(TAG, "Something went wrong when trying to upload file") failUpload() - } catch (e: IOException) { - // Transient network failures (connection reset, timeout, dropped Wi-Fi, ...) shouldn't - // require the user to manually resend - retry a few times with backoff instead, and only - // give up once we've exhausted the allowed attempts. - Log.w( - TAG, - "Network error while uploading file (attempt ${runAttemptCount + 1}/$MAX_UPLOAD_ATTEMPTS)", - e - ) - if (isStopped || isCancelled()) { - Result.failure() - } else if (runAttemptCount < MAX_UPLOAD_ATTEMPTS - 1) { + } catch (e: Exception) { + handleUploadError(e) + } + } + + /** + * Upload and share are two stages. What is done is kept in the workspace, so a run after a retry or after a + * stop does not upload the file or share it a second time. + */ + private fun uploadAndShare(uploadUri: Uri?, metaData: String?): Boolean { + val remotePath = workspace.uploadedPath() ?: getRemotePath(currentUser) + val uploaded = workspace.uploadedPath() != null || uploadFile(checkNotNull(uploadUri), remotePath) + if (uploaded) { + workspace.markUploaded(remotePath, fileName) + } + // Not shared when the user cancelled right as the upload finished. + return uploaded && !isCancelled() && shareFile(remotePath, metaData) + } + + private fun successResult(): Result { + // A stopped work is scheduled again whatever it returns; the stages in the workspace make that run short. + keepWorkspace = isStopped + updatePlaceholderStatus(SendStatus.SENT_PENDING_ACK) + _uploadCompletedFlow.tryEmit(roomToken) + return Result.success() + } + + private fun handleUploadError(e: Exception): Result { + if (isStopped || isCancelled()) { + return stoppedResult() + } + val kind = UploadRetryPolicy.classify(e) + val serverErrors = if (kind == UploadRetryPolicy.FailureKind.SERVER) { + registerServerError() + } else { + workspace.serverErrors() + } + val unavailable = if (kind == UploadRetryPolicy.FailureKind.UNAVAILABLE) { + registerUnavailable() + } else { + Unavailable(System.currentTimeMillis(), 0) + } + val unavailableFor = System.currentTimeMillis() - unavailable.sinceMillis + return when (UploadRetryPolicy.decide(kind, serverErrors, unavailableFor, unavailable.answers)) { + UploadRetryPolicy.Decision.RETRY -> { + Log.w( + TAG, + "Upload interrupted ($kind, server errors: $serverErrors, unavailable for $unavailableFor ms, " + + "${unavailable.answers} answers), will resume", + e + ) + keepWorkspace = true Result.retry() - } else { + } + + UploadRetryPolicy.Decision.FAIL -> { + Log.e(TAG, "Something went wrong when trying to upload file (unavailable for $unavailableFor ms)", e) failUpload() } + } + } + + // A workspace that cannot be written must not end the work as failed (that fails the uploads queued behind it): + // decide with what is known, the upload is tried again. A write error that never goes away means a retry + // without a limit: this is on purpose, it is better than the FAILED cascade over the queue. + private fun registerServerError(): Int = + try { + workspace.registerServerError() + } catch (e: IOException) { + Log.w(TAG, "Could not store the server error", e) + workspace.serverErrors() + 1 + } + + private fun registerUnavailable(): Unavailable = + try { + workspace.registerUnavailable(System.currentTimeMillis()) + } catch (e: IOException) { + Log.w(TAG, "Could not store the unavailable answer", e) + Unavailable(System.currentTimeMillis(), 0) + } + + /** + * A stop by the system keeps the parts on the server and the prepared file, so the next run resumes. + * The result of a work that WorkManager stopped is ignored; WorkManager schedules it again by itself. + */ + private fun stoppedResult(): Result = + if (isCancelled()) { + cancelledResult() + } else { + keepWorkspace = true + Result.retry() + } + + /** + * The user cancelled the upload. Removes the parts from the server (here, in the worker thread, not in + * onStopped, which runs inside a WorkManager cancellation handler) and the placeholder message. Returns + * success, not failure or cancelled: a failed or cancelled work in the queue of the conversation makes + * WorkManager delete the other uploads of that queue when the next file is sent. For the same reason nothing + * is thrown from here: an exception ends the work as failed, and WorkManager fails the uploads queued behind it. + * + * The result of a work that WorkManager stopped is ignored and the work runs again. That run has to find the + * cancel flag, so the workspace stays; it is removed by the run that ends without a stop. + */ + @Suppress("Detekt.TooGenericExceptionCaught") + private fun cancelledResult(): Result { + keepWorkspace = isStopped + abortRemote(deleteUploadedFile = true) + try { + deletePlaceholder() } catch (e: Exception) { - Log.e(TAG, "Something went wrong when trying to upload file", e) - failUpload() + // The chat removes the placeholder of a cancelled upload itself; a failed write must not end the work. + Log.w(TAG, "Could not remove the placeholder of the cancelled upload", e) } + return Result.success() } - // Result.success(), not failure(): the failure is recorded on the placeholder row, and a failed work - // request would cascade to every upload already queued behind it in the conversation. + private fun newChunkedUploader() = + ChunkedFileUploader( + okHttpClient, + currentUser, + this, + ncApiCoroutines, + isRestarted = workspace::isRestarted, + markRestarted = workspace::markRestarted + ) + + /** + * Publishes [uploader] before the last stop check: a stop that comes earlier is seen here, a later one reaches + * the uploader through [onStopped]. + */ + private fun startChunkedUpload(uploader: ChunkedFileUploader, mimeType: MediaType?, path: String): Boolean { + chunkedFileUploader = uploader + if (isStopped || isCancelled()) { + return false + } + return uploader.upload(file!!, mimeType, path) + } + + /** + * Removes what the upload left on the server: the parts of a chunked upload and, when [deleteUploadedFile] is + * set and the file was not shared yet, the assembled file. Never throws. + */ + @Suppress("Detekt.TooGenericExceptionCaught") + private fun abortRemote(deleteUploadedFile: Boolean = false) { + try { + if (!::currentUser.isInitialized) { + return + } + val uploader = chunkedFileUploader ?: newChunkedUploader() + // The parts are keyed by the prepared file: this finds them also when the upload of this run did not + // start yet or ran in an earlier process. + val prepared = workspace.prepared()?.file + if (prepared != null && prepared.length() > CHUNK_UPLOAD_THRESHOLD_SIZE) { + uploader.abortUpload(prepared) {} + } else if (chunkedFileUploader != null) { + uploader.abortUpload {} + } + val uploadedPath = workspace.uploadedPath() + if (deleteUploadedFile && uploadedPath != null && !workspace.isShared()) { + uploader.deleteUploadedFile(uploadedPath) + } + } catch (e: Exception) { + Log.w(TAG, "Could not remove the upload from the server", e) + } + } + + private fun deletePlaceholder() { + val refId = referenceId?.takeIf { it.isNotEmpty() } ?: return + val convId = internalConversationId?.takeIf { it.isNotEmpty() } ?: return + chatDao.deleteTempChatMessageIfPending(convId, refId) + } + + /** + * The notification of a running upload is for files that are uploaded in chunks, which take long. A small file + * is one request: it does not need a foreground service. Unknown size counts as big. Nothing is needed when the + * file is uploaded already: one request is left. A later run has the prepared copy and asks it for the size, + * not the provider of the source (an IPC call, and the copy is what is uploaded). + */ + private fun needsForeground(sourceFileUri: Uri): Boolean { + if (workspace.uploadedPath() != null) { + return false + } + val size = workspace.prepared()?.file?.length() + ?: if (sourceFileUri.scheme == "file") { + sourceFileUri.path?.let { File(it).length() } + } else { + FileUtils.querySize(context, sourceFileUri) + } + return size == null || size > CHUNK_UPLOAD_THRESHOLD_SIZE + } + + @SuppressLint("InlinedApi") + @Suppress("Detekt.TooGenericExceptionCaught") + private fun startForeground() { + try { + setForegroundAsync( + ForegroundInfo( + notificationId, + progressNotification(0), + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + ).get() + isForeground = true + } catch (e: Exception) { + // E.g. Android 12+ does not allow starting a foreground service from the background. + Log.w(TAG, "Could not start the foreground service, uploading as a regular job", e) + } + } + + private fun progressNotification(percent: Int) = + UploadNotification.build( + context, + notificationId, + fileName, + percent, + id.toString(), + referenceId, + internalConversationId + ) + + private fun prepareFile(sourceFileUri: Uri, dir: File): PreparedUpload? { + val original = FileUtils.getFileFromUri(context, sourceFileUri, dir)?.let { FileUtils.ensureInDir(it, dir) } + ?: return null + throwIfStopped() + val compressed = if (inputData.getBoolean(COMPRESS_IMAGES, false)) { + compressMediaIfPossible(sourceFileUri, original, dir) + } else { + null + } + throwIfStopped() + return compressed ?: PreparedUpload(original, fileName) + } + + /** Keeps a stopped run from storing its half-finished file as the prepared one (see [PreparationStopped]). */ + private fun throwIfStopped() { + if (isStopped || isCancelled()) { + throw PreparationStopped() + } + } + + private class PreparationStopped : RuntimeException("Stopped while preparing the file") + + // A work that WorkManager does not know, or knows as finished, cannot resume, so its workspace is garbage. + @Suppress("Detekt.TooGenericExceptionCaught") + private fun deleteFinishedWorkspaces() { + UploadWorkspace.deleteFinished(File(context.noBackupFilesDir, WORKSPACE_DIR)) { name -> + try { + val info = WorkManager.getInstance(context).getWorkInfoById(UUID.fromString(name)).get() + info != null && !info.state.isFinished + } catch (e: Exception) { + Log.w(TAG, "Could not look up work $name, keeping its files", e) + true + } + } + } + + /** + * Returns null when the file cannot be prepared and waiting does not help (full disk, unreadable source). Any + * other IOException, e.g. a lost network while a cloud provider streams the file, is thrown to the retry policy. + */ + private fun prepareOnce(sourceFileUri: Uri): PreparedUpload? = + try { + workspace.prepareOnce { dir -> prepareFile(sourceFileUri, dir) } + } catch (e: IOException) { + if (UploadRetryPolicy.classifyPreparation(e) != UploadRetryPolicy.FailureKind.OTHER) { + throw e + } + Log.e(TAG, "Could not prepare the file", e) + null + } + + /** + * Ends the work as success, not failure: the failure is already shown (notification, FAILED placeholder), and + * a failed work would make WorkManager fail the uploads queued behind it in the conversation without running + * them, leaving their placeholders "sending" for ever. + * + * A file in the draft folder belongs to this upload alone and nobody posts it any more, so it is removed (the + * server moves it out of the draft folder when the message is posted; a file already moved is gone, which is + * fine). A file in the attachment folder of Talk is the user's file and stays. + */ @Suppress("Detekt.TooGenericExceptionCaught") private fun failUpload(): Result { + abortRemote(deleteUploadedFile = workspace.isDraftUploaded()) updatePlaceholderStatus(SendStatus.FAILED) try { showFailedToUploadNotification() @@ -241,50 +529,35 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa // The failure is recorded already; an exception here must not end the work as failed. Log.e(TAG, "Could not show the upload failed notification", e) } - return Result.success() + return Result.success(workDataOf(KEY_UPLOAD_FAILED to true)) } /** - * Replaces [file] and [fileName] with a compressed copy if [sourceFileUri] points to a - * compressible image or video, returning the [Uri] that should be uploaded. + * Returns a compressed copy of [originalFile] inside [dir] if [sourceFileUri] points to a compressible image or + * video, or null when there is nothing to compress. */ - @Suppress("ReturnCount") - private fun compressMediaIfPossible(sourceFileUri: Uri): Uri { - val originalFile = file ?: return sourceFileUri + private fun compressMediaIfPossible(sourceFileUri: Uri, originalFile: File, dir: File): PreparedUpload? { val mimeType = FileUtils.resolveMimeType(context, sourceFileUri) val compressedFile = when { - ImageCompressor.isCompressible(mimeType) -> ImageCompressor.compress(context, originalFile) - VideoCompressor.isCompressible(mimeType) -> VideoCompressor.compress(context, originalFile) + ImageCompressor.isCompressible(mimeType) -> ImageCompressor.compress(context, originalFile, dir) + VideoCompressor.isCompressible(mimeType) -> VideoCompressor.compress(context, originalFile, dir) else -> null - } ?: return sourceFileUri + } ?: return null - file = compressedFile - fileName = compressedFile.name - return Uri.fromFile(compressedFile) + if (originalFile.parentFile == dir) { + originalFile.delete() + } + return PreparedUpload(compressedFile, compressedFile.name) } - private fun uploadFile( - sourceFileUri: Uri, - metaData: String?, - remotePath: String, - useConversationSubfolders: Boolean, - allowUpdate: Boolean - ): Boolean = + private fun uploadFile(sourceFileUri: Uri, remotePath: String): Boolean = if (file == null) { false - } else if (useConversationSubfolders) { - uploadUsingConversationSubfolders(sourceFileUri, metaData, allowUpdate) } else if (isChunkedUploading) { Log.d(TAG, "starting chunked upload because size is " + file!!.length()) - val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull() - chunkedFileUploader = ChunkedFileUploader( - okHttpClient, - currentUser, - this, - ncApiCoroutines - ) - chunkedFileUploader!!.upload(file!!, mimeType, remotePath) + val mimeType = FileUtils.resolveMimeType(context, sourceFileUri)?.toMediaTypeOrNull() + startChunkedUpload(newChunkedUploader(), mimeType, remotePath) } else { Log.d(TAG, "starting normal upload (not chunked) of $fileName") val observable = FileUploader( @@ -329,7 +602,7 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa } private fun uploadUsingConversationSubfolders( - sourceFileUri: Uri, + sourceFileUri: Uri?, metaData: String?, allowUpdate: Boolean ): Boolean = @@ -338,7 +611,6 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa currentUser.username, currentUser.token ) ?: return@runBlocking false - val uploadId = UUID.randomUUID().toString() val fileNames = ProbeConversationAttachmentRequestDto().apply { fileNames = listOf(fileName) this.allowUpdate = allowUpdate @@ -356,23 +628,16 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa return@runBlocking false } val predictedName = resolveFinalFileName(fileName, probeResponse.ocs?.data!!) - val tempRemotePath = "/$draftFolderPath/$uploadId-$fileName" - - val uploadSuccess = if (isChunkedUploading) { - val mimeType = context.contentResolver.getType(sourceFileUri)?.toMediaTypeOrNull() - chunkedFileUploader = ChunkedFileUploader( - okHttpClient, - currentUser, - this@UploadAndShareFilesWorker, - ncApiCoroutines - ) - chunkedFileUploader!!.upload(file!!, mimeType, tempRemotePath) - } else { - FileUploader(okHttpClient, context, currentUser, roomToken, ncApi, file!!, ncApiCoroutines) - .uploadToConversationSubfolder(sourceFileUri, tempRemotePath) - } + // The same id on every run, so a run after the upload does not upload to another path. + val tempRemotePath = workspace.uploadedPath() ?: "/$draftFolderPath/${workspace.uploadId()}-$fileName" - if (!uploadSuccess || isStopped || isCancelled()) { + if (workspace.uploadedPath() == null) { + if (!uploadToDraftFolder(checkNotNull(sourceFileUri), tempRemotePath)) { + return@runBlocking false + } + workspace.markUploaded(tempRemotePath, fileName, draft = true) + } + if (isCancelled()) { return@runBlocking false } @@ -384,15 +649,22 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa this.allowUpdate = allowUpdate } - runCatching { - ncApiCoroutines.postConversationAttachment( - credentials, - ApiUtils.getUrlForChatAttachment(ApiUtils.API_V1, currentUser.baseUrl, roomToken), - params - ) - } - .onFailure { Log.e(TAG, "Failed to finalize uploaded attachment", it) } - .isSuccess + // Errors are not caught: an IOException of a lost network must reach the retry policy. + ncApiCoroutines.postConversationAttachment( + credentials, + ApiUtils.getUrlForChatAttachment(ApiUtils.API_V1, currentUser.baseUrl, roomToken), + params + ) + true + } + + private suspend fun uploadToDraftFolder(sourceFileUri: Uri, tempRemotePath: String): Boolean = + if (isChunkedUploading) { + val mimeType = FileUtils.resolveMimeType(context, sourceFileUri)?.toMediaTypeOrNull() + startChunkedUpload(newChunkedUploader(), mimeType, tempRemotePath) + } else { + FileUploader(okHttpClient, context, currentUser, roomToken, ncApi, file!!, ncApiCoroutines) + .uploadToConversationSubfolder(sourceFileUri, tempRemotePath) } @SuppressLint("CheckResult") @@ -425,6 +697,24 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa override fun onTransferProgress(percentage: Int) { setProgressAsync(Data.Builder().putInt(PROGRESS_KEY, percentage).build()) + if (progressThrottle.shouldUpdate(percentage)) { + recordProgress(percentage) + if (isCancelled()) { + // Interrupts the upload; doWork then sees the flag and aborts. + chunkedFileUploader?.stop() + } else if (isForeground && !isStopped) { + notificationManager.notify(notificationId, progressNotification(percentage)) + } + } + } + + // Called from the upload thread: a workspace that cannot be written must not break the upload. + private fun recordProgress(percentage: Int) { + try { + workspace.recordProgress(percentage) + } catch (e: IOException) { + Log.w(TAG, "Could not store the progress", e) + } } private fun updatePlaceholderStatus(status: SendStatus) { @@ -433,12 +723,20 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa chatDao.updateTempMessageSendStatus(convId, refId, status) } + /** + * WorkManager runs this inside a coroutine cancellation handler: an exception thrown here crashes the process, + * and a network call blocks WorkManager's own thread. So it only interrupts the upload and keeps the parts on + * the server, so the next run resumes. The user's cancel does not come here, see [isCancelled]. + */ + @Suppress("Detekt.TooGenericExceptionCaught") override fun onStopped() { - if (file != null && isChunkedUploading) { - chunkedFileUploader?.abortUpload {} + try { + chunkedFileUploader?.stop() + uploadDisposable?.dispose() + uploadLatch?.countDown() + } catch (e: Exception) { + Log.w(TAG, "Failed to interrupt the upload", e) } - uploadDisposable?.dispose() - uploadLatch?.countDown() super.onStopped() } @@ -482,20 +780,22 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa const val KEY_REFERENCE_ID = "REFERENCE_ID" const val KEY_INTERNAL_CONVERSATION_ID = "INTERNAL_CONVERSATION_ID" const val PROGRESS_KEY = "UPLOAD_PROGRESS" + + /** + * Output data: true when the upload failed for good. A failed upload ends as SUCCEEDED, not FAILED, because + * WorkManager fails the works chained behind a failed one without running them. An observer of the work + * reads this flag instead. A cancelled upload does not set it. Read it from the output data only: the queue + * (APPEND_OR_REPLACE, OverwritingInputMerger) copies the output into the input data of the next work, and + * this worker does not read the flag in its input. + */ + const val KEY_UPLOAD_FAILED = "UPLOAD_FAILED" private const val COMPRESS_IMAGES = "COMPRESS_IMAGES" private const val ALLOW_UPDATE = "ALLOW_UPDATE" private const val CHUNK_UPLOAD_THRESHOLD_SIZE: Long = 1024 * 1024 - // Total attempts allowed for a single upload (1 initial run + retries) before giving up on a - // transient network failure and marking the placeholder FAILED. - private const val MAX_UPLOAD_ATTEMPTS = 4 + private const val WORKSPACE_DIR = "uploads" const val REQUEST_PERMISSION = 3123 - // referenceIds the user cancelled - set synchronously here, before cancelUniqueWork() is - // even called, so doWork() can see it immediately instead of waiting for isStopped, which - // only becomes true once WorkManager's own async cancellation dispatch completes. - private val cancelledReferenceIds: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) - private val _uploadCompletedFlow: MutableSharedFlow = MutableSharedFlow( replay = 1, extraBufferCapacity = 1 @@ -566,13 +866,18 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa .build() val uploadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(UploadAndShareFilesWorker::class.java) .setInputData(data) + .apply { + if (referenceId.isNotEmpty()) { + addTag(referenceTag(referenceId)) + } + } .setConstraints( Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() ) .setBackoffCriteria( - BackoffPolicy.EXPONENTIAL, + BackoffPolicy.LINEAR, WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS ) @@ -596,12 +901,37 @@ class UploadAndShareFilesWorker(val context: Context, workerParameters: WorkerPa private fun uploadQueueName(internalConversationId: String) = "upload_queue_$internalConversationId" - // Cancellation is by the WorkRequest's own id (not enqueueUniqueWork's name) since that name - // is now shared by every file queued in the same conversation - cancelling by name would - // cancel the whole queue instead of just this one upload. - fun cancelUpload(referenceId: String, workerId: UUID) { - cancelledReferenceIds.add(referenceId) - WorkManager.getInstance().cancelWorkById(workerId) + private fun referenceTag(referenceId: String) = "upload_ref:$referenceId" + + private fun workspaceDir(context: Context, workId: UUID) = + File(File(context.noBackupFilesDir, WORKSPACE_DIR), workId.toString()) + + /** + * Asks an upload to cancel itself. Only a flag in the workspace of the work is set: whether the work waits + * for the network or runs right now, it sees the flag and aborts (see [isCancelled]). Nothing is cancelled + * in WorkManager. The id is only known in memory of the chat, so after a restart the work is found by its + * reference id tag. Blocks on WorkManager: call it off the main thread. + */ + @Suppress("Detekt.TooGenericExceptionCaught") + fun cancelUpload(referenceId: String, workId: UUID?) { + // Called from the chat and from the notification right when an upload may be ending, so a failed + // lookup or a workspace deleted meanwhile must not crash the app. + try { + val context = NextcloudTalkApplication.sharedApplication!!.applicationContext + val workManager = WorkManager.getInstance(context) + val infos = if (workId != null) { + listOfNotNull(workManager.getWorkInfoById(workId).get()) + } else if (referenceId.isNotEmpty()) { + workManager.getWorkInfosByTag(referenceTag(referenceId)).get() + } else { + emptyList() + } + infos.filter { !it.state.isFinished }.forEach { info -> + UploadWorkspace(workspaceDir(context, info.id)).markCancelled() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to cancel upload", e) + } } } } diff --git a/app/src/main/java/com/nextcloud/talk/receivers/UploadCancelReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/UploadCancelReceiver.kt new file mode 100644 index 0000000000..f329494225 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/receivers/UploadCancelReceiver.kt @@ -0,0 +1,63 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import autodagger.AutoInjector +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.data.database.dao.ChatMessagesDao +import com.nextcloud.talk.jobs.UploadAndShareFilesWorker +import java.util.UUID +import javax.inject.Inject +import kotlin.concurrent.thread + +/** + * Cancels an upload from its notification the same way the chat does: the worker aborts and the placeholder + * message of the upload is removed. + */ +@AutoInjector(NextcloudTalkApplication::class) +class UploadCancelReceiver : BroadcastReceiver() { + + @Inject + lateinit var chatDao: ChatMessagesDao + + @Suppress("TooGenericExceptionCaught") + override fun onReceive(context: Context, intent: Intent) { + val workId = intent.getStringExtra(KEY_WORK_ID)?.let { runCatching { UUID.fromString(it) }.getOrNull() } + val referenceId = intent.getStringExtra(KEY_REFERENCE_ID).orEmpty() + val internalConversationId = intent.getStringExtra(KEY_INTERNAL_CONVERSATION_ID).orEmpty() + if (workId == null) { + Log.w(TAG, "Cancel request without a valid work id") + return + } + + NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + val pendingResult = goAsync() + thread { + try { + UploadAndShareFilesWorker.cancelUpload(referenceId, workId) + if (referenceId.isNotEmpty() && internalConversationId.isNotEmpty()) { + chatDao.deleteTempChatMessageIfPending(internalConversationId, referenceId) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to cancel upload", e) + } finally { + pendingResult.finish() + } + } + } + + companion object { + private val TAG = UploadCancelReceiver::class.simpleName + const val KEY_WORK_ID = "KEY_UPLOAD_WORK_ID" + const val KEY_REFERENCE_ID = "KEY_UPLOAD_REFERENCE_ID" + const val KEY_INTERNAL_CONVERSATION_ID = "KEY_UPLOAD_INTERNAL_CONVERSATION_ID" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadNotification.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadNotification.kt new file mode 100644 index 0000000000..81fcf86e5f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadNotification.kt @@ -0,0 +1,60 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.upload + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import com.nextcloud.talk.R +import com.nextcloud.talk.receivers.UploadCancelReceiver +import com.nextcloud.talk.utils.NotificationUtils + +/** The ongoing notification of the foreground service that keeps an upload running. */ +object UploadNotification { + + private const val MAX_PERCENT = 100 + + @Suppress("LongParameterList") + fun build( + context: Context, + notificationId: Int, + fileName: String, + percent: Int, + workId: String, + referenceId: String?, + internalConversationId: String? + ): Notification { + val cancelIntent = Intent(context, UploadCancelReceiver::class.java) + .putExtra(UploadCancelReceiver.KEY_WORK_ID, workId) + .putExtra(UploadCancelReceiver.KEY_REFERENCE_ID, referenceId) + .putExtra(UploadCancelReceiver.KEY_INTERNAL_CONVERSATION_ID, internalConversationId) + val cancelPendingIntent = PendingIntent.getBroadcast( + context, + notificationId, + cancelIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder( + context, + NotificationUtils.NotificationChannels.NOTIFICATION_CHANNEL_UPLOADS.name + ) + .setContentTitle(context.getString(R.string.nc_upload_in_progess)) + .setContentText(fileName) + .setSmallIcon(R.drawable.upload_white) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setProgress(MAX_PERCENT, percent, percent <= 0) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .addAction(R.drawable.ic_baseline_close_24, context.getString(R.string.nc_cancel), cancelPendingIntent) + .build() + } +} diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadProgressThrottle.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadProgressThrottle.kt new file mode 100644 index 0000000000..29226b2833 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadProgressThrottle.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.upload + +/** Lets a progress update through only when the value changed and [minIntervalMs] passed since the last one. */ +class UploadProgressThrottle( + private val minIntervalMs: Long = DEFAULT_INTERVAL_MS, + private val clock: () -> Long = System::currentTimeMillis +) { + private var lastPercent = -1 + private var lastUpdateMs = 0L + + @Synchronized + fun shouldUpdate(percent: Int): Boolean { + val now = clock() + val due = lastPercent < 0 || now - lastUpdateMs >= minIntervalMs + if (percent == lastPercent || !due) { + return false + } + lastPercent = percent + lastUpdateMs = now + return true + } + + companion object { + private const val DEFAULT_INTERVAL_MS = 1000L + } +} diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt new file mode 100644 index 0000000000..ece4d2fec9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadRetryPolicy.kt @@ -0,0 +1,143 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.upload + +import android.system.ErrnoException +import android.system.OsConstants +import at.bitfire.dav4jvm.exception.HttpException as DavHttpException +import retrofit2.HttpException as RetrofitHttpException +import java.io.FileNotFoundException +import java.io.IOException +import java.net.UnknownServiceException +import java.security.cert.CertPathValidatorException +import java.security.cert.CertificateException +import javax.net.ssl.SSLException +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException + +/** + * Decides what an upload does after an error or after WorkManager stopped it. + * + * Network problems and stops by the system never use up attempts: the upload resumes once the network is back. + * A server that answers "unavailable" is waited for up to [MAX_UNAVAILABLE_MILLIS]. Other errors answered by the + * server count against [MAX_SERVER_ERRORS]. + */ +object UploadRetryPolicy { + + /** Server errors one upload may hit before it is marked as failed. */ + const val MAX_SERVER_ERRORS = 4 + + /** How long a server may keep answering "unavailable" without taking data before the upload fails: 24 hours. */ + const val MAX_UNAVAILABLE_MILLIS = 24L * 60 * 60 * 1000 + + /** + * "Unavailable" answers the server must have given, besides the 24 hours, before the upload fails. Time without + * a network passes too; a few answers after a long wait do not show a server that keeps refusing. + */ + const val MIN_UNAVAILABLE_ANSWERS = 10 + + private const val MAX_CAUSE_DEPTH = 8 + private const val HTTP_REQUEST_TIMEOUT = 408 + private const val HTTP_TOO_MANY_REQUESTS = 429 + private const val HTTP_SERVER_ERROR = 500 + private const val HTTP_NOT_IMPLEMENTED = 501 + private const val HTTP_VERSION_NOT_SUPPORTED = 505 + private const val HTTP_INSUFFICIENT_STORAGE = 507 + + // A server that cannot do the request or has a full quota does not change within the few attempts. + private val PERMANENT_SERVER_ERRORS = + setOf(HTTP_NOT_IMPLEMENTED, HTTP_VERSION_NOT_SUPPORTED, HTTP_INSUFFICIENT_STORAGE) + + /** + * NETWORK: no connection, retried without a limit. + * UNAVAILABLE: the server answered that it is overloaded or restarting (5xx except 501, 505 and 507, 408, + * 429), retried until [MAX_UNAVAILABLE_MILLIS] pass and [MIN_UNAVAILABLE_ANSWERS] answers came without + * progress of the upload. + * SERVER: an answer or a TLS problem that may not go away, retried up to [MAX_SERVER_ERRORS] times. + * OTHER: a local problem, fails at once. + */ + enum class FailureKind { NETWORK, UNAVAILABLE, SERVER, OTHER } + + enum class Decision { RETRY, FAIL } + + /** + * Looks through the whole chain of causes, because RxJava wraps a checked [IOException] in a RuntimeException. + * An HTTP error wins over everything else. Errors of an overloaded or restarting server (5xx except 501, 505 + * and 507, 408, 429) are UNAVAILABLE: the upload waits and goes on, but not for ever. Other HTTP errors and + * certificate problems count against [MAX_SERVER_ERRORS]. Any other [SSLException] is a network error: on + * Android a connection that drops in the middle of an HTTPS transfer arrives as an SSLException. A + * [FileNotFoundException] anywhere in the chain is OTHER: the prepared file cannot be read. + */ + fun classify(error: Throwable): FailureKind { + val causes = causeChain(error) + val httpCode = causes.firstNotNullOfOrNull(::httpCodeOf) + return when { + httpCode != null -> if (isTransientHttpCode(httpCode)) FailureKind.UNAVAILABLE else FailureKind.SERVER + isCertificateProblem(causes) -> FailureKind.SERVER + // ChunkFromFileRequestBody wraps every error of reading a chunk in a FileNotFoundException, also an + // IOException of the disk. The file is in noBackupFilesDir: waiting does not repair it. + causes.any { it is FileNotFoundException } -> FailureKind.OTHER + causes.any { it is IOException } -> FailureKind.NETWORK + else -> FailureKind.OTHER + } + } + + private fun causeChain(error: Throwable): List = + generateSequence(error) { it.cause }.take(MAX_CAUSE_DEPTH).toList() + + private fun isCertificateProblem(causes: List): Boolean = + causes.any { it is SSLPeerUnverifiedException || it is UnknownServiceException } || + ( + causes.any { it is SSLHandshakeException } && + causes.any { it is CertificateException || it is CertPathValidatorException } + ) + + /** + * Like [classify], for an error while the file is prepared (copy, compression). A full or read-only disk and + * a file that cannot be read do not go away by waiting, so they are OTHER; any other IOException, e.g. a lost + * network while a cloud provider streams the file, is waited for. + */ + fun classifyPreparation(error: Throwable): FailureKind = + if (causeChain(error).any(::isUnrecoverableLocal)) FailureKind.OTHER else classify(error) + + private fun isUnrecoverableLocal(error: Throwable): Boolean { + val errno = (error as? ErrnoException)?.errno + return error is FileNotFoundException || errno == OsConstants.ENOSPC || errno == OsConstants.EROFS + } + + private fun httpCodeOf(error: Throwable): Int? = + when (error) { + is DavHttpException -> error.code + is RetrofitHttpException -> error.code() + else -> null + } + + private fun isTransientHttpCode(code: Int): Boolean = + (code >= HTTP_SERVER_ERROR && code !in PERMANENT_SERVER_ERRORS) || + code == HTTP_REQUEST_TIMEOUT || + code == HTTP_TOO_MANY_REQUESTS + + /** + * @param serverErrorCount server errors of this upload so far, including the one being decided on + * @param unavailableForMillis how long the server has been answering "unavailable" without the upload moving on + * @param unavailableAnswers how many such answers came in that time, including the one being decided on + */ + fun decide( + kind: FailureKind, + serverErrorCount: Int, + unavailableForMillis: Long, + unavailableAnswers: Int + ): Decision = + when { + kind == FailureKind.NETWORK -> Decision.RETRY + kind == FailureKind.UNAVAILABLE && + (unavailableForMillis < MAX_UNAVAILABLE_MILLIS || unavailableAnswers < MIN_UNAVAILABLE_ANSWERS) -> + Decision.RETRY + kind == FailureKind.SERVER && serverErrorCount < MAX_SERVER_ERRORS -> Decision.RETRY + else -> Decision.FAIL + } +} diff --git a/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt new file mode 100644 index 0000000000..f234ff201d --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/upload/UploadWorkspace.kt @@ -0,0 +1,299 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.upload + +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.channels.FileLock +import java.nio.channels.OverlappingFileLockException +import java.util.UUID + +/** Since when the server answers "unavailable" without the upload moving on, and how many answers came. */ +data class Unavailable(val sinceMillis: Long, val answers: Int) + +/** The file an upload sends and the name it gets on the server. */ +data class PreparedUpload(val file: File, val fileName: String) + +/** + * Holds what one upload needs across runs of its worker: the prepared file (copy of the content uri, compressed + * media), the number of server errors, the stages already done (uploaded, shared), the cancel flag and a lock + * that keeps two runs of the same work and the cleanup of dead workspaces apart. + * + * The prepared file is created once. Its name, size and modification time stay the same on every run, so the + * chunk folder on the server, which is keyed by them, stays the same too. + */ +@Suppress("TooManyFunctions") +class UploadWorkspace(private val dir: File) { + + private val preparedFile = File(dir, PREPARED_FILE) + private val serverErrorsFile = File(dir, SERVER_ERRORS_FILE) + private val uploadedFile = File(dir, UPLOADED_FILE) + private val sharedFile = File(dir, SHARED_FILE) + private val cancelledFile = File(dir, CANCELLED_FILE) + private val uploadIdFile = File(dir, UPLOAD_ID_FILE) + private val lockFile = File(dir, LOCK_FILE) + private val restartedFile = File(dir, RESTARTED_FILE) + private val unavailableFile = File(dir, UNAVAILABLE_FILE) + private val progressFile = File(dir, PROGRESS_FILE) + + private var lockChannel: RandomAccessFile? = null + private var lock: FileLock? = null + + /** + * Takes the lock of this workspace. WorkManager does not interrupt a worker thread it stopped, so the old run + * can still be busy when a new run of the same work starts. Returns false when somebody else holds the lock. + * + * The lock protects only inside this process: within one process a [FileLock] is refused with + * [OverlappingFileLockException], and that is what this relies on. Do not rely on it between processes. + */ + @Synchronized + fun tryLock(): Boolean { + if (lock == null) { + dir.mkdirs() + val channel = runCatching { RandomAccessFile(lockFile, "rw") }.getOrNull() + val acquired = channel?.let { acquire(it) } + if (acquired == null) { + channel?.close() + } else { + lockChannel = channel + lock = acquired + } + } + return lock != null + } + + private fun acquire(channel: RandomAccessFile): FileLock? = + try { + channel.channel.tryLock() + } catch (e: OverlappingFileLockException) { + // The lock is held by another workspace object of this process. + null + } catch (e: IOException) { + null + } + + @Synchronized + fun unlock() { + runCatching { lock?.release() } + runCatching { lockChannel?.close() } + lock = null + lockChannel = null + } + + /** + * Returns the prepared file of an earlier run, or runs [prepare] once and keeps its result. + * [prepare] gets an empty directory it may create files in, and returns null when the file cannot be prepared. + * The directory is a subdirectory of the workspace, so a file named like a file of the workspace itself + * ("prepared", "lock", "restarted", anything ending in ".tmp") cannot replace it. + */ + fun prepareOnce(prepare: (File) -> PreparedUpload?): PreparedUpload? { + stored()?.let { return it } + resetFiles() + // A new preparation is a new file: the chunks start from the beginning, the old progress means nothing. + forgetProgress() + val filesDir = File(dir, FILES_DIR).apply { mkdirs() } + return prepare(filesDir)?.also { write(preparedFile, "${it.file.absolutePath}\n${it.fileName}") } + } + + fun serverErrors(): Int = readLong(serverErrorsFile)?.toInt() ?: 0 + + fun registerServerError(): Int { + val count = serverErrors() + 1 + dir.mkdirs() + write(serverErrorsFile, count.toString()) + return count + } + + /** The prepared file of an earlier run, if there is one. */ + fun prepared(): PreparedUpload? = stored() + + /** The path on the server the file was uploaded to, or null while the upload is not complete. */ + fun uploadedPath(): String? = uploadedLines()?.getOrNull(0)?.takeIf { it.isNotEmpty() } + + /** The name the uploaded file was shared under, kept because the prepared file may be gone by then. */ + fun uploadedName(): String? = uploadedLines()?.getOrNull(1)?.takeIf { it.isNotEmpty() } + + /** + * @param draft the file went to the draft folder of the conversation, from where the server moves it when the + * message is posted; see [isDraftUploaded] + */ + fun markUploaded(path: String, fileName: String, draft: Boolean = false) { + dir.mkdirs() + write(uploadedFile, "$path\n$fileName" + if (draft) "\n$DRAFT_MARK" else "") + } + + /** + * Whether the uploaded file is in the draft folder, a file of this upload alone. The file in the attachment + * folder of Talk (the path without conversation subfolders) is a file of the user and is never removed. + */ + fun isDraftUploaded(): Boolean = uploadedLines()?.getOrNull(2) == DRAFT_MARK + + private fun uploadedLines(): List? = runCatching { uploadedFile.readLines() }.getOrNull() + + private class StoredUnavailable(val since: Long, val point: Int, val answers: Int) + + private fun readUnavailable(): StoredUnavailable? { + val lines = runCatching { unavailableFile.readLines() }.getOrNull() ?: return null + val since = lines.getOrNull(0)?.toLongOrNull() + val point = lines.getOrNull(1)?.toIntOrNull() + val answers = lines.getOrNull(2)?.toIntOrNull() + return if (since != null && point != null && answers != null) StoredUnavailable(since, point, answers) else null + } + + /** Remembers the furthest percent of the upload that was sent, in any run. Writes only when it grew. */ + fun recordProgress(percent: Int) { + if (percent > storedProgress()) { + dir.mkdirs() + write(progressFile, percent.toString()) + } + } + + private fun storedProgress(): Int = readLong(progressFile)?.toInt() ?: NO_PROGRESS + + /** How far the upload is: a finished upload counts as further than any percent. */ + private fun progressPoint(): Int = if (uploadedPath() != null) UPLOADED_PROGRESS else storedProgress() + + /** + * Counts an answer "the server is unavailable". The first such answer after which the upload did not move on + * starts the time, together with the progress it had then. A later answer with more progress (more sent, or the + * file uploaded) starts it again, because the server took data in between; so does a clock that went back. + */ + fun registerUnavailable(now: Long): Unavailable { + val stored = readUnavailable() + val current = progressPoint() + val result = if (stored == null || current > stored.point || stored.since > now) { + Unavailable(now, 1) to current + } else { + Unavailable(stored.since, stored.answers + 1) to stored.point + } + dir.mkdirs() + write(unavailableFile, "${result.first.sinceMillis}\n${result.second}\n${result.first.answers}") + return result.first + } + + /** Whether the parts were already removed once after the server rejected the assembly. */ + fun isRestarted(): Boolean = restartedFile.exists() + + fun markRestarted() { + dir.mkdirs() + write(restartedFile, "1") + // The upload starts from the beginning: the progress and the time of the old answers mean nothing. + forgetProgress() + } + + private fun forgetProgress() { + progressFile.delete() + unavailableFile.delete() + } + + fun isShared(): Boolean = sharedFile.exists() + + fun markShared() { + dir.mkdirs() + write(sharedFile, "1") + } + + /** Stays the same on every run, so the target of the assembled file in the draft folder does not change. */ + fun uploadId(): String { + runCatching { uploadIdFile.readText() }.getOrNull()?.takeIf { it.isNotEmpty() }?.let { return it } + val id = UUID.randomUUID().toString() + dir.mkdirs() + write(uploadIdFile, id) + return id + } + + /** Asks the work to abort when it runs. Creates the directory, because the work may not have run yet. */ + fun markCancelled() { + dir.mkdirs() + write(cancelledFile, "1") + } + + fun isCancelled(): Boolean = cancelledFile.exists() + + /** Call only while holding the lock. */ + fun delete() { + dir.deleteRecursively() + } + + private fun stored(): PreparedUpload? { + val lines = runCatching { preparedFile.readLines() }.getOrNull() + val file = lines?.getOrNull(0)?.let(::File) + val name = lines?.getOrNull(1) + return if (file != null && name != null && file.isFile) PreparedUpload(file, name) else null + } + + private fun resetFiles() { + dir.listFiles()?.filter { it.name !in KEPT_FILES }?.forEach { + it.deleteRecursively() + } + } + + private fun readLong(file: File): Long? = runCatching { file.readText().trim().toLong() }.getOrNull() + + private fun write(file: File, text: String) { + val tmp = File(dir, file.name + TMP_SUFFIX) + tmp.writeText(text) + tmp.renameTo(file) + } + + companion object { + private const val PREPARED_FILE = "prepared" + private const val SERVER_ERRORS_FILE = "server_errors" + private const val UPLOADED_FILE = "uploaded" + private const val SHARED_FILE = "shared" + private const val CANCELLED_FILE = "cancelled" + private const val UPLOAD_ID_FILE = "upload_id" + private const val LOCK_FILE = "lock" + private const val RESTARTED_FILE = "restarted" + private const val UNAVAILABLE_FILE = "unavailable" + private const val PROGRESS_FILE = "progress" + private const val NO_PROGRESS = -1 + + /** The progress of an upload that is complete on the server, further than any percent. */ + private const val UPLOADED_PROGRESS = 101 + private const val DRAFT_MARK = "draft" + private const val TMP_SUFFIX = ".tmp" + private const val FILES_DIR = "file" + + /** Not part of the prepared file: survive a new preparation. */ + private val KEPT_FILES = setOf( + SERVER_ERRORS_FILE, + CANCELLED_FILE, + LOCK_FILE, + UPLOAD_ID_FILE, + UPLOADED_FILE, + SHARED_FILE, + RESTARTED_FILE, + UNAVAILABLE_FILE, + PROGRESS_FILE + ) + + /** + * Removes the workspaces of uploads that are no longer alive, e.g. left behind by a killed process. + * [isAlive] gets the name of a workspace directory, which is the id of its work. A workspace whose lock is + * held belongs to a running work and is left alone. + */ + fun deleteFinished(root: File, isAlive: (String) -> Boolean) { + root.listFiles()?.filter { it.isDirectory }?.forEach { deleteIfDead(it, isAlive) } + } + + private fun deleteIfDead(dir: File, isAlive: (String) -> Boolean) { + val workspace = UploadWorkspace(dir) + if (!workspace.tryLock()) { + return + } + try { + if (!isAlive(dir.name)) { + workspace.delete() + } + } finally { + workspace.unlock() + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt b/app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt index c6b2db38be..70e6d5b9d5 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploader.kt @@ -16,6 +16,7 @@ import at.bitfire.dav4jvm.exception.DavException import at.bitfire.dav4jvm.exception.HttpException import at.bitfire.dav4jvm.exception.NotFoundException import at.bitfire.dav4jvm.property.DisplayName +import at.bitfire.dav4jvm.property.GetContentLength import at.bitfire.dav4jvm.property.GetContentType import at.bitfire.dav4jvm.property.GetLastModified import at.bitfire.dav4jvm.property.ResourceType @@ -35,6 +36,8 @@ import com.nextcloud.talk.remotefilebrowser.model.RemoteFileBrowserItem import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype +import okhttp3.Dispatcher +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType import okhttp3.OkHttpClient @@ -46,17 +49,24 @@ import java.io.RandomAccessFile import java.nio.channels.FileChannel import java.util.Locale +@Suppress("TooManyFunctions") @AutoInjector(NextcloudTalkApplication::class) class ChunkedFileUploader( okHttpClient: OkHttpClient, val currentUser: User, val listener: OnDataTransferProgressListener, - val ncApiCoroutines: NcApiCoroutines + val ncApiCoroutines: NcApiCoroutines, + private val isRestarted: () -> Boolean = { false }, + private val markRestarted: () -> Unit = {} ) { private var okHttpClientNoRedirects: OkHttpClient? = null private var remoteChunkUrl: String + + @Volatile private var uploadFolderUri: String = "" + + @Volatile private var isUploadAborted = false init { @@ -65,50 +75,76 @@ class ChunkedFileUploader( } /** - * Uploads [localFile] in chunks, skipping chunks that are already on the server, and assembles them at - * [targetPath]. + * Uploads the parts of [localFile] that the server does not have yet, then assembles them into [targetPath]. + * The upload folder on the server is keyed by the file, so a repeated call resumes a previous one. * - * @return false if the upload was aborted or the server answered with an HTTP error - * @throws IOException on I/O failures (e.g. connection loss or timeout) and chunk folder creation failures, - * so the caller can retry; chunks already on the server are skipped as long as [localFile] is unchanged + * @return true when the file was assembled, false when the upload was stopped or aborted + * @throws IOException on I/O failures (e.g. connection loss or timeout), so the caller can retry; the parts + * already on the server are kept and skipped by the retry as long as [localFile] is unchanged + * @throws Exception when another request fails; the parts already on the server are kept */ - @Suppress("Detekt.TooGenericExceptionCaught") @Throws(IOException::class) fun upload(localFile: File, mimeType: MediaType?, targetPath: String): Boolean { + prepare(localFile) + return uploadMissingParts(localFile, mimeType) && assembleOrRestart(localFile, mimeType, targetPath) + } + + /** @return false when the upload was stopped while it was sent again after a rejected assembly */ + private fun assembleOrRestart(localFile: File, mimeType: MediaType?, targetPath: String): Boolean { try { - var isUploadSuccessful = true - uploadFolderUri = remoteChunkUrl + "/" + FileUtils.md5Sum(localFile) - val davResource = DavResource( - okHttpClientNoRedirects!!, - uploadFolderUri.toHttpUrlOrNull()!! - ) + assembleChunks(uploadFolderUri, targetPath, localFile.length()) + } catch (e: HttpException) { + // The server refused the assembly (e.g. the length does not match its parts): the parts it holds are + // not usable, so remove them once per upload (the worker remembers it) and send the file again. + // A rejected part is not this case and is thrown by uploadMissingParts. + if (e.code != HTTP_BAD_REQUEST || isRestarted()) { + throw e + } + Log.w(TAG, "Server rejected the assembly, uploading the file again", e) + deleteUploadFolder() + markRestarted() + if (!uploadMissingParts(localFile, mimeType)) { + return false + } + assembleChunks(uploadFolderUri, targetPath, localFile.length()) + } + return true + } + + /** + * Fixes the upload folder of [localFile], so that [abortUpload] can remove it. Done by [upload] when not called + * before; hashes the whole file once. + */ + fun prepare(localFile: File) { + if (uploadFolderUri.isEmpty()) { + uploadFolderUri = folderUriOf(localFile) + } + } - createFolder(davResource) + private fun folderUriOf(localFile: File) = remoteChunkUrl + "/" + FileUtils.md5Sum(localFile) + + /** @return false when the upload was stopped before every missing part was sent */ + private fun uploadMissingParts(localFile: File, mimeType: MediaType?): Boolean { + val davResource = DavResource( + okHttpClientNoRedirects!!, + uploadFolderUri.toHttpUrlOrNull()!! + ) - val chunksOnServer: MutableList = getUploadedChunks(davResource, uploadFolderUri) - Log.d(TAG, "chunksOnServer: " + chunksOnServer.size) + createFolder(davResource) - val missingChunks: List = checkMissingChunks(chunksOnServer, localFile.length()) - Log.d(TAG, "missingChunks: " + missingChunks.size) + val chunksOnServer: MutableList = getUploadedChunks(davResource, uploadFolderUri, localFile.length()) + Log.d(TAG, "chunksOnServer: " + chunksOnServer.size) - for (missingChunk in missingChunks) { - if (isUploadAborted) { - isUploadSuccessful = false - break - } - uploadChunk(localFile, uploadFolderUri, mimeType, missingChunk, missingChunk.length()) - } + val missingChunks: List = checkMissingChunks(chunksOnServer, localFile.length()) + Log.d(TAG, "missingChunks: " + missingChunks.size) - if (isUploadSuccessful) { - assembleChunks(uploadFolderUri, targetPath) + for (missingChunk in missingChunks) { + if (isUploadAborted) { + break } - return isUploadSuccessful - } catch (e: IOException) { - throw e - } catch (e: Exception) { - Log.e(TAG, "Something went wrong in ChunkedFileUploader", e) - return false + uploadChunk(localFile, uploadFolderUri, mimeType, missingChunk, missingChunk.length()) } + return !isUploadAborted } @Suppress("Detekt.ThrowsCount") @@ -134,14 +170,21 @@ class ChunkedFileUploader( } @Suppress("Detekt.ComplexMethod", "Detekt.ReturnCount") - private fun getUploadedChunks(davResource: DavResource, uploadFolderUri: String): MutableList { + private fun getUploadedChunks( + davResource: DavResource, + uploadFolderUri: String, + fileLength: Long + ): MutableList { val davResponse = DavResponse() val memberElements: MutableList = ArrayList() val rootElement = arrayOfNulls(1) val remoteFiles: MutableList = ArrayList() try { davResource.propfind( - 1 + 1, + ResourceType.NAME, + GetContentLength.NAME, + DisplayName.NAME ) { response: at.bitfire.dav4jvm.Response, hrefRelation: at.bitfire.dav4jvm.Response.HrefRelation? -> davResponse.setResponse(response) when (hrefRelation) { @@ -152,13 +195,15 @@ class ChunkedFileUploader( } Unit } - } catch (e: IOException) { - // PROPFIND on Nextcloud chunked-upload folders can return unexpected responses - // (e.g. 200 instead of 207). Treat any failure as "no chunks uploaded yet" so - // we fall back to a full upload rather than aborting entirely. - Log.w(TAG, "PROPFIND failed — assuming no chunks on server, will upload from scratch: ${e.message}") - return ArrayList() } catch (e: DavException) { + if (e is HttpException && (e.code >= HTTP_SERVER_ERROR || e.code in LISTING_ERROR_CODES)) { + throw e + } + // Failures of the request itself (login, rate limit, server) are no answer about the parts either. + // An IOException is not caught on purpose: a lost network must repeat the request, + // not send the whole file again. + // PROPFIND on Nextcloud chunked-upload folders can return unexpected responses + // (e.g. 200 instead of 207). Treat any such answer as "no chunks uploaded yet". Log.w(TAG, "PROPFIND failed — assuming no chunks on server, will upload from scratch: ${e.message}") return ArrayList() } @@ -177,24 +222,34 @@ class ChunkedFileUploader( val chunksOnServer: MutableList = ArrayList() for (remoteFile in remoteFiles) { - if (!".file".equals(remoteFile.displayName, ignoreCase = true) && remoteFile.isFile) { - val part: List = remoteFile.displayName!!.split("-") - chunksOnServer.add( - Chunk( - part[0].toLong(), - part[1].toLong() - ) - ) + if (remoteFile.isFile) { + parseUploadedChunk(remoteFile.displayName, remoteFile.size, fileLength)?.let { chunksOnServer.add(it) } } } return chunksOnServer } - private fun checkMissingChunks(chunks: List, length: Long): List { + /** + * Returns the part a file on the server stands for, or null when it is not a part (e.g. `.file`), or its size + * differs from what its name says, so it has to be uploaded again. The last part is named one byte longer than + * its content. + */ + internal fun parseUploadedChunk(name: String?, size: Long?, fileLength: Long): Chunk? { + if (name == null || !CHUNK_NAME_REGEX.matches(name)) { + return null + } + val chunk = + Chunk(name.substring(0, CHUNK_NUMBER_LENGTH).toLong(), name.substring(CHUNK_NUMBER_LENGTH + 1).toLong()) + val expectedSize = minOf(chunk.length(), fileLength - chunk.start) + return if (size == expectedSize) chunk else null + } + + internal fun checkMissingChunks(chunks: List, length: Long): List { val missingChunks: MutableList = java.util.ArrayList() + val sortedChunks = chunks.sortedBy { it.start } var start: Long = 0 while (start <= length) { - val nextChunk: Chunk? = findNextFittingChunk(chunks, start) + val nextChunk: Chunk? = findNextFittingChunk(sortedChunks, start) if (nextChunk == null) { // create new chunk val end: Long = if (start + CHUNK_SIZE <= length) { @@ -287,7 +342,10 @@ class ChunkedFileUploader( } private fun initHttpClient(okHttpClient: OkHttpClient, currentUser: User) { + // Own dispatcher: stop() cancels all calls of the dispatcher, and the one of okHttpClient is shared with + // the whole app (chat requests, signaling websocket, other uploads). val builder = okHttpClient.newBuilder() + .dispatcher(Dispatcher()) .followRedirects(false) .followSslRedirects(false) .protocols(listOf(Protocol.HTTP_1_1)) @@ -306,7 +364,7 @@ class ChunkedFileUploader( this.okHttpClientNoRedirects = builder.build() } - private fun assembleChunks(uploadFolderUri: String, targetPath: String) { + private fun assembleChunks(uploadFolderUri: String, targetPath: String, totalLength: Long) { val destinationUri = ApiUtils.getUrlForFileUpload( currentUser.baseUrl!!, currentUser.userId!!, @@ -316,8 +374,13 @@ class ChunkedFileUploader( createRemoteFolder(targetPath) val originUri = "$uploadFolderUri/.file" + val client = okHttpClientNoRedirects!!.newBuilder() + .addInterceptor { chain -> + chain.proceed(chain.request().newBuilder().header(TOTAL_LENGTH_HEADER, totalLength.toString()).build()) + } + .build() DavResource( - okHttpClientNoRedirects!!, + client, originUri.toHttpUrlOrNull()!! ).move( destinationUri.toHttpUrlOrNull()!!, @@ -347,12 +410,21 @@ class ChunkedFileUploader( } /** + * Interrupts a running [upload] without touching the parts on the server, so a later [upload] can resume. * Called from the worker's onStopped, which WorkManager runs inside a coroutine cancellation handler: * anything thrown here crashes the process, so this function must never throw. */ + fun stop() { + isUploadAborted = true + okHttpClientNoRedirects?.dispatcher?.cancelAll() + } + + /** + * Interrupts a running [upload] and removes its parts from the server. Same contract as [stop]: never throws. + */ @Suppress("Detekt.TooGenericExceptionCaught") fun abortUpload(onSuccess: () -> Unit) { - isUploadAborted = true + stop() val client = okHttpClientNoRedirects val folderUrl = uploadFolderUri.toHttpUrlOrNull() if (client == null || folderUrl == null) { @@ -378,6 +450,36 @@ class ChunkedFileUploader( } } + /** Interrupts this uploader and removes the parts of [localFile], also those an earlier run left. */ + fun abortUpload(localFile: File, onSuccess: () -> Unit) { + uploadFolderUri = folderUriOf(localFile) + abortUpload(onSuccess) + } + + /** + * Removes a file this uploader or an earlier run assembled on the server, e.g. when the user cancelled after + * the upload and before the share. Never throws, like [abortUpload]. + */ + @Suppress("Detekt.TooGenericExceptionCaught") + fun deleteUploadedFile(targetPath: String) { + try { + val url = ApiUtils.getUrlForFileUpload(currentUser.baseUrl!!, currentUser.userId!!, targetPath) + DavResource(okHttpClientNoRedirects!!, url.toHttpUrl()).delete { _ -> } + } catch (e: NotFoundException) { + Log.i(TAG, "Uploaded file is already gone", e) + } catch (e: Exception) { + Log.w(TAG, "Failed to remove the uploaded file", e) + } + } + + private fun deleteUploadFolder() { + try { + DavResource(okHttpClientNoRedirects!!, uploadFolderUri.toHttpUrlOrNull()!!).delete { _ -> } + } catch (e: NotFoundException) { + Log.i(TAG, "Chunk upload folder is already gone", e) + } + } + private fun getModelFromResponse(response: at.bitfire.dav4jvm.Response, remotePath: String): RemoteFileBrowserItem { val remoteFileBrowserItem = RemoteFileBrowserItem() remoteFileBrowserItem.path = Uri.decode(remotePath) @@ -413,6 +515,10 @@ class ChunkedFileUploader( remoteFileBrowserItem.modifiedTimestamp = property.lastModified } + is GetContentLength -> { + remoteFileBrowserItem.size = property.contentLength + } + is GetContentType -> { remoteFileBrowserItem.mimeType = property.type } @@ -448,5 +554,11 @@ class ChunkedFileUploader( private const val READ_PERMISSION = "R" private const val CHUNK_SIZE: Long = 1024000 private const val METHOD_NOT_ALLOWED_CODE: Int = 405 + private const val HTTP_BAD_REQUEST: Int = 400 + private const val HTTP_SERVER_ERROR: Int = 500 + private val LISTING_ERROR_CODES = setOf(401, 403, 408, 429) + private const val CHUNK_NUMBER_LENGTH = 16 + private val CHUNK_NAME_REGEX = Regex("^\\d{16}-\\d{16}$") + private const val TOTAL_LENGTH_HEADER = "OC-Total-Length" } } diff --git a/app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt b/app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt index cbcb919101..b6ca1d622e 100644 --- a/app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt +++ b/app/src/main/java/com/nextcloud/talk/upload/normal/FileUploader.kt @@ -28,6 +28,7 @@ import okhttp3.Protocol import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import retrofit2.HttpException as RetrofitHttpException import java.io.File import java.io.IOException @@ -58,7 +59,11 @@ class FileUploader( ), createRequestBody(sourceFileUri) ?: return false ) - return response.isSuccessful + // An error answer is thrown, so the retry policy can tell an unavailable server from a refusal. + if (!response.isSuccessful) { + throw RetrofitHttpException(response) + } + return true } fun upload(sourceFileUri: Uri, fileName: String, remotePath: String, metaData: String?): Observable = @@ -85,7 +90,7 @@ class FileUploader( ) { createDavResource(sourceFileUri, fileName, remotePath, metaData) } else { - Observable.just(false) + Observable.error(RetrofitHttpException(response)) } } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt index 9e44d9f140..27460e7d55 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -19,15 +19,16 @@ import java.io.File import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException -import java.io.InputStream import java.math.BigInteger import java.security.MessageDigest +@Suppress("TooManyFunctions") object FileUtils { private val TAG = FileUtils::class.java.simpleName private const val RADIX: Int = 16 private const val MD5_LENGTH: Int = 32 private const val SHARED_ATTACHMENTS_DIRECTORY = "shared_attachments" + private const val PARTIAL_SUFFIX = ".part" /** * Returns the dedicated cache directory used for externally shared attachments. @@ -155,7 +156,7 @@ object FileUtils { } @Suppress("ThrowsCount") - fun getFileFromUri(context: Context, sourceFileUri: Uri): File? { + fun getFileFromUri(context: Context, sourceFileUri: Uri, targetDir: File = context.cacheDir): File? { val fileName = getFileName(sourceFileUri, context) val scheme = sourceFileUri.scheme @@ -163,7 +164,7 @@ object FileUtils { Log.d(TAG, "relative uri: " + sourceFileUri.path) throw IllegalArgumentException("relative paths are not supported") } else if (ContentResolver.SCHEME_CONTENT == scheme) { - copyFileToCache(context, sourceFileUri, fileName) + copyFileToCache(context, sourceFileUri, fileName, targetDir) } else if (ContentResolver.SCHEME_FILE == scheme) { val path = sourceFileUri.path ?: throw IllegalArgumentException("uri does not contain path") @@ -181,28 +182,71 @@ object FileUtils { return file } + /** + * Returns [file] when it already lies in [dir], otherwise a copy of it there. A file:// source is the caller's + * own file in the cache; a copy in [dir] keeps the prepared upload independent of it. + */ + fun ensureInDir(file: File, dir: File): File { + if (file.canonicalFile.parentFile == dir.canonicalFile) { + return file + } + val copy = File(dir, file.name) + try { + return file.copyTo(copy, overwrite = true) + } catch (e: IOException) { + copy.delete() + throw e + } + } + @Suppress("NestedBlockDepth") - fun copyFileToCache(context: Context, sourceFileUri: Uri, filename: String): File? { - val cachedFile = File(context.cacheDir, filename) + fun copyFileToCache( + context: Context, + sourceFileUri: Uri, + filename: String, + targetDir: File = context.cacheDir + ): File? { + val cachedFile = File(targetDir, filename) - if (!cachedFile.toPath().normalize().startsWith(context.cacheDir.toPath())) { + if (!cachedFile.toPath().normalize().startsWith(targetDir.toPath())) { Log.w(TAG, "cachedFile was not created in cacheDir. Aborting for security reasons.") cachedFile.delete() return null } - if (cachedFile.exists()) { + val ready = if (cachedFile.exists() && cachedFile.length() > 0) { Log.d(TAG, "file is already in cache") + true } else { - val outputStream = FileOutputStream(cachedFile) + copyAside(context, sourceFileUri, cachedFile) + } + if (!ready) { + cachedFile.delete() + } + return cachedFile.takeIf { ready } + } + + /** Copies aside and renames after success: a copy that dies half way never becomes the cached file. */ + private fun copyAside(context: Context, sourceFileUri: Uri, target: File): Boolean { + val partial = File(target.parentFile, "${target.name}.${System.nanoTime()}$PARTIAL_SUFFIX") + return try { + copyToFile(context, sourceFileUri, partial) && partial.renameTo(target) + } finally { + partial.delete() + } + } + + /** Returns false when the source could not be read completely, so the copy is not a usable file. */ + @Suppress("NestedBlockDepth") + private fun copyToFile(context: Context, sourceFileUri: Uri, target: File): Boolean { + var copied = false + FileOutputStream(target).use { output -> try { - val inputStream: InputStream? = context.contentResolver.openInputStream(sourceFileUri) - inputStream?.use { input -> - outputStream.use { output -> - input.copyTo(output) - } + context.contentResolver.openInputStream(sourceFileUri)?.use { input -> + input.copyTo(output) + copied = true } - outputStream.flush() + output.flush() } catch (e: FileNotFoundException) { Log.w(TAG, "failed to copy file to cache", e) } catch (e: SecurityException) { @@ -212,9 +256,33 @@ object FileUtils { Log.w(TAG, "no longer permitted to read $sourceFileUri", e) } } - return cachedFile + if (!copied) { + return false + } + // The size a provider reports is not reliable (transcoding, cloud providers), so it is only logged. + val reported = querySize(context, sourceFileUri) + if (reported != null && reported > 0 && reported != target.length()) { + Log.w(TAG, "copy of $sourceFileUri has ${target.length()} bytes, the provider reports $reported") + } + // A stream that broke off throws and never gets here, so an empty copy of an empty source is complete. + // An empty copy of a source that reports content is a placeholder that was not loaded: not a usable file. + return target.length() > 0 || reported == null || reported <= 0 } + internal fun querySize(context: Context, uri: Uri): Long? = + try { + context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> + val index = cursor.getColumnIndex(OpenableColumns.SIZE) + if (index >= 0 && cursor.moveToFirst() && !cursor.isNull(index)) cursor.getLong(index) else null + } + } catch (e: SecurityException) { + Log.w(TAG, "could not query the size of $uri", e) + null + } catch (e: IllegalArgumentException) { + Log.w(TAG, "could not query the size of $uri", e) + null + } + fun getFileName(uri: Uri, context: Context?): String { var filename: String? = null if (uri.scheme == "content" && context != null) { diff --git a/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt b/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt index 7f7807ffb1..263b205b67 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ImageCompressor.kt @@ -52,13 +52,13 @@ object ImageCompressor { } /** - * Returns a downscaled, JPEG re-encoded copy of [sourceFile] in the app cache directory, - * or null if the image could not be decoded or written. + * Returns a downscaled, JPEG re-encoded copy of [sourceFile] in [outputDir] (the app cache directory by + * default), or null if the image could not be decoded or written. */ - fun compress(context: Context, sourceFile: File): File? { + fun compress(context: Context, sourceFile: File, outputDir: File = context.cacheDir): File? { val (bitmap, bytes) = encode(sourceFile) ?: return null return try { - val compressedFile = File(context.cacheDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX) + val compressedFile = File(outputDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX) compressedFile.writeBytes(bytes) compressedFile } catch (e: IOException) { diff --git a/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt b/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt index 428bc466ae..7d9d43ad81 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/VideoCompressor.kt @@ -93,18 +93,23 @@ object VideoCompressor { } /** - * Returns a downscaled, re-encoded copy of [sourceFile] in the app cache directory, or null if + * Returns a downscaled, re-encoded copy of [sourceFile] in [outputDir] (default: the app cache dir), or null if * the video was already small enough, could not be read, or transcoding failed/timed out. * * [onProgress] is invoked with a 0-100 value from a background thread while transcoding runs. */ @Suppress("ReturnCount") - fun compress(context: Context, sourceFile: File, onProgress: (Int) -> Unit = {}): File? { + fun compress( + context: Context, + sourceFile: File, + outputDir: File = context.cacheDir, + onProgress: (Int) -> Unit = {} + ): File? { Log.d(TAG, "compressing ${sourceFile.name}") val original = readVideoInfo(sourceFile) ?: return null if (minOf(original.width, original.height) <= TARGET_SHORT_SIDE) return null - val outputFile = File(context.cacheDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX) + val outputFile = File(outputDir, sourceFile.nameWithoutExtension + COMPRESSED_FILE_SUFFIX) outputFile.delete() val success = runTransform(context, sourceFile, outputFile, original.durationMs, onProgress) diff --git a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFailureTest.kt b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFailureTest.kt new file mode 100644 index 0000000000..6832196bbc --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFailureTest.kt @@ -0,0 +1,100 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat.viewmodels + +import android.content.Context +import android.os.Looper +import androidx.work.Configuration +import androidx.work.Data +import androidx.work.ListenableWorker +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import androidx.work.workDataOf +import com.nextcloud.talk.jobs.UploadAndShareFilesWorker +import org.junit.Assert.assertEquals +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * A failed upload must not leave its placeholder pending, whether the worker fails or only reports the failure. + */ +class ChatViewModelUploadFailureTest : ChatViewModelUploadTestBase() { + @Test + fun `an upload that reports its failure before the placeholder exists does not leave the placeholder pending`() { + // The worker ends a failed upload as SUCCEEDED, so the uploads queued behind it still run, and reports the + // failure in its output data. + useUploadWorkerResult(workDataOf(UploadAndShareFilesWorker.KEY_UPLOAD_FAILED to true)) + + val workId = sendSlowFileAndRunItsWorker() + awaitPlaceholderMarkedFailed() + + val workInfo = WorkManager.getInstance(RuntimeEnvironment.getApplication()).getWorkInfoById(workId).get() + assertEquals(WorkInfo.State.SUCCEEDED, workInfo?.state) + assertEquals("the placeholder is marked as failed", 0L, placeholderMarkedFailed.count) + } + + @Test + fun `an upload that succeeds does not mark its placeholder as failed`() { + useUploadWorkerResult(Data.EMPTY) + + val workId = sendSlowFileAndRunItsWorker() + awaitPlaceholderMarkedFailed() + + val workInfo = WorkManager.getInstance(RuntimeEnvironment.getApplication()).getWorkInfoById(workId).get() + assertEquals(WorkInfo.State.SUCCEEDED, workInfo?.state) + assertEquals("the placeholder is left alone", 1L, placeholderMarkedFailed.count) + } + + // Every upload work ends at once with the given output, before the slow provider let the placeholder in. + private fun useUploadWorkerResult(output: Data) { + val factory = object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker = + object : Worker(appContext, workerParameters) { + override fun doWork(): Result = Result.success(output) + } + } + val configuration = Configuration.Builder() + .setExecutor(SynchronousExecutor()) + .setWorkerFactory(factory) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(RuntimeEnvironment.getApplication(), configuration) + } + + private fun sendSlowFileAndRunItsWorker(): UUID { + val viewModel = newViewModel() + viewModel.send("slow-a.pdf", order = 1) + val workId = viewModel.uploadWorkIds().getValue(referenceId(1)) + WorkManagerTestInitHelper.getTestDriver(RuntimeEnvironment.getApplication())!!.setAllConstraintsMet(workId) + return workId + } + + // Waits for the placeholder, then gives the observer the time to mark it. + private fun awaitPlaceholderMarkedFailed() { + val deadline = System.currentTimeMillis() + TIMEOUT_MS + while (placeholderAdded.count > 0 && System.currentTimeMillis() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + placeholderAdded.await(POLL_MS, TimeUnit.MILLISECONDS) + } + val markDeadline = System.currentTimeMillis() + MARK_WAIT_MS + while (placeholderMarkedFailed.count > 0 && System.currentTimeMillis() < markDeadline) { + shadowOf(Looper.getMainLooper()).idle() + placeholderMarkedFailed.await(POLL_MS, TimeUnit.MILLISECONDS) + } + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt index ff0aea2531..e5ec3d9ccf 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt @@ -6,97 +6,24 @@ */ package com.nextcloud.talk.chat.viewmodels -import android.content.ContentProvider -import android.content.ContentValues -import android.database.Cursor -import android.database.MatrixCursor -import android.net.Uri import android.os.Looper -import android.provider.OpenableColumns import androidx.lifecycle.viewModelScope import androidx.work.WorkManager import androidx.work.impl.WorkManagerImpl import androidx.work.testing.WorkManagerTestInitHelper -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.chat.data.ChatMessageRepository -import com.nextcloud.talk.chat.data.model.ChatMessage -import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.utils.ParticipantPermissions -import com.nextcloud.talk.utils.message.SendMessageUtils -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking -import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Mockito -import org.mockito.Mockito.RETURNS_DEEP_STUBS -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever -import org.robolectric.Robolectric -import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf -import org.robolectric.annotation.Config -import java.util.UUID -import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicInteger /** * The name, type and size of a file to send come from the provider of the app that owns it (Nextcloud Files): * a blocking binder call into another process. Sending a file must not make it on the main thread. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [33]) -class ChatViewModelUploadFileTest { - - private val chatRepository: ChatMessageRepository = mock() - private val placeholderAdded = CountDownLatch(1) - private val placeholderMarkedFailed = CountDownLatch(1) - private var previousApplication: NextcloudTalkApplication? = null - - // the setter of the singleton is protected: only the real application sets it - private val sharedApplicationField = NextcloudTalkApplication::class.java - .getDeclaredField("sharedApplication") - .apply { isAccessible = true } - - @Before - fun setUp() { - val context = RuntimeEnvironment.getApplication() - WorkManagerTestInitHelper.initializeTestWorkManager(context) - previousApplication = sharedApplicationField.get(null) as NextcloudTalkApplication? - val application = mock() - whenever(application.contentResolver).thenReturn(context.contentResolver) - whenever(application.applicationContext).thenReturn(context) - sharedApplicationField.set(null, application) - - SlowProvider.reset() - Robolectric.buildContentProvider(SlowProvider::class.java).create(AUTHORITY) - runBlocking { - whenever(chatRepository.addUploadPlaceholderMessage(any(), any(), any(), anyOrNull(), any(), any())) - .thenAnswer { - placeholderAdded.countDown() - flowOf>() - } - whenever(chatRepository.markUploadPlaceholderFailed(any())).thenAnswer { - placeholderMarkedFailed.countDown() - } - } - } - - @After - fun tearDown() { - sharedApplicationField.set(null, previousApplication) - } - +class ChatViewModelUploadFileTest : ChatViewModelUploadTestBase() { @Test fun `sending a file does not ask its provider on the main thread`() { sendAndAwaitPlaceholder("document.pdf") @@ -171,118 +98,4 @@ class ChatViewModelUploadFileTest { assertEquals("the placeholder is marked as failed", 0L, placeholderMarkedFailed.count) } - - private fun queuedUploads(): Int = - WorkManager.getInstance(RuntimeEnvironment.getApplication()) - .getWorkInfosForUniqueWork("upload_queue_1@room") - .get() - .size - - private fun referenceId(order: Int) = SendMessageUtils().generateGroupedReferenceId(UPLOAD_ID, order) - - @Suppress("UNCHECKED_CAST") - private fun ChatViewModel.uploadWorkIds(): Map = - ChatViewModel::class.java.getDeclaredField("uploadReferenceToWorkId") - .apply { isAccessible = true } - .get(this) as Map - - private fun ChatViewModel.send(name: String, order: Int) { - uploadFile( - fileUri = "content://" + AUTHORITY + "/" + name, - isVoiceMessage = false, - displayName = "Team", - uploadId = UPLOAD_ID, - order = order - ) - } - - private fun newViewModel(): ChatViewModel { - val user = User().apply { id = 1L } - val viewModel = ChatViewModel( - logger = mock(), - appPreferences = mock(defaultAnswer = RETURNS_DEEP_STUBS), - chatNetworkDataSource = mock(defaultAnswer = RETURNS_DEEP_STUBS), - chatRepository = chatRepository, - threadsRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS), - conversationRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS), - reactionsRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS), - unifiedSearchRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS), - mediaRecorderManager = mock(defaultAnswer = RETURNS_DEEP_STUBS), - audioFocusRequestManager = mock(defaultAnswer = RETURNS_DEEP_STUBS), - userManager = mock(defaultAnswer = RETURNS_DEEP_STUBS), - appScope = CoroutineScope(Dispatchers.Unconfined), - initialUser = user, - chatRoomToken = "room", - conversationThreadId = null - ) - viewModel.participantPermissions = Mockito.mock(ParticipantPermissions::class.java).also { - whenever(it.hasChatPermission()).thenReturn(true) - } - return viewModel - } - - private fun sendAndAwaitPlaceholder(name: String) { - newViewModel().send(name, order = 1) - - val deadline = System.currentTimeMillis() + TIMEOUT_MS - while (placeholderAdded.count > 0 && System.currentTimeMillis() < deadline) { - shadowOf(Looper.getMainLooper()).idle() - placeholderAdded.await(POLL_MS, TimeUnit.MILLISECONDS) - } - } - - class SlowProvider : ContentProvider() { - override fun onCreate() = true - - override fun getType(uri: Uri): String { - count(uri) - require(uri.lastPathSegment != "rejected.pdf") { "Unknown URL " + uri } - return "application/pdf" - } - - override fun query( - uri: Uri, - projection: Array?, - selection: String?, - selectionArgs: Array?, - sortOrder: String? - ): Cursor { - count(uri) - return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)).apply { - addRow(arrayOf(uri.lastPathSegment, FILE_BYTES)) - } - } - - override fun insert(uri: Uri, values: ContentValues?): Uri? = null - - override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0 - - override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0 - - private fun count(uri: Uri) { - val onMain = Looper.myLooper() == Looper.getMainLooper() - (if (onMain) mainThreadCalls else otherCalls).incrementAndGet() - Thread.sleep(if (uri.lastPathSegment.orEmpty().startsWith("slow")) SLOW_DELAY_MS else PROVIDER_DELAY_MS) - } - - companion object { - val mainThreadCalls = AtomicInteger() - val otherCalls = AtomicInteger() - - fun reset() { - mainThreadCalls.set(0) - otherCalls.set(0) - } - } - } - - companion object { - private const val AUTHORITY = "com.nextcloud.client.documentsfileprovider" - private const val PROVIDER_DELAY_MS = 300L - private const val SLOW_DELAY_MS = 1500L - private const val FILE_BYTES = 2048L - private const val TIMEOUT_MS = 20_000L - private const val POLL_MS = 50L - private const val UPLOAD_ID = "upload" - } } diff --git a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadTestBase.kt b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadTestBase.kt new file mode 100644 index 0000000000..75289c7700 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadTestBase.kt @@ -0,0 +1,206 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat.viewmodels + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Looper +import android.provider.OpenableColumns +import androidx.work.WorkManager +import androidx.work.testing.WorkManagerTestInitHelper +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.chat.data.ChatMessageRepository +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.utils.ParticipantPermissions +import com.nextcloud.talk.utils.message.SendMessageUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.RETURNS_DEEP_STUBS +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +internal const val AUTHORITY = "com.nextcloud.client.documentsfileprovider" +internal const val PROVIDER_DELAY_MS = 300L +internal const val SLOW_DELAY_MS = 1500L +internal const val FILE_BYTES = 2048L +internal const val TIMEOUT_MS = 20_000L +internal const val POLL_MS = 50L +internal const val MARK_WAIT_MS = 3_000L +internal const val UPLOAD_ID = "upload" + +class SlowProvider : ContentProvider() { + override fun onCreate() = true + + override fun getType(uri: Uri): String { + count(uri) + require(uri.lastPathSegment != "rejected.pdf") { "Unknown URL " + uri } + return "application/pdf" + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor { + count(uri) + return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)).apply { + addRow(arrayOf(uri.lastPathSegment, FILE_BYTES)) + } + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0 + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0 + + private fun count(uri: Uri) { + val onMain = Looper.myLooper() == Looper.getMainLooper() + (if (onMain) mainThreadCalls else otherCalls).incrementAndGet() + Thread.sleep(if (uri.lastPathSegment.orEmpty().startsWith("slow")) SLOW_DELAY_MS else PROVIDER_DELAY_MS) + } + + companion object { + val mainThreadCalls = AtomicInteger() + val otherCalls = AtomicInteger() + + fun reset() { + mainThreadCalls.set(0) + otherCalls.set(0) + } + } +} + +/** + * What the upload tests of the view model share: a work manager, a provider that answers slowly, a view model + * and the wait for its placeholder. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +abstract class ChatViewModelUploadTestBase { + + protected val chatRepository: ChatMessageRepository = mock() + protected val placeholderAdded = CountDownLatch(1) + protected val placeholderMarkedFailed = CountDownLatch(1) + private var previousApplication: NextcloudTalkApplication? = null + + // the setter of the singleton is protected: only the real application sets it + private val sharedApplicationField = NextcloudTalkApplication::class.java + .getDeclaredField("sharedApplication") + .apply { isAccessible = true } + + @Before + fun setUp() { + val context = RuntimeEnvironment.getApplication() + WorkManagerTestInitHelper.initializeTestWorkManager(context) + previousApplication = sharedApplicationField.get(null) as NextcloudTalkApplication? + val application = mock() + whenever(application.contentResolver).thenReturn(context.contentResolver) + whenever(application.applicationContext).thenReturn(context) + sharedApplicationField.set(null, application) + + SlowProvider.reset() + Robolectric.buildContentProvider(SlowProvider::class.java).create(AUTHORITY) + runBlocking { + whenever(chatRepository.addUploadPlaceholderMessage(any(), any(), any(), anyOrNull(), any(), any())) + .thenAnswer { + placeholderAdded.countDown() + flowOf>() + } + whenever(chatRepository.markUploadPlaceholderFailed(any())).thenAnswer { + placeholderMarkedFailed.countDown() + } + } + } + + @After + fun tearDown() { + sharedApplicationField.set(null, previousApplication) + } + + protected fun queuedUploads(): Int = + WorkManager.getInstance(RuntimeEnvironment.getApplication()) + .getWorkInfosForUniqueWork("upload_queue_1@room") + .get() + .size + + protected fun referenceId(order: Int) = SendMessageUtils().generateGroupedReferenceId(UPLOAD_ID, order) + + @Suppress("UNCHECKED_CAST") + protected fun ChatViewModel.uploadWorkIds(): Map = + ChatViewModel::class.java.getDeclaredField("uploadReferenceToWorkId") + .apply { isAccessible = true } + .get(this) as Map + + protected fun ChatViewModel.send(name: String, order: Int) { + uploadFile( + fileUri = "content://" + AUTHORITY + "/" + name, + isVoiceMessage = false, + displayName = "Team", + uploadId = UPLOAD_ID, + order = order + ) + } + + protected fun newViewModel(): ChatViewModel { + val user = User().apply { id = 1L } + val viewModel = ChatViewModel( + logger = mock(), + appPreferences = mock(defaultAnswer = RETURNS_DEEP_STUBS), + chatNetworkDataSource = mock(defaultAnswer = RETURNS_DEEP_STUBS), + chatRepository = chatRepository, + threadsRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS), + conversationRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS), + reactionsRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS), + unifiedSearchRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS), + mediaRecorderManager = mock(defaultAnswer = RETURNS_DEEP_STUBS), + audioFocusRequestManager = mock(defaultAnswer = RETURNS_DEEP_STUBS), + userManager = mock(defaultAnswer = RETURNS_DEEP_STUBS), + appScope = CoroutineScope(Dispatchers.Unconfined), + initialUser = user, + chatRoomToken = "room", + conversationThreadId = null + ) + viewModel.participantPermissions = Mockito.mock(ParticipantPermissions::class.java).also { + whenever(it.hasChatPermission()).thenReturn(true) + } + return viewModel + } + + protected fun sendAndAwaitPlaceholder(name: String) { + newViewModel().send(name, order = 1) + + val deadline = System.currentTimeMillis() + TIMEOUT_MS + while (placeholderAdded.count > 0 && System.currentTimeMillis() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + placeholderAdded.await(POLL_MS, TimeUnit.MILLISECONDS) + } + } +} diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt new file mode 100644 index 0000000000..2244536533 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadAndShareFilesWorkerFailureTest.kt @@ -0,0 +1,557 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.jobs + +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Resources +import android.net.Uri +import androidx.concurrent.futures.ResolvableFuture +import androidx.test.core.app.ApplicationProvider +import androidx.work.Data +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.testing.TestListenableWorkerBuilder +import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.api.NcApiCoroutines +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.database.dao.ChatMessagesDao +import com.nextcloud.talk.data.database.model.ChatMessageEntity +import com.nextcloud.talk.data.database.model.SendStatus +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.json.capabilities.CapabilitiesDto +import com.nextcloud.talk.models.json.capabilities.SpreedCapabilityDto +import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentDataDto +import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentFolderOCS +import com.nextcloud.talk.models.json.chatprobeattachmentfolder.ChatProbeAttachmentFolderOverall +import com.nextcloud.talk.models.json.generic.GenericOverall +import com.nextcloud.talk.upload.PreparedUpload +import com.nextcloud.talk.upload.UploadRetryPolicy +import com.nextcloud.talk.upload.UploadWorkspace +import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID +import io.reactivex.Observable +import java.io.File +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import mockwebserver3.Dispatcher +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.RecordedRequest +import okhttp3.OkHttpClient +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import retrofit2.HttpException +import retrofit2.Response + +/** + * What an upload that fails leaves behind: the state of the work, the placeholder message of the chat and the + * workspace. The worker runs directly (doWork), without a WorkManager, so one run is one call. + */ +@Suppress("TooManyFunctions") +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class UploadAndShareFilesWorkerFailureTest { + + private val rows = ConcurrentHashMap() + private val foregroundRequests = AtomicInteger() + private val spreed: SpreedCapabilityDto = mock() + private val capabilities: CapabilitiesDto = mock { on { spreedCapability } doReturn spreed } + private val user: User = mock { + on { username } doReturn "alice" + on { userId } doReturn "alice" + on { token } doReturn "secret" + on { baseUrl } doReturn "https://cloud.example" + on { capabilities } doReturn capabilities + } + private val userManager: UserManager = mock { + on { runBlocking { getUserWithId(ACCOUNT_ID) } } doReturn user + } + private val ncApi: NcApi = mock() + private val ncApiCoroutines: NcApiCoroutines = mock() + private val server = MockWebServer() + private val chatDao: ChatMessagesDao = mock { + on { getTempMessageForConversation(any(), any(), anyOrNull()) } doAnswer { + flowOf(rows[it.getArgument(1)]) + } + } + + init { + // The worker writes only the send status of the temporary row. + doAnswer { + val referenceId = it.getArgument(1) + val row = rows[referenceId] + row?.let { found -> rows[referenceId] = found.copy(sendStatus = it.getArgument(2)) } + if (row == null) 0 else 1 + }.whenever(chatDao).updateTempMessageSendStatus(any(), any(), any()) + } + + @After + fun tearDown() { + runCatching { server.close() } + } + + @Test + fun `an unavailable server is waited for and the placeholder stays pending`() { + val worker = worker() + uploadedAndRefusedByServer(500) + + val result = worker.doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus) + assertEquals(true, File(workspaceDir(worker.id), "unavailable").exists()) + } + + @Test + fun `a server that stays unavailable for 24 hours fails the upload`() { + val worker = worker() + uploadedAndRefusedByServer(503) + // The first "unavailable" answer came 25 hours ago, the server kept answering and the upload did not move on. + repeat(UploadRetryPolicy.MIN_UNAVAILABLE_ANSWERS) { + UploadWorkspace(workspaceDir(worker.id)).registerUnavailable(now() - HOURS_25) + } + + val result = worker.doWork() + + assertTrue(result is ListenableWorker.Result.Success) + assertEquals(SendStatus.FAILED, rows[REFERENCE_ID]?.sendStatus) + assertFalse("the workspace is removed", workspaceDir(worker.id).exists()) + } + + @Test + fun `time without a network does not fail the upload at the first answer of the server`() { + val worker = worker() + uploadedAndRefusedByServer(503) + // One answer 25 hours ago, then no network until now: this is the second answer. + UploadWorkspace(workspaceDir(worker.id)).registerUnavailable(now() - HOURS_25) + + val result = worker.doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus) + } + + @Test + fun `a clock that went back starts the 24 hours again`() { + val worker = worker() + uploadedAndRefusedByServer(503) + repeat(ANSWERS) { UploadWorkspace(workspaceDir(worker.id)).registerUnavailable(now() + HOURS_25) } + val before = now() + + val result = worker.doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + val since = File(workspaceDir(worker.id), "unavailable").readLines().first().toLong() + assertTrue("the clock starts at the time of the answer", since in before..now()) + } + + @Test + fun `the file that got uploaded since the last answer starts the 24 hours again`() { + val worker = worker() + uploadedAndRefusedByServer(503) + // The last answers came before the file was uploaded (the point -1 is "nothing sent yet"). + seedUnavailable(worker, since = now() - HOURS_25, point = -1, answers = ANSWERS) + + val result = worker.doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus) + } + + @Test + fun `progress made after the last answer keeps the upload alive in a later run`() { + // Run 1: unavailable at 10 %. The upload went on to 80 %, then the network was lost and the process killed. + // Run 2, 26 hours later: the first request of the run, the probe, is answered "unavailable". + val worker = worker(source = fileUri(SMALL_FILE_SIZE), uploaded = false, draft = true) + probeRefusedByServer(503) + seedUnavailable(worker, since = now() - HOURS_26, point = 10, answers = ANSWERS) + File(workspaceDir(worker.id), "progress").writeText("80") + + val result = worker.doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus) + } + + @Test + fun `an upload restarted from the beginning is not failed for the progress it had before`() { + val worker = worker(source = fileUri(SMALL_FILE_SIZE), uploaded = false, draft = true) + probeRefusedByServer(503) + seedUnavailable(worker, since = now() - HOURS_25, point = 100, answers = ANSWERS) + val workspace = UploadWorkspace(workspaceDir(worker.id)) + workspace.recordProgress(100) + // The server rejected the assembly: the chunks are sent again from 0 %, now at 30 %. + workspace.markRestarted() + workspace.recordProgress(30) + + val result = worker.doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus) + } + + @Test + fun `progress reported to the worker starts the 24 hours again`() { + val id = UUID.randomUUID() + val first = worker(id = id, source = fileUri(SMALL_FILE_SIZE), uploaded = false, draft = true) + probeRefusedByServer(503) + assertEquals(ListenableWorker.Result.retry(), first.doWork()) + // The upload sends data, the network is lost, 25 hours pass (the workspace of the retry is kept). + first.onTransferProgress(PROGRESS_PERCENT) + seedUnavailable(first, since = now() - HOURS_25, point = -1, answers = ANSWERS) + + val second = worker(id = id, source = fileUri(SMALL_FILE_SIZE), uploaded = false, draft = true) + probeRefusedByServer(503) + val result = second.doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus) + } + + @Test + fun `a workspace that cannot be written does not end the work as failed`() { + val worker = worker() + uploadedAndRefusedByServer(503) + // A directory in the place of the temporary file makes every write of this file fail. + File(workspaceDir(worker.id), "unavailable.tmp").mkdirs() + + val result = worker.doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus) + } + + @Test + fun `a server error that cannot be stored does not end the work as failed`() { + val worker = worker() + uploadedAndRefusedByServer(403) + File(workspaceDir(worker.id), "server_errors.tmp").mkdirs() + + val result = worker.doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(SendStatus.PENDING, rows[REFERENCE_ID]?.sendStatus) + } + + // The file "unavailable" as the workspace writes it: the time, the progress point and the number of answers. + private fun seedUnavailable(worker: UploadAndShareFilesWorker, since: Long, point: Int, answers: Int) { + workspaceDir(worker.id).mkdirs() + File(workspaceDir(worker.id), "unavailable").writeText("$since\n$point\n$answers") + } + + private fun probeRefusedByServer(code: Int) { + val error = HttpException(Response.error(code, "".toResponseBody())) + runBlocking { + whenever(ncApiCoroutines.probeConversationAttachmentFolder(any(), any(), any())).thenThrow(error) + } + } + + @Test + fun `a failed upload reports the failure in the output data`() { + val worker = worker() + whenever(runBlocking { userManager.getUserWithId(ACCOUNT_ID) }).thenReturn(null) + + val result = worker.doWork() + + assertTrue(result is ListenableWorker.Result.Success) + assertEquals(true, result.outputData.getBoolean(UploadAndShareFilesWorker.KEY_UPLOAD_FAILED, false)) + assertEquals(SendStatus.FAILED, rows[REFERENCE_ID]?.sendStatus) + } + + @Test + fun `a cancelled upload does not report a failure`() { + val worker = worker() + UploadWorkspace(workspaceDir(worker.id)).markCancelled() + + val result = worker.doWork() + + assertTrue(result is ListenableWorker.Result.Success) + assertFalse(result.outputData.getBoolean(UploadAndShareFilesWorker.KEY_UPLOAD_FAILED, false)) + } + + @Test + fun `a delivered upload does not report a failure`() { + val worker = worker() + UploadWorkspace(workspaceDir(worker.id)).markShared() + + val result = worker.doWork() + + assertTrue(result is ListenableWorker.Result.Success) + assertFalse(result.outputData.getBoolean(UploadAndShareFilesWorker.KEY_UPLOAD_FAILED, false)) + assertEquals(SendStatus.SENT_PENDING_ACK, rows[REFERENCE_ID]?.sendStatus) + } + + @Test + fun `a failed upload removes its file from the draft folder`() { + val server = startServer() + val worker = worker(draft = true) + refusePublishing(worker) + + worker.doWork() + + assertEquals(SendStatus.FAILED, rows[REFERENCE_ID]?.sendStatus) + val request = server.takeRequest(REQUEST_WAIT_SECONDS, TimeUnit.SECONDS) + assertEquals("DELETE", request?.method) + assertEquals("/remote.php/dav/files/alice/Talk/draft/id-photo.jpg", request?.target) + } + + @Test + fun `a failed upload leaves the file in the attachment folder of Talk`() { + val server = startServer() + val worker = worker(draft = false) + refusePublishing(worker) + + worker.doWork() + + assertEquals(SendStatus.FAILED, rows[REFERENCE_ID]?.sendStatus) + assertEquals(0, server.requestCount) + } + + @Test + fun `a cancelled upload still removes its draft file`() { + val server = startServer() + val worker = worker(draft = true) + UploadWorkspace(workspaceDir(worker.id)).markCancelled() + + worker.doWork() + + assertEquals("DELETE", server.takeRequest(REQUEST_WAIT_SECONDS, TimeUnit.SECONDS)?.method) + } + + // Three server errors are counted already, so the next refusal of the server is the last one. + private fun refusePublishing(worker: UploadAndShareFilesWorker) { + val workspace = UploadWorkspace(workspaceDir(worker.id)) + repeat(UploadRetryPolicy.MAX_SERVER_ERRORS - 1) { workspace.registerServerError() } + val forbidden = HttpException(Response.error(FORBIDDEN, "".toResponseBody())) + // The draft path publishes with postConversationAttachment, the attachment folder path shares. + whenever(runBlocking { ncApiCoroutines.postConversationAttachment(any(), any(), any()) }).thenThrow(forbidden) + whenever(ncApi.createRemoteShare(anyOrNull(), any(), any(), any(), any(), anyOrNull(), any())) + .thenReturn(Observable.error(forbidden)) + } + + private fun startServer(): MockWebServer { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = MockResponse(code = NO_CONTENT) + } + server.start() + whenever(user.baseUrl).thenReturn(server.url("/").toString().trimEnd('/')) + return server + } + + @Test + fun `a small file is uploaded without a foreground service`() { + val worker = worker(source = fileUri(SMALL_FILE_SIZE), uploaded = false) + + worker.doWork() + + assertEquals(0, foregroundRequests.get()) + } + + @Test + fun `a file that is uploaded in chunks runs as a foreground service`() { + val worker = worker(source = fileUri(BIG_FILE_SIZE), uploaded = false) + + worker.doWork() + + assertEquals(1, foregroundRequests.get()) + } + + @Test + fun `an upload that only needs the last request runs without a foreground service`() { + val worker = worker(source = fileUri(BIG_FILE_SIZE), uploaded = true) + uploadedAndRefusedByServer(500) + + worker.doWork() + + assertEquals(0, foregroundRequests.get()) + } + + @Test + fun `a later run takes the size from the prepared copy, not from the source`() { + // The source of unknown size would count as big; its copy is small. + val worker = worker(source = "content://media/photo.jpg", uploaded = false, preparedSize = SMALL_FILE_SIZE) + + worker.doWork() + + assertEquals(0, foregroundRequests.get()) + } + + @Test + fun `a later run with a big prepared copy runs as a foreground service`() { + val worker = worker(source = fileUri(SMALL_FILE_SIZE), uploaded = false, preparedSize = BIG_FILE_SIZE) + + worker.doWork() + + assertEquals(1, foregroundRequests.get()) + } + + @Test + fun `an empty file is uploaded without a foreground service`() { + val worker = worker(source = fileUri(0), uploaded = false) + + worker.doWork() + + assertEquals(0, foregroundRequests.get()) + } + + @Test + fun `an empty prepared copy is taken as it is and needs no foreground service`() { + // The source of unknown size would count as big; the copy is empty and small. + val worker = worker(source = "content://media/empty.txt", uploaded = false, preparedSize = 0) + + worker.doWork() + + assertEquals(0, foregroundRequests.get()) + } + + private fun fileUri(size: Int): String = + Uri.fromFile(File(context().cacheDir, "source-$size.bin").apply { writeBytes(ByteArray(size)) }).toString() + + /** The file is uploaded already and the server refuses the share with [code]. */ + private fun uploadedAndRefusedByServer(code: Int) { + whenever(ncApi.createRemoteShare(anyOrNull(), any(), any(), any(), any(), anyOrNull(), any())) + .thenReturn(Observable.error(HttpException(Response.error(code, "".toResponseBody())))) + } + + private fun worker( + source: String = "content://media/photo.jpg", + uploaded: Boolean = true, + draft: Boolean = false, + id: UUID? = null, + preparedSize: Int? = null + ): UploadAndShareFilesWorker { + rows[REFERENCE_ID] = placeholder() + if (draft) { + // The draft folder path is used when the server has conversation subfolders. + val attachments = hashMapOf("conversation-subfolders" to true) + whenever(spreed.config).thenReturn(hashMapOf("attachments" to attachments)) + runBlocking { + whenever(ncApiCoroutines.probeConversationAttachmentFolder(any(), any(), any())).thenReturn( + ChatProbeAttachmentFolderOverall( + ChatProbeAttachmentFolderOCS(null, ChatProbeAttachmentDataDto(folder = "Talk/draft")) + ) + ) + } + } + // Without capabilities a run that has to upload ends at once, after the foreground service was asked for. + if (!uploaded && !draft) { + whenever(user.capabilities).thenReturn(null) + } + val factory = object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker = + UploadAndShareFilesWorker(appContext, workerParameters).also { + it.userManager = userManager + it.chatDao = chatDao + it.ncApi = ncApi + it.ncApiCoroutines = ncApiCoroutines + it.okHttpClient = OkHttpClient() + } + } + val input = Data.Builder() + .putLong(KEY_INTERNAL_USER_ID, ACCOUNT_ID) + .putString("DEVICE_SOURCE_FILE", source) + .putString("ROOM_TOKEN", "room") + .putString("CONVERSATION_NAME", "Room") + .putString(UploadAndShareFilesWorker.KEY_REFERENCE_ID, REFERENCE_ID) + .putString(UploadAndShareFilesWorker.KEY_INTERNAL_CONVERSATION_ID, CONVERSATION_ID) + .build() + val worker = TestListenableWorkerBuilder(contextWithStrings()) + .setInputData(input) + .setWorkerFactory(factory) + .apply { id?.let { setId(it) } } + .setForegroundUpdater { _, _, _ -> + foregroundRequests.incrementAndGet() + ResolvableFuture.create().also { it.set(null) } + } + .build() + if (uploaded) { + val path = if (draft) "/Talk/draft/id-photo.jpg" else "/Talk/photo.jpg" + UploadWorkspace(workspaceDir(worker.id)).markUploaded(path, "photo.jpg", draft) + } + preparedSize?.let { size -> + UploadWorkspace(workspaceDir(worker.id)).prepareOnce { dir -> + PreparedUpload(File(dir, "photo.jpg").apply { writeBytes(ByteArray(size)) }, "photo.jpg") + } + } + return worker + } + + private fun placeholder() = + ChatMessageEntity( + internalId = "$CONVERSATION_ID@_temp_$REFERENCE_ID", + accountId = ACCOUNT_ID, + token = "room", + internalConversationId = CONVERSATION_ID, + actorDisplayName = "Me", + message = "file", + actorId = "me", + actorType = "users", + messageType = "comment", + systemMessageType = ChatMessage.SystemMessageType.DUMMY, + referenceId = REFERENCE_ID, + isTemporary = true, + sendStatus = SendStatus.PENDING + ) + + private fun workspaceDir(id: UUID) = File(File(context().noBackupFilesDir, "uploads"), id.toString()) + + private fun now() = System.currentTimeMillis() + + // The unit tests have no resources of the app; the failure notification only needs some text. + private fun contextWithStrings(): Context { + val base = context().resources + + @Suppress("DEPRECATION") + val resources = object : Resources(base.assets, base.displayMetrics, base.configuration) { + override fun getString(id: Int): String = "text %s" + } + return object : ContextWrapper(context()) { + override fun getResources(): Resources = resources + } + } + + private fun context(): Context = ApplicationProvider.getApplicationContext() + + companion object { + private const val ACCOUNT_ID = 1L + private const val CONVERSATION_ID = "1@room" + private const val REFERENCE_ID = "ref-a" + private const val HOURS_25 = 25L * 60 * 60 * 1000 + private const val HOURS_26 = 26L * 60 * 60 * 1000 + private const val ANSWERS = 10 + private const val PROGRESS_PERCENT = 50 + private const val FORBIDDEN = 403 + private const val NO_CONTENT = 204 + private const val REQUEST_WAIT_SECONDS = 5L + private const val SMALL_FILE_SIZE = 10 * 1024 + private const val BIG_FILE_SIZE = 1024 * 1024 + 1 + } +} diff --git a/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt b/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt new file mode 100644 index 0000000000..58b3a5687e --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/jobs/UploadCancelCascadeTest.kt @@ -0,0 +1,361 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.jobs + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.ListenableWorker +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.impl.Processor +import androidx.work.impl.StartStopToken +import androidx.work.impl.WorkManagerImpl +import androidx.work.testing.WorkManagerTestInitHelper +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.database.dao.ChatMessagesDao +import com.nextcloud.talk.data.database.model.ChatMessageEntity +import com.nextcloud.talk.data.database.model.SendStatus +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.upload.UploadWorkspace +import com.nextcloud.talk.users.UserManager +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * The uploads of a conversation are chained behind each other (APPEND_OR_REPLACE). Cancelling one upload must + * not cancel or fail the uploads behind it: WorkManager gives every request chained behind a cancelled or failed + * one the same state without running it, and their placeholder rows would stay PENDING for ever. + * + * The works run on a real WorkManager. A request chained behind another one is BLOCKED until the one ahead is + * done, so the network constraint is granted one request after the other, as in [UploadAndShareFilesWorkerTest]. + * The uploads that are not cancelled are marked as shared, so they finish at once without a server. + */ +@Suppress("TooManyFunctions") +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class UploadCancelCascadeTest { + + private val rows = ConcurrentHashMap() + + // Not a SynchronousExecutor: the worker looks up other works (deleteFinishedWorkspaces) and waits for the + // answer, which a worker running on the thread of WorkManager's own task executor would never get. + private val workerExecutor = Executors.newFixedThreadPool(2) + private val previousApplication = NextcloudTalkApplication.sharedApplication + private val user: User = mock() + private val userManager: UserManager = mock { + on { runBlocking { getUserWithId(ACCOUNT_ID) } } doReturn user + } + private val chatDao: ChatMessagesDao = mock { + on { getTempMessageForConversation(any(), any(), anyOrNull()) } doAnswer { + flowOf(rows[it.getArgument(1)]) + } + on { deleteTempChatMessageIfPending(any(), any()) } doAnswer { + val removed = rows[it.getArgument(1)]?.takeIf { row -> row.sendStatus == SendStatus.PENDING } + removed?.let { row -> rows.remove(row.referenceId) } + if (removed == null) 0 else 1 + } + } + + init { + // The worker writes only the send status of the temporary row. + doAnswer { + val referenceId = it.getArgument(1) + rows[referenceId]?.let { row -> rows[referenceId] = row.copy(sendStatus = it.getArgument(2)) } + 1 + }.whenever(chatDao).updateTempMessageSendStatus(any(), any(), any()) + } + + @After + fun tearDown() { + workerExecutor.shutdownNow() + setSharedApplication(previousApplication) + } + + @Test + fun `cancelling the first upload does not cancel the uploads behind it`() { + val workManager = initWorkManager() + val (a, b, c) = enqueue("ref-a", "ref-b", "ref-c") + + UploadAndShareFilesWorker.cancelUpload("ref-a", a) + awaitAllFinished(workManager, a, b, c) + + assertAllSucceeded(workManager, a, b, c) + assertCancelled("ref-a") + assertDelivered("ref-b", "ref-c") + } + + @Test + fun `cancelling a middle upload leaves the ones before and behind it`() { + val workManager = initWorkManager() + val (a, b, c) = enqueue("ref-a", "ref-b", "ref-c") + + UploadAndShareFilesWorker.cancelUpload("ref-b", b) + awaitAllFinished(workManager, a, b, c) + + assertAllSucceeded(workManager, a, b, c) + assertCancelled("ref-b") + assertDelivered("ref-a", "ref-c") + } + + @Test + fun `cancelling the last upload leaves the ones ahead of it`() { + val workManager = initWorkManager() + val (a, b, c) = enqueue("ref-a", "ref-b", "ref-c") + + UploadAndShareFilesWorker.cancelUpload("ref-c", c) + awaitAllFinished(workManager, a, b, c) + + assertAllSucceeded(workManager, a, b, c) + assertCancelled("ref-c") + assertDelivered("ref-a", "ref-b") + } + + @Test + fun `cancelling every upload one after the other finishes them all`() { + val workManager = initWorkManager() + val (a, b, c) = enqueue("ref-a", "ref-b", "ref-c") + + UploadAndShareFilesWorker.cancelUpload("ref-a", a) + UploadAndShareFilesWorker.cancelUpload("ref-b", b) + UploadAndShareFilesWorker.cancelUpload("ref-c", c) + awaitAllFinished(workManager, a, b, c) + + assertAllSucceeded(workManager, a, b, c) + assertCancelled("ref-a", "ref-b", "ref-c") + } + + @Test + fun `an upload cancelled by its tag after a restart does not cancel the uploads behind it`() { + val workManager = initWorkManager() + val (a, b, c) = enqueue("ref-a", "ref-b", "ref-c") + + // After a restart of the app the id of the work is not known, only the reference id. + UploadAndShareFilesWorker.cancelUpload("ref-a", null) + awaitAllFinished(workManager, a, b, c) + + assertAllSucceeded(workManager, a, b, c) + assertCancelled("ref-a") + assertDelivered("ref-b", "ref-c") + } + + @Test + fun `a cancelled upload whose placeholder cannot be removed does not fail the uploads behind it`() { + val workManager = initWorkManager() + whenever(chatDao.deleteTempChatMessageIfPending(any(), any())).thenThrow(IllegalStateException("db closed")) + val (a, b, c) = enqueue("ref-a", "ref-b", "ref-c") + + UploadAndShareFilesWorker.cancelUpload("ref-a", a) + awaitAllFinished(workManager, a, b, c) + + assertAllSucceeded(workManager, a, b, c) + assertDelivered("ref-b", "ref-c") + } + + @Test + fun `an upload cancelled while WorkManager stops it is still cancelled in its next run`() { + val workManager = initWorkManager() + val firstRunInside = CountDownLatch(1) + val firstRunMayGo = CountDownLatch(1) + val lookups = AtomicInteger() + // The first run waits at the lookup of the account, as an upload that is busy when its network is lost. + whenever(runBlocking { userManager.getUserWithId(ACCOUNT_ID) }).thenAnswer { + if (lookups.getAndIncrement() == 0) { + firstRunInside.countDown() + firstRunMayGo.await(WAIT_MILLIS, TimeUnit.MILLISECONDS) + } + user + } + val (a) = enqueue("ref-a") + val driver = WorkManagerTestInitHelper.getTestDriver(context())!! + + driver.setAllConstraintsMet(a) + assertTrue(firstRunInside.await(WAIT_MILLIS, TimeUnit.MILLISECONDS)) + // The network is lost: WorkManager stops the run, which goes on in its thread, and the user cancels. + stopRun(a) + UploadAndShareFilesWorker.cancelUpload("ref-a", a) + firstRunMayGo.countDown() + awaitStoppedRunFinished(a) + assertTrue("the cancel flag survives the stopped run", File(workspaceDir(a), "cancelled").exists()) + + awaitAllFinished(workManager, a) + + assertAllSucceeded(workManager, a) + assertCancelled("ref-a") + assertEquals("the second run looked the account up", 2, lookups.get()) + } + + // What WorkManager does when the network is lost: stops the run, whose thread goes on, and schedules the + // work again. + private fun stopRun(id: UUID) { + val processor = WorkManagerImpl.getInstance(context()).processor + // The processor knows a run by the token it started it with, so the token is taken from its map of runs. + val runs = Processor::class.java.getDeclaredField("mWorkRuns").apply { isAccessible = true } + .get(processor) as Map<*, *> + val token = (runs[id.toString()] as Set<*>).single() as StartStopToken + assertTrue( + "the run was stopped", + processor.stopWork(token, WorkInfo.STOP_REASON_CONSTRAINT_CONNECTIVITY) + ) + } + + // The run that WorkManager stopped is over when it released the lock of its workspace. + private fun awaitStoppedRunFinished(id: UUID) { + val deadline = System.currentTimeMillis() + WAIT_MILLIS + while (true) { + val workspace = UploadWorkspace(workspaceDir(id)) + if (workspace.tryLock()) { + workspace.unlock() + return + } + check(System.currentTimeMillis() < deadline) { "the stopped run did not finish" } + Thread.sleep(POLL_MILLIS) + } + } + + private fun workspaceDir(id: UUID) = File(File(context().noBackupFilesDir, "uploads"), id.toString()) + + private fun assertAllSucceeded(workManager: WorkManager, vararg ids: UUID) { + assertEquals( + ids.map { WorkInfo.State.SUCCEEDED }, + ids.map { workManager.getWorkInfoById(it).get()!!.state } + ) + } + + private fun assertCancelled(vararg referenceIds: String) { + referenceIds.forEach { + assertNull("the placeholder of $it is removed", rows[it]) + } + } + + private fun assertDelivered(vararg referenceIds: String) { + referenceIds.forEach { + assertEquals("the placeholder of $it", SendStatus.SENT_PENDING_ACK, rows[it]?.sendStatus) + } + } + + /** + * Enqueues one upload per reference id into the queue of the conversation, in this order. The placeholder rows + * are PENDING. The uploads are marked as shared; only a cancel changes what a work does. + */ + private fun enqueue(vararg referenceIds: String): List = + referenceIds.map { referenceId -> + rows[referenceId] = placeholder(referenceId) + val id = UploadAndShareFilesWorker.upload( + userId = ACCOUNT_ID, + fileUri = "content://media/$referenceId", + roomToken = "room", + conversationName = "Room", + metaData = null, + referenceId = referenceId, + internalConversationId = CONVERSATION_ID + ) + UploadWorkspace(workspaceDir(id)).markShared() + id + } + + private fun placeholder(referenceId: String) = + ChatMessageEntity( + internalId = "$CONVERSATION_ID@_temp_$referenceId", + accountId = ACCOUNT_ID, + token = "room", + internalConversationId = CONVERSATION_ID, + actorDisplayName = "Me", + message = "file", + actorId = "me", + actorType = "users", + messageType = "comment", + systemMessageType = ChatMessage.SystemMessageType.DUMMY, + referenceId = referenceId, + isTemporary = true, + sendStatus = SendStatus.PENDING + ) + + private fun initWorkManager(): WorkManager { + val factory = object : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker = + UploadAndShareFilesWorker(appContext, workerParameters).also { + it.userManager = userManager + it.chatDao = chatDao + } + } + val configuration = Configuration.Builder() + .setExecutor(workerExecutor) + .setWorkerFactory(factory) + .build() + WorkManagerTestInitHelper.initializeTestWorkManager(context(), configuration) + // cancelUpload finds the context of the application here. + setSharedApplication( + mock { + on { applicationContext } doReturn context() + } + ) + return WorkManager.getInstance(context()) + } + + private fun awaitAllFinished(workManager: WorkManager, vararg ids: UUID) { + val deadline = System.currentTimeMillis() + WAIT_MILLIS + val driver = WorkManagerTestInitHelper.getTestDriver(context())!! + while (ids.any { !workManager.getWorkInfoById(it).get()!!.state.isFinished }) { + // A request only becomes ENQUEUED once the one ahead of it is done. The network constraint is granted + // to every request of the queue that is waiting for it. + workManager.getWorkInfosForUniqueWork("upload_queue_$CONVERSATION_ID").get() + .filter { it.state == WorkInfo.State.ENQUEUED } + .forEach { driver.setAllConstraintsMet(it.id) } + check(System.currentTimeMillis() < deadline) { + "works did not finish: " + ids.map { workManager.getWorkInfoById(it).get()!!.state } + } + Thread.sleep(POLL_MILLIS) + } + } + + // The setter of sharedApplication is protected, and the real application is not started in this test. + private fun setSharedApplication(application: NextcloudTalkApplication?) { + NextcloudTalkApplication::class.java.getDeclaredField("sharedApplication").apply { + isAccessible = true + set(null, application) + } + } + + private fun context(): Context = ApplicationProvider.getApplicationContext() + + companion object { + private const val ACCOUNT_ID = 1L + private const val CONVERSATION_ID = "1@room" + private const val WAIT_MILLIS = 30_000L + private const val POLL_MILLIS = 20L + } +} diff --git a/app/src/test/java/com/nextcloud/talk/upload/UploadProgressThrottleTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadProgressThrottleTest.kt new file mode 100644 index 0000000000..ccaabc16de --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadProgressThrottleTest.kt @@ -0,0 +1,52 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.upload + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class UploadProgressThrottleTest { + + private var now = 0L + private val throttle = UploadProgressThrottle(1000) { now } + + @Test + fun `first update passes`() { + assertTrue(throttle.shouldUpdate(1)) + } + + @Test + fun `updates within a second are dropped`() { + throttle.shouldUpdate(1) + now = 999 + assertFalse(throttle.shouldUpdate(2)) + } + + @Test + fun `update passes after a second`() { + throttle.shouldUpdate(1) + now = 1000 + assertTrue(throttle.shouldUpdate(2)) + } + + @Test + fun `unchanged value is dropped`() { + throttle.shouldUpdate(5) + now = 5000 + assertFalse(throttle.shouldUpdate(5)) + } + + @Test + fun `a dropped update does not restart the interval`() { + throttle.shouldUpdate(1) + now = 600 + throttle.shouldUpdate(2) + now = 1000 + assertTrue(throttle.shouldUpdate(3)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt new file mode 100644 index 0000000000..4edcb6c2f1 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadRetryPolicyTest.kt @@ -0,0 +1,205 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.upload + +import at.bitfire.dav4jvm.exception.HttpException as DavHttpException +import com.nextcloud.talk.upload.UploadRetryPolicy.Decision +import com.nextcloud.talk.upload.UploadRetryPolicy.FailureKind +import android.app.Application +import android.system.ErrnoException +import android.system.OsConstants +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import retrofit2.HttpException as RetrofitHttpException +import retrofit2.Response +import java.io.FileNotFoundException +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.net.UnknownServiceException +import java.security.cert.CertPathValidatorException +import java.security.cert.CertificateExpiredException +import javax.net.ssl.SSLException +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.SSLPeerUnverifiedException + +@Suppress("TooManyFunctions") +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class UploadRetryPolicyTest { + + @Test + fun `connection problems are network errors`() { + assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(SocketTimeoutException())) + assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(UnknownHostException())) + assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(IOException("Canceled"))) + } + + @Test + fun `http errors that may stay are server errors`() { + assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(DavHttpException(403, "no"))) + assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(DavHttpException(400, "bad"))) + listOf(400, 401, 403, 404, 409, 412, 501, 505, 507).forEach { + assertEquals("code $it", FailureKind.SERVER, UploadRetryPolicy.classify(DavHttpException(it, "x"))) + assertEquals("retrofit $it", FailureKind.SERVER, UploadRetryPolicy.classify(retrofit(it))) + } + assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(retrofit(404))) + } + + @Test + fun `a server that is unavailable is waited for, but not for ever`() { + listOf(500, 502, 503, 504, 408, 429).forEach { + assertEquals("code $it", FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(DavHttpException(it, "x"))) + } + listOf(500, 502, 503, 504, 408, 429).forEach { + assertEquals("retrofit $it", FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(retrofit(it))) + } + } + + @Test + fun `http error wrapped in an IOException is still a server error`() { + val wrapped = IOException("failed to create folder", DavHttpException(403, "no")) + assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(wrapped)) + } + + @Test + fun `an IOException wrapped by RxJava is a network error`() { + assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(RuntimeException(IOException("reset")))) + assertEquals( + FailureKind.NETWORK, + UploadRetryPolicy.classify(RuntimeException(RuntimeException(UnknownHostException()))) + ) + } + + @Test + fun `an http error wrapped by RxJava is classified by its code`() { + assertEquals(FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(RuntimeException(retrofit(503)))) + assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(RuntimeException(retrofit(403)))) + } + + @Test + fun `an http error wins over an IOException in the chain`() { + val error = IOException("outer", RuntimeException(DavHttpException(403, "no"))) + assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(error)) + } + + @Test + fun `certificate problems count against the limit`() { + assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(SSLPeerUnverifiedException("who"))) + assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(UnknownServiceException("cleartext"))) + val untrusted = SSLHandshakeException("bad certificate").apply { + initCause(CertPathValidatorException("trust anchor not found")) + } + assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(untrusted)) + assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(RuntimeException(untrusted))) + val expired = SSLHandshakeException("expired").apply { initCause(CertificateExpiredException()) } + assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(expired)) + } + + @Test + fun `a connection that drops inside TLS is a network error`() { + val reset = SSLException("Read error: ssl=0x1: I/O error during system call, Connection reset by peer") + assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(reset)) + assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(SSLException("Write error: Broken pipe"))) + assertEquals( + FailureKind.NETWORK, + UploadRetryPolicy.classify(SSLHandshakeException("Connection closed by peer")) + ) + assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classify(RuntimeException(reset))) + } + + @Test + fun `a full or read only disk fails the preparation at once`() { + val full = IOException("write failed: ENOSPC", ErrnoException("write", OsConstants.ENOSPC)) + val readOnly = IOException("open failed: EROFS", ErrnoException("open", OsConstants.EROFS)) + assertEquals(FailureKind.OTHER, UploadRetryPolicy.classifyPreparation(full)) + assertEquals(FailureKind.OTHER, UploadRetryPolicy.classifyPreparation(readOnly)) + assertEquals(FailureKind.OTHER, UploadRetryPolicy.classifyPreparation(FileNotFoundException())) + } + + @Test + fun `other IO errors while preparing are waited for`() { + assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classifyPreparation(IOException("stream closed"))) + assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classifyPreparation(SocketTimeoutException())) + val brokenPipe = IOException("write failed", ErrnoException("write", OsConstants.EPIPE)) + assertEquals(FailureKind.NETWORK, UploadRetryPolicy.classifyPreparation(brokenPipe)) + } + + @Test + fun `local problems are neither network nor server errors`() { + assertEquals(FailureKind.OTHER, UploadRetryPolicy.classify(FileNotFoundException())) + assertEquals(FailureKind.OTHER, UploadRetryPolicy.classify(RuntimeException(FileNotFoundException()))) + assertEquals(FailureKind.OTHER, UploadRetryPolicy.classify(IllegalArgumentException())) + } + + @Test + fun `a chunk that cannot be read fails at once whatever its cause is`() { + // ChunkFromFileRequestBody wraps every IOException of reading the chunk in a FileNotFoundException. + val unreadable = FileNotFoundException("Exception reading source file").apply { initCause(IOException("EIO")) } + assertEquals(FailureKind.OTHER, UploadRetryPolicy.classify(unreadable)) + assertEquals(FailureKind.OTHER, UploadRetryPolicy.classify(RuntimeException(unreadable))) + assertEquals(FailureKind.OTHER, UploadRetryPolicy.classify(IOException("outer", unreadable))) + } + + @Test + fun `an http error wins over an unreadable file`() { + val error = FileNotFoundException("x").apply { initCause(DavHttpException(403, "no")) } + assertEquals(FailureKind.SERVER, UploadRetryPolicy.classify(error)) + } + + @Test + fun `network errors never use up attempts`() { + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.NETWORK, 0, 0L, 0)) + assertEquals( + Decision.RETRY, + UploadRetryPolicy.decide(FailureKind.NETWORK, 0, UploadRetryPolicy.MAX_UNAVAILABLE_MILLIS * 2, 0) + ) + } + + @Test + fun `an unavailable server is waited for up to 24 hours`() { + val limit = UploadRetryPolicy.MAX_UNAVAILABLE_MILLIS + val answers = UploadRetryPolicy.MIN_UNAVAILABLE_ANSWERS + assertEquals(24L * 60 * 60 * 1000, limit) + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, 0L, 1)) + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit - 1, answers)) + assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit, answers)) + assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit + 1, answers)) + } + + @Test + fun `a few answers after a long wait do not fail the upload`() { + val limit = UploadRetryPolicy.MAX_UNAVAILABLE_MILLIS + val answers = UploadRetryPolicy.MIN_UNAVAILABLE_ANSWERS + assertEquals(10, answers) + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit * 2, 1)) + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit * 2, answers - 1)) + assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.UNAVAILABLE, 0, limit * 2, answers)) + } + + @Test + fun `server errors are retried until the limit`() { + for (count in 1 until UploadRetryPolicy.MAX_SERVER_ERRORS) { + assertEquals(Decision.RETRY, UploadRetryPolicy.decide(FailureKind.SERVER, count, 0L, 0)) + } + assertEquals( + Decision.FAIL, + UploadRetryPolicy.decide(FailureKind.SERVER, UploadRetryPolicy.MAX_SERVER_ERRORS, 0L, 0) + ) + } + + @Test + fun `other errors fail at once`() { + assertEquals(Decision.FAIL, UploadRetryPolicy.decide(FailureKind.OTHER, 0, 0L, 0)) + } + + private fun retrofit(code: Int) = RetrofitHttpException(Response.error(code, "".toResponseBody())) +} diff --git a/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt new file mode 100644 index 0000000000..cb76ad61ea --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/upload/UploadWorkspaceTest.kt @@ -0,0 +1,376 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.upload + +import com.nextcloud.talk.utils.FileUtils +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +@Suppress("TooManyFunctions") +class UploadWorkspaceTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private fun workspace(name: String = "work") = UploadWorkspace(File(tempFolder.root, name)) + + private fun create(dir: File, name: String, lastModified: Long): PreparedUpload { + val file = File(dir, name).apply { + writeBytes(ByteArray(SIZE)) + setLastModified(lastModified) + } + return PreparedUpload(file, name) + } + + @Test + fun `file is prepared once and reused by later runs`() { + var calls = 0 + val first = workspace().prepareOnce { dir -> calls++.let { create(dir, "photo.jpg", 1_000) } } + val second = workspace().prepareOnce { dir -> calls++.let { create(dir, "photo.jpg", 2_000) } } + + assertEquals(1, calls) + assertEquals(first, second) + } + + @Test + fun `chunk folder key stays the same between runs`() { + val first = workspace().prepareOnce { create(it, "video.mp4", 1_000) }!! + val keyBefore = FileUtils.md5Sum(first.file) + val second = workspace().prepareOnce { create(it, "video.mp4", 9_000) }!! + + assertEquals(keyBefore, FileUtils.md5Sum(second.file)) + } + + @Test + fun `a new preparation of the same file gets another chunk folder key`() { + val first = workspace("a").prepareOnce { create(it, "video.mp4", 1_000) }!! + val second = workspace("b").prepareOnce { create(it, "video.mp4", 2_000) }!! + + assertTrue(FileUtils.md5Sum(first.file) != FileUtils.md5Sum(second.file)) + } + + @Test + fun `file is prepared again when it disappeared`() { + val first = workspace().prepareOnce { create(it, "photo.jpg", 1_000) }!! + first.file.delete() + var calls = 0 + val second = workspace().prepareOnce { dir -> calls++.let { create(dir, "photo.jpg", 3_000) } } + + assertEquals(1, calls) + assertNotNull(second) + assertTrue(second!!.file.isFile) + } + + @Test + fun `leftovers of an interrupted preparation are removed before the next one`() { + val ws = workspace() + ws.prepareOnce { dir -> + File(dir, "half.jpg").writeText("partial") + null + } + ws.prepareOnce { dir -> + assertEquals(emptyList(), dir.list()!!.toList()) + create(dir, "photo.jpg", 1_000) + } + } + + @Test + fun `a file named like a file of the workspace stays intact and is found again`() { + val names = WORKSPACE_FILE_NAMES + WORKSPACE_FILE_NAMES.map { "$it.tmp" } + "file" + for (name in names) { + val ws = workspace(name) + // The worker takes the lock before it prepares the file. + assertTrue(ws.tryLock()) + val prepared = ws.prepareOnce { create(it, name, 1_000) }!! + ws.markRestarted() + ws.markCancelled() + ws.markUploaded("/Talk/$name", name) + ws.markShared() + ws.registerServerError() + ws.recordProgress(40) + ws.registerUnavailable(5_000L) + val uploadId = ws.uploadId() + assertFalse(workspace(name).tryLock()) + + assertEquals(name, SIZE.toLong(), prepared.file.length()) + assertEquals(name, prepared, workspace(name).prepareOnce { null }) + assertEquals(name, prepared, workspace(name).prepared()) + assertEquals(name, SIZE.toLong(), prepared.file.length()) + assertTrue(name, workspace(name).isRestarted()) + assertTrue(name, workspace(name).isCancelled()) + assertTrue(name, workspace(name).isShared()) + assertEquals(name, "/Talk/$name", workspace(name).uploadedPath()) + assertEquals(name, name, workspace(name).uploadedName()) + assertEquals(name, 1, workspace(name).serverErrors()) + assertEquals(name, uploadId, workspace(name).uploadId()) + assertEquals(name, Unavailable(5_000L, 2), workspace(name).registerUnavailable(6_000L)) + assertFalse(workspace(name).tryLock()) + ws.unlock() + assertTrue(workspace(name).tryLock()) + } + } + + @Test + fun `an empty prepared file is kept and found again`() { + val prepared = workspace().prepareOnce { dir -> + PreparedUpload(File(dir, "empty.txt").apply { writeBytes(ByteArray(0)) }, "empty.txt") + }!! + + assertEquals(0L, prepared.file.length()) + assertEquals(prepared, workspace().prepareOnce { null }) + assertEquals(prepared, workspace().prepared()) + } + + @Test + fun `server errors are counted across runs`() { + assertEquals(0, workspace().serverErrors()) + assertEquals(1, workspace().registerServerError()) + assertEquals(2, workspace().registerServerError()) + assertEquals(2, workspace().serverErrors()) + } + + @Test + fun `server error count survives a new preparation`() { + workspace().registerServerError() + workspace().prepareOnce { create(it, "photo.jpg", 1_000) } + + assertEquals(1, workspace().serverErrors()) + } + + @Test + fun `delete removes the file and the counters`() { + val ws = workspace() + val prepared = ws.prepareOnce { create(it, "photo.jpg", 1_000) }!! + ws.registerServerError() + ws.delete() + + assertFalse(prepared.file.exists()) + assertEquals(0, workspace().serverErrors()) + assertNull(workspace().prepareOnce { null }) + } + + @Test + fun `workspaces of finished uploads are removed and living ones kept`() { + workspace("finished").prepareOnce { create(it, "a.jpg", 1_000) } + workspace("waiting-for-network").prepareOnce { create(it, "b.jpg", 1_000) } + + UploadWorkspace.deleteFinished(tempFolder.root) { it == "waiting-for-network" } + + assertFalse(File(tempFolder.root, "finished").exists()) + assertTrue(File(tempFolder.root, "waiting-for-network").exists()) + } + + @Test + fun `a locked workspace is not removed even when its work is unknown`() { + val running = workspace("running") + running.prepareOnce { create(it, "a.jpg", 1_000) } + assertTrue(running.tryLock()) + + UploadWorkspace.deleteFinished(tempFolder.root) { false } + + assertTrue(File(tempFolder.root, "running").exists()) + running.unlock() + UploadWorkspace.deleteFinished(tempFolder.root) { false } + assertFalse(File(tempFolder.root, "running").exists()) + } + + @Test + fun `only one run can hold the lock of a workspace`() { + val first = workspace() + val second = workspace() + + assertTrue(first.tryLock()) + assertFalse(second.tryLock()) + first.unlock() + assertTrue(second.tryLock()) + second.unlock() + } + + @Test + fun `the uploaded and shared stages are kept between runs`() { + val ws = workspace() + assertNull(ws.uploadedPath()) + assertFalse(ws.isShared()) + + ws.markUploaded("/Talk/video.mp4", "video_compressed.mp4") + assertEquals("/Talk/video.mp4", workspace().uploadedPath()) + assertEquals("video_compressed.mp4", workspace().uploadedName()) + assertFalse(workspace().isShared()) + + ws.markShared() + assertTrue(workspace().isShared()) + } + + @Test + fun `an uploaded file is marked as draft only when it went to the draft folder`() { + val ws = workspace() + assertFalse(ws.isDraftUploaded()) + + ws.markUploaded("/Talk/a.jpg", "a.jpg") + assertFalse(workspace().isDraftUploaded()) + assertEquals("a.jpg", workspace().uploadedName()) + + ws.markUploaded("/draft/id-a.jpg", "a.jpg", draft = true) + assertTrue(workspace().isDraftUploaded()) + assertEquals("/draft/id-a.jpg", workspace().uploadedPath()) + assertEquals("a.jpg", workspace().uploadedName()) + } + + @Test + fun `the stages survive a new preparation after the prepared file is gone`() { + val ws = workspace() + val prepared = ws.prepareOnce { create(it, "a.jpg", 1_000) }!! + ws.markUploaded("/Talk/a.jpg", "a.jpg") + ws.markShared() + ws.markRestarted() + prepared.file.delete() + + workspace().prepareOnce { create(it, "a.jpg", 2_000) } + + assertEquals("/Talk/a.jpg", workspace().uploadedPath()) + assertTrue(workspace().isShared()) + assertTrue(workspace().isRestarted()) + } + + @Test + fun `the upload id stays the same on every run`() { + val id = workspace().uploadId() + + assertEquals(id, workspace().uploadId()) + } + + @Test + fun `a cancel flag can be set before the work ran and survives a new preparation`() { + val ws = workspace() + assertFalse(ws.isCancelled()) + + ws.markCancelled() + ws.prepareOnce { create(it, "a.jpg", 1_000) } + + assertTrue(workspace().isCancelled()) + } + + @Test + fun `the stored prepared file is available without preparing`() { + assertNull(workspace().prepared()) + + val prepared = workspace().prepareOnce { create(it, "a.jpg", 1_000) } + + assertEquals(prepared, workspace().prepared()) + } + + @Test + fun `the first unavailable answer starts the clock and later answers keep it`() { + assertEquals(Unavailable(1_000L, 1), workspace().registerUnavailable(1_000L)) + assertEquals(Unavailable(1_000L, 2), workspace().registerUnavailable(5_000L)) + assertEquals(Unavailable(1_000L, 3), workspace().registerUnavailable(9_000L)) + } + + @Test + fun `more progress of the upload starts the clock again`() { + workspace().recordProgress(10) + assertEquals(Unavailable(1_000L, 1), workspace().registerUnavailable(1_000L)) + workspace().recordProgress(80) + assertEquals(Unavailable(5_000L, 1), workspace().registerUnavailable(5_000L)) + assertEquals(Unavailable(5_000L, 2), workspace().registerUnavailable(9_000L)) + } + + @Test + fun `the uploaded file starts the clock again`() { + workspace().recordProgress(99) + workspace().registerUnavailable(1_000L) + workspace().markUploaded("/Talk/a.jpg", "a.jpg") + + assertEquals(Unavailable(5_000L, 1), workspace().registerUnavailable(5_000L)) + } + + @Test + fun `a clock that went back starts the clock again`() { + workspace().registerUnavailable(10_000L) + + assertEquals(Unavailable(2_000L, 1), workspace().registerUnavailable(2_000L)) + } + + @Test + fun `progress is kept as the furthest one`() { + workspace().recordProgress(40) + workspace().recordProgress(20) + workspace().registerUnavailable(1_000L) + workspace().recordProgress(40) + + // 40 is not further than the 40 of the first answer. + assertEquals(Unavailable(1_000L, 2), workspace().registerUnavailable(2_000L)) + } + + @Test + fun `the clock and the progress survive a run that reuses the prepared file`() { + workspace().prepareOnce { create(it, "a.jpg", 1_000) } + workspace().recordProgress(40) + workspace().registerUnavailable(1_000L) + + workspace().prepareOnce { create(it, "a.jpg", 2_000) } + + assertEquals(Unavailable(1_000L, 2), workspace().registerUnavailable(9_000L)) + workspace().recordProgress(41) + assertEquals(Unavailable(9_500L, 1), workspace().registerUnavailable(9_500L)) + } + + @Test + fun `a restarted upload forgets the old progress and clock`() { + val ws = workspace() + ws.recordProgress(100) + repeat(ANSWERS) { ws.registerUnavailable(1_000L) } + + ws.markRestarted() + ws.recordProgress(30) + + // The upload sends from the beginning: 30 % is progress, the old 25 hours do not count. + assertEquals(Unavailable(90_000_000L, 1), workspace().registerUnavailable(90_000_000L)) + assertTrue(workspace().isRestarted()) + } + + @Test + fun `a new preparation forgets the old progress and clock`() { + val ws = workspace() + val first = ws.prepareOnce { create(it, "a.jpg", 1_000) }!! + ws.recordProgress(100) + repeat(ANSWERS) { ws.registerUnavailable(1_000L) } + first.file.delete() + + workspace().prepareOnce { create(it, "a.jpg", 2_000) } + workspace().recordProgress(30) + + assertEquals(Unavailable(90_000_000L, 1), workspace().registerUnavailable(90_000_000L)) + } + + companion object { + private const val SIZE = 16 + + // The files of the workspace itself, plus names a later version may use. + private val WORKSPACE_FILE_NAMES = listOf( + "prepared", + "lock", + "restarted", + "server_errors", + "uploaded", + "shared", + "cancelled", + "upload_id", + "unavailable", + "progress" + ) + + private const val ANSWERS = 10 + } +} diff --git a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderAbortTest.kt b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderAbortTest.kt index b5f233a4de..0b39e1c1c0 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderAbortTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderAbortTest.kt @@ -104,6 +104,19 @@ class ChunkedFileUploaderAbortTest { assertEquals(1, successCalls) } + @Test + fun `removes the folder of an uploader that is prepared but not yet uploading`() { + val file = tempFolder.newFile("prepared.txt").apply { writeBytes(ByteArray(FILE_SIZE)) } + val uploader = uploader() + uploader.prepare(file) + server.enqueue(MockResponse(code = NO_CONTENT)) + + uploader.abortUpload { successCalls++ } + + assertEquals(1, successCalls) + assertEquals("DELETE", server.takeRequest(1, TimeUnit.SECONDS)?.method) + } + @Test fun `reports success when the upload folder is deleted`() { val uploader = startedUploader() @@ -139,7 +152,7 @@ class ChunkedFileUploaderAbortTest { server.enqueue(MockResponse(code = NOT_FOUND)) server.enqueue(MockResponse(code = FORBIDDEN)) val uploader = uploader() - uploader.upload(file, "text/plain".toMediaType(), "/Talk/test.txt") + runCatching { uploader.upload(file, "text/plain".toMediaType(), "/Talk/test.txt") } repeat(server.requestCount) { server.takeRequest(1, TimeUnit.SECONDS) } return uploader } diff --git a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt new file mode 100644 index 0000000000..10c8425053 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderResumeTest.kt @@ -0,0 +1,535 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.upload.chunked + +import android.app.Application +import at.bitfire.dav4jvm.exception.HttpException +import com.nextcloud.talk.api.NcApiCoroutines +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.upload.UploadRetryPolicy +import com.nextcloud.talk.utils.FileUtils +import java.io.File +import java.io.IOException +import java.util.Collections +import java.util.concurrent.TimeUnit +import mockwebserver3.Dispatcher +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.RecordedRequest +import mockwebserver3.SocketEffect +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@Suppress("TooManyFunctions") +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class ChunkedFileUploaderResumeTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private val server = MockWebServer() + private val requests: MutableList = Collections.synchronizedList(mutableListOf()) + private lateinit var client: OkHttpClient + private lateinit var user: User + private lateinit var uploader: ChunkedFileUploader + private lateinit var file: File + + /** Parts the fake server already holds: name -> size. */ + private var partsOnServer: Map = emptyMap() + private var putResponseCode = CREATED + private var propfindResponseCode: Int? = null + private var propfindDisconnects = false + private var putDisconnects = false + private var deleteDisconnects = false + + /** Answers of the next MOVE requests; 201 when empty. */ + private val moveResponseCodes: MutableList = Collections.synchronizedList(mutableListOf()) + + @Before + fun setUp() { + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + requests.add(request) + return when (request.method) { + "PROPFIND" -> when { + propfindDisconnects -> disconnect() + propfindResponseCode != null -> MockResponse(code = propfindResponseCode!!) + else -> propfindResponse(request.target) + } + "MOVE" -> MockResponse( + code = if (moveResponseCodes.isEmpty()) CREATED else moveResponseCodes.removeAt(0) + ) + "PUT" -> if (putDisconnects) { + disconnect() + } else { + MockResponse(code = putResponseCode) + } + "MKCOL" -> MockResponse(code = METHOD_NOT_ALLOWED) + "DELETE" -> if (deleteDisconnects) { + disconnect() + } else { + MockResponse(code = CREATED) + } + else -> MockResponse(code = CREATED) + } + } + } + server.start() + client = OkHttpClient.Builder() + .retryOnConnectionFailure(false) + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + user = User( + id = 1, + userId = "alice", + username = "alice", + token = "token", + baseUrl = server.url("/").toString().trimEnd('/') + ) + uploader = ChunkedFileUploader(client, user, mock(), mock()) + file = tempFolder.newFile("video.mp4").apply { writeBytes(ByteArray(FILE_SIZE)) } + } + + @After + fun tearDown() { + server.close() + } + + @Test + fun `uploads only the parts missing on the server`() { + partsOnServer = mapOf(PART_0 to CHUNK, PART_1 to CHUNK) + + assertTrue(uploader.upload(file, null, "/Talk/video.mp4")) + + assertEquals(listOf("/$PART_2"), putPaths()) + } + + @Test + fun `uploads everything when the server has no parts`() { + assertTrue(uploader.upload(file, null, "/Talk/video.mp4")) + + assertEquals(listOf("/$PART_0", "/$PART_1", "/$PART_2"), putPaths()) + } + + @Test + fun `uploads from scratch when the server removed the upload folder`() { + propfindResponseCode = NOT_FOUND + + assertTrue(uploader.upload(file, null, "/Talk/video.mp4")) + + assertEquals(listOf("/$PART_0", "/$PART_1", "/$PART_2"), putPaths()) + } + + @Test + fun `uploads nothing but the assembly when the server has all parts`() { + partsOnServer = mapOf(PART_0 to CHUNK, PART_1 to CHUNK, PART_2 to LAST_CHUNK) + + assertTrue(uploader.upload(file, null, "/Talk/video.mp4")) + + assertEquals(emptyList(), putPaths()) + assertEquals(1, requests.count { it.method == "MOVE" }) + } + + @Test + fun `a part with a wrong size on the server is uploaded again`() { + partsOnServer = mapOf(PART_0 to CHUNK, PART_1 to CHUNK - 1, PART_2 to LAST_CHUNK) + + uploader.upload(file, null, "/Talk/video.mp4") + + assertEquals(listOf("/$PART_1"), putPaths()) + } + + @Test + fun `foreign names in the upload folder do not break the upload`() { + partsOnServer = mapOf(PART_0 to CHUNK, "readme.txt" to 3, "1-2" to 2, ".file" to FILE_SIZE) + + assertTrue(uploader.upload(file, null, "/Talk/video.mp4")) + + assertEquals(listOf("/$PART_1", "/$PART_2"), putPaths()) + } + + @Test + fun `assembly tells the server the total length`() { + uploader.upload(file, null, "/Talk/video.mp4") + + val move = requests.single { it.method == "MOVE" } + assertEquals(FILE_SIZE.toString(), move.headers["OC-Total-Length"]) + assertTrue(move.target.endsWith("/.file")) + } + + @Test + fun `a failing request throws and keeps the parts on the server`() { + putResponseCode = SERVER_ERROR + + try { + uploader.upload(file, null, "/Talk/video.mp4") + fail("expected the upload to throw") + } catch (e: HttpException) { + assertEquals(SERVER_ERROR, e.code) + } + assertEquals(0, requests.count { it.method == "DELETE" }) + } + + @Test + fun `the worker tries again after a service unavailable answer to a part but not after a rejected part`() { + putResponseCode = SERVICE_UNAVAILABLE + val unavailable = assertThrows(HttpException::class.java) { uploader.upload(file, null, "/Talk/video.mp4") } + assertEquals(UploadRetryPolicy.FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(unavailable)) + assertEquals( + UploadRetryPolicy.Decision.RETRY, + decideAfterManyServerErrors(unavailable) + ) + + putResponseCode = BAD_REQUEST + val rejected = assertThrows(HttpException::class.java) { uploader.upload(file, null, "/Talk/video.mp4") } + assertEquals(UploadRetryPolicy.FailureKind.SERVER, UploadRetryPolicy.classify(rejected)) + assertEquals( + UploadRetryPolicy.Decision.FAIL, + decideAfterManyServerErrors(rejected) + ) + } + + // The server errors are used up, the server answered "unavailable" for the first time. + private fun decideAfterManyServerErrors(error: Throwable) = + UploadRetryPolicy.decide(UploadRetryPolicy.classify(error), UploadRetryPolicy.MAX_SERVER_ERRORS, 0L, 1) + + @Test + fun `a lost network while sending a part is an error and keeps the parts on the server`() { + putDisconnects = true + + try { + uploader.upload(file, null, "/Talk/video.mp4") + fail("expected the upload to throw") + } catch (e: IOException) { + // retried later by the worker + } + assertEquals(0, requests.count { it.method == "DELETE" || it.method == "MOVE" }) + } + + @Test + fun `stop interrupts the upload without removing the parts`() { + uploader.stop() + + assertFalse(uploader.upload(file, null, "/Talk/video.mp4")) + + assertEquals(0, requests.count { it.method == "DELETE" || it.method == "MOVE" }) + assertEquals(emptyList(), putPaths()) + } + + @Test + fun `abort removes the parts from the server`() { + uploader.upload(file, null, "/Talk/video.mp4") + requests.clear() + + uploader.abortUpload {} + + assertEquals(1, requests.count { it.method == "DELETE" }) + } + + @Test + fun `stop does not cancel calls of other clients built from the same base client`() { + val hanging = MockWebServer() + hanging.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest) = + MockResponse.Builder().onResponseStart(SocketEffect.Stall).build() + } + hanging.start() + try { + val other = client.newBuilder().build() + val call = other.newCall(Request.Builder().url(hanging.url("/")).build()) + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) = Unit + override fun onResponse(call: Call, response: Response) = Unit + }) + hanging.takeRequest(TIMEOUT_SECONDS, TimeUnit.SECONDS) + + uploader.stop() + + assertFalse(call.isCanceled()) + call.cancel() + } finally { + hanging.close() + } + } + + @Test + fun `a rejected assembly removes the parts once and uploads the file again`() { + moveResponseCodes.add(BAD_REQUEST) + + assertTrue(uploader.upload(file, null, "/Talk/video.mp4")) + + assertEquals(1, requests.count { it.method == "DELETE" }) + assertEquals(2, requests.count { it.method == "MOVE" }) + assertEquals(2, requests.count { it.method == "PROPFIND" }) + } + + @Test + fun `an assembly rejected after an earlier restart is an error at once`() { + moveResponseCodes.add(BAD_REQUEST) + var marked = 0 + val restarted = ChunkedFileUploader( + client, + user, + mock(), + mock(), + isRestarted = { true }, + markRestarted = { marked++ } + ) + + try { + restarted.upload(file, null, "/Talk/video.mp4") + fail("expected the upload to throw") + } catch (e: HttpException) { + assertEquals(BAD_REQUEST, e.code) + } + assertEquals(0, requests.count { it.method == "DELETE" }) + assertEquals(0, marked) + } + + @Test + fun `a restart after a rejected assembly is reported once`() { + moveResponseCodes.add(BAD_REQUEST) + var marked = 0 + val first = ChunkedFileUploader( + client, + user, + mock(), + mock(), + markRestarted = { marked++ } + ) + + assertTrue(first.upload(file, null, "/Talk/video.mp4")) + + assertEquals(1, marked) + } + + @Test + fun `a restart is not reported when the parts could not be removed`() { + moveResponseCodes.add(BAD_REQUEST) + deleteDisconnects = true + var marked = 0 + val first = ChunkedFileUploader( + client, + user, + mock(), + mock(), + markRestarted = { marked++ } + ) + + try { + first.upload(file, null, "/Talk/video.mp4") + fail("expected the upload to throw") + } catch (e: IOException) { + assertEquals(0, marked) + } + } + + @Test + fun `a server error while listing the parts is an error`() { + propfindResponseCode = SERVER_ERROR + + try { + uploader.upload(file, null, "/Talk/video.mp4") + fail("expected the upload to throw") + } catch (e: HttpException) { + assertEquals(SERVER_ERROR, e.code) + } + assertEquals(emptyList(), putPaths()) + } + + @Test + fun `a rejected part is an error and keeps the parts on the server`() { + putResponseCode = BAD_REQUEST + + try { + uploader.upload(file, null, "/Talk/video.mp4") + fail("expected the upload to throw") + } catch (e: HttpException) { + assertEquals(BAD_REQUEST, e.code) + } + assertEquals(0, requests.count { it.method == "DELETE" }) + assertEquals(0, requests.count { it.method == "MOVE" }) + } + + @Test + fun `login, rate limit and timeout answers while listing the parts are errors`() { + for (code in listOf(401, 403, 408, 429)) { + requests.clear() + propfindResponseCode = code + val fresh = ChunkedFileUploader(client, user, mock(), mock()) + + try { + fresh.upload(file, null, "/Talk/video.mp4") + fail("expected the upload to throw for $code") + } catch (e: HttpException) { + assertEquals(code, e.code) + } + assertEquals(emptyList(), putPaths()) + } + } + + @Test + fun `an uploaded file is removed from the server`() { + uploader.deleteUploadedFile("/Talk/video.mp4") + + val delete = requests.single { it.method == "DELETE" } + assertTrue(delete.target.endsWith("/files/alice/Talk/video.mp4")) + } + + @Test + fun `removing an uploaded file never throws`() { + server.close() + + uploader.deleteUploadedFile("/Talk/video.mp4") + } + + @Test + fun `an assembly rejected twice is an error`() { + moveResponseCodes.addAll(listOf(BAD_REQUEST, BAD_REQUEST)) + + try { + uploader.upload(file, null, "/Talk/video.mp4") + fail("expected the upload to throw") + } catch (e: HttpException) { + assertEquals(BAD_REQUEST, e.code) + } + assertEquals(1, requests.count { it.method == "DELETE" }) + } + + @Test + fun `a lost network while listing the parts is an error and does not restart the upload`() { + propfindDisconnects = true + + try { + uploader.upload(file, null, "/Talk/video.mp4") + fail("expected the upload to throw") + } catch (e: IOException) { + // retried later by the worker + } + assertEquals(emptyList(), putPaths()) + } + + @Test + fun `abort of an uploader that did not start removes the folder of the file`() { + val fresh = ChunkedFileUploader(client, user, mock(), mock()) + + fresh.abortUpload(file) {} + + val delete = requests.single { it.method == "DELETE" } + assertTrue(delete.target.endsWith("/" + FileUtils.md5Sum(file))) + } + + @Test + fun `missing parts are planned around existing ones`() { + val onServer = listOf(Chunk(0, CHUNK - 1L), Chunk(2L * CHUNK, FILE_SIZE.toLong())) + + val missing = uploader.checkMissingChunks(onServer, FILE_SIZE.toLong()) + + assertEquals(listOf(Chunk(CHUNK.toLong(), 2L * CHUNK - 1)), missing) + } + + @Test + fun `parts listed out of order are not uploaded again`() { + val onServer = listOf(Chunk(2L * CHUNK, FILE_SIZE.toLong()), Chunk(0, CHUNK - 1L)) + + val missing = uploader.checkMissingChunks(onServer, FILE_SIZE.toLong()) + + assertEquals(listOf(Chunk(CHUNK.toLong(), 2L * CHUNK - 1)), missing) + } + + @Test + fun `all parts are planned when nothing is on the server`() { + val missing = uploader.checkMissingChunks(emptyList(), FILE_SIZE.toLong()) + + assertEquals( + listOf(Chunk(0, CHUNK - 1L), Chunk(CHUNK.toLong(), 2L * CHUNK - 1), Chunk(2L * CHUNK, FILE_SIZE.toLong())), + missing + ) + } + + @Test + fun `only well formed parts of the right size are accepted`() { + val length = FILE_SIZE.toLong() + assertEquals(Chunk(0, CHUNK - 1L), uploader.parseUploadedChunk(PART_0, CHUNK.toLong(), length)) + assertEquals(Chunk(2L * CHUNK, length), uploader.parseUploadedChunk(PART_2, LAST_CHUNK.toLong(), length)) + assertNull(uploader.parseUploadedChunk(PART_0, CHUNK - 1L, length)) + assertNull(uploader.parseUploadedChunk(".file", length, length)) + assertNull(uploader.parseUploadedChunk("readme.txt", 3, length)) + assertNull(uploader.parseUploadedChunk("0-1", 2, length)) + assertNull(uploader.parseUploadedChunk(null, 1, length)) + } + + private fun disconnect() = MockResponse.Builder().onResponseStart(SocketEffect.CloseSocket()).build() + + private fun putPaths(): List = + requests.filter { it.method == "PUT" }.map { "/" + it.target.substringAfterLast('/') } + + private fun propfindResponse(path: String): MockResponse { + val folder = path.trimEnd('/') + val members = partsOnServer.entries.joinToString("") { (name, size) -> + """ + + $folder/$name + $size + HTTP/1.1 200 OK + + """ + } + val body = """ + + + $folder/ + + HTTP/1.1 200 OK + + $members + """ + return MockResponse( + code = MULTI_STATUS, + headers = Headers.headersOf("Content-Type", "application/xml; charset=utf-8"), + body = body + ) + } + + companion object { + private const val CHUNK = 1_024_000 + private const val FILE_SIZE = 2 * CHUNK + 952_000 + private const val LAST_CHUNK = 952_000 + private const val PART_0 = "0000000000000000-0000000001023999" + private const val PART_1 = "0000000001024000-0000000002047999" + private const val PART_2 = "0000000002048000-0000000003000000" + private const val TIMEOUT_SECONDS = 10L + private const val CREATED = 201 + private const val MULTI_STATUS = 207 + private const val METHOD_NOT_ALLOWED = 405 + private const val NOT_FOUND = 404 + private const val BAD_REQUEST = 400 + private const val SERVER_ERROR = 500 + private const val SERVICE_UNAVAILABLE = 503 + } +} diff --git a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderTest.kt b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderTest.kt index c10b7e597d..e0c58917c9 100644 --- a/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderTest.kt +++ b/app/src/test/java/com/nextcloud/talk/upload/chunked/ChunkedFileUploaderTest.kt @@ -7,8 +7,10 @@ package com.nextcloud.talk.upload.chunked import android.app.Application +import at.bitfire.dav4jvm.exception.HttpException import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.upload.UploadRetryPolicy import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import mockwebserver3.MockResponse @@ -16,7 +18,6 @@ import mockwebserver3.MockWebServer import mockwebserver3.SocketEffect import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Before @@ -101,20 +102,21 @@ class ChunkedFileUploaderTest { } @Test - fun `returns false when the server rejects the chunk with an HTTP error`() { + fun `throws the HTTP error when the server rejects the chunk`() { val file = localFile(SMALL_FILE_SIZE) enqueueFolderCreated() enqueueNoChunksOnServer() server.enqueue(MockResponse(code = FORBIDDEN)) - val result = uploader().upload(file, MIME_TYPE, TARGET_PATH) + assertThrows(HttpException::class.java) { + uploader().upload(file, MIME_TYPE, TARGET_PATH) + } - assertFalse(result) assertEquals(listOf("MKCOL", "PROPFIND", "PUT"), takeRequests().map { it.method }) } @Test - fun `returns false when the server rejects assembling the chunks with an HTTP error`() { + fun `throws the HTTP error when the server rejects assembling the chunks`() { val file = localFile(SMALL_FILE_SIZE) enqueueFolderCreated() enqueueNoChunksOnServer() @@ -122,12 +124,31 @@ class ChunkedFileUploaderTest { server.enqueue(MockResponse(code = CREATED)) server.enqueue(MockResponse(code = FORBIDDEN)) - val result = uploader().upload(file, MIME_TYPE, TARGET_PATH) + assertThrows(HttpException::class.java) { + uploader().upload(file, MIME_TYPE, TARGET_PATH) + } - assertFalse(result) assertEquals(listOf("MKCOL", "PROPFIND", "PUT", "MKCOL", "MOVE"), takeRequests().map { it.method }) } + @Test + fun `a chunk that cannot be read fails the upload at once`() { + val file = localFile(SMALL_FILE_SIZE) + enqueueFolderCreated() + enqueueNoChunksOnServer() + server.enqueue(MockResponse(code = CREATED)) + // An IOException while the chunk is written reaches the uploader as a FileNotFoundException. + val failingListener = object : OnDataTransferProgressListener { + override fun onTransferProgress(percentage: Int): Unit = throw IOException("EIO") + } + + val error = assertThrows(IOException::class.java) { + ChunkedFileUploader(client, user, failingListener, ncApiCoroutines).upload(file, MIME_TYPE, TARGET_PATH) + } + + assertEquals(UploadRetryPolicy.FailureKind.OTHER, UploadRetryPolicy.classify(error)) + } + private fun uploader() = ChunkedFileUploader(client, user, progressListener, ncApiCoroutines) private fun localFile(size: Int): File = tempFolder.newFile("test.txt").apply { writeBytes(ByteArray(size)) } diff --git a/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderRetryTest.kt b/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderRetryTest.kt new file mode 100644 index 0000000000..be01ea1646 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/upload/normal/FileUploaderRetryTest.kt @@ -0,0 +1,124 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.upload.normal + +import android.app.Application +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.api.NcApiCoroutines +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.json.generic.GenericOverall +import com.nextcloud.talk.upload.UploadRetryPolicy +import io.reactivex.Observable +import io.reactivex.android.plugins.RxAndroidPlugins +import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import retrofit2.HttpException +import retrofit2.Response + +/** An error answer of the server is thrown, so [UploadRetryPolicy] can tell an unavailable server from a refusal. */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class FileUploaderRetryTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + @Before + fun setUp() { + // The upload observes on the main looper, which a test that blocks on the result never lets run. + RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } + } + + @After + fun tearDown() { + RxAndroidPlugins.reset() + } + + private val ncApi: NcApi = mock() + private val ncApiCoroutines: NcApiCoroutines = mock() + + private fun uploader(file: java.io.File) = + FileUploader( + OkHttpClient.Builder().build(), + ApplicationProvider.getApplicationContext(), + User(id = 1, userId = "alice", username = "alice", token = "token", baseUrl = "https://cloud.example"), + "room", + ncApi, + file, + ncApiCoroutines + ) + + private fun smallFile() = tempFolder.newFile("photo.jpg").apply { writeBytes(ByteArray(SIZE)) } + + @Test + fun `an unavailable server fails the conversation subfolder upload with a retriable error`() { + val file = smallFile() + runBlocking { + whenever(ncApiCoroutines.uploadFile(any(), any(), any())) + .thenReturn(Response.error(UNAVAILABLE, "".toResponseBody())) + } + + try { + runBlocking { uploader(file).uploadToConversationSubfolder(Uri.fromFile(file), "/draft/photo.jpg") } + fail("expected an HttpException") + } catch (e: HttpException) { + assertEquals(UploadRetryPolicy.FailureKind.UNAVAILABLE, UploadRetryPolicy.classify(e)) + } + } + + @Test + fun `an unavailable server fails the plain upload with a retriable error`() { + val file = smallFile() + whenever(ncApi.uploadFile(any(), any(), any())) + .thenReturn(Observable.just(Response.error(UNAVAILABLE, "".toResponseBody()))) + + try { + uploader(file).upload(Uri.fromFile(file), "photo.jpg", "/Talk/photo.jpg", null).blockingFirst() + fail("expected an HttpException") + } catch (e: HttpException) { + assertEquals(UNAVAILABLE, e.code()) + } + } + + @Test + fun `a refusal is an error that counts against the limit`() { + val file = smallFile() + whenever(ncApi.uploadFile(any(), any(), any())) + .thenReturn(Observable.just(Response.error(FORBIDDEN, "".toResponseBody()))) + + try { + uploader(file).upload(Uri.fromFile(file), "photo.jpg", "/Talk/photo.jpg", null).blockingFirst() + fail("expected an HttpException") + } catch (e: HttpException) { + assertTrue(UploadRetryPolicy.classify(e) == UploadRetryPolicy.FailureKind.SERVER) + } + } + + companion object { + private const val SIZE = 16 + private const val UNAVAILABLE = 503 + private const val FORBIDDEN = 403 + } +} diff --git a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt index 600713ab7f..48c20efcc6 100644 --- a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt @@ -9,14 +9,29 @@ package com.nextcloud.talk.utils import android.app.Application import android.content.ContentResolver import android.content.Context +import android.database.MatrixCursor import android.net.Uri +import android.provider.OpenableColumns import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import org.mockito.Mockito.mock import org.mockito.Mockito.`when` +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream // Regression coverage for a crash: the system Photo Picker's ephemeral content:// uris can have // their read grant revoked at any point after being handed to us (e.g. while a batch upload is @@ -26,6 +41,11 @@ import org.robolectric.annotation.Config @Config(application = Application::class, sdk = [33]) class FileUtilsContentResolverTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private val contentUri = Uri.parse("content://media/picker/0/media/42") + @Test fun `getFileName falls back to the uri path when the content provider query throws`() { val uri = Uri.parse("content://media/picker/0/com.android.providers.media.photopicker/media/123") @@ -49,6 +69,124 @@ class FileUtilsContentResolverTest { assertEquals("image/jpeg", mimeType) } + @Test + fun `copyFileToCache keeps a complete copy`() { + val context = contextWithStream(ByteArray(COPY_SIZE)) + + val copy = FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root) + + assertNotNull(copy) + assertEquals(COPY_SIZE.toLong(), copy!!.length()) + } + + @Test + fun `copyFileToCache copies an empty source to an empty file`() { + val context = contextWithStream(ByteArray(0)) + + val copy = FileUtils.copyFileToCache(context, contentUri, "empty.txt", tempFolder.root) + + assertNotNull(copy) + assertEquals(0L, copy!!.length()) + assertEquals(listOf("empty.txt"), tempFolder.root.list()!!.toList()) + } + + @Test + fun `copyFileToCache returns null for an empty copy of a source that reports content`() { + val context = contextWithStream(ByteArray(0), reportedSize = COPY_SIZE.toLong()) + + assertNull(FileUtils.copyFileToCache(context, contentUri, "placeholder.jpg", tempFolder.root)) + assertEquals(0, tempFolder.root.list()!!.size) + } + + @Test + fun `copyFileToCache returns null when the read grant was revoked`() { + val context = mock(Context::class.java) + val resolver = mock(ContentResolver::class.java) + `when`(context.contentResolver).thenReturn(resolver) + `when`(resolver.openInputStream(contentUri)).thenThrow(SecurityException("revoked")) + + assertNull(FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root)) + assertFalse(java.io.File(tempFolder.root, "photo.jpg").exists()) + } + + @Test + fun `copyFileToCache keeps a complete copy whose size differs from the one the provider reports`() { + val context = contextWithStream(ByteArray(COPY_SIZE), reportedSize = COPY_SIZE * 2L) + + val copy = FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root) + + assertEquals(COPY_SIZE.toLong(), copy!!.length()) + } + + @Test + fun `copyFileToCache leaves no partial file when the stream breaks off`() { + val context = mock(Context::class.java) + val resolver = mock(ContentResolver::class.java) + `when`(context.contentResolver).thenReturn(resolver) + `when`(resolver.openInputStream(contentUri)).thenReturn(BrokenStream(COPY_SIZE)) + + try { + FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root) + fail("expected the copy to throw") + } catch (e: IOException) { + // the caller retries + } + assertFalse(java.io.File(tempFolder.root, "photo.jpg").exists()) + } + + @Test + fun `copyFileToCache leaves no file when the copy dies with a runtime exception`() { + val context = mock(Context::class.java) + val resolver = mock(ContentResolver::class.java) + `when`(context.contentResolver).thenReturn(resolver) + `when`(resolver.openInputStream(contentUri)).thenReturn(CrashingStream(COPY_SIZE)) + + try { + FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root) + fail("expected the copy to throw") + } catch (e: IllegalStateException) { + // the caller retries + } + assertEquals(0, tempFolder.root.list()!!.size) + } + + @Test + fun `copyFileToCache copies again over an empty file left by an earlier run`() { + java.io.File(tempFolder.root, "photo.jpg").writeBytes(ByteArray(0)) + val context = contextWithStream(ByteArray(COPY_SIZE)) + + val copy = FileUtils.copyFileToCache(context, contentUri, "photo.jpg", tempFolder.root) + + assertEquals(COPY_SIZE.toLong(), copy!!.length()) + assertEquals(listOf("photo.jpg"), tempFolder.root.list()!!.toList()) + } + + @Test + fun `ensureInDir copies a file from outside and keeps one that is already inside`() { + val dir = tempFolder.newFolder("workspace") + val outside = tempFolder.newFile("camera.jpg").apply { writeBytes(ByteArray(COPY_SIZE)) } + val inside = java.io.File(dir, "inside.jpg").apply { writeBytes(ByteArray(COPY_SIZE)) } + + val copy = FileUtils.ensureInDir(outside, dir) + + assertEquals(dir.canonicalFile, copy.canonicalFile.parentFile) + assertEquals(COPY_SIZE.toLong(), copy.length()) + assertTrue(outside.exists()) + assertEquals(inside, FileUtils.ensureInDir(inside, dir)) + } + + private fun contextWithStream(bytes: ByteArray, reportedSize: Long? = null): Context { + val context = mock(Context::class.java) + val resolver = mock(ContentResolver::class.java) + `when`(context.contentResolver).thenReturn(resolver) + `when`(resolver.openInputStream(contentUri)).thenReturn(ByteArrayInputStream(bytes)) + if (reportedSize != null) { + val cursor = MatrixCursor(arrayOf(OpenableColumns.SIZE)).apply { addRow(arrayOf(reportedSize)) } + `when`(resolver.query(eq(contentUri), any(), anyOrNull(), anyOrNull(), anyOrNull())).thenReturn(cursor) + } + return context + } + private fun mockContextWhoseResolverThrows(uri: Uri): Context { val context = mock(Context::class.java) val resolver = mock(ContentResolver::class.java) @@ -57,4 +195,23 @@ class FileUtilsContentResolverTest { .thenThrow(SecurityException("permission revoked")) return context } + + /** Delivers [size] bytes, then fails like a dropped connection. */ + private class BrokenStream(private var size: Int) : InputStream() { + override fun read(): Int { + if (size-- <= 0) throw IOException("stream broke off") + return 0 + } + } + + private class CrashingStream(private var size: Int) : InputStream() { + override fun read(): Int { + if (size-- <= 0) throw IllegalStateException("provider crashed") + return 0 + } + } + + companion object { + private const val COPY_SIZE = 64 + } }