diff --git a/ADAPTER_ARCHITECTURE.md b/ADAPTER_ARCHITECTURE.md index cb892ca2b..2a9984690 100644 --- a/ADAPTER_ARCHITECTURE.md +++ b/ADAPTER_ARCHITECTURE.md @@ -362,6 +362,79 @@ dialog owns only the temporary name and destination choice. The parent captures an immutable validated destination for the typed mutation, and the transport keeps the source ETag and no-overwrite preconditions. +### Files delete write fence + +Each account's write blocks and its `FileDeleteCoordinator` live in an +`AccountFileWriteFence` held by the process-level `FileWriteFenceRegistry`, +keyed by the non-secret local account identity. Desktop services own one +registry; on Android the `Application` owns it, so the main activity, the share +upload activity, and workers of the same process see the same blocks. The +registry runs deletes and their verification in a main-thread scope that lives +as long as that owner, so a composition only starts a delete and observes its +state: leaving Files or recreating an activity neither cancels the request nor +releases its block, and a recreated screen shows the same typed reason and +receives the pending outcome. + +- After an unknown result, a retry is offered only for positively the same + item: matching stable file IDs, or matching type and ETag only when neither + side has an ID. A differing ID, or an ID on only one side, requires a + refresh. +- A request that provably never left the device is a plain retryable failure + without a verification read: the account-session guard and, on desktop, a + connection, name-resolution, or TLS failure before the mutation executor saw + a network exchange raise `FileMutationNotSentException`. Android's file + mutation transport does not track the exchange, so its I/O failures stay + ambiguous and are verified. +- A 404 runs the same local follow-up as a confirmed delete and reports an + incomplete one. An item found gone after an unknown result reports the + local reconciliation run for that result: on desktop the queued follow-up, + including still running, failed, or dropped; on Android its cache + invalidation. +- Android share uploads check the fence before the first file and before each + later one; a block pauses the upload, keeps finished files, and the next run + resumes at the first unfinished one. The paused request records a typed + delete wait, so its progress asks the user to open or refresh the destination + folder instead of describing a network wait; starting the upload clears it. +- Cancellation before the request can be sent releases the item unchanged. + Cancellation after it may have been sent records a refresh-required block, + so the item is never released without an outcome. +- Sign-out and account removal cancel that account's deletes and drop its + fence object. A late completion lands only in the dropped object, never in a + later incarnation of the same account. + +- An item is blocked while a delete of it, a parent, or a child is unverified, + and after an outcome that leaves its state unknown or stale (stale version, + throttling, rejected credentials or permission) until a server listing of its + folder starts after that outcome. +- Surfaces receive only the narrow `FileWriteFence`, except Files and the + media viewer, which receive the account's delete coordinator to start + deletes. Files actions, the media + viewer, photo editing, text editor saves, version restore, every remote + folder picker (create and destination choice, including folder-sync setup), + and Android share uploads (destination, queueing, and the queued worker) + refuse a blocked write, send nothing, and show the typed `FileWriteBlock` + reason. +- Blocks are in memory only. A new process starts unblocked, and sign-out or + account removal drops that account's blocks with its other private state. +- Not fenced: Memories tags (they target the file ID), Deck attachments and + dynamic-app uploads (application APIs, not Files paths), and the background + folder-sync, media-backup, virtual-file, and Android documents-provider + engines, which keep their own ETag and conflict preconditions. +- A media viewer delete runs in the same coordinator as a Files delete, so it + registers the same blocks, verifies an unknown result, and follows the same + retry rules. The viewer watches outcomes for every item in its list and every + member of their RAW and rendered stacks, not only the shown one. It drops a + file only when its delete was confirmed or the file is no longer at its path, + by removing it from the viewer's navigation route in the process-level route + repository, so a recreated viewer never shows it again. Only that file + leaves: a stack whose cover was deleted is rebuilt in place by the stacking + rule around a surviving member, and an item leaves only when none of its + members survive. The selection moves to the rebuilt stack or a neighbor, or + the viewer closes when nothing is left. It applies an outcome that arrives + after its dialog closed, after the user moved to another item or member, or + after the viewer was recreated. Any other outcome keeps the file and shows + its reason when that file is shown. + ### Groupware (CardDAV and CalDAV) **Multiget requests** diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index 0b7c21453..497c63f61 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.FileMutationNotSentException import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive @@ -181,7 +182,7 @@ internal suspend fun AndroidAccountOperationGuard.withAuthenticatedMuta ): Result = withExactAccountSession( expectedSession = expectedSession, resolveSession = resolveSession, - unavailable = { error("The account changed before the authenticated change could be sent.") }, + unavailable = { throw FileMutationNotSentException("The account changed before the authenticated change could be sent.") }, action = action, ) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index 69c1f1168..fe1461019 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -39,6 +39,7 @@ internal class AndroidAccountOwnedStateCleanup( suspend fun remove(session: NextcloudSession) { dev.obiente.nextcloudnative.app.sharedJvmAuthenticatedAppReadSessions.retireAccount(session.accountId.storageKey) + dropAndroidFileWriteFences(appContext, session.accountId.storageKey) AndroidFileSyncRootAcquisitionGenerations.retireCanonical(session.accountId.storageKey) val accountIdentity = NextcloudDocumentIds.accountKey(session) val cacheIdentity = NextcloudDocumentIds.cacheAccountId(session) @@ -90,6 +91,7 @@ internal class AndroidAccountOwnedStateCleanup( legacyAccountScopeDigest: String?, ) { dev.obiente.nextcloudnative.app.sharedJvmAuthenticatedAppReadSessions.retireAccount(session.accountId.storageKey) + dropAndroidFileWriteFences(appContext, session.accountId.storageKey) AndroidFileSyncRootAcquisitionGenerations.retireCanonical(session.accountId.storageKey) val cacheIdentity = previewCacheIdentity ?: NextcloudDocumentIds.cacheAccountId(session) runAndroidAccountOwnedStateCleanups( @@ -137,6 +139,7 @@ internal class AndroidAccountOwnedStateCleanup( legacyAccountScopeDigest: String? = null, ) { dev.obiente.nextcloudnative.app.sharedJvmAuthenticatedAppReadSessions.retireAccount(accountStorageKey) + dropAndroidFileWriteFences(appContext, accountStorageKey) AndroidFileSyncRootAcquisitionGenerations.retireCanonical(accountStorageKey) runAndroidAccountOwnedStateCleanups( previewCacheIdentity, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileWriteFences.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileWriteFences.kt new file mode 100644 index 000000000..2f775441a --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileWriteFences.kt @@ -0,0 +1,20 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import dev.obiente.nextcloudnative.app.FileWriteFenceRegistry + +/** + * The process's write-fence owner. The Application holds it, so the main activity, the share + * activity, and workers of this process see the same account blocks even though each creates its + * own services instance. Blocks are in memory only; a new process starts without any. + */ +internal fun androidFileWriteFences(context: Context): FileWriteFenceRegistry = + (context.applicationContext as NextcloudNativeApplication).fileWriteFences + +/** + * Drops [accountStorageKey]'s write blocks with its other private state at sign-out or removal. + * A context that is not this process's Application never registered blocks, so nothing is dropped. + */ +internal fun dropAndroidFileWriteFences(context: Context, accountStorageKey: String) { + (context.applicationContext as? NextcloudNativeApplication)?.fileWriteFences?.removeAccount(accountStorageKey) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingSharePersistence.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingSharePersistence.kt index a50627b02..f7f62e6da 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingSharePersistence.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingSharePersistence.kt @@ -121,6 +121,7 @@ internal fun AndroidIncomingShareRequest.toJson(): JSONObject = JSONObject() }) .put("message", message) .put("discardRequested", discardRequested) + .put("waitingForDestinationDelete", waitingForDestinationDelete) .put("files", JSONArray().also { array -> files.forEach { file -> array.put( @@ -163,6 +164,7 @@ internal fun JSONObject.toIncomingShareRequest(): AndroidIncomingShareRequest = }, message = optString("message").takeIf(String::isNotBlank), discardRequested = optBoolean("discardRequested"), + waitingForDestinationDelete = optBoolean("waitingForDestinationDelete"), files = getJSONArray("files").let { array -> List(array.length()) { index -> array.getJSONObject(index).let { file -> diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt index f02be0886..190b13875 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt @@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters +import dev.obiente.nextcloudnative.app.incomingShareUploadBlock import dev.obiente.nextcloudnative.app.useAndroidNextcloudCertificateTrust import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -116,6 +117,14 @@ internal class AndroidIncomingShareUploadWorker( } return failUnavailableAccount(store, requestId) } + // A delete of the destination or a parent that is unverified in this process finishes first; + // the queued upload is retried later and nothing is sent meanwhile. + val destinationFence = services.fileWriteFences.account(session.accountId) + val destinationBlocked = { incomingShareUploadBlock(destinationFence, requireNotNull(request.destinationPath)) != null } + if (destinationBlocked()) { + store.pauseForDestinationDelete(requestId) ?: return Result.success() + return Result.retry() + } AndroidNotificationCoordinator(applicationContext).ensureChannels() var foregroundPromotionAvailable = setForegroundIfAvailable(request) request = store.beginUpload(requestId) ?: return Result.success() @@ -141,11 +150,11 @@ internal class AndroidIncomingShareUploadWorker( addAll(request.uploadedNames) } val transfer = AndroidIncomingShareFileTransfer(store, remote, requestCancellation) - for (index in request.completedFiles until request.files.size) { + val (uploaded, allPublished) = uploadIncomingShareFilesUntilBlocked(request, destinationBlocked) { current, index -> ensureNotCanceled(requestId, store) - request = transfer.upload( + transfer.upload( requestId, - request, + current, index, occupiedNames, destinationSnapshot.complete, @@ -154,10 +163,17 @@ internal class AndroidIncomingShareUploadWorker( throw CancellationException("Incoming share upload canceled") } mutationInFlight = inFlight + }.also { published -> + if (foregroundPromotionAvailable) { + foregroundPromotionAvailable = setForegroundIfAvailable(published) + } } - if (foregroundPromotionAvailable) { - foregroundPromotionAvailable = setForegroundIfAvailable(request) - } + } + request = uploaded + if (!allPublished) { + // Finished files stay completed; the rest continue from completedFiles on the next run. + store.pauseForDestinationDelete(requestId) ?: return Result.success() + return Result.retry() } scheduleIncomingShareCleanup(applicationContext, request.id) request = store.transition( @@ -347,3 +363,41 @@ internal fun Throwable.incomingShareRetryNotBeforeEpochMillis(nowEpochMillis: Lo ?: return null return nowEpochMillis + retryAfterSeconds * 1_000L } + +internal const val INCOMING_SHARE_PAUSED_FOR_DELETE = + "Upload paused while a delete in the destination folder finishes. Finished files are kept." + +/** + * Queues [current] again, before its first file or between files, recording as a typed state that + * a delete covering the destination must be verified or refreshed first. Starting the upload clears + * it. Returns null when the request was canceled or finished meanwhile. + */ +internal fun pauseIncomingShareRequestForDestinationDelete(current: AndroidIncomingShareRequest): AndroidIncomingShareRequest? = + transitionIncomingShareRequest( + current, + expected = setOf(AndroidIncomingShareState.Queued, AndroidIncomingShareState.Uploading), + target = AndroidIncomingShareState.Queued, + message = INCOMING_SHARE_PAUSED_FOR_DELETE, + )?.copy(waitingForDestinationDelete = true) + +/** + * Publishes the remaining files of [request] in order, from its completed count. Before each file + * it consults [blocked], the account's write fence for the destination, and stops before sending + * that file when a covering delete is unverified or needs a refresh. Returns the updated request + * and whether every file was published; a later run resumes at the first unpublished file, so + * finished files are never sent again. + */ +internal suspend fun uploadIncomingShareFilesUntilBlocked( + request: AndroidIncomingShareRequest, + blocked: () -> Boolean, + publish: suspend (AndroidIncomingShareRequest, Int) -> AndroidIncomingShareRequest, +): Pair { + var current = request + while (current.completedFiles < current.files.size) { + if (blocked()) return current to false + val index = current.completedFiles + current = publish(current, index) + check(current.completedFiles > index) { "A published file must advance the completed count." } + } + return current to true +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploads.kt index e36ea27e3..e710790a7 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploads.kt @@ -68,6 +68,7 @@ internal data class AndroidIncomingShareRequest( val retryNotBeforeEpochMillis: Long? = null, val message: String? = null, val discardRequested: Boolean = false, + val waitingForDestinationDelete: Boolean = false, ) { init { require(runCatching { UUID.fromString(id) }.isSuccess) @@ -270,6 +271,12 @@ internal class AndroidIncomingShareStore(private val context: Context) { updated } + fun pauseForDestinationDelete(id: String): AndroidIncomingShareRequest? = synchronized(LOCK) { + val updated = load(id)?.let(::pauseIncomingShareRequestForDestinationDelete) ?: return@synchronized null + save(updated) + updated + } + fun setVisibleMutationInFlight( id: String, inFlight: Boolean, @@ -573,7 +580,7 @@ internal fun transitionIncomingShareRequest( message: String? = null, ): AndroidIncomingShareRequest? = current .takeIf { it.state in expected } - ?.copy(state = target, message = message) + ?.copy(state = target, message = message, waitingForDestinationDelete = false) internal fun prepareIncomingShareRequestForQueue( current: AndroidIncomingShareRequest, @@ -609,6 +616,7 @@ internal fun prepareIncomingShareRequestForQueue( automaticTransferAttempts = 0, retryNotBeforeEpochMillis = null, message = null, + waitingForDestinationDelete = false, ) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 9a1c60e3f..375eb78a2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -197,6 +197,7 @@ import dev.obiente.nextcloudnative.app.fileVersionHistoryRequest import dev.obiente.nextcloudnative.app.fileVersionContentRequest import dev.obiente.nextcloudnative.app.fileVersionRestoreRequest import dev.obiente.nextcloudnative.app.historicalFileCopyName +import dev.obiente.nextcloudnative.app.invalidateAfterConfirmedFileMutation import dev.obiente.nextcloudnative.app.isExactHttpByteContentRange import dev.obiente.nextcloudnative.app.normalizeFileVersionHistory import dev.obiente.nextcloudnative.app.requireMatchingFileVersion @@ -686,9 +687,8 @@ internal class AndroidNextcloudServices( override suspend fun clearSupportDiagnostics(): Boolean = supportDiagnostics.clear() - override fun recordSupportDiagnostic(event: SupportDiagnosticEventDraft) { - supportDiagnostics.record(event) - } + override fun recordSupportDiagnostic(event: SupportDiagnosticEventDraft) = supportDiagnostics.record(event) + override val fileWriteFences get() = androidFileWriteFences(appContext) internal fun recordSupportDiagnosticForAccountIdentity( accountIdentity: String, @@ -2721,21 +2721,19 @@ internal class AndroidNextcloudServices( put("Overwrite", if (spec.overwrite) "T" else "F") } } - val response = request( + val accountId = NextcloudDocumentIds.accountKey(currentSession) + fun invalidateLocal() = invalidateAfterConfirmedFileMutation(listOfNotNull(spec.sourcePath, spec.destinationPath), + { event -> recordSupportDiagnosticForAccountIdentity(accountId, event) }) { path -> fileReadCache.invalidate(accountId, path) } + val response = dev.obiente.nextcloudnative.app.reportingUnknownFileMutationResult(::invalidateLocal) { request( method = spec.method, url = buildNextcloudFileUrl(currentSession.serverUrl, userId, spec.sourcePath), session = currentSession, headers = headers, maxResponseBytes = 64 * 1024, accountMutationSerialized = accountMutationSerialized, - ) - if (response.status !in 200..299) throw fileOperationException(response.status, spec.sourceIsDirectory) - val accountId = NextcloudDocumentIds.accountKey(currentSession) - runCatching { fileReadCache.invalidate(accountId, spec.sourcePath) } - spec.destinationPath?.let { destination -> - runCatching { fileReadCache.invalidate(accountId, destination) } - } - NextcloudFileMutationResult(spec.destinationPath, response.etag) + ) } + if (response.status !in 200..299) throw fileOperationException(response.status, spec.sourceIsDirectory, if (response.status == 404) invalidateLocal() else null) + NextcloudFileMutationResult(spec.destinationPath, response.etag, invalidateLocal()) } } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt index 4631255f7..cb8580db9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt @@ -22,6 +22,8 @@ import dev.obiente.nextcloudnative.app.RemoteFolderPickerOperations import dev.obiente.nextcloudnative.app.RemoteFolderSelectionAccess import dev.obiente.nextcloudnative.app.design.NextcloudAppBackground import dev.obiente.nextcloudnative.app.design.NextcloudNativeTheme +import dev.obiente.nextcloudnative.app.incomingShareFolderPickerOperations +import dev.obiente.nextcloudnative.app.incomingShareUploadBlock import dev.obiente.nextcloudnative.app.remoteFolderPickerOperations import dev.obiente.nextcloudnative.app.useAndroidNextcloudCertificateTrust import java.util.UUID @@ -82,7 +84,7 @@ class AndroidShareUploadActivity : ComponentActivity() { val activeUserId = serverInfo?.userId val folderOperations = remember(activeSession, activeUserId) { if (activeSession != null && activeUserId != null) { - incomingShareFolderPickerOperations( + shareUploadFolderPickerOperations( services, activeSession, activeUserId, @@ -249,6 +251,10 @@ class AndroidShareUploadActivity : ComponentActivity() { val activeSession = session ?: return val info = serverInfo ?: return val staged = request ?: return + incomingShareUploadBlock(services.fileWriteFences.account(activeSession.accountId), destinationPath)?.let { block -> + error = block.message + return + } val generation = restoreGeneration queueing = true error = null @@ -417,32 +423,30 @@ internal suspend fun restoreIncomingShareForActiveSession( restore: suspend (NextcloudSession) -> Result, ): Result = guard.withExactAccountSession(expectedSession, resolveActiveSession, unavailable, restore) -private fun AndroidShareUploadActivity.incomingShareFolderPickerOperations( +/** + * The share upload's picker: the account's process-wide write fence, resolved from the same owner + * the main activity uses, then this folder's permissions. + */ +private fun AndroidShareUploadActivity.shareUploadFolderPickerOperations( services: AndroidNextcloudServices, session: NextcloudSession, userId: String, webDav: NextcloudDocumentWebDav, -): RemoteFolderPickerOperations { - val base = remoteFolderPickerOperations(services, session, userId) - return RemoteFolderPickerOperations( - identity = base.identity + "|incoming-share", - listCached = base.listCached, - listNetwork = base.listNetwork, - createDirectoryIfAbsent = { path -> - val parent = path.substringBeforeLast('/', "") - val access = inspectIncomingShareDirectoryAccess(session, userId, parent, webDav) - require(access.canCreateDirectories) { - "This Nextcloud folder does not allow creating subfolders." - } - base.createDirectoryIfAbsent(path) - }, - selectionAccess = { path -> - incomingShareFolderSelectionAccess( - inspectIncomingShareDirectoryAccess(session, userId, path, webDav), - ) - }, - ) -} +): RemoteFolderPickerOperations = androidIncomingShareFolderPickerOperations( + remoteFolderPickerOperations(services, session, userId, services.fileWriteFences.account(session.accountId)), +) { path -> inspectIncomingShareDirectoryAccess(session, userId, path, webDav) } + +/** Builds the share upload's picker over [base], which carries the account's write fence. */ +internal fun androidIncomingShareFolderPickerOperations( + base: RemoteFolderPickerOperations, + inspect: suspend (path: String) -> DocumentDirectoryAccess, +): RemoteFolderPickerOperations = incomingShareFolderPickerOperations( + base, + selectionAccess = { path -> incomingShareFolderSelectionAccess(inspect(path)) }, + requireCreateDirectories = { parent -> + require(inspect(parent).canCreateDirectories) { "This Nextcloud folder does not allow creating subfolders." } + }, +) internal fun incomingShareFolderSelectionAccess(access: DocumentDirectoryAccess): RemoteFolderSelectionAccess = when { access.canCreateFiles -> RemoteFolderSelectionAccess.Allowed @@ -489,6 +493,7 @@ internal fun AndroidIncomingShareRequest.toPresentation(): IncomingShareUploadPr destinationPath = destinationPath, completedFiles = completedFiles, message = message, + waitingForDestinationDelete = waitingForDestinationDelete, canVerifyOutcome = (visibleMutationTargetName != null || chunkSession?.targetName != null) && ( state == AndroidIncomingShareState.OutcomeUnknown || state == AndroidIncomingShareState.Canceled && message == CANCELED_INCOMING_SHARE_MUTATION_WARNING diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index 2c0c55f2e..186bc65fd 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.SharedPreferences import androidx.work.WorkInfo import androidx.work.WorkManager +import dev.obiente.nextcloudnative.app.FileWriteFenceRegistry import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity @@ -17,6 +18,12 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch class NextcloudNativeApplication : Application() { + /** + * The process's account-scoped write fences and Files delete owners, shared by the main and share + * activities. Their main-thread scope lives as long as the process, so recreating an activity + * never cancels a delete or releases its block. + */ + internal val fileWriteFences = FileWriteFenceRegistry(CoroutineScope(SupervisorJob() + Dispatchers.Main)) private val startupRecoveryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var accountCleanupListener: SharedPreferences.OnSharedPreferenceChangeListener? = null diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileMutationLocalFollowUpTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileMutationLocalFollowUpTest.kt new file mode 100644 index 000000000..a2c5c35d1 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileMutationLocalFollowUpTest.kt @@ -0,0 +1,140 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileMutationLocalFollowUp +import dev.obiente.nextcloudnative.app.FileMutationOutcomeUnknownException +import dev.obiente.nextcloudnative.app.NextcloudFile +import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft +import dev.obiente.nextcloudnative.app.invalidateAfterConfirmedFileMutation +import dev.obiente.nextcloudnative.app.reportingUnknownFileMutationResult +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking + +class AndroidFileMutationLocalFollowUpTest { + @Test + fun invalidatedListingsReportCompletedLocalBookkeeping() = withCache { root, cache -> + val events = mutableListOf() + cache.storeListing(ACCOUNT, "Documents", listOf(file("Documents/report.txt")), nowEpochMillis = 1) + + val followUp = invalidateAfterConfirmedFileMutation(listOf("Documents/report.txt", "Archive/report.txt"), events::add) { path -> + cache.invalidate(ACCOUNT, path) + } + + assertEquals(FileMutationLocalFollowUp.Completed, followUp) + assertNull(AndroidFileReadCache(root).cachedListing(ACCOUNT, "Documents"), "The parent listing is invalidated.") + assertTrue(events.isEmpty()) + } + + @Test + fun aFailedCacheInvalidationIsReportedWithItsCauseInsteadOfSuccess() = withCache { root, cache -> + val events = mutableListOf() + // A file where the account's cache directory belongs makes every index write fail. + File(root, ACCOUNT).writeText("blocked") + val attempted = mutableListOf() + + val followUp = invalidateAfterConfirmedFileMutation(listOf("Documents/a.txt", "Archive/a.txt"), events::add) { path -> + attempted += path + cache.invalidate(ACCOUNT, path) + } + + assertEquals(FileMutationLocalFollowUp.Failed, followUp) + assertEquals(listOf("Documents/a.txt", "Archive/a.txt"), attempted, "Every path is still attempted.") + assertEquals(2, events.size) + assertTrue(events.all { it.code == "FILE_MUTATION_LOCAL_FOLLOW_UP_FAILED" && it.outcome == "failed" }) + assertTrue(events.all { it.exception != null }, "The typed cause is kept for diagnostics.") + } + + @Test + fun cancellationIsNotReportedAsALocalFailure() { + val events = mutableListOf() + + assertFailsWith { + invalidateAfterConfirmedFileMutation(listOf("a"), events::add) { throw CancellationException("closed") } + } + assertTrue(events.isEmpty()) + } + + @Test + fun aFailingDiagnosticDoesNotHideTheLocalFailure() { + val followUp = invalidateAfterConfirmedFileMutation(listOf("a"), { error("diagnostics unavailable") }) { + error("cache unavailable") + } + + assertEquals(FileMutationLocalFollowUp.Failed, followUp) + } + + @Test + fun anInterruptedRequestReportsItsCacheInvalidationWithTheUnknownResult() = withCache { root, cache -> runBlocking { + val events = mutableListOf() + fun invalidate() = invalidateAfterConfirmedFileMutation(listOf("Documents/a.txt"), events::add) { path -> + cache.invalidate(ACCOUNT, path) + } + + val completed = assertIs(failureOf { + reportingUnknownFileMutationResult(::invalidate) { throw java.io.IOException("connection reset") } + }) + // A file where the account's cache directory belongs makes the next invalidation fail. + File(root, ACCOUNT).deleteRecursively() + File(root, ACCOUNT).writeText("blocked") + val failed = assertIs(failureOf { + reportingUnknownFileMutationResult(::invalidate) { throw java.io.IOException("connection reset") } + }) + + assertEquals(FileMutationLocalFollowUp.Completed, completed.localFollowUp) + assertIs(completed.cause) + assertEquals(FileMutationLocalFollowUp.Failed, failed.localFollowUp, "A failed invalidation is not reported as complete.") + } } + + @Test + fun aDefinitiveFailureOrCancellationIsNotAnUnknownResult(): Unit = runBlocking { + var invalidated = false + val invalidate = { invalidated = true; FileMutationLocalFollowUp.Completed } + + assertIs(failureOf { reportingUnknownFileMutationResult(invalidate) { throw IllegalArgumentException("bad") } }) + assertIs(failureOf { reportingUnknownFileMutationResult(invalidate) { throw CancellationException("closed") } }) + assertEquals(7, reportingUnknownFileMutationResult(invalidate) { 7 }) + assertFalse(invalidated) + } + + /** The failure [block] ends with; a suspend call cannot run inside `assertFailsWith`. */ + private suspend fun failureOf(block: suspend () -> Unit): Throwable? = + try { + block() + null + } catch (failure: Throwable) { + failure + } + + private fun withCache(block: (File, AndroidFileReadCache) -> Unit) { + val root = Files.createTempDirectory("android-file-mutation-follow-up").toFile() + try { + block(root, AndroidFileReadCache(root)) + } finally { + root.deleteRecursively() + } + } + + private fun file(path: String) = NextcloudFile( + path = path, + name = path.substringAfterLast('/'), + isDirectory = false, + mimeType = "text/plain", + size = 1, + lastModified = null, + fileId = 7L, + hasPreview = false, + etag = "\"e\"", + ) + + private companion object { + const val ACCOUNT = "cccccccccccccccccccccccccccccccc" + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt new file mode 100644 index 000000000..5d7642298 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt @@ -0,0 +1,209 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileMutationNotSentException +import dev.obiente.nextcloudnative.app.FileWriteBlock +import dev.obiente.nextcloudnative.app.FileWriteBlockedException +import dev.obiente.nextcloudnative.app.FileWriteFence +import dev.obiente.nextcloudnative.app.FileWriteFenceRegistry +import dev.obiente.nextcloudnative.app.NextcloudPlatformServices +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.RemoteFolderSelectionAccess +import dev.obiente.nextcloudnative.app.incomingShareProgressLabel +import dev.obiente.nextcloudnative.app.incomingShareUploadBlock +import dev.obiente.nextcloudnative.app.remoteFolderPickerOperations +import java.lang.reflect.Proxy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import org.json.JSONObject + +class AndroidShareUploadWriteFenceTest { + private val session = NextcloudSession("https://share-fence.example.test", "alice", "fixture-password") + private val block = FileWriteBlock.RefreshRequired("Inbox/Old") + private val fence = FileWriteFence { path -> block.takeIf { path == "Inbox/Old" || path.startsWith("Inbox/Old/") } } + + @Test + fun blockedDestinationIsRefusedBeforeFolderPermissionsAreInspected(): Unit = runBlocking { + val inspected = mutableListOf() + val operations = androidIncomingShareFolderPickerOperations( + remoteFolderPickerOperations(services(mutableListOf()), session, "alice", fence), + ) { path -> inspected += path; access(canCreateFiles = true) } + + assertEquals(RemoteFolderSelectionAccess.Denied(block.message), operations.selectionAccess("Inbox/Old")) + assertTrue(inspected.isEmpty(), "A blocked destination makes no permission request.") + assertEquals(RemoteFolderSelectionAccess.Allowed, operations.selectionAccess("Inbox")) + assertEquals(listOf("Inbox"), inspected) + } + + @Test + fun newFolderInsideABlockedFolderIsNotCreated(): Unit = runBlocking { + val calls = mutableListOf() + val operations = androidIncomingShareFolderPickerOperations( + remoteFolderPickerOperations(services(calls), session, "alice", fence), + ) { access(canCreateDirectories = true) } + + assertFailsWith { operations.createDirectoryIfAbsent("Inbox/Old/Photos") } + operations.createDirectoryIfAbsent("Inbox/Photos") + + assertEquals(listOf("createDirectoryIfAbsent:Inbox/Photos"), calls) + } + + @Test + fun folderPermissionsStillApplyWhenTheFenceAllows(): Unit = runBlocking { + val operations = androidIncomingShareFolderPickerOperations( + remoteFolderPickerOperations(services(mutableListOf()), session, "alice", FileWriteFenceRegistry(CoroutineScope(Job())).account(session.accountId)), + ) { access(canCreateFiles = false, canCreateDirectories = false) } + + assertEquals( + RemoteFolderSelectionAccess.Denied("This Nextcloud folder is read-only for this account."), + operations.selectionAccess("Inbox"), + ) + assertFailsWith { operations.createDirectoryIfAbsent("Inbox/New") } + } + + @Test + fun queueingIntoABlockedFolderIsRefusedAndAnUnblockedAccountIsNot() { + assertEquals(block, incomingShareUploadBlock(fence, "Inbox/Old")) + assertNull(incomingShareUploadBlock(fence, "Inbox")) + assertNull(incomingShareUploadBlock(FileWriteFenceRegistry(CoroutineScope(Job())).account(session.accountId), "Inbox/Old")) + } + + @Test + fun aBlockBetweenFilesStopsBeforeTheNextFileAndAResumedRunNeverResendsFinishedFiles(): Unit = runBlocking { + val published = mutableListOf() + var deleteStarted = false + + val (paused, allPublished) = uploadIncomingShareFilesUntilBlocked(threeFiles(), { deleteStarted }) { current, index -> + published += index + deleteStarted = true + current.published(index) + } + + assertFalse(allPublished) + assertEquals(listOf(0), published, "The next file is not sent once the delete started.") + assertEquals(1, paused.completedFiles, "The finished file stays completed.") + + val (finished, resumedAll) = uploadIncomingShareFilesUntilBlocked(paused, { false }) { current, index -> + published += index + current.published(index) + } + + assertTrue(resumedAll) + assertEquals(listOf(0, 1, 2), published, "The resumed run starts at the first unfinished file.") + assertEquals(3, finished.completedFiles) + } + + @Test + fun aBlockBeforeTheFirstFileSendsNothing(): Unit = runBlocking { + val (unchanged, allPublished) = uploadIncomingShareFilesUntilBlocked(threeFiles(), { true }) { _, _ -> + error("No file may be sent while the destination is blocked.") + } + + assertFalse(allPublished) + assertEquals(0, unchanged.completedFiles) + } + + @Test + fun aDestinationBlockedAtStartIsPersistedAndShownUntilTheUploadStarts() { + val queued = threeFiles().copy(state = AndroidIncomingShareState.Queued) + + val paused = assertNotNull(pauseIncomingShareRequestForDestinationDelete(queued)) + val restored = JSONObject(paused.toJson().toString()).toIncomingShareRequest() + + assertEquals(AndroidIncomingShareState.Queued, restored.state) + assertTrue(restored.waitingForDestinationDelete, "The reason survives a restart.") + assertEquals(INCOMING_SHARE_PAUSED_FOR_DELETE, restored.message, "The per-file pause message is reused.") + val shown = incomingShareProgressLabel(restored.toPresentation()) + assertTrue("Open or refresh the destination folder" in shown, shown) + assertFalse("network" in shown, "A delete wait is not described as a network wait.") + + val started = assertNotNull( + transitionIncomingShareRequest(restored, setOf(AndroidIncomingShareState.Queued), AndroidIncomingShareState.Uploading), + ) + assertFalse(started.waitingForDestinationDelete, "Starting the upload clears the reason.") + assertEquals(null, started.message) + } + + @Test + fun aBlockBetweenFilesKeepsFinishedFilesAndRecordsTheSameReason() { + val uploading = threeFiles().published(0) + + val paused = assertNotNull(pauseIncomingShareRequestForDestinationDelete(uploading)) + + assertEquals(AndroidIncomingShareState.Queued, paused.state) + assertEquals(1, paused.completedFiles) + assertTrue(paused.waitingForDestinationDelete) + assertEquals(INCOMING_SHARE_PAUSED_FOR_DELETE, paused.message) + assertNull(pauseIncomingShareRequestForDestinationDelete(uploading.copy(state = AndroidIncomingShareState.Canceled))) + val requeued = prepareIncomingShareRequestForQueue( + paused.copy(state = AndroidIncomingShareState.Failed, userId = "alice"), "account-1", "alice", "Inbox", + ) + assertFalse(requeued.waitingForDestinationDelete, "A new queueing starts without the old reason.") + } + + @Test + fun aNetworkWaitStillShowsTheNetworkText() { + val queued = threeFiles().copy(state = AndroidIncomingShareState.Queued) + val legacy = JSONObject(queued.toJson().toString()).apply { remove("waitingForDestinationDelete") } + + assertEquals("Waiting for a network connection.", incomingShareProgressLabel(queued.toPresentation())) + assertFalse(legacy.toIncomingShareRequest().waitingForDestinationDelete, "An older record has no delete wait.") + } + + @Test + fun anAccountChangeBeforeSendingIsReportedAsNotSent(): Unit = runBlocking { + val other = NextcloudSession("https://share-fence.example.test", "bob", "fixture-password") + var sent = false + + assertFailsWith { + AndroidAccountOperationGuard().withAuthenticatedMutationSession(session, { other }) { sent = true } + } + assertFalse(sent) + } + + private fun threeFiles() = AndroidIncomingShareRequest( + id = "01234567-89ab-cdef-0123-456789abcdef", + files = listOf("first.txt", "second.txt", "third.txt").mapIndexed { index, name -> + AndroidIncomingShareFile( + id = "1111111$index-2222-3333-4444-555555555555", + displayName = name, + mimeType = "text/plain", + sizeBytes = 5, + stagedName = "staged-$index", + ) + }, + state = AndroidIncomingShareState.Uploading, + accountId = "account-1", + destinationPath = "Inbox", + ) + + private fun AndroidIncomingShareRequest.published(index: Int) = + copy(completedFiles = index + 1, uploadedNames = uploadedNames + files[index].displayName) + + private fun access(canCreateFiles: Boolean = false, canCreateDirectories: Boolean = false) = + DocumentDirectoryAccess(canCreateFiles = canCreateFiles, canCreateDirectories = canCreateDirectories) + + private fun services(calls: MutableList): NextcloudPlatformServices = + Proxy.newProxyInstance( + NextcloudPlatformServices::class.java.classLoader, + arrayOf(NextcloudPlatformServices::class.java), + ) { proxy, method, args -> + when (method.name) { + "toString" -> "SyntheticShareUploadServices" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.singleOrNull() + "createDirectoryIfAbsent" -> { + calls += "createDirectoryIfAbsent:${args!![2]}" + true + } + else -> error("Unexpected synthetic operation: ${method.name}") + } + } as NextcloudPlatformServices +} diff --git a/changes/unreleased/477-delete-dialog-completion.md b/changes/unreleased/477-delete-dialog-completion.md new file mode 100644 index 000000000..0321359d0 --- /dev/null +++ b/changes/unreleased/477-delete-dialog-completion.md @@ -0,0 +1,7 @@ +category: fix +issue: 477 +pull: none +platforms: android, desktop +user-facing: yes + +Files deletes always finish, even after leaving Files. Edits, folder pickers, sync setup, and Android share uploads wait until a covering delete is verified or refreshed, and file changes report incomplete local status. diff --git a/changes/unreleased/477-delete-write-fence-scope.md b/changes/unreleased/477-delete-write-fence-scope.md new file mode 100644 index 000000000..26d4b5686 --- /dev/null +++ b/changes/unreleased/477-delete-write-fence-scope.md @@ -0,0 +1,7 @@ +category: fix +issue: 477 +pull: none +platforms: android, desktop +user-facing: yes + +Pending deletes do not pause photo tags, Deck or app uploads, or background sync, backup, and file providers, and a restarted app starts with no pending delete blocks. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 833b720ce..30baf9d66 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,4 +1,4 @@ -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4129 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4127 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|900 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1212 @@ -20,8 +20,8 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt|1172 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface.kt|1114 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt|1234 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1877 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|11087 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1329 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|11021 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1550 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1716 @@ -48,7 +48,7 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAppUpdates.kt|9 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt|1536 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt|876 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|857 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6168 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6163 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2759 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1069 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionMenu.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionMenu.kt index ed29694dd..93b6c71ef 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionMenu.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionMenu.kt @@ -15,6 +15,7 @@ internal fun FileActionMenu( file: NextcloudFile, offlineAvailability: FileOfflineAvailability, offlineStorageSupported: Boolean, + writeBlock: FileWriteBlock? = null, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, expanded: Boolean, @@ -37,6 +38,7 @@ internal fun FileActionMenu( discoverDocumentEditing = true, ), offlineState = offlineAvailability.toFileActionOfflineState(), + writeBlock = writeBlock, ).actions.forEach { action -> DropdownMenuItem( text = { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanning.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanning.kt index e5bccfe6a..58323bf7e 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanning.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanning.kt @@ -213,11 +213,14 @@ fun planFilesScreenActions( file: NextcloudFile, support: FileActionSupport = FileActionSupport(), offlineState: FileOfflineState = FileOfflineState.NotStored, + writeBlock: FileWriteBlock? = null, ): FileLongPressActionPlan { val plan = planFileActions(file, support, offlineState) return FileLongPressActionPlan( plan.actions.map { action -> - if (!action.enabled || action.action in filesScreenImplementedActions) { + if (writeBlock != null && action.enabled && action.action in fileWriteActions) { + action.copy(enabled = false, disabledReason = writeBlock.message) + } else if (!action.enabled || action.action in filesScreenImplementedActions) { action } else { action.copy( @@ -418,6 +421,23 @@ private fun action( tone = tone, ) +/** Actions that change the remote item or open a workflow that can change it. */ +private val fileWriteActions = setOf( + FileMenuAction.EditText, + FileMenuAction.EditWith, + FileMenuAction.AddFavorite, + FileMenuAction.RemoveFavorite, + FileMenuAction.VersionHistory, + FileMenuAction.Rename, + FileMenuAction.Move, + FileMenuAction.Copy, + FileMenuAction.Share, + FileMenuAction.Delete, +) + +/** True when [action] can change the remote item, so it must wait for an unverified delete. */ +internal fun FileMenuAction.changesRemoteItem(): Boolean = this in fileWriteActions + private val filesScreenImplementedActions = setOf( FileMenuAction.Open, FileMenuAction.Preview, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt new file mode 100644 index 000000000..5507ded00 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt @@ -0,0 +1,234 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch + +internal data class FileDeleteRequest( + val key: FileDeleteResourceKey, + val token: Long, + val file: NextcloudFile, + val expectedEtag: String, +) { + val parentPath: String get() = file.path.substringBeforeLast('/', missingDelimiterValue = "") +} + +internal data class FileDeleteCompletion(val request: FileDeleteRequest, val outcome: FileDeleteOutcome) + +/** + * Owns Files deletes for one account in this process; [AccountFileWriteFence] holds the only + * instance. A delete and its verification run under the process-level owner's scope, so leaving + * the dialog or the Files screen, or recreating the activity, never cancels the request or + * releases the resource without an outcome. Compositions start requests and observe state. + * Finished outcomes wait until a Files screen consumes them. Only [close], when the account is + * signed out or removed, cancels unfinished work. + * + * The resulting write blocks live in [fence], so every surface of the account sees them. An + * outcome that must not be retried as-is (stale version, throttling, rejected credentials or + * permission) stays recorded per resource until a server listing of its folder starts after it, + * and until then blocks every other write to that item, its folders, and its contents. Work + * cancelled after the request may have been sent records such a block instead of releasing it. + */ +internal class FileDeleteCoordinator(val fence: AccountFileWriteFence, parent: CoroutineScope) : FileWriteFence by fence { + val account: NextcloudAccountId get() = fence.account + val inFlight: FileDeleteInFlight get() = fence.inFlight + private val job = SupervisorJob(parent.coroutineContext[Job]) + private val scope = CoroutineScope(parent.coroutineContext + job) + private val completions = mutableStateListOf() + + val nextCompletion: FileDeleteCompletion? get() = completions.firstOrNull() + + /** + * Why other writes to [file] must wait: a delete of it, a parent, or a child is unverified, or + * such a delete finished with an outcome that left the item's state unknown or stale. + */ + fun writeBlock(file: NextcloudFile): FileWriteBlock? = fence.writeBlock(file.path) + + fun blocksWrites(file: NextcloudFile): Boolean = writeBlock(file) != null + + /** Why a write that creates or changes [path] must wait, or null when it may proceed. */ + fun writeBlockFor(path: String): FileWriteBlock? = fence.writeBlock(path) + + fun writeBlockedMessage(path: String): String? = fence.writeBlock(path)?.message + + fun refreshBlockFor(path: String): FileWriteBlock.RefreshRequired? = fence.refreshBlockFor(path) + + /** + * Applies [completion] to [dialog]. When the outcome asks for a folder reload but the item's + * folder is not [shownFolder], that folder is read here, through [readFolder], so the screen's + * own reload is not the only way to lift its retry block. A failed read leaves the block; the + * message then tells the user which folder to open. + */ + fun applyCompletion( + completion: FileDeleteCompletion, + dialog: FileDeleteDialogState, + shownFolder: String, + read: suspend (String) -> NextcloudFileListing, + ): FileDeleteScreenEffect { + val effect = dialog.complete(completion) + val folder = completion.request.parentPath + if (effect.reloadFolder && folder != shownFolder && fence.hasRetryBlock(completion.request.key)) { + scope.launch { + try { + readFolder(folder) { read(folder) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // Best effort: the block stays until the user opens or refreshes the folder. + } + } + } + return effect + } + + /** Why another delete of [file] must wait for a fresh folder listing, or null. */ + fun retryBlockFor(file: NextcloudFile): String? = fence.retryBlockMessage(keyFor(file)) + + fun keyFor(file: NextcloudFile) = FileDeleteResourceKey(account, file.path) + + /** See [AccountFileWriteFence.readFolder]: only a listing started after an outcome lifts it. */ + suspend fun readFolder(path: String, read: suspend () -> NextcloudFileListing): NextcloudFileListing = + fence.readFolder(path, read) + + fun start( + request: FileDeleteRequest, + delete: suspend (NextcloudFileMutation.Delete) -> NextcloudFileMutationResult, + readFolder: suspend (String) -> NextcloudFileListing, + ): Job = scope.launch(start = CoroutineStart.ATOMIC) { + // Atomic start runs the body, and with it the release below, even when cancelled before it began. + var maySendRequest = false + var outcomeRecorded = false + try { + val mutation = NextcloudFileMutation.Delete( + request.file.path, + request.expectedEtag, + sourceIsDirectory = request.file.isDirectory, + ) + val outcome = deleteFileWithVerifiedOutcome( + target = request.file, + expectedEtag = request.expectedEtag, + delete = { + currentCoroutineContext().ensureActive() + maySendRequest = true + delete(mutation) + }, + readParent = { readFolder(request.parentPath) }, + ) + // Reject even a non-cooperative service's completion after the account was removed. + ensureActive() + fence.recordOutcome(request.key, outcome.retryBlockMessage()) + outcomeRecorded = true + completions += FileDeleteCompletion(request, outcome) + } finally { + // Before the request could be sent, the item is unchanged. Afterwards the server state is + // unknown, so another write must wait for a fresh listing of the item's folder. + if (maySendRequest && !outcomeRecorded) fence.recordOutcome(request.key, unconfirmedDeleteMessage(request.file.name)) + inFlight.release(request.key, request.token) + } + } + + /** The oldest unconsumed outcome for [key], for a screen that shows only that item. */ + fun completionFor(key: FileDeleteResourceKey): FileDeleteCompletion? = completions.firstOrNull { it.request.key == key } + + /** The oldest unconsumed outcome for any of [paths], for a screen that shows a list of items. */ + fun completionFor(paths: Set): FileDeleteCompletion? = completions.firstOrNull { it.request.key.path in paths } + + /** Claims [completion] for one screen; false when another screen already applied it. */ + fun consume(completion: FileDeleteCompletion): Boolean = completions.remove(completion) + + fun close() = job.cancel() +} + +internal fun unconfirmedDeleteMessage(name: String) = + "Could not confirm whether $name was deleted. Refresh the folder before trying again." + +/** The account's process-level delete owner. Leaving the composition neither cancels nor releases it. */ +@Composable +internal fun rememberFileDeleteCoordinator(session: NextcloudSession, fences: FileWriteFenceRegistry): FileDeleteCoordinator = + remember(session, fences) { fences.account(session.accountId).deletes } + + +/** + * Main-thread owner of one Files screen's delete dialog. A closed or replaced dialog no longer + * receives its request's outcome, unless a reopened dialog shows the same version of that item. + */ +internal class FileDeleteDialogState(private val deletes: FileDeleteCoordinator) { + var target by mutableStateOf(null) + private set + private var localError by mutableStateOf(null) + private var shownVersionBlocked by mutableStateOf(false) + private var attachedToken: Long? = null + + val error: String? get() = localError ?: target?.let(deletes::retryBlockFor) + + /** A retry block survives reopening the dialog; a refresh lifts it for the item's new version. */ + val retryBlocked: Boolean get() = shownVersionBlocked || target?.let(deletes::retryBlockFor) != null + + /** True while a delete of the shown item is unverified, including one from a closed dialog. */ + val running: Boolean + get() = target?.let { deletes.keyFor(it) in deletes.inFlight } == true + + fun open(file: NextcloudFile) = reset(file) + + /** The delete continues if it is running and still blocks other writes to the item. */ + fun dismiss() = reset(null) + + fun begin(): FileDeleteRequest? { + val file = target ?: return null + if (running || retryBlocked) return null + val etag = file.etag?.takeIf(String::isNotBlank) ?: run { + localError = "Refresh the folder before deleting this item." + return null + } + val key = deletes.keyFor(file) + // A parent or child whose delete left its state unknown must be refreshed first. + deletes.refreshBlockFor(file.path)?.let { + localError = it.message + return null + } + val token = deletes.inFlight.claim(key) ?: run { + localError = "Another delete in this location is still finishing. Try again when it completes." + return null + } + localError = null + attachedToken = token + return FileDeleteRequest(key, token, file, etag) + } + + fun complete(completion: FileDeleteCompletion): FileDeleteScreenEffect { + val (request, outcome) = completion + val shown = target + val current = request.token == attachedToken || + (shown != null && deletes.keyFor(shown) == request.key && shown.etag == request.expectedEtag) + if (current) { + when (outcome) { + is FileDeleteOutcome.Deleted, is FileDeleteOutcome.NoLongerAtLocation -> reset(null) + is FileDeleteOutcome.NotDeleted -> localError = outcome.message + else -> { + // The shown version stays blocked even after a refresh lifts the resource block. + localError = outcome.retryBlockMessage() + shownVersionBlocked = true + } + } + } + return outcome.screenEffect(request.file.name, dialogShowsResult = current) + } + + private fun reset(file: NextcloudFile?) { + attachedToken = null + target = file + localError = null + shownVersionBlocked = false + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt new file mode 100644 index 000000000..a04e55e80 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt @@ -0,0 +1,107 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.obiente.nextcloudnative.app.design.NextcloudSpacing + +/** + * Shows the delete dialog of one Files screen showing [shownFolder] and applies finished deletes, + * including ones started before this screen was shown. The requests themselves belong to [deletes]. + */ +@Composable +internal fun FileDeleteHost( + dialog: FileDeleteDialogState, + deletes: FileDeleteCoordinator, + shownFolder: String, + delete: suspend (NextcloudFileMutation.Delete) -> NextcloudFileMutationResult, + readFolder: suspend (String) -> NextcloudFileListing, + onEffect: (FileDeleteScreenEffect) -> Unit, +) { + val currentOnEffect by rememberUpdatedState(onEffect) + val currentShownFolder by rememberUpdatedState(shownFolder) + val currentReadFolder by rememberUpdatedState(readFolder) + val completion = deletes.nextCompletion + LaunchedEffect(deletes, completion) { + if (completion != null && deletes.consume(completion)) { + currentOnEffect(deletes.applyCompletion(completion, dialog, currentShownFolder, currentReadFolder)) + } + } + dialog.target?.let { target -> + FileDeleteDialog( + target = target, + running = dialog.running, + error = dialog.error, + retryBlocked = dialog.retryBlocked, + onConfirm = { dialog.begin()?.let { request -> deletes.start(request, delete, readFolder) } }, + onDismiss = dialog::dismiss, + ) + } +} + +/** Confirms one Files delete. Closing it while the delete runs never cancels the request. */ +@Composable +internal fun FileDeleteDialog( + target: NextcloudFile, + running: Boolean, + error: String?, + retryBlocked: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Delete ${target.name}?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium)) { + Text( + if (target.isDirectory) { + "This removes the folder and everything inside it from Nextcloud. This cannot be undone here." + } else { + "This removes the file from Nextcloud. This cannot be undone here." + }, + ) + Text( + if (running) { + "You can close this dialog. The delete continues and the folder refreshes when it finishes." + } else { + "The delete is ETag-protected and will stop if the item changed since this folder was loaded." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(if (running) "Close" else "Cancel") } + }, + confirmButton = { + Button( + enabled = !running && !retryBlocked, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + onClick = onConfirm, + ) { + if (running) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(Modifier.size(8.dp)) + } + Text(if (running) "Deleting..." else "Delete") + } + }, + ) +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt new file mode 100644 index 000000000..f9ef6d605 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt @@ -0,0 +1,179 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.CancellationException + +/** The verified result of one ETag-guarded Files delete. */ +internal sealed interface FileDeleteOutcome { + /** The server confirmed the delete. [localFollowUp] reports the local bookkeeping separately. */ + data class Deleted(val localFollowUp: FileMutationLocalFollowUp) : FileDeleteOutcome + + /** + * The item is no longer at its path. That is not proof of deletion: after an unknown result + * ([afterUnknownResult]) another client may have moved or renamed it instead. [localFollowUp] + * reports the local bookkeeping for the vanished path, as for a confirmed delete. + */ + data class NoLongerAtLocation( + val afterUnknownResult: Boolean, + val localFollowUp: FileMutationLocalFollowUp = FileMutationLocalFollowUp.Completed, + ) : FileDeleteOutcome + + /** + * The same item is verified to be unchanged on the server, or the request was never sent, so + * trying again is safe. + */ + data class NotDeleted(val message: String) : FileDeleteOutcome + + /** + * The outcome is unknown, or the item changed so its version precondition is stale. The folder + * must be refreshed before another try. + */ + data class RefreshRequired(val message: String) : FileDeleteOutcome + + /** + * The server rate-limited the request without changing anything. File mutations do not expose + * Retry-After, so no delay is invented: writes stay blocked and no extra read is started. + */ + data class Throttled(val message: String) : FileDeleteOutcome + + /** + * The server rejected the account's credentials or permission. Repeating the request cannot + * succeed until the user signs in again or the item's permissions change. + */ + data class NotPermitted(val message: String) : FileDeleteOutcome +} + +/** Why another delete of the same item must wait for a fresh folder listing, or null. */ +internal fun FileDeleteOutcome.retryBlockMessage(): String? = when (this) { + is FileDeleteOutcome.RefreshRequired -> message + is FileDeleteOutcome.Throttled -> message + is FileDeleteOutcome.NotPermitted -> message + is FileDeleteOutcome.Deleted, is FileDeleteOutcome.NoLongerAtLocation, is FileDeleteOutcome.NotDeleted -> null +} + +/** + * Deletes [target] once and never retries. A result without a definitive server answer is resolved + * by reading the parent folder from the server, never by sending the delete again. + */ +internal suspend fun deleteFileWithVerifiedOutcome( + target: NextcloudFile, + expectedEtag: String, + delete: suspend () -> NextcloudFileMutationResult, + readParent: suspend () -> NextcloudFileListing, +): FileDeleteOutcome { + val failure = try { + return FileDeleteOutcome.Deleted(delete().localFollowUp) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + failure + } + // Nothing left this device, so the item is unchanged and no verification request is needed. + if (failure is FileMutationNotSentException) { + return FileDeleteOutcome.NotDeleted(failure.message ?: "${target.name} was not deleted. Nothing was sent.") + } + if (failure is NextcloudFileOperationException) { + when (failure.error) { + NextcloudFileOperationError.NotFound -> return FileDeleteOutcome.NoLongerAtLocation( + afterUnknownResult = false, + localFollowUp = failure.localFollowUp ?: FileMutationLocalFollowUp.Completed, + ) + NextcloudFileOperationError.ServerFailure -> Unit + // Resending the same ETag precondition cannot succeed. + NextcloudFileOperationError.Conflict -> return FileDeleteOutcome.RefreshRequired( + failure.message ?: "${target.name} changed on the server. Refresh the folder before deleting it.", + ) + NextcloudFileOperationError.Throttled -> return FileDeleteOutcome.Throttled( + failure.message ?: "The server is limiting requests. Wait a while, then try again.", + ) + NextcloudFileOperationError.AuthenticationRequired, + NextcloudFileOperationError.PermissionDenied, + -> return FileDeleteOutcome.NotPermitted(failure.message ?: "You cannot delete ${target.name}.") + // A lock is released and storage can be freed without changing this item: retry is safe. + NextcloudFileOperationError.Locked, + NextcloudFileOperationError.InsufficientStorage, + -> return FileDeleteOutcome.NotDeleted(failure.message ?: "Could not delete ${target.name}.") + } + } + // The local reconciliation run for an unknown result, reported if the item turns out to be gone. + val unknownFollowUp = (failure as? FileMutationOutcomeUnknownException)?.localFollowUp ?: FileMutationLocalFollowUp.Completed + return verifyFileDeletePostcondition(target, expectedEtag, unknownFollowUp, readParent) +} + +private suspend fun verifyFileDeletePostcondition( + target: NextcloudFile, + expectedEtag: String, + unknownFollowUp: FileMutationLocalFollowUp, + readParent: suspend () -> NextcloudFileListing, +): FileDeleteOutcome { + val unconfirmed = FileDeleteOutcome.RefreshRequired(unconfirmedDeleteMessage(target.name)) + val listing = try { + readParent() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + return unconfirmed + } + if (listing.source != NextcloudFileListingSource.Network) return unconfirmed + val current = listing.files.firstOrNull { it.path == target.path } + if (current == null) { + // The stable file ID can only be reconciled within the listing already read. + val renamedInPlace = target.fileId != null && listing.files.any { it.fileId == target.fileId } + return if (renamedInPlace) { + FileDeleteOutcome.RefreshRequired("${target.name} was renamed on the server. Refresh the folder.") + } else { + FileDeleteOutcome.NoLongerAtLocation(afterUnknownResult = true, localFollowUp = unknownFollowUp) + } + } + // Retrying is safe only for positively the same item. Matching stable file IDs prove it; a + // differing ID means a replacement holds the path, and an ID on only one side cannot be + // compared, so both need a refresh even when type and ETag match. Only when neither side has + // an ID are the type and ETag the identity evidence. + if (target.fileId != current.fileId) { + return FileDeleteOutcome.RefreshRequired( + if (target.fileId != null && current.fileId != null) { + "${target.name} was replaced on the server. Refresh the folder." + } else { + "Could not confirm that ${target.name} is still the same item. Refresh the folder." + }, + ) + } + return if (current.isDirectory == target.isDirectory && current.etag == expectedEtag) { + FileDeleteOutcome.NotDeleted("The delete did not finish. ${target.name} is still on the server.") + } else { + FileDeleteOutcome.RefreshRequired( + "${target.name} changed on the server. Refresh the folder before deleting it.", + ) + } +} + +/** Screen effects of a finished delete. They apply even after its dialog was closed. */ +internal data class FileDeleteScreenEffect(val notice: String?, val reloadFolder: Boolean) + +internal fun FileDeleteOutcome.screenEffect(name: String, dialogShowsResult: Boolean): FileDeleteScreenEffect = + when (this) { + is FileDeleteOutcome.Deleted -> FileDeleteScreenEffect(notice = localFollowUp.reportFor("Deleted $name"), reloadFolder = true) + is FileDeleteOutcome.NoLongerAtLocation -> FileDeleteScreenEffect( + notice = ( + if (afterUnknownResult) { + "$name is no longer in this folder. The delete response was interrupted, so it may " + + "have been deleted, moved, or renamed" + } else { + "$name is no longer in this folder" + } + ).let { done -> if (localFollowUp == FileMutationLocalFollowUp.Completed) "$done." else localFollowUp.reportFor(done) }, + reloadFolder = true, + ) + is FileDeleteOutcome.NotDeleted -> FileDeleteScreenEffect( + notice = if (dialogShowsResult) null else "$name was not deleted. $message", + reloadFolder = false, + ) + is FileDeleteOutcome.RefreshRequired -> FileDeleteScreenEffect( + notice = if (dialogShowsResult) null else message, + reloadFolder = true, + ) + // A folder reload is another request; leave it to the user after the rate limit or sign-in. + is FileDeleteOutcome.Throttled, is FileDeleteOutcome.NotPermitted -> FileDeleteScreenEffect( + notice = if (dialogShowsResult) null else "$name was not deleted. ${retryBlockMessage()}", + reloadFolder = false, + ) + } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt index 737a0fd1d..47d85c86d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt @@ -78,7 +78,7 @@ internal enum class FileOfflineWorkspaceSection( @Composable internal fun FileOfflineCenterScreen( - services: NextcloudPlatformServices, + services: NextcloudPlatformServices, writeFence: FileWriteFence, session: NextcloudSession, userId: String, onBack: () -> Unit, @@ -955,7 +955,7 @@ internal fun FileOfflineCenterScreen( if (virtualFolderPickerVisible) { RemoteFolderPickerDialog( - services = services, + services = services, writeFence = writeFence, session = session, userId = userId, initialPath = "", @@ -982,7 +982,7 @@ internal fun FileOfflineCenterScreen( val localRootForDestination = pendingLocalRoot if (remoteFolderPickerVisible && localRootForDestination != null) { RemoteFolderPickerDialog( - services = services, + services = services, writeFence = writeFence, session = session, userId = userId, initialPath = pendingRemotePath diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt index 5ecacb051..569f9a407 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt @@ -68,8 +68,31 @@ fun FileWebDavMutationSpec.conflictConditionHeaders(): Map = data class NextcloudFileMutationResult( val destinationPath: String?, val etag: String?, + /** Local cache and virtual-file bookkeeping after the server confirmed the change. */ + val localFollowUp: FileMutationLocalFollowUp = FileMutationLocalFollowUp.Completed, ) +/** + * The server result is authoritative. Local bookkeeping is reported separately so a slow or + * failed local update never hides or delays a change the server already applied. + */ +enum class FileMutationLocalFollowUp { + Completed, + + /** Still running in the background after the bounded wait. */ + StillRunning, + + /** Finished without updating every local cache or virtual-file record. */ + Failed, +} + +/** What to tell the user after the server confirmed [done]; incomplete local bookkeeping is never hidden. */ +internal fun FileMutationLocalFollowUp.reportFor(done: String): String = when (this) { + FileMutationLocalFollowUp.Completed -> done + FileMutationLocalFollowUp.StillRunning -> "$done. Local file status is still updating in the background." + FileMutationLocalFollowUp.Failed -> "$done. Local file status could not be updated; refresh the folder if it looks out of date." +} + enum class NextcloudFileOperationError { AuthenticationRequired, PermissionDenied, @@ -77,6 +100,9 @@ enum class NextcloudFileOperationError { Conflict, Locked, InsufficientStorage, + + /** The server rejected the request under its rate limit; nothing was changed. */ + Throttled, ServerFailure, } @@ -84,8 +110,31 @@ class NextcloudFileOperationException( val error: NextcloudFileOperationError, val status: Int, message: String, + /** Local bookkeeping run for a definitive answer such as 404, where the path is known to be gone. */ + val localFollowUp: FileMutationLocalFollowUp? = null, ) : Exception(message) +/** + * A file mutation that never left this device, for example because the account changed before it + * could be sent or no connection to the server could be opened. Only code that can prove nothing + * was sent raises it, so the server state is known to be unchanged and the mutation may be retried. + */ +class FileMutationNotSentException(message: String, cause: Throwable? = null) : IllegalStateException(message, cause) + +/** + * The server result of a file mutation is unknown because the exchange was interrupted after the + * request may have been sent. [localFollowUp] reports the local reconciliation run for that + * unknown result, so an outcome verified later reports the local state truthfully. + */ +open class FileMutationOutcomeUnknownException( + message: String, + cause: Throwable?, + val localFollowUp: FileMutationLocalFollowUp, +) : Exception(message, cause) + +const val FILE_MUTATION_OUTCOME_UNKNOWN_MESSAGE = + "The server response was interrupted, so the result is unknown. Refresh the folder before trying again." + fun NextcloudFileMutation.toWebDavMutationSpec(): FileWebDavMutationSpec { val safeSource = requireSafeFilePath(sourcePath, allowRoot = false) val safeEtag = expectedEtag.trim().also { @@ -121,13 +170,18 @@ fun NextcloudFileMutation.toWebDavMutationSpec(): FileWebDavMutationSpec { ) } -fun fileOperationException(status: Int, sourceIsDirectory: Boolean = false): NextcloudFileOperationException { +fun fileOperationException( + status: Int, + sourceIsDirectory: Boolean = false, + localFollowUp: FileMutationLocalFollowUp? = null, +): NextcloudFileOperationException { val error = when (status) { 401 -> NextcloudFileOperationError.AuthenticationRequired 403 -> NextcloudFileOperationError.PermissionDenied 404 -> NextcloudFileOperationError.NotFound 405, 409, 412 -> NextcloudFileOperationError.Conflict 423 -> NextcloudFileOperationError.Locked + 429 -> NextcloudFileOperationError.Throttled 507 -> NextcloudFileOperationError.InsufficientStorage else -> NextcloudFileOperationError.ServerFailure } @@ -141,9 +195,10 @@ fun fileOperationException(status: Int, sourceIsDirectory: Boolean = false): Nex } else "The file or destination changed. Refresh and try again." NextcloudFileOperationError.Locked -> "The file is locked by another operation." NextcloudFileOperationError.InsufficientStorage -> "The server does not have enough free storage." + NextcloudFileOperationError.Throttled -> "The server is limiting requests. Wait a while, then refresh and try again." NextcloudFileOperationError.ServerFailure -> "The file operation failed (HTTP $status)." } - return NextcloudFileOperationException(error, status, message) + return NextcloudFileOperationException(error, status, message, localFollowUp) } enum class FileShareTarget(val wireValue: Int) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileTransferDialog.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileTransferDialog.kt index 588c5b7dd..0e5c721da 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileTransferDialog.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileTransferDialog.kt @@ -32,7 +32,9 @@ internal data class FileTransferDialogIdentity( val moving: Boolean, ) -internal data class FileTransferDestination(val directory: String, val name: String) +internal data class FileTransferDestination(val directory: String, val name: String) { + val path: String get() = if (directory.isEmpty()) name else "$directory/$name" +} /** Ephemeral dialog choices; transport, preconditions, and mutation recovery stay with the caller. */ internal data class FileTransferDialogState( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistorySection.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistorySection.kt index 99a89d763..588fbc9d0 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistorySection.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistorySection.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.launch @Composable internal fun FileVersionHistorySection( services: NextcloudPlatformServices, + writeFence: FileWriteFence, session: NextcloudSession, userId: String, file: NextcloudFile, @@ -232,6 +233,13 @@ internal fun FileVersionHistorySection( val version = pending.version if (previewLoadingId != null) return@FileVersionConfirmationDialog confirmation = null + // Restoring rewrites the file; it must not race a delete that covers it. + if (pending is FileVersionConfirmation.Restore) { + writeFence.writeBlock(file.path)?.let { block -> + error = block.message + return@FileVersionConfirmationDialog + } + } previewLoadingId = version.id actionMessage = null error = null diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileWriteFence.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileWriteFence.kt new file mode 100644 index 000000000..d6eab0c51 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileWriteFence.kt @@ -0,0 +1,166 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.mutableStateMapOf +import kotlinx.coroutines.CoroutineScope + +/** + * The account-scoped fence every surface that changes account files consults before it writes, + * so no surface can create, move, or change an item while a delete that covers it is unverified + * or needs a refresh. Surfaces receive only this interface. + */ +fun interface FileWriteFence { + /** Why a write that creates or changes [path] must wait, or null when it may proceed. */ + fun writeBlock(path: String): FileWriteBlock? + + /** + * Why creating a new item directly inside [folder] must wait: a delete of the folder or one of + * its parents is unverified or needs a refresh. A delete of an existing child does not block a + * new sibling. The default is the stricter [writeBlock] of the folder itself. + */ + fun newChildBlock(folder: String): FileWriteBlock? = writeBlock(folder) +} + +/** A write refused by a [FileWriteFence]; its message is the block's typed reason. */ +class FileWriteBlockedException(val block: FileWriteBlock) : IllegalStateException(block.message) + +/** Why writes to a Files item must wait. User-facing copy derives only from this type. */ +sealed interface FileWriteBlock { + val message: String + + /** A delete of the item, one of its folders, or its contents is not verified yet. */ + data object DeleteRunning : FileWriteBlock { + override val message = "Wait until the delete of this item or its folder finishes." + } + + /** A finished delete of [blockedPath] needs a server listing of its folder first. */ + data class RefreshRequired(val blockedPath: String) : FileWriteBlock { + val folder: String get() = blockedPath.substringBeforeLast('/', missingDelimiterValue = "") + + override val message: String + get() = "Open or refresh ${folder.ifEmpty { "your Nextcloud root" }} before changing " + + "${blockedPath.substringAfterLast('/')} or anything inside it." + } +} + +/** One remote resource in one account. */ +internal data class FileDeleteResourceKey(val account: NextcloudAccountId, val path: String) { + val parentPath: String get() = path.substringBeforeLast('/', missingDelimiterValue = "") + + fun overlaps(other: FileDeleteResourceKey): Boolean = account == other.account && ( + path == other.path || path.startsWith("${other.path}/") || other.path.startsWith("$path/") + ) + + /** True when [other] is this resource or lies inside it. */ + fun contains(other: FileDeleteResourceKey): Boolean = + account == other.account && (path == other.path || other.path.startsWith("$path/")) +} + +/** Account-scoped Files deletes that have not reached a verified outcome. */ +internal class FileDeleteInFlight { + private val pending = mutableStateMapOf() + private var nextToken = 0L + + operator fun contains(key: FileDeleteResourceKey): Boolean = key in pending + + /** True when an unverified delete targets [key], one of its folders, or one of its children. */ + fun blocks(key: FileDeleteResourceKey): Boolean = pending.keys.any { it.overlaps(key) } + + /** True when an unverified delete targets [key] itself or one of its folders. */ + fun covers(key: FileDeleteResourceKey): Boolean = pending.keys.any { it.contains(key) } + + fun claim(key: FileDeleteResourceKey): Long? { + if (blocks(key)) return null + return (++nextToken).also { pending[key] = it } + } + + fun release(key: FileDeleteResourceKey, token: Long) { + if (pending[key] == token) pending.remove(key) + } +} + +/** + * The write blocks of one account in this process: unverified deletes, and finished deletes whose + * outcome needs a server listing of the item's folder. Every surface of the account reads the same + * instance, including ones outside the composition that started the delete, such as another + * activity. State is held in memory only: a new process starts without blocks. + * + * The account's Files deletes run in [deletes], under [scope] from the process-level owner, so + * recreating an activity or leaving Files neither cancels a request nor releases its block. + */ +class AccountFileWriteFence internal constructor(val account: NextcloudAccountId, scope: CoroutineScope) : FileWriteFence { + private class RetryBlock(val message: String, val recordedAt: Long) + + internal val inFlight = FileDeleteInFlight() + private val retryBlocks = mutableStateMapOf() + private var sequence = 0L + + /** The account's delete owner. Only [FileWriteFenceRegistry.removeAccount] closes it. */ + internal val deletes = FileDeleteCoordinator(this, scope) + + override fun writeBlock(path: String): FileWriteBlock? = + FileWriteBlock.DeleteRunning.takeIf { inFlight.blocks(FileDeleteResourceKey(account, path)) } ?: refreshBlockFor(path) + + /** + * Why a write to [path] must wait for a folder refresh after a finished delete of it, a parent, + * or a child, or null. The old path may now name a replacement item, so path-based writes wait too. + */ + internal fun refreshBlockFor(path: String): FileWriteBlock.RefreshRequired? { + val key = FileDeleteResourceKey(account, path) + return retryBlocks.keys.firstOrNull { it.overlaps(key) }?.let { FileWriteBlock.RefreshRequired(it.path) } + } + + override fun newChildBlock(folder: String): FileWriteBlock? { + val key = FileDeleteResourceKey(account, folder) + if (inFlight.covers(key)) return FileWriteBlock.DeleteRunning + return retryBlocks.keys.firstOrNull { it.contains(key) }?.let { FileWriteBlock.RefreshRequired(it.path) } + } + + internal fun retryBlockMessage(key: FileDeleteResourceKey): String? = retryBlocks[key]?.message + + internal fun hasRetryBlock(key: FileDeleteResourceKey): Boolean = key in retryBlocks + + /** Records [key]'s finished delete: [message] keeps it blocked until a later listing, null lifts it. */ + internal fun recordOutcome(key: FileDeleteResourceKey, message: String?) { + if (message == null) retryBlocks.remove(key) else retryBlocks[key] = RetryBlock(message, ++sequence) + } + + /** + * Reads the folder at [path]. A server listing lifts the retry blocks of its items that were + * recorded before the read started, never ones recorded while it ran. + */ + internal suspend fun readFolder(path: String, read: suspend () -> NextcloudFileListing): NextcloudFileListing { + val startedAfter = sequence + val listing = read() + if (listing.source == NextcloudFileListingSource.Network) { + retryBlocks.filter { (key, block) -> key.parentPath == path && block.recordedAt <= startedAfter } + .keys.forEach(retryBlocks::remove) + } + return listing + } +} + +/** Exposes the process-level fence owner; implementations return the one shared registry of their process. */ +interface FileWriteFencePlatformServices { + val fileWriteFences: FileWriteFenceRegistry +} + +/** + * Process-level owner of [AccountFileWriteFence]s, keyed by the non-secret local account identity. + * Each platform's process-scoped service owner holds one registry, so the main window, other + * activities, and pickers of the same account share blocks. Deletes run under [scope], which that + * owner keeps for the process lifetime on the thread that owns UI state. Sign-out and account + * removal call [removeAccount], which cancels that account's deletes and drops its blocks with its + * other private state; a later account of the same identity starts a new, unblocked incarnation. + */ +class FileWriteFenceRegistry(private val scope: CoroutineScope) { + private val lock = DynamicNativeMemoryCacheLock() + private val accounts = mutableMapOf() + + fun account(account: NextcloudAccountId): AccountFileWriteFence = lock.withLock { + accounts.getOrPut(account.storageKey) { AccountFileWriteFence(account, scope) } + } + + fun removeAccount(accountStorageKey: String) { + lock.withLock { accounts.remove(accountStorageKey) }?.deletes?.close() + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt index 9e653167a..c27724c2e 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt @@ -73,6 +73,7 @@ internal fun NativeFilesWorkspace( onLayoutChanged: (FileLayout) -> Unit, offlineAvailability: Map, offlineStorageSupported: Boolean, + writeBlock: (NextcloudFile) -> FileWriteBlock?, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -115,6 +116,7 @@ internal fun NativeFilesWorkspace( layout = layout, offlineAvailability = offlineAvailability, offlineStorageSupported = offlineStorageSupported, + writeBlock = writeBlock, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -176,6 +178,7 @@ internal fun NativeFilesWorkspace( file = selectedFile, offlineAvailability = selectedFile?.let { offlineAvailability[it.path] }, offlineStorageSupported = offlineStorageSupported, + writeBlock = writeBlock, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -328,6 +331,7 @@ private fun FilesBrowserPane( layout: FileLayout, offlineAvailability: Map, offlineStorageSupported: Boolean, + writeBlock: (NextcloudFile) -> FileWriteBlock?, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -388,6 +392,7 @@ private fun FilesBrowserPane( files = files, offlineAvailability = offlineAvailability, offlineStorageSupported = offlineStorageSupported, + writeBlock = writeBlock, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -405,6 +410,7 @@ private fun FilesBrowserPane( compact = layout == FileLayout.Compact, offlineAvailability = offlineAvailability, offlineStorageSupported = offlineStorageSupported, + writeBlock = writeBlock, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, selectedFile = selectedFile, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceInspector.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceInspector.kt index 543887626..b32ca647b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceInspector.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceInspector.kt @@ -48,6 +48,7 @@ internal fun FilesInspector( file: NextcloudFile?, offlineAvailability: FileOfflineAvailability?, offlineStorageSupported: Boolean, + writeBlock: (NextcloudFile) -> FileWriteBlock?, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -104,6 +105,7 @@ internal fun FilesInspector( file = file, offlineAvailability = offlineAvailability ?: FileOfflineAvailability.OnlineOnly, offlineStorageSupported = offlineStorageSupported, + writeBlock = writeBlock(file), fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, expanded = menuExpanded, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceItems.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceItems.kt index f7543b435..0ca07a4ca 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceItems.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceItems.kt @@ -60,6 +60,7 @@ internal fun NativeFileWorkspaceList( compact: Boolean, offlineAvailability: Map, offlineStorageSupported: Boolean, + writeBlock: (NextcloudFile) -> FileWriteBlock? = { null }, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, selectedFile: NextcloudFile?, @@ -181,6 +182,7 @@ internal fun NativeFileWorkspaceList( file = file, offlineAvailability = availability, offlineStorageSupported = offlineStorageSupported, + writeBlock = writeBlock(file), fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, expanded = menuExpanded, @@ -200,6 +202,7 @@ internal fun NativeFileWorkspaceGrid( files: List, offlineAvailability: Map, offlineStorageSupported: Boolean, + writeBlock: (NextcloudFile) -> FileWriteBlock? = { null }, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -225,6 +228,7 @@ internal fun NativeFileWorkspaceGrid( selected = selectedFile?.path == file.path, availability = offlineAvailability[file.path] ?: FileOfflineAvailability.OnlineOnly, offlineStorageSupported = offlineStorageSupported, + writeBlock = writeBlock, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -249,6 +253,7 @@ private fun NativeFileWorkspaceTile( selected: Boolean, availability: FileOfflineAvailability, offlineStorageSupported: Boolean, + writeBlock: (NextcloudFile) -> FileWriteBlock?, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -321,6 +326,7 @@ private fun NativeFileWorkspaceTile( file = file, offlineAvailability = availability, offlineStorageSupported = offlineStorageSupported, + writeBlock = writeBlock(file), fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, expanded = menuExpanded, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingShareUploadScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingShareUploadScreen.kt index f770570c3..52a8adfd4 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingShareUploadScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingShareUploadScreen.kt @@ -72,6 +72,8 @@ data class IncomingShareUploadPresentation( val message: String?, val canVerifyOutcome: Boolean = false, val isCorruptRecovery: Boolean = false, + /** A queued upload waits until a delete covering its destination is verified or refreshed. */ + val waitingForDestinationDelete: Boolean = false, ) data class IncomingShareRecoveryPage( @@ -415,12 +417,17 @@ fun IncomingShareUploadScreen( } } -@Composable -private fun IncomingShareProgress(request: IncomingShareUploadPresentation) { - val label = when (request.state) { +/** The progress line for [request]. A queued upload says what it waits for. */ +fun incomingShareProgressLabel(request: IncomingShareUploadPresentation): String = + when (request.state) { IncomingShareUploadState.Staged -> "Choose a destination. Existing names are kept as numbered copies, never overwritten." - IncomingShareUploadState.Queued -> "Waiting for a network connection." + IncomingShareUploadState.Queued -> if (request.waitingForDestinationDelete) { + "Paused until a delete in the destination folder is verified. Open or refresh the destination " + + "folder in Files to continue. Finished files are kept." + } else { + "Waiting for a network connection." + } IncomingShareUploadState.Uploading -> "${request.completedFiles} of ${request.files.size} files uploaded." IncomingShareUploadState.Completed -> "All files were uploaded." IncomingShareUploadState.Failed -> @@ -431,6 +438,10 @@ private fun IncomingShareProgress(request: IncomingShareUploadPresentation) { (request.message ?: "The next file result is unknown. Check Files before trying again.") IncomingShareUploadState.Canceled -> request.message ?: "Upload canceled." } + +@Composable +private fun IncomingShareProgress(request: IncomingShareUploadPresentation) { + val label = incomingShareProgressLabel(request) if (request.state in setOf(IncomingShareUploadState.Queued, IncomingShareUploadState.Uploading)) { LinearProgressIndicator( progress = { request.completedFiles.toFloat() / request.files.size }, @@ -576,3 +587,35 @@ internal fun incomingShareRecoveryRefreshMillis(hasRecoveries: Boolean): Long = private const val INCOMING_SHARE_RECOVERY_REFRESH_MILLIS = 2_000L private const val EMPTY_INCOMING_SHARE_RECOVERY_REFRESH_MILLIS = 5_000L + +/** + * Folder picker operations for an incoming share upload. [base] carries the account's write fence, + * so a destination must pass it before [selectionAccess] checks folder permissions, and a new + * folder is created only where [requireCreateDirectories] allows it and the fence permits. + */ +fun incomingShareFolderPickerOperations( + base: RemoteFolderPickerOperations, + selectionAccess: suspend (path: String) -> RemoteFolderSelectionAccess, + requireCreateDirectories: suspend (parentPath: String) -> Unit, +): RemoteFolderPickerOperations = RemoteFolderPickerOperations( + identity = base.identity + "|incoming-share", + listCached = base.listCached, + listNetwork = base.listNetwork, + createDirectoryIfAbsent = { path -> + requireCreateDirectories(path.substringBeforeLast('/', "")) + base.createDirectoryIfAbsent(path) + }, + selectionAccess = { path -> + when (val fenced = base.selectionAccess(path)) { + RemoteFolderSelectionAccess.Allowed -> selectionAccess(path) + else -> fenced + } + }, +) + +/** + * Why queueing shared files into [destinationPath] must wait, or null. Checked again when the + * upload is queued, because a delete may have started after the folder was chosen. + */ +fun incomingShareUploadBlock(fence: FileWriteFence, destinationPath: String): FileWriteBlock? = + fence.newChildBlock(destinationPath) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaStacking.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaStacking.kt index 4cec04377..88a6f9368 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaStacking.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaStacking.kt @@ -407,7 +407,7 @@ fun NextcloudFile.mediaAssetFormat(): MediaAssetFormat { } } -private fun createMediaStack(files: List): MediaStack { +internal fun createMediaStack(files: List): MediaStack { val ordered = files.sortedWith(mediaSourceOrder) val cover = ordered.first() return MediaStack( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt index cf0ff893e..e34b44f5b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt @@ -201,6 +201,72 @@ internal class MediaViewerNavigationRepository private constructor( } } + /** + * Removes the file [removed] from the route after its delete was confirmed or it is no longer + * at its path, so a recreated viewer never shows it again. Only that file leaves: when it was + * the cover of a stack whose other members survive, the stack is rebuilt by the stacking rule + * with a surviving member as its cover, in the same position. An item leaves only when none of + * its members survive. + * + * Returns the route for the item to show: the same file when it survives, else its rebuilt + * stack, else the next item, else the previous one. The route gets a new key, so a holder whose + * indices did not change still observes the removal and a stale copy of the old route no longer + * resolves. Returns null, releasing the route, when no item is left or the route is gone. + */ + fun remove( + accountId: NextcloudAccountId, + route: MediaViewerNavigationRoute, + removed: NextcloudFile, + ): MediaViewerNavigationRoute? = gate.read(accountId.storageKey, null) { + lock.withLock { + val content = routes[route.key]?.takeIf { it.accountId == accountId } ?: return@withLock null + val removedIdentity = mediaViewerFileIdentity(removed) + // The navigation item each source belongs to, as registered. + val itemIdentities = content.media.mapTo(mutableSetOf(), ::mediaViewerFileIdentity) + val itemOf = content.sourceMembers.associate { source -> + val identity = mediaViewerFileIdentity(source) + identity to ( + content.navigationIdentityBySourceIdentity[identity] + ?: identity.takeIf(itemIdentities::contains) + ?: content.media.firstOrNull { it.sharesMediaStackWith(source) }?.let(::mediaViewerFileIdentity) + ?: identity + ) + } + val survivingSources = content.sourceMembers.filterNot { mediaViewerFileIdentity(it) == removedIdentity } + val promoted = survivingSources + .filter { itemOf[mediaViewerFileIdentity(it)] == removedIdentity } + .takeIf { removedIdentity in itemIdentities && it.isNotEmpty() } + ?.let { survivors -> createMediaStack(survivors).cover } + val media = content.media.mapNotNull { item -> if (mediaViewerFileIdentity(item) == removedIdentity) promoted else item } + if (media.isEmpty()) { + routes.remove(route.key) + return@withLock null + } + val retained = media.mapTo(mutableSetOf(), ::mediaViewerFileIdentity) + val navigationIdentities = survivingSources.mapNotNull { source -> + val identity = mediaViewerFileIdentity(source) + val item = itemOf.getValue(identity).let { if (it == removedIdentity) promoted?.let(::mediaViewerFileIdentity) else it } + item?.takeIf(retained::contains)?.let { identity to it } + }.toMap() + val sources = survivingSources.filter { mediaViewerFileIdentity(it) in navigationIdentities } + val shown = content.sourceMembers.getOrNull(route.selectedSourceIndex) + val shownItem = shown?.let { itemOf[mediaViewerFileIdentity(it)] } + ?.let { if (it == removedIdentity) promoted?.let(::mediaViewerFileIdentity) else it } + val next = shown?.takeIf { mediaViewerFileIdentity(it) in navigationIdentities } + ?: media.firstOrNull { mediaViewerFileIdentity(it) == shownItem } + ?: media.getOrNull(route.selectedIndex) + ?: media.last() + val nextIdentity = mediaViewerFileIdentity(next) + val navigationIndex = media.indexOfFirst { mediaViewerFileIdentity(it) == navigationIdentities[nextIdentity] } + val sourceIndex = sources.indexOfFirst { mediaViewerFileIdentity(it) == nextIdentity } + routes.remove(route.key) + if (navigationIndex < 0 || sourceIndex < 0) return@withLock null + val key = "media-${nextRouteId++}" + routes[key] = content.copy(media = media, sourceMembers = sources, navigationIdentityBySourceIdentity = navigationIdentities) + MediaViewerNavigationRoute(key, selectedIndex = navigationIndex, selectedSourceIndex = sourceIndex) + } + } + fun release(accountId: NextcloudAccountId, routeKey: String) { gate.read(accountId.storageKey, Unit) { lock.withLock { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt index ee1812ae7..e9c0c261c 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt @@ -74,19 +74,23 @@ import kotlinx.coroutines.launch * Arrow keys navigate on desktop; Escape closes the viewer. Focusable icon buttons also support * normal keyboard activation. Image edits are reversible previews until the user explicitly saves * a new recipe sidecar; the source file is never passed to a write operation. + * Deletes run in the account's shared [fileDeletes] owner, like deletes from Files. An applied + * delete goes to [onItemRemoved], whose owner removes it from the navigable sequence. */ @Composable -fun NextcloudMediaViewer( +internal fun NextcloudMediaViewer( media: List, selected: NextcloudFile, session: NextcloudSession, userId: String, services: NextcloudPlatformServices, + fileDeletes: FileDeleteCoordinator, taggingAvailable: Boolean, memoriesLivePhotoCapability: MemoriesLivePhotoCapability, sharingCapabilities: NextcloudFileSharingCapabilities, onSelect: (NextcloudFile) -> Unit, onSourceRemoved: (NextcloudFile) -> Unit, + onItemRemoved: (NextcloudFile) -> Unit, onClose: () -> Unit, navigationRequest: NextcloudPendingNavigationRequest? = null, onNavigationConfirmed: (NextcloudPendingNavigationRequest) -> Unit = {}, @@ -96,9 +100,7 @@ fun NextcloudMediaViewer( onStateObserved: (MediaViewerStateObservation) -> Unit = {}, modifier: Modifier = Modifier, ) { - val items = remember(media, selected) { - if (mediaViewerNavigationIndex(media, selected) >= 0) media else listOf(selected) - } + val items = remember(media, selected) { mediaViewerItems(media, selected) } val sourcePlan = remember(sourceMembers, selected) { planMediaSources(sourceMembers, selected) } @@ -175,6 +177,7 @@ fun NextcloudMediaViewer( mutableStateOf(false) } var viewerAction by remember(selected.path) { mutableStateOf(null) } + var writeBlockNotice by remember(selected.path) { mutableStateOf(null) } val scope = rememberCoroutineScope() val onActionMutationRunningChanged: (Boolean) -> Unit = remember(selected.path) { { running -> @@ -563,11 +566,7 @@ fun NextcloudMediaViewer( }.getOrNull() } } - DisposableEffect(playbackRangeSource) { - onDispose { - playbackRangeSource?.close() - } - } + DisposableEffect(playbackRangeSource) { onDispose { playbackRangeSource?.close() } } if (editing && displayImage != null) { NextcloudPhotoEditor( image = displayImage, @@ -766,7 +765,7 @@ fun NextcloudMediaViewer( highDetailSourceFile = fullQuality?.source?.file, ) ) { - { editing = true } + { writeBlockNotice = fileDeletes.writeBlock(selected.path); if (writeBlockNotice == null) editing = true } } else { null }, @@ -849,17 +848,16 @@ fun NextcloudMediaViewer( ) } + writeBlockNotice?.let { block -> MediaWriteBlockedDialog(block) { writeBlockNotice = null } } + MediaDeleteHost(items, sourceMembers, selected, session, userId, services, fileDeletes, viewerAction == MediaViewerAction.Delete, onClose = { viewerAction = null }) { removed -> viewerAction = null; onItemRemoved(removed) } viewerAction?.let { action -> MediaViewerActionDialog( action = action, file = selected, - session = session, - userId = userId, - services = services, + session = session, userId = userId, + services = services, writeFence = fileDeletes, sharingCapabilities = sharingCapabilities, - onDismiss = { - if (activeActionMutations == 0) viewerAction = null - }, + onDismiss = { if (activeActionMutations == 0) viewerAction = null }, onSourceRemoved = { viewerAction = null onSourceRemoved(selected) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt index 3087a6933..ff8272601 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -24,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -86,11 +86,14 @@ internal fun MediaViewerActionDialog( session: NextcloudSession, userId: String, services: NextcloudPlatformServices, + writeFence: FileWriteFence, sharingCapabilities: NextcloudFileSharingCapabilities, onDismiss: () -> Unit, onSourceRemoved: () -> Unit, onMutationRunningChanged: (Boolean) -> Unit, ) { + // Read in composition, so a dialog that is open when a block appears switches to its reason. + if (action in mediaWriteActions) writeFence.writeBlock(file.path)?.let { block -> return MediaWriteBlockedDialog(block, onDismiss) } when (action) { MediaViewerAction.Move, MediaViewerAction.Copy -> MediaTransferDialog( moving = action == MediaViewerAction.Move, @@ -98,19 +101,13 @@ internal fun MediaViewerActionDialog( session = session, userId = userId, services = services, + writeFence = writeFence, onDismiss = onDismiss, onMoved = onSourceRemoved, onMutationRunningChanged = onMutationRunningChanged, ) - MediaViewerAction.Delete -> MediaDeleteDialog( - file = file, - session = session, - userId = userId, - services = services, - onDismiss = onDismiss, - onDeleted = onSourceRemoved, - onMutationRunningChanged = onMutationRunningChanged, - ) + // The viewer's MediaDeleteHost owns delete, so its outcome survives this dialog. + MediaViewerAction.Delete -> Unit MediaViewerAction.AddToAlbum -> MediaAlbumDialog( file = file, session = session, @@ -145,6 +142,7 @@ private fun MediaTransferDialog( session: NextcloudSession, userId: String, services: NextcloudPlatformServices, + writeFence: FileWriteFence, onDismiss: () -> Unit, onMoved: () -> Unit, onMutationRunningChanged: (Boolean) -> Unit, @@ -156,9 +154,12 @@ private fun MediaTransferDialog( var name by remember(file.path) { mutableStateOf(file.name) } var running by remember(file.path) { mutableStateOf(false) } var error by remember(file.path) { mutableStateOf(null) } + var followUpNotice by remember(file.path) { mutableStateOf(null) } MediaMutationRunningEffect(running, onMutationRunningChanged) val validationError = fileTransferValidationError(file, directory, name) val scope = rememberCoroutineScope() + val finished = if (moving) onMoved else onDismiss + followUpNotice?.let { notice -> return MediaLocalFollowUpDialog(notice, onClose = finished) } AlertDialog( onDismissRequest = { if (!running) onDismiss() }, title = { Text("$verb ${file.name}") }, @@ -209,6 +210,11 @@ private fun MediaTransferDialog( error = "Refresh the media item before changing it." return@Button } + // The destination may be inside a folder whose delete is not verified yet. + writeFence.writeBlock(if (directory.isEmpty()) name else "$directory/$name")?.let { block -> + error = block.message + return@Button + } running = true error = null scope.launch { @@ -218,7 +224,11 @@ private fun MediaTransferDialog( NextcloudFileMutation.Copy(file.path, directory, name, etag) } runCatching { services.executeFileMutation(session, userId, mutation) } - .onSuccess { if (moving) onMoved() else onDismiss() } + .onSuccess { result -> + val done = if (moving) "Moved ${file.name}" else "Copied ${file.name}" + if (result.localFollowUp == FileMutationLocalFollowUp.Completed) finished() + else followUpNotice = result.localFollowUp.reportFor(done) + } .onFailure { error = it.message ?: "Could not ${verb.lowercase()} this file." } running = false } @@ -234,71 +244,122 @@ private fun MediaTransferDialog( ) } +/** + * The media viewer's deletes. They run in the account's shared [deletes] owner exactly like a Files + * delete: the same in-flight and refresh blocks for every screen, verification of an unknown + * result, retry rules, local follow-up, and completion fencing. The host stays composed with the + * viewer and watches every item in [items] and every member of their stacks in [sources], not only + * [selected], so a delete whose dialog was closed is applied even after the user moved to another + * item or stack member, or the viewer was recreated. + * + * An item leaves the viewer, through [onRemoved], only when the server confirmed its delete or it + * is no longer at its path. Any other outcome keeps it, and its reason is shown in the open dialog + * or, for an item without one, as a notice once that item is selected. + */ @Composable -private fun MediaDeleteDialog( - file: NextcloudFile, +internal fun MediaDeleteHost( + items: List, + sources: List, + selected: NextcloudFile, session: NextcloudSession, userId: String, services: NextcloudPlatformServices, - onDismiss: () -> Unit, - onDeleted: () -> Unit, - onMutationRunningChanged: (Boolean) -> Unit, + deletes: FileDeleteCoordinator, + open: Boolean, + onClose: () -> Unit, + onRemoved: (NextcloudFile) -> Unit, ) { - var running by remember(file.path) { mutableStateOf(false) } - var error by remember(file.path) { mutableStateOf(null) } - MediaMutationRunningEffect(running, onMutationRunningChanged) - val scope = rememberCoroutineScope() - AlertDialog( - onDismissRequest = { if (!running) onDismiss() }, - title = { Text("Delete ${file.name}?") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium)) { - Text("This removes the original file from Nextcloud. Album membership and previews are not substitutes for the original.") - Text( - "The delete checks the exact ETag and stops if the file changed.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - error?.let { - Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) - } - } - }, - dismissButton = { - TextButton(enabled = !running, onClick = onDismiss) { Text("Cancel") } - }, - confirmButton = { - Button( - enabled = !running, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), - onClick = { - val etag = file.etag?.takeIf(String::isNotBlank) - if (etag == null) { - error = "Refresh the media item before deleting it." - return@Button - } - running = true - error = null - scope.launch { - runCatching { - services.executeFileMutation( - session, - userId, - NextcloudFileMutation.Delete(file.path, etag), - ) - }.onSuccess { onDeleted() } - .onFailure { error = it.message ?: "Could not delete this file." } - running = false + val dialog = remember(deletes, selected.path) { FileDeleteDialogState(deletes) } + var removalNotices by remember(deletes) { mutableStateOf(emptyList>()) } + var keptNotices by remember(deletes) { mutableStateOf(emptyMap()) } + val currentOnRemoved by rememberUpdatedState(onRemoved) + val paths = remember(items, sources, selected) { (items + sources).mapTo(HashSet()) { it.path } + selected.path } + val completion = deletes.completionFor(paths) + LaunchedEffect(deletes, completion) { + if (completion == null || !deletes.consume(completion)) return@LaunchedEffect + val file = completion.request.file + val shown = file.path == selected.path + val effect = if (shown) dialog.complete(completion) else completion.outcome.screenEffect(file.name, dialogShowsResult = false) + when (val outcome = completion.outcome) { + is FileDeleteOutcome.Deleted -> + if (outcome.localFollowUp == FileMutationLocalFollowUp.Completed) currentOnRemoved(file) + else removalNotices = removalNotices + (file to requireNotNull(effect.notice)) + is FileDeleteOutcome.NoLongerAtLocation -> removalNotices = removalNotices + (file to requireNotNull(effect.notice)) + // An open dialog for the item shows the reason itself; otherwise it waits for that item. + else -> if (!shown || dialog.target == null) effect.notice?.let { keptNotices = keptNotices + (file.path to it) } + } + } + LaunchedEffect(open, selected.path) { if (open && dialog.target == null) dialog.open(selected) } + removalNotices.firstOrNull()?.let { (file, notice) -> + return MediaLocalFollowUpDialog(notice) { + removalNotices = removalNotices.drop(1) + currentOnRemoved(file) + } + } + keptNotices[selected.path]?.let { notice -> + return AlertDialog( + onDismissRequest = { keptNotices = keptNotices - selected.path }, + title = { Text("${selected.name} was not deleted") }, + text = { Text(notice) }, + confirmButton = { TextButton(onClick = { keptNotices = keptNotices - selected.path }) { Text("Close") } }, + ) + } + dialog.target?.let { target -> + FileDeleteDialog( + target = target, + running = dialog.running, + error = dialog.error, + retryBlocked = dialog.retryBlocked, + onConfirm = { + dialog.begin()?.let { request -> + deletes.start(request, { mutation -> services.executeFileMutation(session, userId, mutation) }) { folder -> + services.listFilesWithSource(session, userId, folder) } - }, - ) { - if (running) { - CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) - Spacer(Modifier.size(8.dp)) } - Text(if (running) "Deleting..." else "Delete") - } - }, + }, + // Closing never cancels a running delete; its outcome is still applied by this host. + onDismiss = { + dialog.dismiss() + onClose() + }, + ) + } +} + +/** The viewer's items: [media], or only [selected] when it is not listed. */ +internal fun mediaViewerItems(media: List, selected: NextcloudFile): List = + if (mediaViewerNavigationIndex(media, selected) >= 0) media else listOf(selected) + +/** Actions that change the item, create files next to it, or bind it to a share or album. */ +private val mediaWriteActions = setOf( + MediaViewerAction.Move, + MediaViewerAction.Copy, + MediaViewerAction.ShareNextcloud, + MediaViewerAction.AddToAlbum, +) + +/** A media change refused by the account's write fence, with its typed reason. */ +@Composable +internal fun MediaWriteBlockedDialog(block: FileWriteBlock, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("This file cannot be changed yet") }, + text = { Text(block.message) }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } }, + ) +} + +/** + * The server already applied the change, but local bookkeeping is incomplete. The viewer moves on + * only after the user has seen that, so stale local state is never reported as a plain success. + */ +@Composable +private fun MediaLocalFollowUpDialog(notice: String, onClose: () -> Unit) { + AlertDialog( + onDismissRequest = onClose, + title = { Text("Saved on the server") }, + text = { Text(notice) }, + confirmButton = { Button(onClick = onClose) { Text("Close") } }, ) } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 3e2919a7e..34ed7624b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -684,6 +684,7 @@ private fun AuthenticatedApp( val certificateScope = rememberCoroutineScope() var groupwareMutationInProgress by remember(session) { mutableStateOf(false) } val inlineEditorNavigation = remember(session) { NativeInlineEditorNavigation() } + val fileDeletes = rememberFileDeleteCoordinator(session, services.fileWriteFences) var linkNavigationFailure by rememberSaveable( session.serverUrl, session.loginName, @@ -1533,7 +1534,7 @@ private fun AuthenticatedApp( onSettings = { destination = NextcloudDestination.Settings }, ) RootDestinationContent.FolderSync -> FileOfflineCenterScreen( - services = services, + services = services, writeFence = fileDeletes, session = session, userId = serverInfo?.userId.orEmpty(), onBack = { destination = NextcloudDestination.Home }, @@ -1599,7 +1600,7 @@ private fun AuthenticatedApp( onBack = ::navigateBack, ) Screen.OfflineCenter -> FileOfflineCenterScreen( - services = services, + services = services, writeFence = fileDeletes, session = session, userId = serverInfo?.userId.orEmpty(), onBack = ::navigateBack, @@ -1612,6 +1613,7 @@ private fun AuthenticatedApp( is Screen.Files -> FilesScreen( services = services, session = session, + fileDeletes = fileDeletes, userId = serverInfo?.userId, fileSharing = serverInfo?.fileSharing ?: NextcloudFileSharingCapabilities.Unavailable, path = current.path, @@ -1881,12 +1883,13 @@ private fun AuthenticatedApp( } LoadingMessage("Restoring the media timeline...") } else { + fun closeViewer() { mediaViewerNavigationRepository.release(session.accountId, current.navigationKey); screen = current.returnTo } NextcloudMediaViewer( media = snapshot.media, selected = snapshot.selected, session = session, userId = serverInfo?.userId.orEmpty(), - services = services, + services = services, fileDeletes = fileDeletes, taggingAvailable = serverInfo?.apps?.any { it.id == "memories" } == true, memoriesLivePhotoCapability = memoriesLivePhotoCapability, sharingCapabilities = serverInfo?.fileSharing @@ -1899,14 +1902,13 @@ private fun AuthenticatedApp( ) } }, - onSourceRemoved = { - mediaViewerNavigationRepository.release(session.accountId, current.navigationKey) - screen = current.returnTo - }, - onClose = { - mediaViewerNavigationRepository.release(session.accountId, current.navigationKey) - screen = current.returnTo + onSourceRemoved = { closeViewer() }, + onItemRemoved = { removed -> + mediaViewerNavigationRepository.remove(session.accountId, route, removed)?.let { next -> + screen = current.copy(navigationKey = next.key, selectedIndex = next.selectedIndex, selectedSourceIndex = next.selectedSourceIndex) + } ?: closeViewer() }, + onClose = ::closeViewer, navigationRequest = pendingEditorNavigationRequest, onNavigationConfirmed = ::applyPendingNavigationRequest, onNavigationCancelled = ::cancelPendingNavigationRequest, @@ -1915,7 +1917,7 @@ private fun AuthenticatedApp( } } is Screen.FileInfo -> FileInfoScreen( - services = services, + services = services, writeFence = fileDeletes, session = session, userId = serverInfo?.userId.orEmpty(), file = current.file, @@ -1939,7 +1941,7 @@ private fun AuthenticatedApp( ) } is Screen.TextEditor -> TextEditorScreen( - services = services, + services = services, writeFence = fileDeletes, session = session, userId = serverInfo?.userId.orEmpty(), file = current.file, @@ -6470,6 +6472,7 @@ private fun readableAppName(value: String): String = value internal fun FilesScreen( services: NextcloudPlatformServices, session: NextcloudSession, + fileDeletes: FileDeleteCoordinator, userId: String?, fileSharing: NextcloudFileSharingCapabilities, path: String, @@ -6491,7 +6494,7 @@ internal fun FilesScreen( var renameTarget by remember(session, path, userId) { mutableStateOf(null) } var renameValue by remember(session, path, userId) { mutableStateOf("") } var transferTarget by remember(session, path, userId) { mutableStateOf?>(null) } - var deleteTarget by remember(session, path, userId) { mutableStateOf(null) } + val deleteDialog = remember(fileDeletes, path, userId) { FileDeleteDialogState(fileDeletes) } var creationKind by remember(session, path, userId) { mutableStateOf(null) } var creationName by remember(session, path, userId) { mutableStateOf("") } var creationError by remember(session, path, userId) { mutableStateOf(null) } @@ -6560,7 +6563,7 @@ internal fun FilesScreen( } val hasRetainedFiles = cached != null || retainedFiles != null refreshing = hasRetainedFiles - filesRequest { services.listFilesWithSource(session, userId, path) } + filesRequest { fileDeletes.readFolder(path) { services.listFilesWithSource(session, userId, path) } } .onSuccess { listing -> files = favorites.refreshed(listing.files, readRevision) listingSource = listing.source @@ -6683,6 +6686,7 @@ internal fun FilesScreen( action: FileMenuAction, loadedFiles: List, ) { + if (action.changesRemoteItem()) fileDeletes.writeBlockedMessage(file.path)?.let { mutationError = it; return } when (action) { FileMenuAction.Rename -> { renameTarget = file @@ -6717,7 +6721,7 @@ internal fun FilesScreen( } } FileMenuAction.Delete -> { - deleteTarget = file + deleteDialog.open(file) mutationError = null } FileMenuAction.Move, FileMenuAction.Copy -> { @@ -6936,7 +6940,7 @@ internal fun FilesScreen( ) } } - if (renameTarget == null && transferTarget == null && deleteTarget == null) { + if (renameTarget == null && transferTarget == null && deleteDialog.target == null) { mutationError?.let { message -> Surface( color = MaterialTheme.colorScheme.errorContainer, @@ -7018,6 +7022,7 @@ internal fun FilesScreen( onLayoutChanged = onLayoutChanged, offlineAvailability = offlineAvailability, offlineStorageSupported = services.supportsFileOfflineStorage, + writeBlock = fileDeletes::writeBlock, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -7116,6 +7121,7 @@ internal fun FilesScreen( enabled = creationPlan != null && !creationRunning && userId != null, onClick = { val plan = creationPlan ?: return@Button + fileDeletes.writeBlockedMessage(plan.path)?.let { creationError = it; return@Button } val loadedFiles = files.orEmpty() creationRunning = true creationError = null @@ -7247,9 +7253,9 @@ internal fun FilesScreen( sourceIsDirectory = target.isDirectory, ), ) - }.onSuccess { + }.onSuccess { result -> renameTarget = null - mutationNotice = "Renamed to $renameValue" + mutationNotice = result.localFollowUp.reportFor("Renamed to $renameValue") files = null loadAttempt += 1 }.onFailure { @@ -7274,7 +7280,7 @@ internal fun FilesScreen( val moving = action == FileMenuAction.Move val verb = if (moving) "Move" else "Copy" val pickerOperations = remember(services, session, currentUserId) { - remoteFolderPickerOperations(services, session, currentUserId) + remoteFolderPickerOperations(services, session, currentUserId, writeFence = fileDeletes) } FileTransferDialog( file = target, @@ -7286,6 +7292,7 @@ internal fun FilesScreen( onEdited = { mutationError = null }, onDismiss = { transferTarget = null; mutationError = null }, onConfirm = confirm@{ destination -> + fileDeletes.writeBlockedMessage(destination.path)?.let { mutationError = it; return@confirm } val etag = target.etag?.takeIf(String::isNotBlank) if (etag == null) { mutationError = "Refresh the folder before changing this item." @@ -7313,13 +7320,9 @@ internal fun FilesScreen( } filesRequest { services.executeFileMutation(session, requireNotNull(userId), mutation) - }.onSuccess { + }.onSuccess { result -> transferTarget = null - mutationNotice = if (moving) { - "Moved ${target.name}" - } else { - "Copied ${target.name}" - } + mutationNotice = result.localFollowUp.reportFor(if (moving) "Moved ${target.name}" else "Copied ${target.name}") files = null loadAttempt += 1 }.onFailure { @@ -7331,84 +7334,15 @@ internal fun FilesScreen( ) } - deleteTarget?.let { target -> - AlertDialog( - onDismissRequest = { - if (!mutationRunning) { - deleteTarget = null - mutationError = null - } - }, - title = { Text("Delete ${target.name}?") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium)) { - Text( - if (target.isDirectory) { - "This removes the folder and everything inside it from Nextcloud. This cannot be undone here." - } else { - "This removes the file from Nextcloud. This cannot be undone here." - }, - ) - Text( - "The delete is ETag-protected and will stop if the item changed since this folder was loaded.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - mutationError?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } - } - }, - dismissButton = { - TextButton( - enabled = !mutationRunning, - onClick = { - deleteTarget = null - mutationError = null - }, - ) { Text("Cancel") } - }, - confirmButton = { - Button( - enabled = !mutationRunning, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), - onClick = { - val etag = target.etag?.takeIf(String::isNotBlank) - if (etag == null) { - mutationError = "Refresh the folder before deleting this item." - return@Button - } - mutationRunning = true - mutationError = null - scope.launch { - filesRequest { - services.executeFileMutation( - session, - requireNotNull(userId), - NextcloudFileMutation.Delete( - target.path, - etag, - sourceIsDirectory = target.isDirectory, - ), - ) - }.onSuccess { - deleteTarget = null - mutationNotice = "Deleted ${target.name}" - files = null - loadAttempt += 1 - }.onFailure { - mutationError = it.message ?: "Could not delete this item." - } - mutationRunning = false - } - }, - ) { - if (mutationRunning) { - CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) - Spacer(Modifier.size(8.dp)) - } - Text(if (mutationRunning) "Deleting..." else "Delete") - } - }, - ) + userId?.let { uid -> + FileDeleteHost( + deleteDialog, fileDeletes, path, + delete = { mutation -> services.executeFileMutation(session, uid, mutation) }, + readFolder = { folder -> services.listFilesWithSource(session, uid, folder) }, + ) { effect -> + effect.notice?.let { mutationNotice = it } + if (effect.reloadFolder) { files = null; loadAttempt += 1 } + } } shareTarget?.let { target -> @@ -10514,7 +10448,7 @@ internal enum class MediaTileLayout { @Composable private fun FileInfoScreen( - services: NextcloudPlatformServices, + services: NextcloudPlatformServices, writeFence: FileWriteFence, session: NextcloudSession, userId: String, file: NextcloudFile, @@ -10564,7 +10498,7 @@ private fun FileInfoScreen( if (!file.isDirectory && file.fileId != null) { item { FileVersionHistorySection( - services = services, + services = services, writeFence = writeFence, session = session, userId = userId, file = file, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index 40d8fc4b0..1d2175128 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -428,7 +428,7 @@ data class NextcloudPerson( val coverEtag: String?, val backend: String, ) -interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCardDraftPlatformServices, TextEditorDraftPlatformServices { +interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCardDraftPlatformServices, TextEditorDraftPlatformServices, FileWriteFencePlatformServices { /** Loads public project news from the fixed Obiente feed, with a bounded platform cache. */ suspend fun loadProjectNews(forceRefresh: Boolean = false): ProjectNewsResult = error("Project news is unavailable on this platform.") diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt index 0d7bb8b4c..cd4cf9add 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt @@ -55,17 +55,29 @@ internal suspend fun RemoteFolderPickerOperations.createAndConfirmDestination( } } +/** + * [writeFence] refuses creating a folder inside one whose delete is unverified or needs a refresh, + * and refuses choosing such a folder as a destination for later writes. + */ fun remoteFolderPickerOperations( services: NextcloudPlatformServices, session: NextcloudSession, userId: String, + writeFence: FileWriteFence, ): RemoteFolderPickerOperations { require(userId.isNotBlank()) return RemoteFolderPickerOperations( identity = "${session.accountId.storageKey}|$userId", listCached = { path -> services.listFilesCachedWithSource(session, userId, path) }, listNetwork = { path -> services.listFilesWithSource(session, userId, path) }, - createDirectoryIfAbsent = { path -> services.createDirectoryIfAbsent(session, userId, path) }, + createDirectoryIfAbsent = { path -> + writeFence.writeBlock(path)?.let { block -> throw FileWriteBlockedException(block) } + services.createDirectoryIfAbsent(session, userId, path) + }, + selectionAccess = { path -> + writeFence.newChildBlock(path)?.let { block -> RemoteFolderSelectionAccess.Denied(block.message) } + ?: RemoteFolderSelectionAccess.Allowed + }, ) } @@ -74,13 +86,14 @@ fun RemoteFolderPickerDialog( services: NextcloudPlatformServices, session: NextcloudSession, userId: String, + writeFence: FileWriteFence, initialPath: String, selectionError: String? = null, onDismiss: () -> Unit, onSelected: (String) -> Unit, ) { - val operations = remember(services, session, userId) { - remoteFolderPickerOperations(services, session, userId) + val operations = remember(services, session, userId, writeFence) { + remoteFolderPickerOperations(services, session, userId, writeFence) } RemoteFolderPickerDialog( operations = operations, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextEditorScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextEditorScreen.kt index ddfe1019e..b8516780a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextEditorScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextEditorScreen.kt @@ -48,6 +48,7 @@ internal fun TextEditorScreen( userId: String, file: NextcloudFile, onBack: () -> Unit, + writeFence: FileWriteFence, navigationRequest: NextcloudPendingNavigationRequest? = null, onNavigationConfirmed: (NextcloudPendingNavigationRequest) -> Unit = {}, onNavigationCancelled: (NextcloudPendingNavigationRequest) -> Unit = {}, @@ -55,12 +56,13 @@ internal fun TextEditorScreen( ) { val descriptor = remember(file) { describeDocument(file) } val isMarkdown = descriptor.kind == DocumentKind.Markdown - val editor = remember(services, session, userId, file.path) { + val editor = remember(services, session, userId, file.path, writeFence) { TextEditorState( store = services.textEditorDraftStore(session, file.path), download = { services.downloadFile(session, userId, file.path, maxBytes = MAX_EDITABLE_TEXT_BYTES) }, upload = { text, revision -> services.saveTextFile(session, userId, file.path, text, revision) }, listingEtag = file.etag, + writeBlock = { writeFence.writeBlock(file.path) }, ) } val originalText = editor.originalText diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextEditorState.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextEditorState.kt index 96a834ad4..e7eecd9cb 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextEditorState.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextEditorState.kt @@ -36,6 +36,8 @@ internal class TextEditorState( private val upload: suspend (String, String) -> SavedTextFile, // The Files listing revision is a conflict-safe fallback when the download omits its ETag. private val listingEtag: String? = null, + // Why a save must wait, such as an unverified delete of the file or its folder. + private val writeBlock: () -> FileWriteBlock? = { null }, ) { var originalText by mutableStateOf(null) private set @@ -116,6 +118,11 @@ internal class TextEditorState( suspend fun save() { if (!canSave) return + // Nothing is sent and the edits stay; the reason says what has to finish first. + writeBlock()?.let { block -> + saveError = block.message + return + } val submitted = draft val expected = requireNotNull(etag) saving = true diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanningTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanningTest.kt index d4dfe8566..254ed4762 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanningTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanningTest.kt @@ -128,6 +128,38 @@ class FileActionPlanningTest { assertNull(plan.action(FileMenuAction.Share).disabledReason) } + @Test + fun unverifiedDeleteDisablesEveryRemoteWriteWithAReason() { + val file = file(name = "notes.txt", mimeType = "text/plain", size = 10L, etag = "v1") + val support = FileActionSupport(sharing = true, platformEditor = true) + + for ((block, reason) in listOf( + FileWriteBlock.DeleteRunning to "Wait until the delete of this item or its folder finishes.", + FileWriteBlock.RefreshRequired("Archive/notes.txt") to + "Open or refresh Archive before changing notes.txt or anything inside it.", + FileWriteBlock.RefreshRequired("notes.txt") to + "Open or refresh your Nextcloud root before changing notes.txt or anything inside it.", + )) { + val plan = planFilesScreenActions(file, support, writeBlock = block) + + val writes = plan.actions.filter { it.action.changesRemoteItem() } + assertEquals( + setOf( + FileMenuAction.EditText, FileMenuAction.EditWith, FileMenuAction.AddFavorite, + FileMenuAction.Rename, FileMenuAction.Move, FileMenuAction.Copy, FileMenuAction.Share, + FileMenuAction.Delete, + ), + writes.map { it.action }.toSet(), + ) + writes.forEach { action -> + assertFalse(action.enabled, "${action.action} must wait.") + assertEquals(reason, action.disabledReason, "A finished delete needs a refresh, not waiting.") + } + assertTrue(plan.action(FileMenuAction.Details).enabled) + } + assertTrue(planFilesScreenActions(file, support).action(FileMenuAction.Rename).enabled) + } + @Test fun seekableStreamingEnablesLargeOpenAndSendCopyWithoutEnablingBoundedDownload() { val file = file( diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt new file mode 100644 index 000000000..e164094a8 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -0,0 +1,897 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FileDeleteFlowTest { + private val account = NextcloudSession("https://example.invalid", "alice", "x").accountId + private val report = file("Documents/report.txt", etag = "v1", fileId = 7L) + + @Test + fun `confirmed delete closes the dialog and refreshes the folder`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { NextcloudFileMutationResult(null, null) }) + + val effect = deletes.finish(state, fake) + + assertEquals(FileDeleteScreenEffect("Deleted report.txt", reloadFolder = true), effect) + assertNull(state.target) + assertFalse(state.running) + assertEquals(listOf(NextcloudFileMutation.Delete("Documents/report.txt", "v1")), fake.deletes) + assertTrue(fake.reads.isEmpty()) + } + + @Test + fun `directory delete keeps the collection precondition and verifies its own parent`(): Unit = runBlocking { + val folder = file("Documents/Archive", etag = "d1", directory = true) + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(folder) } + val fake = FakeDeleteBoundary( + deleteResult = { throw IllegalStateException("connection reset") }, + listing = { NextcloudFileListing(emptyList(), NextcloudFileListingSource.Network) }, + ) + + deletes.finish(state, fake) + + assertTrue(fake.deletes.single().sourceIsDirectory) + assertEquals(listOf("Documents"), fake.reads) + } + + @Test + fun `server-confirmed delete is reported even when local cleanup is still running or failed`(): Unit = + runBlocking { + for ((followUp, notice) in listOf( + FileMutationLocalFollowUp.StillRunning to + "Deleted report.txt. Local file status is still updating in the background.", + FileMutationLocalFollowUp.Failed to + "Deleted report.txt. Local file status could not be updated; refresh the folder if it looks out of date.", + )) { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { NextcloudFileMutationResult(null, null, followUp) }) + + assertEquals(FileDeleteScreenEffect(notice, reloadFolder = true), deletes.finish(state, fake)) + assertNull(state.target, "The dialog must close after the server confirmed the delete.") + assertTrue(fake.reads.isEmpty(), "A confirmed result must not be verified by another request.") + } + } + + @Test + fun `definitive server rejection stays in the dialog and allows a safe retry`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(423) }) + + assertEquals(FileDeleteScreenEffect(null, reloadFolder = false), deletes.finish(state, fake)) + assertEquals(report, state.target) + assertFalse(state.running) + assertEquals("The file is locked by another operation.", state.error) + assertFalse(state.retryBlocked) + assertEquals(1, fake.deletes.size) + assertTrue(fake.reads.isEmpty()) + assertNotNull(state.begin(), "A definitive rejection did not change the server, so retry is safe.") + } + + @Test + fun `stale version precondition blocks retry until the folder is refreshed`(): Unit = runBlocking { + for (directory in listOf(false, true)) { + val item = report.copy(isDirectory = directory) + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(item) } + val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(412, directory) }) + + assertEquals(FileDeleteScreenEffect(null, reloadFolder = true), deletes.finish(state, fake)) + assertEquals(item, state.target) + assertTrue(state.retryBlocked) + assertEquals(fileOperationException(412, directory).message, state.error) + assertNull(state.begin(), "The same ETag precondition cannot succeed again.") + assertEquals(1, fake.deletes.size) + assertTrue(fake.reads.isEmpty(), "A 412 is a definitive answer, not an unknown result.") + } + } + + @Test + fun `throttled delete is not treated as unknown and does not allow an immediate retry`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(429) }) + + val effect = deletes.finish(state, fake) + + assertEquals(FileDeleteScreenEffect(null, reloadFolder = false), effect, "A reload is another request.") + assertEquals("The server is limiting requests. Wait a while, then refresh and try again.", state.error) + assertTrue(state.retryBlocked) + assertNull(state.begin()) + assertEquals(1, fake.deletes.size) + assertTrue(fake.reads.isEmpty(), "Throttling must not start ambiguity verification reads.") + } + + @Test + fun `rejected credentials or permission block retry without another request`(): Unit = runBlocking { + for (status in listOf(401, 403)) { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(status) }) + + assertEquals(FileDeleteScreenEffect(null, reloadFolder = false), deletes.finish(state, fake)) + assertEquals(fileOperationException(status).message, state.error) + assertTrue(state.retryBlocked, "HTTP $status cannot succeed until the user changes something.") + state.dismiss() + state.open(report) + assertTrue(state.retryBlocked, "Reopening the dialog must not lift the block.") + assertNull(state.begin()) + assertEquals(1, fake.deletes.size) + assertTrue(fake.reads.isEmpty()) + } + } + + @Test + fun `locked and storage-limited deletes deliberately allow a retry`(): Unit = runBlocking { + for (status in listOf(423, 507)) { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(status) }) + + deletes.finish(state, fake) + + assertFalse(state.retryBlocked, "HTTP $status can clear without changing this item.") + assertNotNull(state.begin()) + } + } + + @Test + fun `throttling survives reopening until a server listing started afterwards`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val earlierReadStarted = CompletableDeferred() + val releaseEarlierRead = CompletableDeferred() + val earlierRead = async { + deletes.readFolder("Documents") { + earlierReadStarted.complete(Unit) + releaseEarlierRead.await() + NextcloudFileListing(listOf(report), NextcloudFileListingSource.Network) + } + } + earlierReadStarted.await() + deletes.finish(state, FakeDeleteBoundary(deleteResult = { throw fileOperationException(429) })) + + state.dismiss() + state.open(report) + assertTrue(state.retryBlocked) + assertNull(state.begin(), "A throttled delete must not be resent by reopening the dialog.") + releaseEarlierRead.complete(Unit) + earlierRead.await() + assertTrue(state.retryBlocked, "A listing that started before the throttling does not lift it.") + deletes.readFolder("Documents") { NextcloudFileListing(listOf(report), NextcloudFileListingSource.Cache) } + assertTrue(state.retryBlocked, "A cached listing is not a server answer.") + deletes.readFolder("Music") { NextcloudFileListing(emptyList(), NextcloudFileListingSource.Network) } + assertTrue(state.retryBlocked, "Another folder's listing does not lift it.") + + deletes.readFolder("Documents") { NextcloudFileListing(listOf(report), NextcloudFileListingSource.Network) } + + assertFalse(state.retryBlocked) + assertNull(state.error) + assertNotNull(state.begin()) + } + + @Test + fun `a refreshed version is deletable while the stale dialog stays blocked`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + deletes.finish(state, FakeDeleteBoundary(deleteResult = { throw fileOperationException(412) })) + + deletes.readFolder("Documents") { NextcloudFileListing(listOf(report.copy(etag = "v2")), NextcloudFileListingSource.Network) } + + assertTrue(state.retryBlocked, "The shown version still has the stale precondition.") + state.open(report.copy(etag = "v2")) + assertFalse(state.retryBlocked) + assertNotNull(state.begin()) + } + + @Test + fun `creating or moving into a folder being deleted waits for verification`(): Unit = runBlocking { + val folder = file("Documents", etag = "f1", directory = true) + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(folder) } + val release = CompletableDeferred() + deletes.start( + assertNotNull(state.begin()), + FakeDeleteBoundary(deleteResult = { + release.await() + NextcloudFileMutationResult(null, null) + })::delete, + ) { error("not read") } + + assertEquals(FileWriteBlock.DeleteRunning.message, deletes.writeBlockedMessage("Documents/New folder")) + assertEquals(FileWriteBlock.DeleteRunning.message, deletes.writeBlockedMessage("Documents/Sub/notes.md")) + assertNull(deletes.writeBlockedMessage("New folder")) + assertNull(deletes.writeBlockedMessage("Documents2/notes.md")) + + release.complete(Unit) + deletes.awaitCompletion(state) + assertNull(deletes.writeBlockedMessage("Documents/New folder")) + } + + @Test + fun `deleting one child does not block creating its siblings`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val release = CompletableDeferred() + deletes.start( + assertNotNull(state.begin()), + FakeDeleteBoundary(deleteResult = { + release.await() + NextcloudFileMutationResult(null, null) + })::delete, + ) { error("not read") } + + assertNull(deletes.writeBlockedMessage("Documents/new.txt")) + assertEquals(FileWriteBlock.DeleteRunning.message, deletes.writeBlockedMessage("Documents/report.txt")) + release.complete(Unit) + deletes.awaitCompletion(state) + } + + @Test + fun `a delete needing a refresh blocks every related write until the folder is read`(): Unit = runBlocking { + val outcomes = listOf NextcloudFileMutationResult>( + { throw fileOperationException(412) }, + { throw fileOperationException(429) }, + { throw fileOperationException(403) }, + ) + for (outcome in outcomes) { + val folder = file("Documents/Sub", etag = "f1", directory = true) + val deletes = coordinator() + val otherAccount = coordinator(NextcloudSession("https://example.invalid", "bob", "x").accountId) + deletes.finish(FileDeleteDialogState(deletes).apply { open(folder) }, FakeDeleteBoundary(deleteResult = outcome)) + val expected = "Open or refresh Documents before changing Sub or anything inside it." + + assertEquals(expected, deletes.writeBlockedMessage("Documents/Sub"), "The old path may name a replacement.") + assertEquals(expected, deletes.writeBlockedMessage("Documents/Sub/new.txt")) + assertEquals(expected, deletes.writeBlockedMessage("Documents"), "Its folder is blocked too.") + assertTrue(deletes.blocksWrites(file("Documents/Sub/notes.md"))) + assertNull(deletes.writeBlockedMessage("Documents/new.txt"), "A sibling is unaffected.") + assertNull(deletes.writeBlockedMessage("Documents/Sub2")) + assertNull(otherAccount.writeBlockedMessage("Documents/Sub")) + val child = FileDeleteDialogState(deletes).apply { open(file("Documents/Sub/notes.md")) } + assertNull(child.begin(), "An overlapping delete must wait for the refresh too.") + assertEquals(expected, child.error) + + deletes.readFolder("Documents") { NextcloudFileListing(listOf(folder.copy(etag = "f2")), NextcloudFileListingSource.Network) } + + assertNull(deletes.writeBlockedMessage("Documents/Sub/new.txt")) + assertNotNull(child.begin()) + otherAccount.close() + } + } + + @Test + fun `a refresh that started before the outcome does not lift the write block`(): Unit = runBlocking { + val deletes = coordinator() + val readStarted = CompletableDeferred() + val releaseRead = CompletableDeferred() + val staleRead = async { + deletes.readFolder("Documents") { + readStarted.complete(Unit) + releaseRead.await() + NextcloudFileListing(listOf(report), NextcloudFileListingSource.Network) + } + } + readStarted.await() + + deletes.finish(FileDeleteDialogState(deletes).apply { open(report) }, FakeDeleteBoundary(deleteResult = { throw fileOperationException(412) })) + releaseRead.complete(Unit) + staleRead.await() + + assertEquals("Open or refresh Documents before changing report.txt or anything inside it.", deletes.writeBlockedMessage("Documents/report.txt")) + assertEquals( + "Open or refresh your Nextcloud root before changing Documents or anything inside it.", + FileWriteBlock.RefreshRequired("Documents").message, + ) + } + + @Test + fun `a delete outside the shown folder reads its own folder so the refresh block lifts`(): Unit = runBlocking { + val archived = file("Archive/old.txt", etag = "a1") + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(archived) } + val reads = mutableListOf() + val completion = deletes.finishUnapplied(state) { throw fileOperationException(412) } + + val effect = deletes.applyCompletion(completion, state, shownFolder = "Documents") { folder -> + reads += folder + NextcloudFileListing(listOf(archived.copy(etag = "a2")), NextcloudFileListingSource.Network) + } + + assertTrue(effect.reloadFolder) + withTimeout(5_000L) { while (deletes.writeBlockedMessage("Archive/old.txt") != null) yield() } + assertEquals(listOf("Archive"), reads, "Only the deleted item's own folder is read.") + } + + @Test + fun `the shown folder's own reload is not duplicated`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val completion = deletes.finishUnapplied(state) { throw fileOperationException(412) } + + deletes.applyCompletion(completion, state, shownFolder = "Documents") { error("The Files screen reloads its own folder.") } + repeat(10) { yield() } + + assertNotNull(deletes.writeBlockedMessage("Documents/report.txt"), "The screen's reload lifts it.") + } + + @Test + fun `a failed folder read keeps the block until the user opens that folder`(): Unit = runBlocking { + val archived = file("Archive/old.txt", etag = "a1") + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(archived) } + val completion = deletes.finishUnapplied(state) { throw fileOperationException(412) } + val attempted = CompletableDeferred() + + deletes.applyCompletion(completion, state, shownFolder = "") { + attempted.complete(Unit) + throw IllegalStateException("offline") + } + attempted.await() + repeat(10) { yield() } + + assertEquals( + "Open or refresh Archive before changing old.txt or anything inside it.", + deletes.writeBlockedMessage("Archive/old.txt"), + ) + deletes.readFolder("Archive") { NextcloudFileListing(emptyList(), NextcloudFileListingSource.Network) } + assertNull(deletes.writeBlockedMessage("Archive/old.txt"), "Opening the folder lifts it.") + } + + @Test + fun `throttled or rejected deletes start no folder read`(): Unit = runBlocking { + for (status in listOf(429, 401, 403)) { + val archived = file("Archive/old.txt", etag = "a1") + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(archived) } + val completion = deletes.finishUnapplied(state) { throw fileOperationException(status) } + + val effect = deletes.applyCompletion(completion, state, shownFolder = "Documents") { error("No extra request.") } + repeat(10) { yield() } + + assertFalse(effect.reloadFolder) + assertNotNull(deletes.writeBlockedMessage("Archive/old.txt")) + } + } + + @Test + fun `after an unknown result only positively the same item is retryable`(): Unit = runBlocking { + val cases = listOf( + // Same stable ID, type, and ETag: the original is still there and unchanged. + Triple(7L, 7L, "The delete did not finish. report.txt is still on the server."), + // A replacement with matching type and ETag but another ID must not be deleted by a retry. + Triple(7L, 8L, "report.txt was replaced on the server. Refresh the folder."), + // An ID on only one side cannot be compared, so the item is not positively the same. + Triple(null, 8L, "Could not confirm that report.txt is still the same item. Refresh the folder."), + Triple(7L, null, "Could not confirm that report.txt is still the same item. Refresh the folder."), + // Without an ID on either side, type and ETag are the only identity evidence. + Triple(null, null, "The delete did not finish. report.txt is still on the server."), + ) + for ((shownId, listedId, message) in cases) { + val shown = file("Documents/report.txt", etag = "v1", fileId = shownId) + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(shown) } + val fake = FakeDeleteBoundary( + deleteResult = { throw IllegalStateException("response interrupted") }, + listing = { + NextcloudFileListing(listOf(file("Documents/report.txt", etag = "v1", fileId = listedId)), NextcloudFileListingSource.Network) + }, + ) + + deletes.finish(state, fake) + + assertEquals(message, state.error, "shown=$shownId listed=$listedId") + assertEquals(shownId != listedId, state.retryBlocked, "Only positively the same item may be retried.") + assertEquals(1, fake.deletes.size) + } + } + + @Test + fun `a delete answered with 404 reports the local follow-up for the vanished path`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { + throw fileOperationException(404, localFollowUp = FileMutationLocalFollowUp.Failed) + }) + + val effect = deletes.finish(state, fake) + + assertEquals( + FileDeleteScreenEffect( + "report.txt is no longer in this folder. Local file status could not be updated; refresh the folder if it looks out of date.", + reloadFolder = true, + ), + effect, + ) + assertTrue(fake.reads.isEmpty(), "A 404 is definitive and needs no verification request.") + } + + @Test + fun `a delete that never left the device is a plain retryable failure without verification`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { + throw FileMutationNotSentException("Could not reach the server, so nothing was changed. Try again.") + }) + + deletes.finish(state, fake) + + assertEquals("Could not reach the server, so nothing was changed. Try again.", state.error) + assertFalse(state.retryBlocked) + assertNull(deletes.writeBlockFor("Documents/report.txt"), "Nothing was sent, so related writes may proceed.") + assertTrue(fake.reads.isEmpty(), "No second request is made for a failure that sent nothing.") + assertNotNull(state.begin(), "Retrying is safe.") + } + + @Test + fun `an ambiguous failure is still verified on the server`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary( + deleteResult = { throw IllegalStateException("connection reset after sending") }, + listing = { throw IllegalStateException("offline") }, + ) + + deletes.finish(state, fake) + + assertEquals(listOf("Documents"), fake.reads) + assertEquals(unconfirmedDeleteMessage("report.txt"), state.error) + assertTrue(state.retryBlocked) + } + + @Test + fun `missing item is reported as no longer in the folder, not as deleted`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(404) }) + + assertEquals(FileDeleteScreenEffect("report.txt is no longer in this folder.", true), deletes.finish(state, fake)) + assertNull(state.target) + } + + @Test + fun `path absence after an unknown result never claims deletion and never resends`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary( + deleteResult = { throw IllegalStateException("response interrupted") }, + listing = { NextcloudFileListing(listOf(file("Documents/other.txt", fileId = 8L)), NextcloudFileListingSource.Network) }, + ) + + val effect = deletes.finish(state, fake) + + assertEquals( + FileDeleteScreenEffect( + "report.txt is no longer in this folder. The delete response was interrupted, so it may " + + "have been deleted, moved, or renamed.", + reloadFolder = true, + ), + effect, + ) + assertNull(state.target) + assertEquals(1, fake.deletes.size) + assertEquals(listOf("Documents"), fake.reads) + } + + @Test + fun `an item found gone after an unknown result reports the reconciliation run for that result`(): Unit = runBlocking { + val interrupted = "report.txt is no longer in this folder. The delete response was interrupted, so it may " + + "have been deleted, moved, or renamed" + for ((failure, notice) in listOf>( + FileMutationOutcomeUnknownException("unknown", null, FileMutationLocalFollowUp.Completed) to "$interrupted.", + FileMutationOutcomeUnknownException("unknown", null, FileMutationLocalFollowUp.StillRunning) to + "$interrupted. Local file status is still updating in the background.", + // A failed reconciliation, and one that could not be queued at all, both report a failure. + FileMutationOutcomeUnknownException("unknown", null, FileMutationLocalFollowUp.Failed) to + "$interrupted. Local file status could not be updated; refresh the folder if it looks out of date.", + // Without reconciliation evidence the outcome keeps the plain notice. + IllegalStateException("reset") to "$interrupted.", + )) { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary( + deleteResult = { throw failure }, + listing = { NextcloudFileListing(emptyList(), NextcloudFileListingSource.Network) }, + ) + + assertEquals(FileDeleteScreenEffect(notice, reloadFolder = true), deletes.finish(state, fake), "$failure") + assertEquals(listOf("Documents"), fake.reads) + assertNull(state.target) + } + } + + @Test + fun `same file ID under another name after an unknown result is a rename, not a delete`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val renamed = file("Documents/final.txt", fileId = 7L) + val fake = FakeDeleteBoundary( + deleteResult = { throw IllegalStateException("reset") }, + listing = { NextcloudFileListing(listOf(renamed), NextcloudFileListingSource.Network) }, + ) + + assertEquals(FileDeleteScreenEffect(null, reloadFolder = true), deletes.finish(state, fake)) + assertEquals("report.txt was renamed on the server. Refresh the folder.", state.error) + assertTrue(state.retryBlocked) + } + + @Test + fun `server failure is verified because a partial delete is possible`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary( + deleteResult = { throw fileOperationException(503) }, + listing = { NextcloudFileListing(listOf(report), NextcloudFileListingSource.Network) }, + ) + + deletes.finish(state, fake) + + assertEquals("The delete did not finish. report.txt is still on the server.", state.error) + assertFalse(state.retryBlocked, "The exact version is still present, so retry is safe.") + assertEquals(1, fake.deletes.size) + } + + @Test + fun `unverifiable or changed state blocks retry until the folder is refreshed`(): Unit = runBlocking { + val unverifiable = listOf NextcloudFileListing>( + { throw IllegalStateException("offline") }, + { NextcloudFileListing(emptyList(), NextcloudFileListingSource.Cache) }, + { NextcloudFileListing(listOf(report.copy(etag = "v2")), NextcloudFileListingSource.Network) }, + ) + for (listing in unverifiable) { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { throw IllegalStateException("reset") }, listing = listing) + + assertEquals(FileDeleteScreenEffect(null, reloadFolder = true), deletes.finish(state, fake)) + assertTrue(state.retryBlocked) + assertNotNull(state.error) + assertNull(state.begin(), "An unknown delete result must not be retried blindly.") + assertEquals(1, fake.deletes.size) + } + } + + @Test + fun `leaving the Files screen during verification keeps the delete owned and blocked`(): Unit = runBlocking { + val deletes = coordinator() + val firstScreen = FileDeleteDialogState(deletes).apply { open(report) } + val readStarted = CompletableDeferred() + val releaseRead = CompletableDeferred() + val fake = FakeDeleteBoundary( + deleteResult = { throw IllegalStateException("response interrupted") }, + listing = { + readStarted.complete(Unit) + releaseRead.await() + NextcloudFileListing(emptyList(), NextcloudFileListingSource.Network) + }, + ) + val screenScope = CoroutineScope(coroutineContext + Job()) + deletes.start(assertNotNull(firstScreen.begin()), fake::delete, fake::read) + readStarted.await() + + screenScope.cancel() + val returnedScreen = FileDeleteDialogState(deletes).apply { open(report) } + + assertTrue(returnedScreen.running, "The unverified delete still owns the item.") + assertTrue(deletes.blocksWrites(report)) + assertNull(returnedScreen.begin()) + releaseRead.complete(Unit) + val effect = deletes.awaitCompletion(returnedScreen) + + assertEquals(true, effect.reloadFolder) + assertNull(returnedScreen.target, "The same version shown again receives the verified outcome.") + assertFalse(deletes.blocksWrites(report)) + assertEquals(1, fake.deletes.size) + } + + @Test + fun `leaving the Files screen while local follow-up runs still reports the server delete`(): Unit = + runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val serverConfirmed = CompletableDeferred() + val followUpDone = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + serverConfirmed.complete(Unit) + followUpDone.await() + NextcloudFileMutationResult(null, null, FileMutationLocalFollowUp.StillRunning) + }) + deletes.start(assertNotNull(state.begin()), fake::delete, fake::read) + serverConfirmed.await() + + state.dismiss() + followUpDone.complete(Unit) + val effect = deletes.awaitCompletion(FileDeleteDialogState(deletes)) + + assertEquals( + FileDeleteScreenEffect("Deleted report.txt. Local file status is still updating in the background.", true), + effect, + ) + assertFalse(deletes.blocksWrites(report)) + } + + @Test + fun `cancelling after the request may have been sent leaves a refresh-required block`(): Unit = + runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val started = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + started.complete(Unit) + awaitCancellation() + }) + val job = deletes.start(assertNotNull(state.begin()), fake::delete, fake::read) + started.await() + + deletes.close() + withTimeout(5_000L) { job.join() } + + assertTrue(job.isCancelled) + assertNull(deletes.nextCompletion) + assertFalse(state.running) + assertEquals(unconfirmedDeleteMessage("report.txt"), state.error, "The block is never released without an outcome.") + assertTrue(state.retryBlocked) + assertEquals(FileWriteBlock.RefreshRequired("Documents/report.txt"), deletes.writeBlockFor("Documents/report.txt")) + assertTrue(fake.reads.isEmpty(), "Cancellation sends no verification request.") + } + + @Test + fun `cancelling before the request is sent releases the item unchanged`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { error("The request must not be sent.") }) + + val job = deletes.start(assertNotNull(state.begin()), fake::delete, fake::read) + deletes.close() + withTimeout(5_000L) { job.join() } + + assertTrue(fake.deletes.isEmpty()) + assertFalse(state.running) + assertFalse(state.retryBlocked) + assertNull(deletes.writeBlockFor("Documents/report.txt"), "Nothing was sent, so the item is unchanged.") + } + + @Test + fun `noncooperative completion after teardown is not published`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val started = CompletableDeferred() + val release = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + withContext(NonCancellable) { + started.complete(Unit) + release.await() + } + NextcloudFileMutationResult(null, null) + }) + val job = deletes.start(assertNotNull(state.begin()), fake::delete, fake::read) + started.await() + + deletes.close() + release.complete(Unit) + withTimeout(5_000L) { job.join() } + + assertNull(deletes.nextCompletion) + assertFalse(state.running) + assertEquals(report, state.target) + assertTrue(state.retryBlocked, "The unpublished result leaves the item waiting for a refresh.") + } + + @Test + fun `closing a running dialog keeps the item blocked until the delete is verified`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val release = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + release.await() + NextcloudFileMutationResult(null, null) + }) + deletes.start(assertNotNull(state.begin()), fake::delete, fake::read) + + state.dismiss() + state.open(report) + assertTrue(state.running, "The reopened dialog shows the unresolved delete.") + assertNull(state.begin(), "A second DELETE must not be sent while the first is unresolved.") + state.open(report.copy(etag = "v2")) + assertNull(state.begin(), "Another version of the same item is blocked too.") + state.open(report) + release.complete(Unit) + + assertEquals(FileDeleteScreenEffect("Deleted report.txt", true), deletes.awaitCompletion(state)) + assertNull(state.target, "The reopened dialog for the same version receives the verified result.") + assertEquals(1, fake.deletes.size) + } + + @Test + fun `verified rejection releases the item for a reopened dialog of another version`(): Unit = runBlocking { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val release = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + release.await() + throw fileOperationException(423) + }) + deletes.start(assertNotNull(state.begin()), fake::delete, fake::read) + + state.dismiss() + val refreshed = report.copy(etag = "v2") + state.open(refreshed) + assertTrue(state.running) + release.complete(Unit) + deletes.awaitCompletion(state) + + assertEquals(refreshed, state.target) + assertNull(state.error, "The result belongs to the earlier version.") + assertFalse(state.running) + assertEquals("v2", assertNotNull(state.begin()).expectedEtag) + } + + @Test + fun `unverified delete blocks writes to the item, its folders, and its children in that account only`() = + runBlocking { + val folder = file("Documents", directory = true) + val deletes = coordinator() + val otherAccount = coordinator(NextcloudSession("https://example.invalid", "bob", "x").accountId) + val state = FileDeleteDialogState(deletes).apply { open(folder.copy(etag = "f1")) } + val release = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + release.await() + NextcloudFileMutationResult(null, null) + }) + deletes.start(assertNotNull(state.begin()), fake::delete, fake::read) + + assertTrue(deletes.blocksWrites(folder)) + assertTrue(deletes.blocksWrites(report), "A child of a folder being deleted must wait.") + assertFalse(deletes.blocksWrites(file("Music/song.mp3"))) + assertFalse(deletes.blocksWrites(file("Documents2/a.txt")), "A name prefix is not a parent.") + assertFalse(otherAccount.blocksWrites(report)) + val child = FileDeleteDialogState(deletes).apply { open(report) } + assertNull(child.begin()) + assertEquals("Another delete in this location is still finishing. Try again when it completes.", child.error) + + release.complete(Unit) + deletes.awaitCompletion(state) + assertFalse(deletes.blocksWrites(report)) + otherAccount.close() + } + + @Test + fun `completion after the dialog closed reports to the screen without changing a newer dialog`(): Unit = + runBlocking { + val other = file("Documents/other.txt", etag = "o1") + val confirmed: suspend () -> NextcloudFileMutationResult = { NextcloudFileMutationResult(null, null) } + val locked: suspend () -> NextcloudFileMutationResult = { throw fileOperationException(423) } + for ((result, expected) in listOf( + confirmed to FileDeleteScreenEffect("Deleted report.txt", true), + locked to FileDeleteScreenEffect( + "report.txt was not deleted. The file is locked by another operation.", + reloadFolder = false, + ), + )) { + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } + val release = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + release.await() + result() + }) + deletes.start(assertNotNull(state.begin()), fake::delete, fake::read) + + state.dismiss() + assertNull(state.target) + assertFalse(state.running, "A closed dialog no longer shows the running delete.") + state.open(other) + release.complete(Unit) + + assertEquals(expected, deletes.awaitCompletion(state)) + assertEquals(other, state.target) + assertFalse(state.running) + assertNull(state.error) + assertFalse(state.retryBlocked) + } + } + + @Test + fun `delete without a current version asks for a refresh instead of sending`(): Unit = runBlocking { + val state = FileDeleteDialogState(coordinator()).apply { open(report.copy(etag = " ")) } + + assertNull(state.begin()) + assertEquals("Refresh the folder before deleting this item.", state.error) + assertFalse(state.running) + } + + @Test + fun `a claimed delete cannot be started twice`(): Unit = runBlocking { + val state = FileDeleteDialogState(coordinator()).apply { open(report) } + + assertNotNull(state.begin()) + assertNull(state.begin()) + } + + /** The event loop stays the test's, but its job does not keep the test running. */ + private fun CoroutineScope.coordinator(accountId: NextcloudAccountId = account) = + FileWriteFenceRegistry(CoroutineScope(coroutineContext.minusKey(Job))).account(accountId).deletes + + /** Starts the open dialog's delete and applies its outcome as a Files screen would. */ + private suspend fun FileDeleteCoordinator.finish( + state: FileDeleteDialogState, + fake: FakeDeleteBoundary, + ): FileDeleteScreenEffect { + val job = start(assertNotNull(state.begin()), fake::delete, fake::read) + withTimeout(5_000L) { job.join() } + return awaitCompletion(state) + } + + /** Runs the open dialog's delete to its outcome and claims the completion without applying it. */ + private suspend fun FileDeleteCoordinator.finishUnapplied( + state: FileDeleteDialogState, + result: suspend () -> NextcloudFileMutationResult, + ): FileDeleteCompletion { + val job = start(assertNotNull(state.begin()), FakeDeleteBoundary(deleteResult = result)::delete) { error("not read") } + withTimeout(5_000L) { job.join() } + return assertNotNull(nextCompletion).also { assertTrue(consume(it)) } + } + + /** Applies the next outcome as a Files screen would, failing instead of waiting forever. */ + private suspend fun FileDeleteCoordinator.awaitCompletion(state: FileDeleteDialogState): FileDeleteScreenEffect = + withTimeout(5_000L) { + while (true) { + val completion = nextCompletion + if (completion != null && consume(completion)) return@withTimeout state.complete(completion) + yield() + } + @Suppress("UNREACHABLE_CODE") + error("unreachable") + } + + private class FakeDeleteBoundary( + private val deleteResult: suspend () -> NextcloudFileMutationResult, + private val listing: suspend () -> NextcloudFileListing = { error("The folder must not be read.") }, + ) { + val deletes = mutableListOf() + val reads = mutableListOf() + + suspend fun delete(mutation: NextcloudFileMutation.Delete): NextcloudFileMutationResult { + deletes += mutation + return deleteResult() + } + + suspend fun read(folder: String): NextcloudFileListing { + reads += folder + return listing() + } + } + + private fun file(path: String, etag: String? = "e", directory: Boolean = false, fileId: Long? = null) = + NextcloudFile( + path = path, + name = path.substringAfterLast('/'), + isDirectory = directory, + mimeType = if (directory) null else "text/plain", + size = 1, + lastModified = null, + fileId = fileId, + hasPreview = false, + etag = etag, + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileOperationsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileOperationsTest.kt index 552f3697f..2e41ada86 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileOperationsTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileOperationsTest.kt @@ -8,6 +8,19 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class FileOperationsTest { + @Test + fun serverConfirmedChangeReportsIncompleteLocalBookkeeping() { + assertEquals("Renamed to b.txt", FileMutationLocalFollowUp.Completed.reportFor("Renamed to b.txt")) + assertEquals( + "Moved a.txt. Local file status is still updating in the background.", + FileMutationLocalFollowUp.StillRunning.reportFor("Moved a.txt"), + ) + assertEquals( + "Copied a.txt. Local file status could not be updated; refresh the folder if it looks out of date.", + FileMutationLocalFollowUp.Failed.reportFor("Copied a.txt"), + ) + } + @Test fun renameBuildsConflictSafeMoveWithoutOverwrite() { val spec = NextcloudFileMutation.Rename( @@ -102,6 +115,7 @@ class FileOperationsTest { assertEquals(NextcloudFileOperationError.Conflict, fileOperationException(412).error) assertEquals(NextcloudFileOperationError.Locked, fileOperationException(423).error) assertEquals(NextcloudFileOperationError.InsufficientStorage, fileOperationException(507).error) + assertEquals(NextcloudFileOperationError.Throttled, fileOperationException(429).error) assertEquals(NextcloudFileOperationError.ServerFailure, fileOperationException(500).error) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerRouteRemovalTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerRouteRemovalTest.kt new file mode 100644 index 000000000..a5181ecc6 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerRouteRemovalTest.kt @@ -0,0 +1,141 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** A deleted file leaves the viewer's route; a stack keeps its surviving members. */ +class MediaViewerRouteRemovalTest { + private val alice = NextcloudSession("https://route-removal.invalid", "alice", "synthetic").accountId + private val bob = NextcloudSession("https://route-removal.invalid", "bob", "synthetic").accountId + private val first = photo("first.jpg", 7L) + private val second = photo("second.jpg", 8L) + private val third = photo("third.jpg", 9L) + private val stackJpeg = photo("stack.jpg", 21L) + private val stackRaw = raw("stack.dng", 22L) + private val otherJpeg = photo("other.jpg", 31L) + private val otherRaw = raw("other.dng", 32L) + + @Test + fun aRemovedItemLeavesAndTheNextOrPreviousItemIsShownUntilNoneIsLeft() { + fun removeFrom(removed: NextcloudFile, shown: NextcloudFile): Pair { + val repository = MediaViewerNavigationRepository() + val route = assertNotNull(repository.register(alice, listOf(first, second, third), shown)) + val snapshot = repository.after(repository.remove(alice, route, removed)) + assertEquals(null, snapshot.sourceMembers.firstOrNull { it.path == removed.path }, "The removed item is not navigable.") + return snapshot.selected.name to snapshot.media.size + } + + assertEquals("first.jpg" to 2, removeFrom(third, first), "Another item only leaves the sequence.") + assertEquals("third.jpg" to 2, removeFrom(second, second), "The next item replaces the shown one.") + assertEquals("second.jpg" to 2, removeFrom(third, third), "The previous item replaces the last one.") + val repository = MediaViewerNavigationRepository() + val only = assertNotNull(repository.register(alice, listOf(first), first)) + assertNull(repository.remove(alice, only, first), "Removing the last item ends the route.") + assertNull(repository.resolve(alice, only), "The ended route cannot be restored.") + val other = assertNotNull(repository.register(alice, listOf(first, second), first)) + assertNull(repository.remove(bob, other, first), "Another account cannot change the route.") + assertEquals(2, assertNotNull(repository.resolve(alice, other)).media.size) + val kept = assertNotNull(repository.remove(alice, other, first)) + assertNotEquals(other, kept, "A removal changes the route even when the next item takes the same index.") + assertNull(repository.resolve(alice, other), "A stale copy of the old route no longer resolves.") + assertEquals(listOf(first), mediaViewerItems(listOf(second, third), first), "A shown item outside the list stays shown.") + } + + @Test + fun deletingAStackCoverPromotesTheSurvivingMemberInTheSamePosition() { + val repository = MediaViewerNavigationRepository() + val route = repository.registerStacks(listOf(first, stackJpeg, stackRaw, third), stackJpeg) + + val snapshot = repository.after(repository.remove(alice, route, stackJpeg)) + + assertEquals(listOf(first, stackRaw, third), snapshot.media, "The RAW member becomes the cover in place.") + assertEquals(stackRaw, snapshot.selected, "The viewer stays open on the surviving member.") + assertEquals(setOf(first, stackRaw, third), snapshot.sourceMembers.toSet(), "Only the deleted cover leaves the sources.") + } + + @Test + fun deletingAMemberThatIsNotTheCoverKeepsTheItemAndItsCover() { + val repository = MediaViewerNavigationRepository() + val shownCover = repository.registerStacks(listOf(stackJpeg, stackRaw, third), stackJpeg) + val coverKept = repository.after(repository.remove(alice, shownCover, stackRaw)) + + assertEquals(listOf(stackJpeg, third), coverKept.media) + assertEquals(setOf(stackJpeg, third), coverKept.sourceMembers.toSet(), "Only the RAW member leaves.") + assertEquals(stackJpeg, coverKept.selected) + + val shownRaw = repository.registerStacks(listOf(stackJpeg, stackRaw, third), stackRaw) + val rawRemoved = repository.after(repository.remove(alice, shownRaw, stackRaw)) + assertEquals(stackJpeg, rawRemoved.selected, "A removed shown member moves to its stack's cover.") + assertEquals(0, rawRemoved.selectedIndex) + } + + @Test + fun deletingEveryMemberRemovesTheItem() { + val repository = MediaViewerNavigationRepository() + val route = repository.registerStacks(listOf(stackJpeg, stackRaw, third), stackJpeg) + val afterCover = assertNotNull(repository.remove(alice, route, stackJpeg)) + val afterRaw = repository.after(repository.remove(alice, afterCover, stackRaw)) + + assertEquals(listOf(third), afterRaw.media) + assertEquals(third, afterRaw.selected, "The next item is shown once the whole stack is gone.") + + val single = repository.registerStacks(listOf(stackJpeg, stackRaw), stackRaw) + val rawOnly = assertNotNull(repository.remove(alice, single, stackJpeg)) + assertNull(repository.remove(alice, rawOnly, stackRaw), "The viewer closes when no member is left.") + } + + @Test + fun siblingsMappedToADeletedCoverFollowTheirPromotedCover() { + val repository = MediaViewerNavigationRepository() + val route = repository.registerStacks(listOf(stackJpeg, stackRaw, otherJpeg, otherRaw), otherJpeg) + + val next = assertNotNull(repository.remove(alice, route, stackJpeg)) + val snapshot = repository.after(next) + + assertEquals(listOf(stackRaw, otherJpeg), snapshot.media) + assertEquals(otherJpeg, snapshot.selected, "The shown item in another stack stays shown.") + assertEquals(1, snapshot.selectedIndex) + val promoted = repository.after(repository.select(alice, next, stackRaw)) + assertEquals(0, promoted.selectedIndex, "The surviving RAW member is its own navigation item.") + val otherMember = repository.after(repository.select(alice, next, otherRaw)) + assertEquals(1, otherMember.selectedIndex, "Another stack keeps its mapping.") + assertEquals(otherRaw, otherMember.selected) + } + + private fun MediaViewerNavigationRepository.registerStacks( + files: List, + selected: NextcloudFile, + ): MediaViewerNavigationRoute { + val sequence = mediaStackViewerSequence(stackMediaFiles(files)) + val route = assertNotNull( + register( + alice, + sequence.navigationItems, + sequence.navigationItems.first { it.sharesMediaStackWith(selected) }, + sourceMembers = sequence.sourceMembers, + navigationIdentityBySourceIdentity = sequence.navigationIdentityBySourceIdentity, + ), + ) + return assertNotNull(select(alice, route, selected)).also { assertEquals(selected, after(it).selected) } + } + + private fun MediaViewerNavigationRepository.after(route: MediaViewerNavigationRoute?): MediaViewerNavigationSnapshot = + assertNotNull(resolve(alice, assertNotNull(route))) + + private fun photo(name: String, id: Long) = NextcloudFile( + path = "Photos/$name", + name = name, + isDirectory = false, + mimeType = "image/jpeg", + size = 1, + lastModified = null, + fileId = id, + hasPreview = true, + etag = "v1", + ) + + private fun raw(name: String, id: Long) = photo(name, id).copy(mimeType = "image/x-adobe-dng") +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/TextEditorStateTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/TextEditorStateTest.kt index 9fdc0d9c1..257464953 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/TextEditorStateTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/TextEditorStateTest.kt @@ -53,6 +53,35 @@ class TextEditorStateTest { assertNull(editor.saveError) } + @Test + fun `a covering delete refuses the save without sending or losing edits`(): Unit = runBlocking { + val store = MemoryStore() + var block: FileWriteBlock? = FileWriteBlock.DeleteRunning + var uploads = 0 + val editor = TextEditorState( + store, + { content("old", "v1") }, + { _, _ -> uploads += 1; SavedTextFile("v2", false) }, + writeBlock = { block }, + ) + editor.open() + editor.edit("draft") + + editor.save() + + assertEquals(0, uploads, "Nothing is sent while the delete is unverified.") + assertEquals(FileWriteBlock.DeleteRunning.message, editor.saveError) + assertEquals("draft", editor.draft) + assertTrue(editor.canSave, "The known revision stays usable once the block lifts.") + + block = null + editor.save() + + assertEquals(1, uploads) + assertNull(editor.saveError) + assertEquals("draft", editor.originalText) + } + @Test fun `undoing all edits removes the stale recovery draft`() = runBlocking { val store = MemoryStore() diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/captures/MarketingAppCapture.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/captures/MarketingAppCapture.kt index 9225aa6cd..7c35965b0 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/captures/MarketingAppCapture.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/captures/MarketingAppCapture.kt @@ -3,6 +3,8 @@ package dev.obiente.nextcloudnative.app import androidx.compose.material3.Typography import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import dev.obiente.nextcloudnative.app.design.NextcloudAppBackground import dev.obiente.nextcloudnative.app.design.NextcloudDestination import dev.obiente.nextcloudnative.app.design.NextcloudNativeTheme @@ -87,6 +89,7 @@ fun NextcloudNativeMarketingCapture( FilesScreen( services = assets.services, session = marketingHomepageSession, + fileDeletes = rememberFileDeleteCoordinator(marketingHomepageSession, rememberCoroutineScope().let { scope -> remember(scope) { FileWriteFenceRegistry(scope) } }), userId = marketingHomepageTalkUserId, fileSharing = nextcloudNativeMarketingFileShareFixture.capabilities, path = "", @@ -103,6 +106,7 @@ fun NextcloudNativeMarketingCapture( FilesScreen( services = assets.services, session = marketingHomepageSession, + fileDeletes = rememberFileDeleteCoordinator(marketingHomepageSession, rememberCoroutineScope().let { scope -> remember(scope) { FileWriteFenceRegistry(scope) } }), userId = marketingHomepageTalkUserId, fileSharing = nextcloudNativeMarketingFileShareFixture.capabilities, path = "", diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 6134c2c80..55db19294 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -90,8 +90,8 @@ internal suspend fun DesktopAccountOperationGuard.withAuthenticatedMuta action: suspend (NextcloudSession) -> Result, ): Result = serialize { val current = resolveSession() - check(desktopSyncRunMatchesActiveSession(current, expectedSession)) { - "The account changed before the authenticated change could be sent." + if (!desktopSyncRunMatchesActiveSession(current, expectedSession)) { + throw FileMutationNotSentException("The account changed before the authenticated change could be sent.") } action(requireNotNull(current)) } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt new file mode 100644 index 000000000..4ebbc170a --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt @@ -0,0 +1,292 @@ +package dev.obiente.nextcloudnative.app + +import java.io.IOException +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Local cache and virtual-file bookkeeping after a remote file mutation. + * + * The remote result is authoritative and must reach the caller even when this bookkeeping stalls, + * for example while a virtual-file provider holds its monitor across platform calls. A monitor wait + * can be neither cancelled nor interrupted, so the work never runs on the caller's coroutine. + * + * One drain worker refreshes one remote path at a time in submission order. Pending refreshes are + * coalesced per account and path, keeping only the newest refresh, and at most [maximumPending] + * paths wait behind a stalled refresh. A path beyond that bound is reported as a local failure + * instead of growing the queue, both after a confirmed change and after an unknown result. + */ +internal class DesktopFileMutationFollowUps( + private val scope: CoroutineScope, + private val completionWaitMillis: Long = DEFAULT_FILE_MUTATION_FOLLOW_UP_WAIT_MILLIS, + private val maximumPending: Int = MAX_PENDING_FILE_MUTATION_FOLLOW_UPS, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val recordDiagnostic: (accountId: String, SupportDiagnosticEventDraft) -> Unit = { _, _ -> }, +) { + private data class Key(val accountId: String, val path: String) + + /** One caller's view of one path. The first result wins and stays readable after a timeout. */ + private class Waiter { + private val value = AtomicReference(null) + val done = CompletableDeferred() + val result: FileMutationLocalFollowUp? get() = value.get() + + fun finish(result: FileMutationLocalFollowUp) { + if (value.compareAndSet(null, result)) done.complete(Unit) + } + } + + private class Pending(var refresh: () -> Throwable?, val waiters: MutableList = mutableListOf()) { + /** Set once, when the refresh finishes or is abandoned. Guarded by the follow-ups lock. */ + var result: FileMutationLocalFollowUp? = null + } + + /** + * One remote mutation's local follow-up. The refresh queued when the server accepted the + * request, or when its result became unknown, and the wait after confirmation share one entry + * per path. A path is therefore refreshed once, and a full queue never fails a path whose + * refresh for this mutation is already queued, running, or finished. + */ + inner class Mutation internal constructor( + private val accountId: String, + paths: List, + private val refresh: (path: String) -> Throwable?, + ) { + private val paths = paths.distinct() + private val joined = HashMap() + + /** Queues the refresh of every path. Returns false when a path could not be queued. */ + fun onAcceptedOrUnknownResult(): Boolean = paths.map { path -> + val entry = submit(Key(accountId, path), { refresh(path) }, waiter = null, previous = joinedEntry(path)) + synchronized(lock) { entry?.let { joined[path] = it } } + entry != null + }.all { it } + + /** + * Runs [mutation], which sends one request through the desktop mutation executor and reports + * an accepted response or an interrupted exchange through its callback. When the + * reconciliation for an unknown result cannot be queued, the failure says so explicitly. + * + * The executor reports every I/O failure after a network exchange began as + * [DesktopFileSyncAmbiguousMutationException]; any other I/O failure from it happened before + * the request could leave this device (name resolution, connection, or TLS setup), so it + * becomes [FileMutationNotSentException]. + */ + suspend inline fun reconcilingUnknownResult(mutation: (onAcceptedOrUnknownResult: () -> Unit) -> T): T { + var queued = true + try { + return mutation { queued = onAcceptedOrUnknownResult() } + } catch (unknown: DesktopFileSyncAmbiguousMutationException) { + if (!queued) throw DesktopFileMutationReconciliationDroppedException(unknown) + // Report the queued reconciliation like a confirmed change: bounded, never blocking. + throw FileMutationOutcomeUnknownException(FILE_MUTATION_OUTCOME_UNKNOWN_MESSAGE, unknown, afterConfirmedMutation()) + } catch (unsent: IOException) { + throw FileMutationNotSentException("Could not reach the server, so nothing was changed. Try again.", unsent) + } + } + + /** + * Waits at most [completionWaitMillis] for the refresh of every path after the server + * confirmed the mutation, reusing the refresh this mutation already queued. + */ + suspend fun afterConfirmedMutation(): FileMutationLocalFollowUp { + val waiters = paths.map { path -> + Waiter().also { waiter -> + val entry = submit(Key(accountId, path), { refresh(path) }, waiter, previous = joinedEntry(path)) + if (entry == null) waiter.finish(FileMutationLocalFollowUp.Failed) + } + } + try { + withTimeoutOrNull(completionWaitMillis) { waiters.forEach { it.done.await() } } + } finally { + synchronized(lock) { + pending.values.forEach { entry -> entry.waiters.removeAll(waiters) } + running?.second?.waiters?.removeAll(waiters) + } + } + // A failed or dropped path is known now; it must not be hidden behind one still running. + val results = waiters.map(Waiter::result) + val followUp = when { + FileMutationLocalFollowUp.Failed in results -> FileMutationLocalFollowUp.Failed + results.all { it == FileMutationLocalFollowUp.Completed } -> FileMutationLocalFollowUp.Completed + else -> FileMutationLocalFollowUp.StillRunning + } + if (followUp == FileMutationLocalFollowUp.StillRunning) { + recordOutcome(accountId, "still-running", "FILE_MUTATION_LOCAL_FOLLOW_UP_DELAYED") + } + return followUp + } + + private fun joinedEntry(path: String): Pending? = synchronized(lock) { joined[path] } + } + + private val lock = Any() + private val pending = LinkedHashMap() + private var running: Pair? = null + private var draining = false + + init { + require(completionWaitMillis > 0L) + require(maximumPending > 0) + } + + /** Paths waiting behind the running refresh. Bounded by [maximumPending]. */ + internal val pendingCount: Int get() = synchronized(lock) { pending.size } + + /** The local follow-up of one mutation of [paths]. [refresh] returns the first local failure it recovered from. */ + fun mutation(accountId: String, paths: List, refresh: (path: String) -> Throwable?) = + Mutation(accountId, paths, refresh) + + /** Refreshes [paths] after the server confirmed a mutation that queued nothing earlier. */ + suspend fun afterConfirmedMutation( + accountId: String, + paths: List, + refresh: (path: String) -> Throwable?, + ): FileMutationLocalFollowUp = mutation(accountId, paths, refresh).afterConfirmedMutation() + + /** + * Queues a refresh after an unknown remote result without delaying the caller's failure. + * Returns false when a path could not be queued, so the caller must not imply local recovery. + */ + fun afterUnknownResult(accountId: String, paths: List, refresh: (path: String) -> Throwable?): Boolean = + mutation(accountId, paths, refresh).onAcceptedOrUnknownResult() + + /** + * Drops [accountId]'s queued refreshes at sign-out or account removal; their waiters and those + * of its running refresh see a local failure. A running refresh cannot be interrupted, and its + * late completion no longer changes that result. Every refresh also carries the file cache + * incarnation it was created under, which teardown retires, so it cannot touch or recreate the + * removed account's state. + */ + fun abandonAccount(accountId: String) { + val abandoned = synchronized(lock) { + val current = running?.takeIf { (key, _) -> key.accountId == accountId }?.second + val queued = pending.keys.filter { it.accountId == accountId }.map { key -> requireNotNull(pending.remove(key)) } + (listOfNotNull(current) + queued).flatMap { entry -> entry.abandon() } + } + abandoned.forEach { it.finish(FileMutationLocalFollowUp.Failed) } + } + + /** Marks [this] failed unless it already finished, and returns the waiters to notify. Called under the lock. */ + private fun Pending.abandon(): List { + if (result == null) result = FileMutationLocalFollowUp.Failed + return waiters.toList() + } + + /** + * Joins or creates the entry for [key], or returns null when the path was dropped. [previous] + * is the entry this mutation already joined for [key]; a waiter attaches to it while it runs and + * reads its result once it finished, instead of queueing the same refresh again. + */ + private fun submit( + key: Key, + refresh: () -> Throwable?, + waiter: Waiter?, + previous: Pending?, + ): Pending? { + var finished: FileMutationLocalFollowUp? = null + val entry = synchronized(lock) { + if (!scope.isActive) return@synchronized null + val queued = pending[key] + val entry = when { + queued != null -> queued.also { it.refresh = refresh } + // Finished or abandoned, including an abandoned refresh that is still running. + previous?.result != null -> previous.also { finished = it.result } + previous != null && previous === running?.second -> previous + pending.size >= maximumPending -> return@synchronized null + else -> Pending(refresh).also { pending[key] = it } + } + if (finished == null) waiter?.let { entry.waiters += it } + if (!draining && pending.isNotEmpty()) { + draining = true + startDrain() + } + entry + } + finished?.let { result -> waiter?.finish(result) } + if (entry == null) recordOutcome(key.accountId, "dropped", "FILE_MUTATION_LOCAL_FOLLOW_UP_DROPPED") + return entry + } + + private fun startDrain() { + scope.launch(dispatcher) { drain { isActive } }.invokeOnCompletion { cause -> + if (cause != null) abandonQueue() + } + } + + /** The service scope closed; nothing queued may run any more. */ + private fun abandonQueue() { + val abandoned = synchronized(lock) { + draining = false + pending.values.flatMap { entry -> entry.abandon() }.also { pending.clear() } + } + abandoned.forEach { it.finish(FileMutationLocalFollowUp.Failed) } + } + + private fun drain(active: () -> Boolean) { + while (true) { + // A refresh cannot observe cancellation while it blocks, so check before each one. + if (!active()) return abandonQueue() + val (key, entry) = synchronized(lock) { + val next = pending.entries.firstOrNull() + if (next == null) { + draining = false + return + } + pending.remove(next.key) + (next.key to next.value).also { running = it } + } + // Worker fault boundary: refreshes are blocking and do not suspend, so they cannot + // observe cancellation; an unexpected failure is reported instead of stopping the drain. + val failure = try { + entry.refresh() + } catch (failure: Exception) { + failure + } + val result = if (failure == null) { + FileMutationLocalFollowUp.Completed + } else { + recordOutcome(key.accountId, "failed", "FILE_MUTATION_LOCAL_FOLLOW_UP_FAILED", failure) + FileMutationLocalFollowUp.Failed + } + // An abandoned entry keeps its failure; its waiters were already told. + synchronized(lock) { + running = null + if (entry.result == null) entry.result = result + entry.waiters.toList() + }.forEach { it.finish(result) } + } + } + + private fun recordOutcome(accountId: String, outcome: String, code: String, failure: Throwable? = null) { + try { + recordDiagnostic(accountId, fileMutationLocalFollowUpDiagnostic(outcome, code, failure)) + } catch (diagnosticFailure: Exception) { + // Diagnostics must never change the reported mutation result. + failure?.addSuppressed(diagnosticFailure) + } + } +} + +/** The mutation's server result is unknown, and its local reconciliation could not be queued. */ +internal class DesktopFileMutationReconciliationDroppedException(cause: IOException) : FileMutationOutcomeUnknownException( + "The server response was interrupted, and local file status could not be scheduled for an update. " + + "Refresh the folder before trying again.", + cause, + FileMutationLocalFollowUp.Failed, +) + +/** + * How long a caller waits for local bookkeeping before reporting a server-confirmed change. + * Ordinary cache invalidation takes milliseconds; anything slower is reported as still running. + */ +internal const val DEFAULT_FILE_MUTATION_FOLLOW_UP_WAIT_MILLIS = 3_000L + +/** Distinct remote paths that may wait behind one stalled local refresh. */ +internal const val MAX_PENDING_FILE_MUTATION_FOLLOW_UPS = 256 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index e8f167da4..0e0512dc8 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1245,14 +1245,15 @@ class DesktopNextcloudServices( accountId: String, path: String, cacheProducer: DesktopFileReadCacheProducer?, - ) { + ): Throwable? { + // Each step stays best effort; the first recovered failure is returned for reporting. + var failure: Throwable? = null + fun Result.recorded(): Result = onFailure { failure = failure ?: it } synchronized(virtualFileProviderLock) { - if (!runCatching { invalidateDesktopFileMetadata(accountId, path, cacheProducer) }.getOrDefault(false)) return - val cache = runCatching { virtualRangeCache(accountId) }.getOrNull() ?: return - val roots = runCatching { - cache.retainedFoldersAffectedByListingChanges(accountId, listOf(path)) - } - .getOrDefault(emptyList()) + if (!runCatching { invalidateDesktopFileMetadata(accountId, path, cacheProducer) }.recorded().getOrDefault(false)) return failure + val cache = runCatching { virtualRangeCache(accountId) }.recorded().getOrNull() ?: return failure + val roots = runCatching { cache.retainedFoldersAffectedByListingChanges(accountId, listOf(path)) } + .recorded().getOrDefault(emptyList()) synchronized(virtualFolderMutationLock) { advanceAffectedVirtualFolderGenerations( virtualFolderMutationGenerationsByJob, @@ -1261,12 +1262,11 @@ class DesktopNextcloudServices( roots, ) } - runCatching { cache.invalidate(accountId, path) } - runCatching { cache.queueRetainedFoldersForRefresh(accountId, path) } - roots.forEach { root -> - runCatching { scheduleVirtualFolderHydration(session, userId, root, accountId, cache) } - } + runCatching { cache.invalidate(accountId, path) }.recorded() + runCatching { cache.queueRetainedFoldersForRefresh(accountId, path) }.recorded() + roots.forEach { root -> runCatching { scheduleVirtualFolderHydration(session, userId, root, accountId, cache) }.recorded() } } + return failure } private fun refreshRetainedFoldersAfterRemoteListing( @@ -1339,6 +1339,8 @@ class DesktopNextcloudServices( ) { recordSupportDiagnostic(desktopAccountSyncPairCleanupJournalMalformedDiagnostic()) } private val startOnLoginController = DesktopStartOnLoginController() private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val fileMutationFollowUps = DesktopFileMutationFollowUps(serviceScope) { id, event -> supportDiagnostics.recordForAccountIdentity(id, event) } + override val fileWriteFences = FileWriteFenceRegistry(CoroutineScope(serviceScope.coroutineContext + Dispatchers.Main)) private var backgroundFileSyncJob: Job? = null private val mutableFileSyncTraySnapshot = MutableStateFlow( DesktopFileSyncTraySnapshot( @@ -3257,9 +3259,7 @@ class DesktopNextcloudServices( supportDiagnostics.clear() } - override fun recordSupportDiagnostic(event: SupportDiagnosticEventDraft) { - supportDiagnostics.record(event) - } + override fun recordSupportDiagnostic(event: SupportDiagnosticEventDraft) = supportDiagnostics.record(event) override fun registerSupportDiagnosticPrivateValue(value: String?) { supportDiagnostics.registerPrivateValue(value) @@ -3842,6 +3842,8 @@ class DesktopNextcloudServices( private fun fenceDesktopAccountPrivateCaches(accountStorageKey: String, fileCacheAccountId: String) { fileReadCache.retireAccount(fileCacheAccountId) + fileMutationFollowUps.abandonAccount(fileCacheAccountId) + fileWriteFences.removeAccount(accountStorageKey) sharedJvmAuthenticatedAppReadSessions.retireAccount(accountStorageKey) dynamicDiscoveryCache.fenceAccount(accountStorageKey) } @@ -4831,26 +4833,19 @@ class DesktopNextcloudServices( } } val (accountId, cacheProducer) = fileReadCache.producerFor(session) - fun invalidateAffectedMetadata() { - runCatching { - refreshRetainedFoldersAfterMutation(session, userId, accountId, spec.sourcePath, cacheProducer) - spec.destinationPath?.let { destination -> - refreshRetainedFoldersAfterMutation(session, userId, accountId, destination, cacheProducer) - } - } - } - val response = request( + fun refreshLocalState(path: String) = refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) + val followUp = fileMutationFollowUps.mutation(accountId, listOfNotNull(spec.sourcePath, spec.destinationPath), ::refreshLocalState) + val response = followUp.reconcilingUnknownResult { onUnknownResult -> request( method = spec.method, url = buildNextcloudFileUrl(session.serverUrl, userId, spec.sourcePath), session = session, headers = headers, maxResponseBytes = 64 * 1024, mutationExecutor = fileMutationHttpExecutor, - onAmbiguousMutationResult = ::invalidateAffectedMetadata, - ) - if (response.status !in 200..299) throw fileOperationException(response.status, spec.sourceIsDirectory) - invalidateAffectedMetadata() - NextcloudFileMutationResult(spec.destinationPath, response.etag) + onAmbiguousMutationResult = onUnknownResult, + ) } + if (response.status !in 200..299) throw fileOperationException(response.status, spec.sourceIsDirectory, if (response.status == 404) followUp.afterConfirmedMutation() else null) + NextcloudFileMutationResult(spec.destinationPath, response.etag, followUp.afterConfirmedMutation()) } override suspend fun executeNextcloudApi( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt new file mode 100644 index 000000000..7e2115fbb --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt @@ -0,0 +1,570 @@ +package dev.obiente.nextcloudnative.app + +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class DesktopFileMutationFollowUpsTest { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val diagnostics = Collections.synchronizedList(mutableListOf>()) + private val releases = mutableListOf() + + @AfterTest + fun tearDown() { + releases.forEach(CountDownLatch::countDown) + scope.cancel() + } + + @Test + fun `completed local bookkeeping refreshes every affected path`(): Unit = runBlocking { + val followUps = followUps() + val refreshed = Collections.synchronizedList(mutableListOf()) + + val followUp = followUps.afterConfirmedMutation("account", listOf("a.txt", "b/a.txt")) { path -> + refreshed += path + null + } + + assertEquals(FileMutationLocalFollowUp.Completed, followUp) + assertEquals(listOf("a.txt", "b/a.txt"), refreshed.toList()) + assertTrue(diagnostics.isEmpty()) + } + + @Test + fun `recovered or thrown local failure is reported separately from the server result`(): Unit = runBlocking { + val followUps = followUps() + + val recovered = followUps.afterConfirmedMutation("account", listOf("a")) { IllegalStateException("cache") } + val thrown = followUps.afterConfirmedMutation("account", listOf("a")) { throw IllegalStateException("index") } + + assertEquals(FileMutationLocalFollowUp.Failed, recovered) + assertEquals(FileMutationLocalFollowUp.Failed, thrown) + assertEquals(listOf("failed", "failed"), diagnostics.map { it.second.outcome }) + assertTrue(diagnostics.all { (account, event) -> account == "account" && event.code == "FILE_MUTATION_LOCAL_FOLLOW_UP_FAILED" }) + } + + @Test + fun `stalled local bookkeeping cannot hold a server-confirmed result`(): Unit = runBlocking { + val followUps = followUps(waitMillis = 50L) + val release = latch() + val finished = CountDownLatch(1) + + val followUp = withTimeout(5_000L) { + followUps.afterConfirmedMutation("account", listOf("a")) { + // Stands in for a monitor wait that neither cancellation nor interruption can end. + release.awaitUninterruptibly() + finished.countDown() + null + } + } + + assertEquals(FileMutationLocalFollowUp.StillRunning, followUp) + assertEquals("still-running", diagnostics.single().second.outcome) + release.countDown() + assertTrue(finished.await(5, TimeUnit.SECONDS), "The bookkeeping still finishes in the background.") + } + + @Test + fun `many mutations behind a stall stay coalesced and run once the stall clears`(): Unit = runBlocking { + val followUps = followUps(waitMillis = 20L) + val release = latch() + val started = CountDownLatch(1) + followUps.afterUnknownResult("account", listOf("stalled")) { + started.countDown() + release.awaitUninterruptibly() + null + } + assertTrue(started.await(5, TimeUnit.SECONDS)) + val runs = Collections.synchronizedList(mutableListOf()) + + repeat(1_000) { mutation -> + val path = "folder/${mutation % 10}" + followUps.afterUnknownResult("account", listOf(path)) { + runs += "$path#$mutation" + null + } + } + repeat(20) { mutation -> + val followUp = followUps.afterConfirmedMutation("account", listOf("folder/${mutation % 10}")) { path -> + runs += "$path#confirmed$mutation" + null + } + assertEquals(FileMutationLocalFollowUp.StillRunning, followUp) + } + + assertEquals(10, followUps.pendingCount, "Repeated paths must not grow the queue.") + assertTrue(runs.isEmpty(), "Nothing may overtake the stalled refresh.") + release.countDown() + withTimeout(5_000L) { while (followUps.pendingCount > 0 || runs.size < 10) delay(10L) } + assertEquals( + (0 until 10).map { index -> "folder/$index#confirmed${10 + index}" }, + runs.toList(), + "Each path refreshes once, with its newest refresh.", + ) + } + + @Test + fun `pending paths behind a stall are bounded and overflow is reported as a local failure`(): Unit = + runBlocking { + val followUps = followUps(waitMillis = 20L, maximumPending = 4) + val release = latch() + val started = CountDownLatch(1) + followUps.afterUnknownResult("account", listOf("stalled")) { + started.countDown() + release.awaitUninterruptibly() + null + } + assertTrue(started.await(5, TimeUnit.SECONDS)) + + val results = (0 until 10).map { index -> + followUps.afterConfirmedMutation("account", listOf("path/$index")) { null } + } + + assertEquals(4, followUps.pendingCount) + assertEquals(List(4) { FileMutationLocalFollowUp.StillRunning }, results.take(4)) + assertEquals(List(6) { FileMutationLocalFollowUp.Failed }, results.drop(4)) + assertEquals(6, diagnostics.count { it.second.outcome == "dropped" }) + } + + @Test + fun `a dropped path is reported as failed even while another path is still running`(): Unit = runBlocking { + val followUps = followUps(waitMillis = 20L, maximumPending = 1) + val release = latch() + val started = CountDownLatch(1) + followUps.afterUnknownResult("account", listOf("stalled")) { + started.countDown() + release.awaitUninterruptibly() + null + } + assertTrue(started.await(5, TimeUnit.SECONDS)) + + val followUp = followUps.afterConfirmedMutation("account", listOf("source", "destination")) { null } + + assertEquals(FileMutationLocalFollowUp.Failed, followUp, "The dropped destination will never refresh.") + assertEquals(1, followUps.pendingCount) + } + + @Test + fun `service teardown abandons queued refreshes once the blocked one returns`(): Unit = runBlocking { + val followUps = followUps(waitMillis = 60_000L) + val release = latch() + val started = CountDownLatch(1) + val stalledReturned = CountDownLatch(1) + followUps.afterUnknownResult("account", listOf("stalled")) { + started.countDown() + release.awaitUninterruptibly() + stalledReturned.countDown() + null + } + assertTrue(started.await(5, TimeUnit.SECONDS)) + val runs = Collections.synchronizedList(mutableListOf()) + (0 until 3).forEach { index -> + followUps.afterUnknownResult("account", listOf("queued/$index")) { + runs += "queued/$index" + null + } + } + val caller = async(Dispatchers.Default) { + followUps.afterConfirmedMutation("account", listOf("confirmed")) { + runs += "confirmed" + null + } + } + withTimeout(5_000L) { while (followUps.pendingCount < 4) delay(10L) } + + scope.cancel() + release.countDown() + + assertTrue(stalledReturned.await(5, TimeUnit.SECONDS)) + assertEquals(FileMutationLocalFollowUp.Failed, withTimeout(5_000L) { caller.await() }) + withTimeout(5_000L) { while (followUps.pendingCount > 0) delay(10L) } + delay(100L) + assertTrue(runs.isEmpty(), "No queued refresh may run after teardown: $runs") + } + + @Test + fun `account teardown drops only that account's queued refreshes`(): Unit = runBlocking { + val followUps = followUps(waitMillis = 60_000L) + val release = latch() + val started = CountDownLatch(1) + val stalledReturned = CountDownLatch(1) + followUps.afterUnknownResult("retired", listOf("stalled")) { + started.countDown() + release.awaitUninterruptibly() + stalledReturned.countDown() + null + } + assertTrue(started.await(5, TimeUnit.SECONDS)) + val runs = Collections.synchronizedList(mutableListOf()) + followUps.afterUnknownResult("retired", listOf("queued")) { runs += "retired/$it"; null } + followUps.afterUnknownResult("active", listOf("queued")) { runs += "active/$it"; null } + val retiredCaller = async(Dispatchers.Default) { + followUps.afterConfirmedMutation("retired", listOf("confirmed")) { runs += "retired/$it"; null } + } + withTimeout(5_000L) { while (followUps.pendingCount < 3) delay(10L) } + + followUps.abandonAccount("retired") + + assertEquals(FileMutationLocalFollowUp.Failed, withTimeout(5_000L) { retiredCaller.await() }) + assertEquals(1, followUps.pendingCount, "The other account's refresh stays queued.") + release.countDown() + assertTrue(stalledReturned.await(5, TimeUnit.SECONDS), "A running refresh cannot be interrupted.") + withTimeout(5_000L) { while (runs.isEmpty()) delay(10L) } + delay(100L) + assertEquals(listOf("active/queued"), runs.toList(), "No retired refresh may run after teardown.") + } + + @Test + fun `a running refresh completing after account teardown cannot change the reported failure`(): Unit = + runBlocking { + val followUps = followUps(waitMillis = 60_000L) + val release = latch() + val started = CountDownLatch(1) + val finished = CountDownLatch(1) + val caller = async(Dispatchers.Default) { + followUps.afterConfirmedMutation("retired", listOf("stalled")) { + started.countDown() + release.awaitUninterruptibly() + finished.countDown() + null + } + } + assertTrue(started.await(5, TimeUnit.SECONDS)) + + followUps.abandonAccount("retired") + + assertEquals( + FileMutationLocalFollowUp.Failed, + withTimeout(5_000L) { caller.await() }, + "The caller learns of the teardown without waiting for the blocked refresh.", + ) + release.countDown() + assertTrue(finished.await(5, TimeUnit.SECONDS)) + assertEquals(FileMutationLocalFollowUp.Failed, caller.await()) + } + + @Test + fun `an unknown result whose reconciliation cannot be queued says so`(): Unit = runBlocking { + val followUps = followUps(waitMillis = 20L, maximumPending = 1) + val release = latch() + val started = CountDownLatch(1) + followUps.afterUnknownResult("account", listOf("stalled")) { + started.countDown() + release.awaitUninterruptibly() + null + } + assertTrue(started.await(5, TimeUnit.SECONDS)) + val interrupted = DesktopFileSyncAmbiguousMutationException(java.io.IOException("reset")) + + val queued = assertIs( + failureOf { + followUps.mutation("account", listOf("source")) { null }.reconcilingUnknownResult { onUnknown -> + onUnknown() + throw interrupted + } + }, + ) + val dropped = assertIs( + failureOf { + followUps.mutation("account", listOf("source", "destination")) { null }.reconcilingUnknownResult { onUnknown -> + onUnknown() + throw interrupted + } + }, + ) + + assertSame(interrupted, queued.cause, "A queued reconciliation keeps the original unknown result.") + assertEquals(FileMutationLocalFollowUp.StillRunning, queued.localFollowUp, "It waits behind the stall.") + assertSame(interrupted, dropped.cause) + assertEquals(FileMutationLocalFollowUp.Failed, dropped.localFollowUp, "A dropped reconciliation is reported.") + assertFalse(followUps.afterUnknownResult("account", listOf("other")) { null }) + assertEquals("ok", followUps.mutation("account", listOf("other")) { null }.reconcilingUnknownResult { "ok" }) + } + + @Test + fun `confirmation attaches to the accepted-response refresh while it runs`(): Unit = runBlocking { + val followUps = followUps(waitMillis = 60_000L) + val release = latch() + val started = CountDownLatch(1) + val runs = Collections.synchronizedList(mutableListOf()) + val mutation = followUps.mutation("account", listOf("a")) { path -> + runs += path + started.countDown() + release.awaitUninterruptibly() + null + } + + assertTrue(mutation.onAcceptedOrUnknownResult()) + assertTrue(started.await(5, TimeUnit.SECONDS)) + val confirmed = async(Dispatchers.Default) { mutation.afterConfirmedMutation() } + delay(100L) + assertEquals(0, followUps.pendingCount, "The running refresh is not queued again.") + release.countDown() + + assertEquals(FileMutationLocalFollowUp.Completed, withTimeout(5_000L) { confirmed.await() }) + assertEquals(listOf("a"), runs.toList()) + } + + @Test + fun `confirmation attaches to the accepted-response refresh while it is queued or after it finished`(): Unit = + runBlocking { + val followUps = followUps(waitMillis = 60_000L) + val release = latch() + val started = CountDownLatch(1) + followUps.afterUnknownResult("account", listOf("stalled")) { + started.countDown() + release.awaitUninterruptibly() + null + } + assertTrue(started.await(5, TimeUnit.SECONDS)) + val runs = Collections.synchronizedList(mutableListOf()) + val queued = followUps.mutation("account", listOf("queued")) { path -> runs += path; null } + + assertTrue(queued.onAcceptedOrUnknownResult()) + val confirmed = async(Dispatchers.Default) { queued.afterConfirmedMutation() } + delay(100L) + assertEquals(1, followUps.pendingCount) + release.countDown() + assertEquals(FileMutationLocalFollowUp.Completed, withTimeout(5_000L) { confirmed.await() }) + + val finished = followUps.mutation("account", listOf("finished")) { path -> runs += path; null } + assertTrue(finished.onAcceptedOrUnknownResult()) + withTimeout(5_000L) { while ("finished" !in runs) delay(10L) } + delay(50L) + assertEquals(FileMutationLocalFollowUp.Completed, finished.afterConfirmedMutation()) + delay(100L) + assertEquals(listOf("queued", "finished"), runs.toList(), "Each path refreshes once.") + } + + @Test + fun `a full queue does not fail a path whose refresh for this mutation is already running`(): Unit = + runBlocking { + val followUps = followUps(waitMillis = 60_000L, maximumPending = 1) + val release = latch() + val started = CountDownLatch(1) + val mutation = followUps.mutation("account", listOf("a")) { + started.countDown() + release.awaitUninterruptibly() + null + } + assertTrue(mutation.onAcceptedOrUnknownResult()) + assertTrue(started.await(5, TimeUnit.SECONDS)) + assertTrue(followUps.afterUnknownResult("account", listOf("b")) { null }) + assertEquals(1, followUps.pendingCount, "The queue is full.") + + val confirmed = async(Dispatchers.Default) { mutation.afterConfirmedMutation() } + delay(100L) + release.countDown() + + assertEquals(FileMutationLocalFollowUp.Completed, withTimeout(5_000L) { confirmed.await() }) + assertTrue(diagnostics.none { it.second.outcome == "dropped" }) + } + + @Test + fun `another mutation's running refresh is never reused for a dropped path`(): Unit = runBlocking { + val followUps = followUps(waitMillis = 20L, maximumPending = 1) + val release = latch() + val started = CountDownLatch(1) + followUps.afterUnknownResult("account", listOf("a")) { + started.countDown() + release.awaitUninterruptibly() + null + } + assertTrue(started.await(5, TimeUnit.SECONDS)) + assertTrue(followUps.afterUnknownResult("account", listOf("b")) { null }) + val later = followUps.mutation("account", listOf("a")) { null } + + assertFalse(later.onAcceptedOrUnknownResult(), "The queue is full, so its own refresh was dropped.") + assertEquals( + FileMutationLocalFollowUp.Failed, + later.afterConfirmedMutation(), + "The running refresh started before this mutation was accepted.", + ) + } + + @Test + fun `confirmation after account teardown reports the abandoned refresh as failed`(): Unit = runBlocking { + val followUps = followUps(waitMillis = 60_000L) + val release = latch() + val started = CountDownLatch(1) + val mutation = followUps.mutation("retired", listOf("a")) { + started.countDown() + release.awaitUninterruptibly() + null + } + assertTrue(mutation.onAcceptedOrUnknownResult()) + assertTrue(started.await(5, TimeUnit.SECONDS)) + + followUps.abandonAccount("retired") + + assertEquals(FileMutationLocalFollowUp.Failed, withTimeout(5_000L) { mutation.afterConfirmedMutation() }) + release.countDown() + } + + @Test + fun `a refused connection is reported as not sent and queues no reconciliation`(): Unit = runBlocking { + val port = java.net.ServerSocket(0).use { it.localPort } + val executor = DesktopHttpMutationExecutor(okhttp3.OkHttpClient()) + val request = okhttp3.Request.Builder().url("http://127.0.0.1:$port/remote.php/dav/files/alice/a.txt") + .method("DELETE", null).build() + val runs = Collections.synchronizedList(mutableListOf()) + val mutation = followUps().mutation("account", listOf("a.txt")) { path -> runs += path; null } + + val unsent = assertIs( + failureOf { mutation.reconcilingUnknownResult { onUnknown -> executor.execute(request, onUnknown) { it.code } } }, + ) + + assertIs(unsent.cause) + delay(100L) + assertTrue(runs.isEmpty(), "Nothing was sent, so there is nothing to reconcile.") + } + + @Test + fun `a failure after the request left the device stays ambiguous`(): Unit = runBlocking { + mockwebserver3.MockWebServer().use { server -> + server.enqueue(mockwebserver3.MockResponse.Builder().code(204).headersDelay(5, TimeUnit.SECONDS).build()) + server.start() + val executor = DesktopHttpMutationExecutor( + okhttp3.OkHttpClient.Builder().readTimeout(200, TimeUnit.MILLISECONDS).build(), + ) + val request = okhttp3.Request.Builder().url(server.url("/remote.php/dav/files/alice/a.txt")) + .method("DELETE", null).build() + val mutation = followUps().mutation("account", listOf("a.txt")) { null } + + val unknown = assertIs( + failureOf { mutation.reconcilingUnknownResult { onUnknown -> executor.execute(request, onUnknown) { it.code } } }, + ) + assertIs(unknown.cause) + assertEquals(FileMutationLocalFollowUp.Completed, unknown.localFollowUp) + } + } + + @Test + fun `an unknown result reports a failed or still-running reconciliation`(): Unit = runBlocking { + val interrupted = DesktopFileSyncAmbiguousMutationException(java.io.IOException("reset")) + for ((refresh, expected) in listOf Throwable?, FileMutationLocalFollowUp>>( + { _: String -> null } to FileMutationLocalFollowUp.Completed, + { _: String -> IllegalStateException("cache") } to FileMutationLocalFollowUp.Failed, + )) { + val unknown = assertIs( + failureOf { + followUps().mutation("account", listOf("a.txt"), refresh).reconcilingUnknownResult { onUnknown -> + onUnknown() + throw interrupted + } + }, + ) + assertEquals(expected, unknown.localFollowUp) + } + val release = latch() + val stalled = assertIs( + failureOf { + followUps(waitMillis = 20L).mutation("account", listOf("slow.txt")) { + release.awaitUninterruptibly() + null + }.reconcilingUnknownResult { onUnknown -> + onUnknown() + throw interrupted + } + }, + ) + assertEquals(FileMutationLocalFollowUp.StillRunning, stalled.localFollowUp, "A slow reconciliation is not awaited.") + } + + /** The failure of [block], for wrappers that are suspending and cannot run inside assertFailsWith. */ + private suspend fun failureOf(block: suspend () -> Unit): Throwable? = try { + block() + null + } catch (failure: Throwable) { + failure + } + + @Test + fun `an account change before sending is reported as not sent`(): Unit = runBlocking { + val guard = DesktopAccountOperationGuard() + val expected = NextcloudSession("https://guard.invalid", "alice", "synthetic") + val other = NextcloudSession("https://guard.invalid", "bob", "synthetic") + var sent = false + + assertFailsWith { + guard.withAuthenticatedMutationSession(expected, { other }) { sent = true } + } + assertFalse(sent) + } + + @Test + fun `caller cancellation propagates promptly while bookkeeping completes`(): Unit = runBlocking { + val followUps = followUps(waitMillis = 60_000L) + val release = latch() + val started = CountDownLatch(1) + val finished = CountDownLatch(1) + + val caller = launch(Dispatchers.Default) { + followUps.afterConfirmedMutation("account", listOf("a")) { + started.countDown() + release.awaitUninterruptibly() + finished.countDown() + null + } + } + assertTrue(started.await(5, TimeUnit.SECONDS)) + withTimeout(2_000L) { caller.cancelAndJoin() } + + assertTrue(caller.isCancelled) + release.countDown() + assertTrue(finished.await(5, TimeUnit.SECONDS)) + } + + @Test + fun `closed service scope reports a local failure instead of cancelling an active caller`(): Unit = + runBlocking { + val followUps = followUps() + scope.cancel() + + assertEquals( + FileMutationLocalFollowUp.Failed, + followUps.afterConfirmedMutation("account", listOf("a")) { null }, + ) + } + + private fun followUps(waitMillis: Long = 5_000L, maximumPending: Int = MAX_PENDING_FILE_MUTATION_FOLLOW_UPS) = + DesktopFileMutationFollowUps( + scope = scope, + completionWaitMillis = waitMillis, + maximumPending = maximumPending, + recordDiagnostic = { account, event -> diagnostics += account to event }, + ) + + private fun latch() = CountDownLatch(1).also(releases::add) + + private fun CountDownLatch.awaitUninterruptibly() { + var interrupted = false + while (true) { + try { + await() + break + } catch (_: InterruptedException) { + interrupted = true + } + } + if (interrupted) Thread.currentThread().interrupt() + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt new file mode 100644 index 000000000..01593c94e --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt @@ -0,0 +1,231 @@ +package dev.obiente.nextcloudnative.app + +import java.lang.reflect.Proxy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import androidx.compose.runtime.LaunchedEffect +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking + +/** One process-level registry shares a Files delete's blocks with every surface of that account. */ +class FileWriteFenceRegistryTest { + private val alice = NextcloudSession("https://fence-registry.invalid", "alice", "synthetic") + private val bob = NextcloudSession("https://fence-registry.invalid", "bob", "synthetic") + private val folder = file("Projects/Old", etag = "f1", directory = true) + + @Test + fun `a Files delete blocks the sync-center picker and the share upload of that account only`(): Unit = runBlocking { + val registry = registry() + val release = CompletableDeferred() + val deletes = coordinator(registry, alice) + val dialog = FileDeleteDialogState(deletes).apply { open(folder) } + val job = deletes.start(assertNotNull(dialog.begin()), { release.await(); NextcloudFileMutationResult(null, null) }) { + error("not read") + } + val calls = mutableListOf() + + // The sync center builds its picker from the same registry, outside the Files composition. + val syncPicker = remoteFolderPickerOperations(services(calls), alice, "alice", registry.account(alice.accountId)) + assertIs(runCatching { syncPicker.createDirectoryIfAbsent("Projects/Old/Synced") }.exceptionOrNull()) + assertEquals(RemoteFolderSelectionAccess.Denied(FileWriteBlock.DeleteRunning.message), syncPicker.selectionAccess("Projects/Old")) + assertEquals(RemoteFolderSelectionAccess.Allowed, syncPicker.selectionAccess("Projects"), "A child delete does not block its folder.") + + // The share upload resolves the fence for the account it uploads into. + val sharePicker = incomingShareFolderPickerOperations( + remoteFolderPickerOperations(services(calls), alice, "alice", registry.account(alice.accountId)), + selectionAccess = { error("Permissions are not inspected for a blocked destination.") }, + requireCreateDirectories = {}, + ) + assertEquals(RemoteFolderSelectionAccess.Denied(FileWriteBlock.DeleteRunning.message), sharePicker.selectionAccess("Projects/Old/Inbox")) + assertEquals(FileWriteBlock.DeleteRunning, incomingShareUploadBlock(registry.account(alice.accountId), "Projects/Old")) + assertNull(incomingShareUploadBlock(registry.account(alice.accountId), "Projects")) + + // Another account of the same process is unaffected. + val bobPicker = remoteFolderPickerOperations(services(calls), bob, "bob", registry.account(bob.accountId)) + bobPicker.createDirectoryIfAbsent("Projects/Old/Synced") + assertNull(incomingShareUploadBlock(registry.account(bob.accountId), "Projects/Old")) + assertEquals(listOf("createDirectoryIfAbsent:Projects/Old/Synced"), calls, "Only the other account wrote.") + + release.complete(Unit) + withTimeout(5_000L) { job.join() } + assertNull(incomingShareUploadBlock(registry.account(alice.accountId), "Projects/Old"), "A verified delete lifts it.") + } + + @Test + fun `sign-out or removal drops that account's blocks and keeps other accounts'`(): Unit = runBlocking { + val registry = registry() + refreshRequired(registry, alice) + refreshRequired(registry, bob) + + registry.removeAccount(alice.accountId.storageKey) + + assertNull(registry.account(alice.accountId).writeBlock("Projects/Old/new.txt"), "A new incarnation starts unblocked.") + assertNotNull(registry.account(bob.accountId).writeBlock("Projects/Old/new.txt")) + } + + @Test + fun `a refresh block lifts only through a listing that started after the outcome`(): Unit = runBlocking { + val registry = registry() + val fence = registry.account(alice.accountId) + val readStarted = CompletableDeferred() + val releaseRead = CompletableDeferred() + val staleRead = async { + fence.readFolder("Projects") { + readStarted.complete(Unit) + releaseRead.await() + NextcloudFileListing(emptyList(), NextcloudFileListingSource.Network) + } + } + readStarted.await() + refreshRequired(registry, alice) + releaseRead.complete(Unit) + staleRead.await() + val expected = FileWriteBlock.RefreshRequired("Projects/Old") + + assertEquals(expected, fence.writeBlock("Projects/Old/new.txt"), "A listing that started earlier does not lift it.") + val picker = remoteFolderPickerOperations(services(mutableListOf()), alice, "alice", fence) + assertEquals(RemoteFolderSelectionAccess.Denied(expected.message), picker.selectionAccess("Projects/Old")) + + fence.readFolder("Projects") { NextcloudFileListing(emptyList(), NextcloudFileListingSource.Cache) } + assertEquals(expected, fence.writeBlock("Projects/Old/new.txt"), "A cached listing is not a server answer.") + fence.readFolder("Projects") { NextcloudFileListing(emptyList(), NextcloudFileListingSource.Network) } + assertNull(fence.writeBlock("Projects/Old/new.txt")) + assertEquals(RemoteFolderSelectionAccess.Allowed, picker.selectionAccess("Projects/Old")) + } + + @Test + fun `disposing the composition that started a delete neither cancels it nor releases its block`(): Unit = + withTestDeleteOwnerThread { registry -> disposalScenario(registry) } + + private fun disposalScenario(registry: FileWriteFenceRegistry) { + val accepted = CompletableDeferred() + val release = CompletableDeferred() + var job: Job? = null + nativeSceneTest(400, 300, content = { + val deletes = rememberFileDeleteCoordinator(alice, registry) + LaunchedEffect(deletes) { + val dialog = FileDeleteDialogState(deletes).apply { open(folder) } + job = deletes.start(assertNotNull(dialog.begin()), { + accepted.complete(Unit) + release.await() + throw fileOperationException(412) + }) { error("not read") } + } + }) { + settleUntil { accepted.isCompleted } + } + awaitTestCondition("the owner thread starts the delete") { accepted.isCompleted } + + // The first composition is gone, as after an activity recreation; the request still runs. + assertTrue(job?.isActive == true, "Disposal must not cancel the request.") + assertEquals(FileWriteBlock.DeleteRunning, registry.account(alice.accountId).writeBlock("Projects/Old/new.txt")) + nativeSceneTest(400, 300, content = { BlockReason(registry) }) { + assertTrue(has(FileWriteBlock.DeleteRunning.message), "A new composition observes the running delete.") + } + + release.complete(Unit) + awaitTestCondition("the delete finishes on the owner thread") { assertNotNull(job).isCompleted } + val refresh = FileWriteBlock.RefreshRequired("Projects/Old") + assertEquals(refresh, registry.account(alice.accountId).writeBlock("Projects/Old/new.txt"), "Blocked until verified.") + val completion = assertNotNull(registry.account(alice.accountId).deletes.nextCompletion, "The outcome waits for a screen.") + assertIs(completion.outcome) + nativeSceneTest(400, 300, content = { BlockReason(registry) }) { + assertTrue(has(refresh.message), "A recreated screen still shows the typed reason.") + } + } + + @Test + fun `removing the account mid-request cancels it and a late completion reaches no later incarnation`(): Unit = runBlocking { + val registry = registry() + val started = CompletableDeferred() + val release = CompletableDeferred() + val removed = registry.account(alice.accountId) + val dialog = FileDeleteDialogState(removed.deletes).apply { open(folder) } + val job = removed.deletes.start(assertNotNull(dialog.begin()), { + withContext(NonCancellable) { + started.complete(Unit) + release.await() + } + NextcloudFileMutationResult(null, null) + }) { error("not read") } + started.await() + + registry.removeAccount(alice.accountId.storageKey) + val next = registry.account(alice.accountId) + release.complete(Unit) + withTimeout(5_000L) { job.join() } + + assertTrue(job.isCancelled) + assertTrue(next !== removed, "Signing in again starts a new incarnation.") + assertNull(next.writeBlock("Projects/Old/new.txt"), "The old generation's result never reaches it.") + assertNull(next.deletes.nextCompletion) + assertNull(removed.deletes.nextCompletion, "A completion after removal is not published.") + assertNull(registry.account(bob.accountId).writeBlock("Projects/Old/new.txt")) + } + + @androidx.compose.runtime.Composable + private fun BlockReason(registry: FileWriteFenceRegistry) { + val deletes = rememberFileDeleteCoordinator(alice, registry) + androidx.compose.material3.Text(deletes.writeBlockFor("Projects/Old/new.txt")?.message ?: "unblocked") + } + + @Test + fun `the registry hands out one fence per account`() { + val registry = FileWriteFenceRegistry(CoroutineScope(Job())) + + assertTrue(registry.account(alice.accountId) === registry.account(alice.accountId)) + assertTrue(registry.account(alice.accountId) !== registry.account(bob.accountId)) + } + + /** Leaves a refresh-required block on Projects/Old for [session]'s account. */ + private suspend fun CoroutineScope.refreshRequired(registry: FileWriteFenceRegistry, session: NextcloudSession) { + val deletes = coordinator(registry, session) + val dialog = FileDeleteDialogState(deletes).apply { open(folder) } + val job = deletes.start(assertNotNull(dialog.begin()), { throw fileOperationException(412) }) { error("not read") } + withTimeout(5_000L) { job.join() } + } + + private fun coordinator(registry: FileWriteFenceRegistry, session: NextcloudSession) = registry.account(session.accountId).deletes + + /** The event loop stays the test's, but its job does not keep the test running. */ + private fun CoroutineScope.registry() = FileWriteFenceRegistry(CoroutineScope(coroutineContext.minusKey(Job))) + + private fun file(path: String, etag: String, directory: Boolean) = NextcloudFile( + path = path, + name = path.substringAfterLast('/'), + isDirectory = directory, + mimeType = null, + size = null, + lastModified = null, + fileId = 9L, + hasPreview = false, + etag = etag, + ) + + private fun services(calls: MutableList): NextcloudPlatformServices = + Proxy.newProxyInstance( + NextcloudPlatformServices::class.java.classLoader, + arrayOf(NextcloudPlatformServices::class.java), + ) { proxy, method, args -> + when (method.name) { + "toString" -> "SyntheticRegistryServices" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.singleOrNull() + "createDirectoryIfAbsent" -> { + calls += "createDirectoryIfAbsent:${args!![2]}" + true + } + else -> error("Unexpected synthetic operation: ${method.name}") + } + } as NextcloudPlatformServices +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceSurfacesTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceSurfacesTest.kt new file mode 100644 index 000000000..ae7efcee7 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceSurfacesTest.kt @@ -0,0 +1,98 @@ +package dev.obiente.nextcloudnative.app + +import java.lang.reflect.Proxy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +/** Surfaces outside the Files workspace consult the same account-scoped write fence. */ +class FileWriteFenceSurfacesTest { + private val session = NextcloudSession("https://fence-scene.invalid", "alice", "synthetic") + private val block = FileWriteBlock.RefreshRequired("Projects/Old") + private val fence = FileWriteFence { path -> block.takeIf { path == "Projects/Old" || path.startsWith("Projects/Old/") } } + + @Test + fun `the folder picker refuses creating a folder inside one being deleted`(): Unit = runBlocking { + val calls = mutableListOf() + val operations = remoteFolderPickerOperations(services(calls), session, "alice", writeFence = fence) + + val refused = assertFailsWith { operations.createDirectoryIfAbsent("Projects/Old/New") } + operations.createDirectoryIfAbsent("Projects/New") + + assertSame(block, refused.block) + assertEquals(block.message, refused.message) + assertEquals(listOf("createDirectoryIfAbsent:Projects/New"), calls, "Only the unblocked folder is created.") + } + + @Test + fun `restoring a version of a file inside a folder being deleted is refused with the typed reason`() { + val calls = mutableListOf() + nativeSceneTest(800, 900, content = { + FileVersionHistorySection(services(calls), fence, session, "alice", file("Projects/Old/notes.txt"), initiallyExpanded = true) + }) { + settleUntil { has("Restore as current file") } + click("Restore as current file") + click("Restore version") + + assertTrue(has(block.message), "The reason names the folder to open or refresh.") + assertTrue(calls.none { it.startsWith("restoreFileVersion") }, "Nothing was restored: $calls") + } + } + + @Test + fun `restoring a version elsewhere is unaffected`() { + val calls = mutableListOf() + nativeSceneTest(800, 900, content = { + FileVersionHistorySection(services(calls), fence, session, "alice", file("Projects/notes.txt"), initiallyExpanded = true) + }) { + settleUntil { has("Restore as current file") } + click("Restore as current file") + click("Restore version") + settleUntil { calls.any { it.startsWith("restoreFileVersion") } } + + assertTrue(calls.any { it == "restoreFileVersion:Projects/notes.txt" }, "$calls") + } + } + + private fun file(path: String) = NextcloudFile( + path = path, + name = path.substringAfterLast('/'), + isDirectory = false, + mimeType = "application/octet-stream", + size = 1, + lastModified = null, + fileId = 7L, + hasPreview = false, + etag = "v1", + permissions = "RGDNVW", + ) + + private fun services(calls: MutableList): NextcloudPlatformServices = + Proxy.newProxyInstance( + NextcloudPlatformServices::class.java.classLoader, + arrayOf(NextcloudPlatformServices::class.java), + ) { proxy, method, args -> + when (method.name) { + "toString" -> "SyntheticFenceServices" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.singleOrNull() + "getExternalFileHandoffSupport" -> ExternalFileHandoffSupport.Unsupported("synthetic") + "listFileVersions" -> FileVersionHistory( + fileId = 7L, + versions = listOf(NextcloudFileVersion(7L, "1700000000", 1L, "Older", "e0", null, null)), + ) + "createDirectoryIfAbsent" -> { + calls += "createDirectoryIfAbsent:${args!![2]}" + true + } + "restoreFileVersion" -> { + calls += "restoreFileVersion:${(args!![2] as NextcloudFile).path}" + Unit + } + else -> error("Unexpected synthetic operation: ${method.name}") + } + } as NextcloudPlatformServices +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt new file mode 100644 index 000000000..4dbb586ef --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt @@ -0,0 +1,408 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.Column +import java.lang.reflect.Proxy +import java.util.Collections +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED +import kotlin.coroutines.intrinsics.intercepted +import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred + +/** The media viewer's delete runs in the account's shared delete owner, like a Files delete. */ +class MediaViewerDeleteSceneTest { + private val alice = NextcloudSession("https://media-delete.invalid", "alice", "synthetic") + private val bob = NextcloudSession("https://media-delete.invalid", "bob", "synthetic") + private val first = photo("first.jpg", 7L) + private val second = photo("second.jpg", 8L) + private val third = photo("third.jpg", 9L) + + @Test + fun `a confirmed delete removes the item, and incomplete local bookkeeping is reported first`() { + for ((followUp, notice) in listOf( + FileMutationLocalFollowUp.Completed to null, + FileMutationLocalFollowUp.StillRunning to "Deleted first.jpg. Local file status is still updating in the background.", + )) { + val events = mutableListOf() + val fake = FakeMediaServices(delete = { NextcloudFileMutationResult(null, null, followUp) }) + nativeSceneTest(800, 700, content = { Viewer(fake, listOf(first, second), events) }) { + click("Delete") + settleUntil { events.isNotEmpty() || (notice != null && has(notice)) } + + if (notice != null) { + assertTrue(has(notice)) + assertEquals(emptyList(), events, "The viewer does not move on as if everything finished.") + click("Close") + } + assertEquals(listOf("removed:first.jpg"), events) + assertTrue(has("showing second.jpg of 1"), "The next item is selected.") + assertEquals(listOf("delete"), fake.calls) + } + } + } + + @Test + fun `a running viewer delete blocks Files writes for the same account until it is verified`() { + val events = mutableListOf() + val release = CompletableDeferred() + val fake = FakeMediaServices(delete = { release.await(); NextcloudFileMutationResult(null, null) }) + var registry: FileWriteFenceRegistry? = null + nativeSceneTest(800, 700, content = { + val scope = rememberCoroutineScope() + val owned = remember { FileWriteFenceRegistry(scope).also { registry = it } } + Viewer(fake, listOf(first, second), events, owned.account(alice.accountId).deletes) + }) { + click("Delete") + val fence = assertNotNull(registry).account(alice.accountId) + + assertEquals(FileWriteBlock.DeleteRunning, fence.writeBlock("Photos/first.jpg")) + assertEquals(FileWriteBlock.DeleteRunning, fence.writeBlock("Photos"), "Its folder waits too.") + assertNull(fence.writeBlock("Photos/second.jpg"), "A sibling is unaffected.") + assertNull(assertNotNull(registry).account(bob.accountId).writeBlock("Photos/first.jpg")) + val files = FileDeleteDialogState(fence.deletes).apply { open(first) } + assertNull(files.begin(), "A Files delete of the same item must wait.") + + release.complete(Unit) + settleUntil { events.isNotEmpty() } + assertEquals(listOf("removed:first.jpg"), events) + assertNull(fence.writeBlock("Photos/first.jpg")) + } + } + + @Test + fun `a stale version keeps the item, offers no immediate retry, and shows the reason`() { + val events = mutableListOf() + val fake = FakeMediaServices(delete = { throw fileOperationException(412) }) + nativeSceneTest(800, 700, content = { Viewer(fake, listOf(first, second), events) }) { + click("Delete") + settleUntil { has("The file or destination changed. Refresh and try again.") } + + assertTrue(has("The file or destination changed. Refresh and try again.")) + click("Delete") + assertEquals(listOf("delete"), fake.calls, "The stale precondition is not sent again.") + assertTrue(events.isEmpty(), "The item stays in the viewer.") + } + } + + @Test + fun `an unknown result that cannot be verified keeps the item and blocks a retry`() { + val events = mutableListOf() + val fake = FakeMediaServices( + delete = { throw IllegalStateException("response interrupted") }, + listing = { throw IllegalStateException("offline") }, + ) + nativeSceneTest(800, 700, content = { Viewer(fake, listOf(first, second), events) }) { + click("Delete") + settleUntil { has(unconfirmedDeleteMessage("first.jpg")) } + + assertTrue(has(unconfirmedDeleteMessage("first.jpg"))) + click("Delete") + assertEquals(listOf("delete", "list:Photos"), fake.calls, "The unknown result is verified, never resent.") + assertTrue(events.isEmpty()) + } + } + + @Test + fun `an unknown result whose listing omits the file ID keeps the item and blocks a retry`() { + val events = mutableListOf() + val fake = FakeMediaServices( + delete = { throw IllegalStateException("response interrupted") }, + // Same path, type, and ETag, but no file ID to compare with the shown item's. + listing = { NextcloudFileListing(listOf(first.copy(fileId = null)), NextcloudFileListingSource.Network) }, + ) + nativeSceneTest(800, 700, content = { Viewer(fake, listOf(first, second), events) }) { + click("Delete") + val reason = "Could not confirm that first.jpg is still the same item. Refresh the folder." + settleUntil { has(reason) } + + assertTrue(has(reason)) + click("Delete") + assertEquals(listOf("delete", "list:Photos"), fake.calls, "A possible replacement is never deleted by a retry.") + assertTrue(events.isEmpty()) + } + } + + @Test + fun `a confirmed delete applies after the dialog closed and the user moved to another item`() { + val events = mutableListOf() + val release = CompletableDeferred() + val fake = FakeMediaServices(delete = { release.await(); NextcloudFileMutationResult(null, null) }) + nativeSceneTest(800, 700, content = { Viewer(fake, listOf(first, second, third), events) }) { + click("Delete") + click("Close") + click("Next") + assertTrue(has("showing second.jpg of 3")) + + release.complete(Unit) + settleUntil { events.contains("removed:first.jpg") } + + assertEquals(listOf("closed", "removed:first.jpg"), events) + assertTrue(has("showing second.jpg of 2"), "The selection stays on the item the user moved to.") + } + } + + @Test + fun `a non-confirmed outcome for an item the user moved away from keeps it and shows the reason on return`() { + val events = mutableListOf() + val release = CompletableDeferred() + val fake = FakeMediaServices(delete = { release.await(); throw fileOperationException(412) }) + nativeSceneTest(800, 700, content = { Viewer(fake, listOf(first, second), events) }) { + click("Delete") + click("Close") + click("Next") + release.complete(Unit) + settleUntil { fake.calls.isNotEmpty() } + repeat(3) { settle() } + + assertTrue(has("showing second.jpg of 2"), "The item is kept.") + assertFalse(has("first.jpg was not deleted"), "Its reason waits until it is shown.") + click("Previous") + settleUntil { has("first.jpg was not deleted") } + assertTrue(has("The file or destination changed. Refresh and try again.")) + assertEquals(listOf("closed"), events) + } + } + + @Test + fun `deleting the last item closes the viewer`() { + val events = mutableListOf() + val fake = FakeMediaServices(delete = { NextcloudFileMutationResult(null, null) }) + nativeSceneTest(800, 700, content = { Viewer(fake, listOf(first), events) }) { + click("Delete") + settleUntil { events.isNotEmpty() } + + assertEquals(listOf("removed:first.jpg", "viewer closed"), events) + } + } + + @Test + fun `closing the viewer mid-delete keeps the block, and a later viewer applies the outcome`() = + withTestDeleteOwnerThread { registry -> + val deletes = registry.account(alice.accountId).deletes + val release = CompletableDeferred() + val fake = FakeMediaServices(delete = { release.await(); NextcloudFileMutationResult(null, null) }) + val firstEvents = mutableListOf() + nativeSceneTest(800, 700, content = { Viewer(fake, listOf(first, second), firstEvents, deletes) }) { + click("Delete") + } + + assertEquals(FileWriteBlock.DeleteRunning, deletes.fence.writeBlock("Photos/first.jpg")) + release.complete(Unit) + // The owner publishes the outcome, then releases the in-flight claim; wait for both. + awaitTestCondition("the verified delete records its outcome and lifts the block") { + deletes.completionFor(deletes.keyFor(first)) != null && deletes.fence.writeBlock("Photos/first.jpg") == null + } + assertTrue(firstEvents.isEmpty()) + + val laterEvents = mutableListOf() + nativeSceneTest(800, 700, content = { Viewer(fake, listOf(first, second), laterEvents, deletes, open = false) }) { + settleUntil { laterEvents.isNotEmpty() } + assertEquals(listOf("removed:first.jpg"), laterEvents, "A recreated viewer applies the pending outcome.") + } + } + + @Test + fun `a confirmed delete stays applied when the viewer is recreated`() { + val events = mutableListOf() + val fake = FakeMediaServices(delete = { NextcloudFileMutationResult(null, null) }) + val route = ViewerRoute(listOf(first, second, third)) + nativeSceneTest(800, 700, content = { Viewer(fake, route, events) }) { + click("Delete") + settleUntil { events.isNotEmpty() } + assertTrue(has("showing second.jpg of 2")) + } + + // A new composition with a new delete owner reads the same route, as after an activity recreation. + nativeSceneTest(800, 700, content = { Viewer(fake, route, mutableListOf(), open = false) }) { + assertTrue(has("showing second.jpg of 2"), "The deleted item is not navigable again.") + click("Previous") + assertTrue(has("showing second.jpg of 2"), "The selection stays valid.") + click("Next") + assertTrue(has("showing third.jpg of 2")) + } + assertEquals(listOf("delete"), fake.calls) + } + + @Test + fun `a non-confirmed outcome keeps the item after the viewer is recreated`() { + val events = mutableListOf() + val fake = FakeMediaServices(delete = { throw fileOperationException(412) }) + val route = ViewerRoute(listOf(first, second)) + nativeSceneTest(800, 700, content = { Viewer(fake, route, events) }) { + click("Delete") + settleUntil { has("The file or destination changed. Refresh and try again.") } + } + + nativeSceneTest(800, 700, content = { Viewer(fake, route, mutableListOf(), open = false) }) { + assertTrue(has("showing first.jpg of 2"), "The item is kept.") + click("Next") + assertTrue(has("showing second.jpg of 2")) + } + assertTrue(events.none { it.startsWith("removed") }) + } + + @Test + fun `a stack member deleted while another member is shown leaves the route for good`() { + val events = mutableListOf() + val release = CompletableDeferred() + val fake = FakeMediaServices(delete = { release.await(); NextcloudFileMutationResult(null, null) }) + val stackJpeg = photo("stack.jpg", 21L) + val stackRaw = photo("stack.dng", 22L).copy(mimeType = "image/x-adobe-dng") + val route = ViewerRoute(mediaStackViewerSequence(stackMediaFiles(listOf(stackJpeg, stackRaw, third))), stackRaw) + nativeSceneTest(800, 700, content = { Viewer(fake, route, events) }) { + assertTrue(has("showing stack.dng of 2")) + click("Delete") + click("Close") + click("select:stack.jpg") + assertTrue(has("showing stack.jpg of 2")) + + release.complete(Unit) + settleUntil { events.contains("removed:stack.dng") } + assertEquals(listOf("closed", "removed:stack.dng"), events) + assertTrue(has("showing stack.jpg of 2"), "The shown member stays shown.") + assertFalse(has("select:stack.dng"), "The deleted RAW member is not offered.") + } + val snapshot = assertNotNull(route.repository.resolve(alice.accountId, assertNotNull(route.route))) + assertEquals(listOf("Photos/stack.jpg", "Photos/third.jpg"), snapshot.sourceMembers.map { it.path }) + + nativeSceneTest(800, 700, content = { Viewer(fake, route, mutableListOf(), open = false) }) { + assertTrue(has("showing stack.jpg of 2")) + assertFalse(has("select:stack.dng"), "A recreated viewer does not bring the RAW member back.") + } + } + + /** The viewer's navigable sequence, held by a route repository that outlives any composition. */ + private inner class ViewerRoute(sequence: MediaStackViewerSequence, selected: NextcloudFile = sequence.navigationItems.first()) { + constructor(media: List) : this(MediaStackViewerSequence(media, media)) + + val repository = MediaViewerNavigationRepository() + var route: MediaViewerNavigationRoute? = repository.register( + alice.accountId, + sequence.navigationItems, + sequence.navigationItems.first { it.sharesMediaStackWith(selected) }, + sourceMembers = sequence.sourceMembers, + navigationIdentityBySourceIdentity = sequence.navigationIdentityBySourceIdentity, + )?.let { repository.select(alice.accountId, it, selected) } + } + + @Composable + private fun Viewer( + fake: FakeMediaServices, + media: List, + events: MutableList, + deletes: FileDeleteCoordinator = rememberFileDeletesForTests(alice), + open: Boolean = true, + ) = Viewer(fake, remember { ViewerRoute(media) }, events, deletes, open) + + /** A minimal viewer: the shared delete host, the route repository, and next/previous selection. */ + @Composable + private fun Viewer( + fake: FakeMediaServices, + state: ViewerRoute, + events: MutableList, + deletes: FileDeleteCoordinator = rememberFileDeletesForTests(alice), + open: Boolean = true, + ) { + var route by remember { mutableStateOf(state.route) } + var deleteOpen by remember { mutableStateOf(open) } + fun show(next: MediaViewerNavigationRoute?) { + route = next + state.route = next + } + val current = route + val snapshot = current?.let { state.repository.resolve(alice.accountId, it) } + if (current == null || snapshot == null) { + Text("viewer closed") + return + } + val selected = snapshot.selected + val items = mediaViewerItems(snapshot.media, selected) + val index = items.indexOfFirst { it.path == selected.path } + fun move(to: NextcloudFile?) { + to?.let { state.repository.select(alice.accountId, current, it) }?.let(::show) + } + Column { + Text("showing ${selected.name} of ${items.size}") + TextButton(onClick = { move(items.getOrNull(index + 1)) }) { Text("Next") } + TextButton(onClick = { move(items.getOrNull(index - 1)) }) { Text("Previous") } + snapshot.sourceMembers.forEach { member -> + TextButton(onClick = { move(member) }) { Text("select:${member.name}") } + } + } + MediaDeleteHost(items, snapshot.sourceMembers, selected, alice, "alice", fake.services, deletes, deleteOpen, onClose = { + deleteOpen = false + events += "closed" + }) { removed -> + events += "removed:${removed.name}" + val next = state.repository.remove(alice.accountId, current, removed) + show(next) + if (next == null) events += "viewer closed" + } + } + + private fun photo(name: String, id: Long) = NextcloudFile( + path = "Photos/$name", + name = name, + isDirectory = false, + mimeType = "image/jpeg", + size = 1, + lastModified = null, + fileId = id, + hasPreview = true, + etag = "v1", + ) + + private class FakeMediaServices( + private val delete: suspend () -> NextcloudFileMutationResult, + private val listing: suspend () -> NextcloudFileListing = { error("The folder must not be read.") }, + ) { + val calls: MutableList = Collections.synchronizedList(mutableListOf()) + + val services = Proxy.newProxyInstance( + NextcloudPlatformServices::class.java.classLoader, + arrayOf(NextcloudPlatformServices::class.java), + ) { proxy, method, args -> + when (method.name) { + "toString" -> "SyntheticMediaDeleteServices" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.singleOrNull() + "executeFileMutation" -> { + calls += "delete" + suspendInto(args!!.last()) { delete() } + } + "listFilesWithSource" -> { + calls += "list:${args!![2]}" + suspendInto(args.last()) { listing() } + } + else -> error("Unexpected synthetic media operation: ${method.name}") + } + } as NextcloudPlatformServices + + /** Runs [operation] for a proxied suspend call; a thrown failure resumes the caller instead of the proxy wrapping it. */ + @Suppress("UNCHECKED_CAST") + private fun suspendInto(continuation: Any, operation: suspend () -> Any?): Any? { + val caller = continuation as Continuation + return try { + operation.startCoroutineUninterceptedOrReturn(caller) + } catch (failure: Exception) { + // Dispatched, so the caller resumes after this call has returned suspended. + caller.intercepted().resumeWith(Result.failure(failure)) + COROUTINE_SUSPENDED + } + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt new file mode 100644 index 000000000..b376c9fd2 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt @@ -0,0 +1,120 @@ +package dev.obiente.nextcloudnative.app + +import java.lang.reflect.Proxy +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class MediaViewerLocalFollowUpSceneTest { + private val session = NextcloudSession("https://media-scene.invalid", "alice", "synthetic") + private val photo = NextcloudFile( + path = "Photos/photo.jpg", + name = "photo.jpg", + isDirectory = false, + mimeType = "image/jpeg", + size = 1, + lastModified = null, + fileId = 7L, + hasPreview = true, + etag = "v1", + ) + + @Test + fun `move whose local bookkeeping failed is reported, then removes the source`() { + val events = mutableListOf() + nativeSceneTest(800, 700, content = { dialog(MediaViewerAction.Move, FileMutationLocalFollowUp.Failed, events) }) { + replaceText("photo.jpg", "moved.jpg") + click("Move") + + assertTrue(has("Moved photo.jpg. Local file status could not be updated; refresh the folder if it looks out of date.")) + assertEquals(emptyList(), events) + click("Close") + assertEquals(listOf("removed"), events) + } + } + + @Test + fun `completed local bookkeeping finishes without an extra step`() { + for ((action, button, expected) in listOf( + Triple(MediaViewerAction.Move, "Move", "removed"), + Triple(MediaViewerAction.Copy, "Copy", "dismissed"), + )) { + val events = mutableListOf() + nativeSceneTest(800, 700, content = { dialog(action, FileMutationLocalFollowUp.Completed, events) }) { + replaceText("photo.jpg", "other.jpg") + click(button) + + assertEquals(listOf(expected), events) + assertFalse(has("Saved on the server")) + } + } + } + + @Test + fun `every media change is refused with the typed reason while a covering delete is unverified`() { + for (action in listOf( + MediaViewerAction.Move, MediaViewerAction.Copy, + MediaViewerAction.ShareNextcloud, MediaViewerAction.AddToAlbum, + )) { + val events = mutableListOf() + val fence = FileWriteFence { path -> FileWriteBlock.DeleteRunning.takeIf { path.startsWith("Photos/") } } + nativeSceneTest(800, 700, content = { dialog(action, FileMutationLocalFollowUp.Completed, events, fence) }) { + assertTrue(has("This file cannot be changed yet"), "$action must be refused.") + assertTrue(has(FileWriteBlock.DeleteRunning.message)) + click("Close") + assertEquals(listOf("dismissed"), events, "Nothing was sent for $action.") + } + } + } + + @Test + fun `a move into a folder that needs a refresh is refused before anything is sent`() { + val events = mutableListOf() + val block = FileWriteBlock.RefreshRequired("Archive") + val fence = FileWriteFence { path -> block.takeIf { path == "Archive" || path.startsWith("Archive/") } } + nativeSceneTest(800, 700, content = { dialog(MediaViewerAction.Move, FileMutationLocalFollowUp.Completed, events, fence) }) { + replaceText("Photos", "Archive") + click("Move") + + assertTrue(has("Open or refresh your Nextcloud root before changing Archive or anything inside it.")) + assertEquals(emptyList(), events) + } + } + + @androidx.compose.runtime.Composable + private fun dialog( + action: MediaViewerAction, + followUp: FileMutationLocalFollowUp, + events: MutableList, + fence: FileWriteFence = NoFileWriteBlocksForTests, + ) = + MediaViewerActionDialog( + action = action, + file = photo, + session = session, + userId = "alice", + services = services(followUp), + writeFence = fence, + sharingCapabilities = NextcloudFileSharingCapabilities(), + onDismiss = { events += "dismissed" }, + onSourceRemoved = { events += "removed" }, + onMutationRunningChanged = {}, + ) + + private fun services(followUp: FileMutationLocalFollowUp): NextcloudPlatformServices = + Proxy.newProxyInstance( + NextcloudPlatformServices::class.java.classLoader, + arrayOf(NextcloudPlatformServices::class.java), + ) { proxy, method, args -> + when (method.name) { + "toString" -> "SyntheticMediaServices" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.singleOrNull() + // The server confirmed the change; only the local bookkeeping differs. + "executeFileMutation" -> NextcloudFileMutationResult(null, "v2", followUp) + "getExternalFileHandoffSupport" -> ExternalFileHandoffSupport.Unsupported("synthetic") + else -> error("Unexpected synthetic media operation: ${method.name}") + } + } as NextcloudPlatformServices +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt new file mode 100644 index 000000000..ff686fe92 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt @@ -0,0 +1,47 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import java.util.concurrent.Executors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlin.test.fail + +/** Test-only fence for scenes that do not exercise deletes; production callers always pass a real fence. */ +internal val NoFileWriteBlocksForTests = FileWriteFence { null } + +/** A delete owner for [session] in a test-only registry scoped to the scene. */ +@Composable +internal fun rememberFileDeletesForTests(session: NextcloudSession): FileDeleteCoordinator { + val scope = rememberCoroutineScope() + return remember(scope, session) { FileWriteFenceRegistry(scope).account(session.accountId).deletes } +} + +/** + * Runs [block] with a registry whose deletes run on a dedicated single thread owned by the test, so + * they outlive any scene without sharing a thread with it or with the test's own waits. + */ +internal fun withTestDeleteOwnerThread(block: (FileWriteFenceRegistry) -> T): T { + val dispatcher = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "test-delete-owner").apply { isDaemon = true } + }.asCoroutineDispatcher() + val scope = CoroutineScope(SupervisorJob() + dispatcher) + return try { + block(FileWriteFenceRegistry(scope)) + } finally { + scope.cancel() + dispatcher.close() + } +} + +/** Waits on the calling test thread, never a scene or owner thread, and fails with [message] after [timeoutMillis]. */ +internal fun awaitTestCondition(message: String, timeoutMillis: Long = 5_000L, condition: () -> Boolean) { + val deadline = System.nanoTime() + timeoutMillis * 1_000_000L + while (!condition()) { + if (System.nanoTime() > deadline) fail("Timed out after $timeoutMillis ms: $message") + Thread.sleep(10L) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TextEditorScreenInteractionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TextEditorScreenInteractionTest.kt index fabf68d6b..c7b4005ad 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TextEditorScreenInteractionTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TextEditorScreenInteractionTest.kt @@ -21,7 +21,7 @@ class TextEditorScreenInteractionTest { listOf(800 to 360, 390 to 844).forEach { (width, height) -> val fixture = EditorFixture() nativeSceneTest(width, height, content = { - TextEditorScreen(fixture.services, session, "synthetic-user", file, {}) + TextEditorScreen(fixture.services, session, "synthetic-user", file, {}, NoFileWriteBlocksForTests) }) { assertTrue(has("Document content")) val field = assertNotNull(nodes().firstOrNull { @@ -48,7 +48,7 @@ class TextEditorScreenInteractionTest { val fixture = EditorFixture().apply { stored = TextEditorDraft("", "Restored synthetic draft", "etag") } val navigationBusy = mutableStateOf(false) nativeSceneTest(800, 360, content = { - TextEditorScreen(fixture.services, session, "synthetic-user", file, {}, + TextEditorScreen(fixture.services, session, "synthetic-user", file, {}, NoFileWriteBlocksForTests, navigationCommitInProgress = navigationBusy.value) }) { assertTrue(has("Document content")) @@ -74,7 +74,7 @@ class TextEditorScreenInteractionTest { val visibility = MutableStateFlow(true) nativeSceneTest(800, 360, content = { CompositionLocalProvider(LocalAppWindowVisibility provides visibility) { - TextEditorScreen(fixture.services, session, "synthetic-user", file, {}) + TextEditorScreen(fixture.services, session, "synthetic-user", file, {}, NoFileWriteBlocksForTests) } }) { val field = assertNotNull(nodes().lastOrNull { @@ -94,7 +94,7 @@ class TextEditorScreenInteractionTest { fun unknownRevisionStillExplainsDisabledSavingAndOffersVerification() { val fixture = EditorFixture().apply { revision = null } nativeSceneTest(800, 360, content = { - TextEditorScreen(fixture.services, session, "synthetic-user", file, {}) + TextEditorScreen(fixture.services, session, "synthetic-user", file, {}, NoFileWriteBlocksForTests) }) { assertTrue(has("Saving is disabled until the server version is verified.")) assertTrue(has("Verify server version")) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NativeTiffMarketingCapture.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NativeTiffMarketingCapture.kt index 707e10074..38734e23e 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NativeTiffMarketingCapture.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NativeTiffMarketingCapture.kt @@ -3,6 +3,7 @@ package dev.obiente.nextcloudnative.nativeui.preview import androidx.compose.runtime.Composable import androidx.compose.material3.Typography import dev.obiente.nextcloudnative.app.ExternalFileHandoffSupport +import dev.obiente.nextcloudnative.app.rememberFileDeletesForTests import dev.obiente.nextcloudnative.app.MAX_PHOTO_EDIT_SOURCE_BYTES import dev.obiente.nextcloudnative.app.MAX_RAW_DISPLAY_PREVIEW_BYTES import dev.obiente.nextcloudnative.app.MarketingCaptureScenario @@ -51,7 +52,7 @@ internal class NativeTiffMarketingCapture private constructor( selected = tiffFile, session = fixtureSession, userId = FIXTURE_USER_ID, - services = services, + services = services, fileDeletes = rememberFileDeletesForTests(fixtureSession), taggingAvailable = false, memoriesLivePhotoCapability = MemoriesLivePhotoCapability.NotAdvertised, sharingCapabilities = NextcloudFileSharingCapabilities.Unavailable, @@ -59,6 +60,7 @@ internal class NativeTiffMarketingCapture private constructor( onSourceRemoved = { error("The isolated TIFF capture is read-only.") }, + onItemRemoved = { error("The isolated TIFF capture is read-only.") }, onClose = {}, initialZoom = HIGH_DETAIL_CAPTURE_ZOOM, onStateObserved = { observation -> observations += observation }, diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/RawMediaMarketingCapture.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/RawMediaMarketingCapture.kt index 7286fd390..2bf8fde2b 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/RawMediaMarketingCapture.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/RawMediaMarketingCapture.kt @@ -3,6 +3,7 @@ package dev.obiente.nextcloudnative.nativeui.preview import androidx.compose.runtime.Composable import androidx.compose.material3.Typography import dev.obiente.nextcloudnative.app.ExternalFileHandoffSupport +import dev.obiente.nextcloudnative.app.rememberFileDeletesForTests import dev.obiente.nextcloudnative.app.MAX_PHOTO_EDIT_SOURCE_BYTES import dev.obiente.nextcloudnative.app.MAX_RAW_DISPLAY_PREVIEW_BYTES import dev.obiente.nextcloudnative.app.MediaDisplayPayloadKind @@ -62,7 +63,7 @@ internal class RawMediaMarketingCapture private constructor( selected = rawFile, session = session, userId = FIXTURE_USER_ID, - services = services, + services = services, fileDeletes = rememberFileDeletesForTests(session), taggingAvailable = false, memoriesLivePhotoCapability = MemoriesLivePhotoCapability.NotAdvertised, sharingCapabilities = NextcloudFileSharingCapabilities.Unavailable, @@ -74,6 +75,7 @@ internal class RawMediaMarketingCapture private constructor( onSourceRemoved = { error("The isolated RAW capture is read-only.") }, + onItemRemoved = { error("The isolated RAW capture is read-only.") }, onClose = {}, initialZoom = if (mode == RawCaptureMode.HighDetail) HIGH_DETAIL_CAPTURE_ZOOM else 1f, onStateObserved = { observation -> observations += observation }, diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmFileMutationLocalFollowUp.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmFileMutationLocalFollowUp.kt new file mode 100644 index 000000000..9c80a1eee --- /dev/null +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmFileMutationLocalFollowUp.kt @@ -0,0 +1,63 @@ +package dev.obiente.nextcloudnative.app + +import java.io.IOException +import kotlinx.coroutines.CancellationException + +/** The support diagnostic for local bookkeeping after a server-confirmed file change. */ +fun fileMutationLocalFollowUpDiagnostic( + outcome: String, + code: String, + failure: Throwable? = null, +): SupportDiagnosticEventDraft = SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Files, + operation = "files.mutation-local-follow-up", + outcome = outcome, + code = code, + exception = failure?.toSupportDiagnosticExceptionDraft(), +) + +/** + * Runs the local invalidation of every path after the server confirmed a change. Every path is + * attempted; a failure is recorded with its cause and makes the result + * [FileMutationLocalFollowUp.Failed], so the change is never reported as fully applied locally. + * The server result stays authoritative, and cancellation propagates. + */ +fun invalidateAfterConfirmedFileMutation( + paths: List, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, + invalidate: (path: String) -> Unit, +): FileMutationLocalFollowUp { + var followUp = FileMutationLocalFollowUp.Completed + paths.distinct().forEach { path -> + try { + invalidate(path) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + followUp = FileMutationLocalFollowUp.Failed + try { + recordDiagnostic(fileMutationLocalFollowUpDiagnostic("failed", "FILE_MUTATION_LOCAL_FOLLOW_UP_FAILED", failure)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (diagnosticFailure: Exception) { + // Diagnostics must never change the reported mutation result. + failure.addSuppressed(diagnosticFailure) + } + } + } + return followUp +} + +/** + * Runs one file mutation request whose transport cannot tell whether a failed exchange was sent. + * An I/O failure leaves the server result unknown, so the local state is invalidated with + * [invalidate] and its result travels with [FileMutationOutcomeUnknownException], letting a later + * verified outcome report it. Other failures and cancellation propagate unchanged. + */ +suspend fun reportingUnknownFileMutationResult(invalidate: () -> FileMutationLocalFollowUp, request: suspend () -> T): T = + try { + request() + } catch (unknown: IOException) { + throw FileMutationOutcomeUnknownException(FILE_MUTATION_OUTCOME_UNKNOWN_MESSAGE, unknown, invalidate()) + } diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 1fdc84404..7d0dd6548 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -107,6 +107,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileBrowserPresentation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileCreation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDavQueries.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIdentityResolution.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIntegrationPlanning.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenter.kt", @@ -144,6 +147,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistory.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistorySection.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionRestoreHttpPolicy.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileWriteFence.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesRequestState.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceInspector.kt", @@ -599,20 +603,23 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt": "df5c2b3ef90a8a7d0ea02d6587b563ebde8e84d471073f718a7d901f2c0a65fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt": "fc7f8ac5ffb094da9d9a9f05bb2d072b13ff9da9281708f247b546258e0fc2e2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExternalFileHandoff.kt": "f3304a4a68482a0f335d0ea565dc94806350fbf6e08bd118952fc8d384590bea", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionMenu.kt": "9b93b5a919fe1b43b93610be95c4dc711949cc7fe5b46bb12cd9a700b2cffa77", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanning.kt": "616ba15f32430f7bc61850c2dd51ffd25f278c3c0c237c26ee562b0df4e32865", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionMenu.kt": "6f6e1384c879673fd621d001f592b7e3b4395351019587a0b78ab7769af745e8", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanning.kt": "0498134d9ccdb24e5337ff02d86b2970e8d51c85d9854b029aef3d99a03a953c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActions.kt": "697405dcc0290fb5bfd9a9786ec7740ec156168735a9d4efe0f84a0db4839d77", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileAudioPlayback.kt": "54bdda9c88075ec71ff1a622262d7b6fa8196dff90a8ec9426eca8001e89df97", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileBrowserPresentation.kt": "3d584f00eb89967047f7891f9fdd16f3452db6fc55df851146effa233a70f6c4", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileCreation.kt": "6d233545021958fbf4a8613d8d5713c3f9c350217738811a548c888af5b95c1a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDavQueries.kt": "656cda7579abbb4825d32d7383679dcf1af963e604b7152924f2b8a81713721c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt": "76c604f8161015cd780ea702340af2bf47364ab417798b3004698e3a6cb0425d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt": "b0a52c940d5f4807293c77446fdbc22da9b4bef994328f570e4dbab6cf1651b5", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "3367a72c158e81ecef4000ea2cb43b485a08f6ef575f5c4acfe608229595282c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIdentityResolution.kt": "f8658c54cf14dec5b60037a27770ea3c2eb06b509bd28d3b9c97c228adfae83a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIntegrationPlanning.kt": "312ace8532d4f7aca78eb50ee5e35afd33bf77923b7729987c3906968b92fdda", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenter.kt": "8b529e61c68ec3ee7937fc3695832284b0b7c88841893fe40d3921234b566e51", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt": "c5ba7cc704da82da28edb8bc8f3e3ad1d4b1e3793cb91c704ffbc30ad8eca7c6", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt": "8c6a8c48ddcbae74bf47035c95ed04cde36fcb73bf7b538af2d16125b42fe920", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueue.kt": "51fced0946f36654515a8a3b2a827f564e61e7626d4fdd5f194139d6aeaddaf9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueueSnapshot.kt": "1fb930db8f65e0e410af6eaacde0c4f3d071f4115c39f78a4f49a9532b9f7961", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "5c6b0e8c9fa5922dbe7c575779f8bba49a66778f46140eb7320563f71d918a3a", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "74fb975ce99ffa9319a8cbee16219ad731d014b73c7a9591daad727d6ab02811", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileShareCreationFields.kt": "ae14656030b9c107907408209d07ed352a8307d577d02f912ee2f9ab8bbe330d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileShareDialog.kt": "e26cee24080b124efb1016fc05168e578965e4265a85c221626d954e8abc655a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileShareEmailAddress.kt": "48ac473d694955b0591b33224f782717ff1b6cfde92b20e50f566bd830101c64", @@ -639,14 +646,15 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncSetupRestoration.kt": "de4f952c4fb70e144876e2028735c566681f8fb514111cf313bf443a4b2cf3d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadCheckpoint.kt": "de8740ee24e98477b7ac3fac51001f7fb8d86a5a2b589905724a621e32ddc7b9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadOwnership.kt": "b3dbc9fa663592e783991faaa9eea0b43bf368886b6ecc9834a6ffab019b6f1a", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileTransferDialog.kt": "6e3f36960f99f77920892e6db2394f29e63f06548fcf88c35aed150d45a47c2d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileTransferDialog.kt": "06d4ca3ab5a19d25fe9bf98a02aee7142bcfe2299edfd831398411863a0fd230", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistory.kt": "7d01527acc00eafe092885d19f318734263597b4c2c5c09cf39159d174a371dd", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistorySection.kt": "b3205f33d9072dec6715b9a3e643dcc4c76fbebe166281d610f752938cb1d8c3", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistorySection.kt": "f92fbf60947be94f5d58da7c0be429ed59c2e8344341ff9a19fa7dc6ace2bf58", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionRestoreHttpPolicy.kt": "2887700cfc59c51ebcc4ec87575b037c74bd7e47e33568f46ace10bf2a1fc2c5", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileWriteFence.kt": "9b2035cbb70f1046ee61ae34a415fb1e245de7ad041925613fce27652cc7fb4f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesRequestState.kt": "0e88380b24d51e1b9fb12931b471f9cc4c5e544714a9c0964eaded177905e5dc", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt": "b5d6c5f1226c47b9134b3f76eb7ff369146931bd7ce45e154a3389b3430abcb1", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceInspector.kt": "a17f31c524aef1e66114b3006984629c62396a9bc1182e511ca574966b34d1b1", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceItems.kt": "df7a28953b8b0c9e36a26b480ea984bdbb53fb0a215971edbfd1adef643bfeb8", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt": "9af2699cb3665f9cb83c73ccf9ed1dcd32c6d698b63f815f1f16477e3eee5912", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceInspector.kt": "a1f9032f4086100492f64d7e596c45f8b06320bf2cb02c97f7404c793b941398", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceItems.kt": "7faf5a9a4c92e1e13a6081a9f7d0944053be0d2d435f1cd7a527ed2b44e5aee2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspacePanes.kt": "e8f548a9198d47750cd36620ad9d6b751600fc7a1ea89f5714ea23336e36b48d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspacePresentation.kt": "9367058355063448ae06c874208f3c9e8b3cb7535bee6613c578eb98542045c8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceToolbar.kt": "644e576f094b73a78dae541d15621edf86e75c0408553f6319d4fcefe55ed3d2", @@ -677,7 +685,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt": "51a29eb6313c071412229f73a36c44f888c4f1fb7fdce0af1125d449ec45b26d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ImageDecodeBounds.kt": "6a218194682e175396d57b4d4a3fc8aea2c0b39a11212daa61a4d60bc8ed42a5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingFileShare.kt": "2ffac5d12aea372662848799769a9a4f2c887fa31bc2def8647a5773b89d2e35", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingShareUploadScreen.kt": "11a04fc6629a3a46bea7d8343fb21375654b1e2f03acd2c78daf4a374245d7e7", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingShareUploadScreen.kt": "9e1735445b08cb5192268ebd38a6a30d2477556ee25abc677fb9912e3ea373dd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/InstalledAppVersion.kt": "b1262ba2e9e7955f973b52d59d539e56c053920e4575853e309f33f39693c798", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/LivePhotoDetection.kt": "d4afebb50e544a7e1ff8de73be5a48e60b657f85f127172f4b6d59213f0100f5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/LivePhotos.kt": "380a1f135dd50b1746967a6805b69dd4e48ab77dc864aeef394440c021c223d7", @@ -695,12 +703,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaImageDecoder.kt": "547f040341a49e9c31e158f67c49b540e8f028639c29497018a7f6656a96bf5c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaInformation.kt": "cda30f55ef2b1cae839723ad7385eda60bbf8cbc0a965aee34bf43c316f420ca", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt": "9ff45eaf8a433eb3f9c9ebdc75d16c7a57c79982cc7fd01d106950f28429d392", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaStacking.kt": "7a787d4417e3d45e408c6b16edea1977ae8ad52cc57a51974a5f25ff9acc0aaf", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaStacking.kt": "aeb619c47eefa61b0d0155b6b763055f420c1420527d6d2a04feef7d6eb1178c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaThumbnailCache.kt": "a98bab69fe6db9e41916580e5d4f20627b8b4252a7171654608a8d3abd7ce6e1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaTimelineDavCarryoverStore.kt": "70e080f249cd7d3d47e993afcbda55f10e67888f1413014f9f97bd308341d0d5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaTransferCenterScreen.kt": "f35e75b6348e577a53188a13b2e8922b50d095d345c6643b921b16dd2906a0b1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerFeedback.kt": "698e9d0b12915264e6a7293c37408faee70e46364625fc7b574fd05e7f6170a5", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt": "9b46db810cff7082e30ba11c6cd2a6956dfae40ce5f5b12b19ce6a00768f9dc4", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt": "7cf5d5c0fce003153f99400e53a9c316903623e34c63f4a73905622f87c9f004", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewportTransform.kt": "b5d75403f3be594ea5aefcb631c985a452c5a6792dfb16e097068e93ee3c79ef", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesFolderBrowsing.kt": "a68c6cf314182178d90805f3ad96b778f7330db7b20003a81aeb7e03ea979c6d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimeline.kt": "4ebdf54b62d91d5c37f72c652abf22bceb6c78fc05e72c258c35e60201e98369", @@ -738,14 +746,14 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileListing.kt": "1f3de4f31d44d3ead139686d3fbcfbd27ccb520a9dab06ccc1d3e582ee25fb71", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt": "e2d786bc7e80199aad9507edd2a0d93d508ca621e093d7b87a131993ab90ed14", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "d1a9fbf7d82b48ed227708221eec71c27dcc9828fb12467d132bde9a6a00f6f8", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "fb424bf8979ac292ef30daba64e905e6f5123066cda793aa0244907e11b465c9", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "e6125cba79c6867061201edcd888103d7957f8b31444f365317ab535e2166de3", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "0c8192e04b537e618538acc24fb06e003419bda0f8852eb4c35d5a41966c2375", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "ff7b04865462f28448deff0230f6c817019e95b9f8b95c9cae81d324694c2afc", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "c7e2f2d01fcf2291c01c48109c28b45860b7e5966c14c2de88370b6cd18e10c1", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "3946e0f9ade815f340b6ec0bad48e07387aaac63ffbbbf0c041c9c36ff949eab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "85bcc0e5bb26cd7a77982094b65972c212b7fb92e1e60bb690444f40b4f0f075", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "70b83f289d3b5dbe4b6494b62827bb35b3f228066eeaf514092c0f9a6bfa9de1", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "29346c0450adb949018ed57d1997b80f9e279982c86e1e2ae5a5c2a0bddc8561", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "f017fa7ab65c626963530805fd99fb7f3d2b43597b144910303fda201c558fc7", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "0d1605e5c2cfc43ef45d0ed4c20a7260d09b254efe1231bd21c65c36884ae69f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "df00a758c8a29e9d1b221911a03f31547dc3510816da6228f8d38c607d0f4092", @@ -790,7 +798,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RecognizedFaceRemovalPicker.kt": "962d19bac4cdbbfe2c183c763097f2a4d1a275d2ea9c4f1eb27961b65d5d4de0", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RecognizedFaceSelection.kt": "a41835093c50db3b4c8414525dafe7542aa71aef295b480f352c7d5afc7abc24", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPicker.kt": "e78c0dccb27918465f2d9d72e555e07c7996a06721f3afef80911b7392b6f746", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt": "d01f7fe50933f3d3a7303a04788e8c3c839e461bf6cf551592c92ce7a8d35201", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt": "7b842e4b1c2e420382b36ca9a87a2a1a8e4a7c1f0cd9df75b0bc5a29df202f43", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RetainedContentNotice.kt": "77bcf477e60d7e3022abea07ada109c92e57b4a3c2dd57e61de7bdf53d242b4f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ServerCertificateReviewDialog.kt": "f7c4d2489596788486893ed746eb762060b5080f64dae5659eca8b48feabf0ea", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SettingsSectionContent.kt": "2b4a9844744aa57a7cd57122df43fcbea571d4d076e44b19c8f63c3026ba94a6", @@ -809,8 +817,8 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TalkMessageRenderModel.kt": "f1a91e6af3c1722a2c97d8139a89aeedffe6fadb6c797bb3601a144cf2608bd6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TalkMessageTime.kt": "52a148d0d22dba02324bbfe429b927529086c469243f0cc42a4f63860685de6a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TalkMessages.kt": "26bc89d381d23c9f314aeb0f4c3ec9ebca2130ec7f31281b29b22dffa9d3ff35", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextEditorScreen.kt": "d8850e743c6c892903dba706a1a531a15f2f9ea46c607245c7bf184cb03e420e", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextEditorState.kt": "d0233ada95d82f82979dfd180d8ea0ad84fa93ed5cd335ff81bf1a80772447fd", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextEditorScreen.kt": "07dd6e7f285e5a4c06eed15b9cbeffe73eaf6121850c2f3d95577ba6ab68c169", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextEditorState.kt": "0ee67a706b8c072436fa107c4a1a2e8d762a48cace2d71cb11230e446d875c32", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TextFileDavSavePolicy.kt": "a47bd8440a7681a1ac2b20461789c2d36f886a7b4cd996a03acdaeaf2820a5c2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TrustedCertificateSettings.kt": "1ecc3c0397e2363a18a2e3e8f3721f6604a9909ab027323523e8a1601a630a97", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/UnifiedSearch.kt": "0d9222d2765cc2a417c8563c00c44f9ddd104591d011ac5e30dad0aa4a546d81", @@ -952,7 +960,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/RuntimeState.kt": "51a3e4e8e65f781070ccfc45df279858c4eb3ac7f90401c47a19ba8b01cf83af", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/template/BracedTemplate.kt": "3178d751b6849c4567af76e7ddba392ddbabab27a3b0237c35b285e67c89be67", "ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/captures/MarketingAdaptiveAppScenario.kt": "f7dc8e65e7e396b7205dcc5c1f06f94bea2ad334ece8c7e0207254f1500aa277", - "ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/captures/MarketingAppCapture.kt": "8804d4a6c81ab39a13c40dd2ccd4e3ca86a9c0576445eafd14e9d9a9abcbd16a", + "ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/captures/MarketingAppCapture.kt": "6a8d9bae9ccbcbbd1d39fb5ae5a1f78e572a9b84763b429fcb186e928c65fba5", "ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/captures/MarketingBudgetCaptureScenario.kt": "e6072a6d6be597cff7285ca4dbf89d76c1dd51ebdce71c7e9914015cacf2a939", "ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/captures/MarketingCalendarWorkspaceScenario.kt": "c9ee755e50feb686a9d545598320db128b35fe4c05b216b21efa24fd71ab4d65", "ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/captures/MarketingCaptureRegistry.kt": "317e3ecf0570ae6ca157fbfc4b9c7aabb21b1cf0ad246039b84ce97955c7dc8d", @@ -983,10 +991,10 @@ "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnership.kt": "c5bd9b63793a8884cf6b74cadf3061dc0ac295a982c97ae7f0a906ac3ae6f3c9", "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureOwnershipTest.kt": "3decba0edc19f7d0127ee3bbe34621f9d6b38509c8e0359ff6ead8c952e7e852", "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/MarketingCaptureSourceDiscoveryTest.kt": "8f959954b49837eae60227dbd84cdd426b2ca2528daca4f210220b6837ec355b", - "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NativeTiffMarketingCapture.kt": "9a0b60b6829b0176644dd1b324b1d22292cb7ed160727a3f01d537aa08349786", + "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NativeTiffMarketingCapture.kt": "f4f304aea18f7200059bf2e5e6e2bf731cf445f21f4bc245480e7e2316620d17", "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NetworkInertMarketingServices.kt": "8ba0481fa65feaf9c473b375e3c37c30f056b475dc01e56c4baee8b479d32121", "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/PhotoTimelinePreviewMain.kt": "29b50379a82959d30fe76decb18c2a34c3a134e396ffbd7b8a0c2314f8d36dce", - "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/RawMediaMarketingCapture.kt": "4287c11ea76cacb3231425fb99ac99585f2bbe711ebe9967436544b6fc8758c6", + "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/RawMediaMarketingCapture.kt": "e37e251c4d963d79e8e05dc86be7d531b1d6c326653b42db1b4f056b2e6e187c", "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/RawMediaMarketingCaptureTest.kt": "75c3bc1b382ff3aca80d0a42b30dad5758e0b6d41385c5cfaa7cde6360c1f026", "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/ShellMarketingCapture.kt": "1ece2d528bc7a80295ae677247c460ed700608996ccaf22b31eb63e87a7604e2", "ui/src/desktopTest/resources/marketing/README.md": "477e5e0af74bf9ba9c3ffdc39ee43661e30700d2b30fd61bee692ced8d90ec2d",