From e8129163adc59a2aee619182e1e068d4e7623e72 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 8 Oct 2026 01:08:46 +0200 Subject: [PATCH 01/30] fix(files): always finish the delete dialog after the server result A server-confirmed WebDAV delete ran its local cache and virtual-file bookkeeping inline before returning. That bookkeeping blocks on the desktop virtual-file provider monitor, which Windows Cloud Files code holds across long provider operations, so the dialog could stay on "Deleting..." indefinitely although the server had already deleted the file. A monitor wait cannot be cancelled or interrupted. Desktop file mutations now run that bookkeeping on one ordered background worker, wait at most three seconds, and report the local result separately from the server result. Unknown results queue the bookkeeping without blocking failure or cancellation. The Files delete dialog now uses a fenced state holder: a definitive rejection allows a safe retry, an unknown or server-failure result is resolved by reading the parent folder instead of resending the delete, and the dialog can be closed while the delete finishes without a later completion changing a newer dialog. --- .../477-delete-dialog-completion.md | 7 + tools/kotlin-file-size-baseline.txt | 4 +- .../nextcloudnative/app/FileDeleteDialog.kt | 70 +++++ .../nextcloudnative/app/FileDeleteFlow.kt | 213 +++++++++++++ .../nextcloudnative/app/FileOperations.kt | 16 + .../nextcloudnative/app/NextcloudNativeApp.kt | 98 ++---- .../nextcloudnative/app/FileDeleteFlowTest.kt | 293 ++++++++++++++++++ .../app/DesktopFileMutationFollowUps.kt | 100 ++++++ .../app/DesktopNextcloudServices.kt | 43 ++- .../app/DesktopFileMutationFollowUpsTest.kt | 150 +++++++++ 10 files changed, 894 insertions(+), 100 deletions(-) create mode 100644 changes/unreleased/477-delete-dialog-completion.md create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt create mode 100644 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt diff --git a/changes/unreleased/477-delete-dialog-completion.md b/changes/unreleased/477-delete-dialog-completion.md new file mode 100644 index 000000000..4a2cebfa8 --- /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: stalled local virtual-file updates no longer hold the dialog, and interrupted responses are checked on the server instead of retried. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index a875c8a79..5703b7027 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -21,7 +21,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface. 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/NextcloudNativeApp.kt|11033 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|884 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|857 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6169 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6168 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/FileDeleteDialog.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt new file mode 100644 index 000000000..453a0371b --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt @@ -0,0 +1,70 @@ +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.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.obiente.nextcloudnative.app.design.NextcloudSpacing + +/** 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..e906e8fc9 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt @@ -0,0 +1,213 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive + +/** The verified result of one ETag-guarded Files delete. */ +internal sealed interface FileDeleteOutcome { + /** + * The server confirmed the delete, or a fresh server listing showed the item is gone after + * an unknown result. [localFollowUp] is null when the local update was not reported. + */ + data class Deleted( + val confirmedByRead: Boolean, + val localFollowUp: FileMutationLocalFollowUp?, + ) : FileDeleteOutcome + + /** The server reported that the item no longer exists. */ + data object AlreadyGone : FileDeleteOutcome + + /** The item is verified to be unchanged on the server, so trying again is safe. */ + data class NotDeleted(val message: String) : FileDeleteOutcome + + /** The outcome is unknown or the item changed. The folder must be refreshed before another try. */ + data class RefreshRequired(val message: String) : FileDeleteOutcome +} + +/** + * 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 { + val result = delete() + return FileDeleteOutcome.Deleted(confirmedByRead = false, localFollowUp = result.localFollowUp) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + failure + } + if (failure is NextcloudFileOperationException) { + when (failure.error) { + NextcloudFileOperationError.NotFound -> return FileDeleteOutcome.AlreadyGone + NextcloudFileOperationError.ServerFailure -> Unit + NextcloudFileOperationError.AuthenticationRequired, + NextcloudFileOperationError.PermissionDenied, + NextcloudFileOperationError.Conflict, + NextcloudFileOperationError.Locked, + NextcloudFileOperationError.InsufficientStorage, + -> return FileDeleteOutcome.NotDeleted(failure.message ?: "Could not delete ${target.name}.") + } + } + return verifyFileDeletePostcondition(target, expectedEtag, readParent) +} + +private suspend fun verifyFileDeletePostcondition( + target: NextcloudFile, + expectedEtag: String, + readParent: suspend () -> NextcloudFileListing, +): FileDeleteOutcome { + val unconfirmed = FileDeleteOutcome.RefreshRequired( + "Could not confirm whether ${target.name} was deleted. Refresh the folder before trying again.", + ) + 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 } + ?: return FileDeleteOutcome.Deleted(confirmedByRead = true, localFollowUp = null) + 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.", + ) + } +} + +internal data class FileDeleteRequest( + val generation: Long, + val file: NextcloudFile, + val expectedEtag: String, +) { + val parentPath: String get() = file.path.substringBeforeLast('/', missingDelimiterValue = "") +} + +/** Screen effects of a finished delete. They apply even after its dialog was closed. */ +internal data class FileDeleteScreenEffect(val notice: String?, val reloadFolder: Boolean) + +/** + * Main-thread owner of the Files delete dialog. Closing or replacing the dialog invalidates its + * pending completion, which can then only report a screen notice and refresh the folder. + */ +internal class FileDeleteDialogState { + var target by mutableStateOf(null) + private set + var running by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + var retryBlocked by mutableStateOf(false) + private set + private var generation = 0L + + fun open(file: NextcloudFile) = reset(file) + + /** The delete continues if it is running; its completion no longer changes this dialog. */ + 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 { + error = "Refresh the folder before deleting this item." + return null + } + running = true + error = null + return FileDeleteRequest(generation, file, etag) + } + + fun complete(request: FileDeleteRequest, outcome: FileDeleteOutcome): FileDeleteScreenEffect { + val current = request.generation == generation + if (current) { + running = false + when (outcome) { + is FileDeleteOutcome.Deleted, FileDeleteOutcome.AlreadyGone -> reset(null) + is FileDeleteOutcome.NotDeleted -> error = outcome.message + is FileDeleteOutcome.RefreshRequired -> { + error = outcome.message + retryBlocked = true + } + } + } + return outcome.screenEffect(request.file.name, dialogShowsResult = current) + } + + /** Releases a request that ended without an outcome, such as a cancelled screen scope. */ + fun abandon(request: FileDeleteRequest) { + if (request.generation == generation) running = false + } + + private fun reset(file: NextcloudFile?) { + generation += 1 + target = file + running = false + error = null + retryBlocked = false + } +} + +/** + * Runs one delete for [request]. Cancellation propagates without changing the dialog beyond + * releasing its running state. + */ +internal suspend fun FileDeleteDialogState.runDelete( + request: FileDeleteRequest, + delete: suspend (NextcloudFileMutation.Delete) -> NextcloudFileMutationResult, + readFolder: suspend (String) -> NextcloudFileListing, +): FileDeleteScreenEffect = try { + val mutation = NextcloudFileMutation.Delete( + request.file.path, + request.expectedEtag, + sourceIsDirectory = request.file.isDirectory, + ) + val outcome = deleteFileWithVerifiedOutcome( + target = request.file, + expectedEtag = request.expectedEtag, + delete = { delete(mutation) }, + readParent = { readFolder(request.parentPath) }, + ) + // Reject even a non-cooperative service's completion after the caller is cancelled. + currentCoroutineContext().ensureActive() + complete(request, outcome) +} finally { + abandon(request) +} + +private fun FileDeleteOutcome.screenEffect(name: String, dialogShowsResult: Boolean): FileDeleteScreenEffect = + when (this) { + is FileDeleteOutcome.Deleted -> FileDeleteScreenEffect( + notice = when { + confirmedByRead -> "Deleted $name. The server response was interrupted, so the folder was checked." + localFollowUp == FileMutationLocalFollowUp.StillRunning -> + "Deleted $name. Local file status is still updating in the background." + localFollowUp == FileMutationLocalFollowUp.Failed -> + "Deleted $name. Local file status could not be updated; refresh if it still appears." + else -> "Deleted $name" + }, + reloadFolder = true, + ) + FileDeleteOutcome.AlreadyGone -> FileDeleteScreenEffect("$name is no longer on the server.", 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, + ) + } 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 d8b77adcd..834ff2ab9 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,24 @@ 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, +} + enum class NextcloudFileOperationError { AuthenticationRequired, PermissionDenied, 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 3e5d61c50..aad6f9604 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -6491,7 +6491,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(session, path, userId) { FileDeleteDialogState() } 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) } @@ -6717,7 +6717,7 @@ internal fun FilesScreen( } } FileMenuAction.Delete -> { - deleteTarget = file + deleteDialog.open(file) mutationError = null } FileMenuAction.Move, FileMenuAction.Copy -> { @@ -6936,7 +6936,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, @@ -7331,81 +7331,27 @@ 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, + deleteDialog.target?.let { target -> + FileDeleteDialog( + target = target, + running = deleteDialog.running, + error = deleteDialog.error, + retryBlocked = deleteDialog.retryBlocked, + onDismiss = deleteDialog::dismiss, + onConfirm = confirm@{ + val currentUserId = userId ?: return@confirm + val request = deleteDialog.begin() ?: return@confirm + scope.launch { + val effect = deleteDialog.runDelete( + request, + delete = { mutation -> services.executeFileMutation(session, currentUserId, mutation) }, + readFolder = { folder -> services.listFilesWithSource(session, currentUserId, folder) }, ) - 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)) + effect.notice?.let { mutationNotice = it } + if (effect.reloadFolder) { + files = null + loadAttempt += 1 } - Text(if (mutationRunning) "Deleting..." else "Delete") } }, ) 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..a59c80d03 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -0,0 +1,293 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +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 report = file("Documents/report.txt", etag = "v1") + + @Test + fun `confirmed delete closes the dialog and refreshes the folder`(): Unit = runBlocking { + val state = FileDeleteDialogState().apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { NextcloudFileMutationResult(null, null) }) + + val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) + + 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 state = FileDeleteDialogState().apply { open(folder) } + val fake = FakeDeleteBoundary( + deleteResult = { throw IllegalStateException("connection reset") }, + listing = { NextcloudFileListing(emptyList(), NextcloudFileListingSource.Network) }, + ) + + state.runDelete(state.beginOrFail(), fake::delete, fake::read) + + 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 if it still appears.", + )) { + val state = FileDeleteDialogState().apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { NextcloudFileMutationResult(null, null, followUp) }) + + val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) + + assertEquals(FileDeleteScreenEffect(notice, reloadFolder = true), effect) + 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 state = FileDeleteDialogState().apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(412) }) + + val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) + + assertEquals(FileDeleteScreenEffect(null, reloadFolder = false), effect) + assertEquals(report, state.target) + assertFalse(state.running) + assertEquals("The file or destination changed. Refresh and try again.", 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 `missing item closes the dialog without claiming this request deleted it`(): Unit = runBlocking { + val state = FileDeleteDialogState().apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(404) }) + + val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) + + assertEquals(FileDeleteScreenEffect("report.txt is no longer on the server.", true), effect) + assertNull(state.target) + } + + @Test + fun `unknown result is verified by reading the folder and never resends the delete`(): Unit = runBlocking { + val state = FileDeleteDialogState().apply { open(report) } + val fake = FakeDeleteBoundary( + deleteResult = { throw IllegalStateException("response interrupted") }, + listing = { NextcloudFileListing(listOf(file("Documents/other.txt")), NextcloudFileListingSource.Network) }, + ) + + val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) + + assertEquals( + FileDeleteScreenEffect( + "Deleted report.txt. The server response was interrupted, so the folder was checked.", + reloadFolder = true, + ), + effect, + ) + assertNull(state.target) + assertEquals(1, fake.deletes.size) + assertEquals(listOf("Documents"), fake.reads) + } + + @Test + fun `server failure is verified because a partial delete is possible`(): Unit = runBlocking { + val state = FileDeleteDialogState().apply { open(report) } + val fake = FakeDeleteBoundary( + deleteResult = { throw fileOperationException(503) }, + listing = { NextcloudFileListing(listOf(report), NextcloudFileListingSource.Network) }, + ) + + state.runDelete(state.beginOrFail(), fake::delete, fake::read) + + 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 state = FileDeleteDialogState().apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { throw IllegalStateException("reset") }, listing = listing) + + val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) + + assertEquals(FileDeleteScreenEffect(null, reloadFolder = true), effect) + assertTrue(state.retryBlocked) + assertNotNull(state.error) + assertFalse(state.running) + assertNull(state.begin(), "An unknown delete result must not be retried blindly.") + assertEquals(1, fake.deletes.size) + } + } + + @Test + fun `cancellation propagates and releases the dialog without an error or effect`(): Unit = runBlocking { + val state = FileDeleteDialogState().apply { open(report) } + val started = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + started.complete(Unit) + awaitCancellation() + }) + var effect: FileDeleteScreenEffect? = null + val request = state.beginOrFail() + + val job = launch { effect = state.runDelete(request, fake::delete, fake::read) } + started.await() + assertTrue(state.running) + job.cancel() + job.join() + + assertNull(effect) + assertEquals(report, state.target) + assertFalse(state.running) + assertNull(state.error) + assertTrue(fake.reads.isEmpty(), "Cancellation is not an unknown result to verify.") + } + + @Test + fun `cancelled noncooperative completion cannot change the dialog`(): Unit = runBlocking { + val state = FileDeleteDialogState().apply { open(report) } + val started = CompletableDeferred() + val release = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + withContext(NonCancellable) { + started.complete(Unit) + release.await() + } + NextcloudFileMutationResult(null, null) + }) + val request = state.beginOrFail() + + val job = launch { state.runDelete(request, fake::delete, fake::read) } + started.await() + job.cancel() + release.complete(Unit) + job.join() + + assertEquals(report, state.target) + assertFalse(state.running) + assertNull(state.error) + } + + @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 state = FileDeleteDialogState().apply { open(report) } + val release = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + release.await() + result() + }) + val request = state.beginOrFail() + var effect: FileDeleteScreenEffect? = null + val job = launch(start = CoroutineStart.UNDISPATCHED) { + effect = state.runDelete(request, 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) + job.join() + + assertEquals(expected, effect) + 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`() { + val state = FileDeleteDialogState().apply { open(report.copy(etag = " ")) } + + assertNull(state.begin()) + assertEquals("Refresh the folder before deleting this item.", state.error) + assertFalse(state.running) + } + + @Test + fun `a running delete cannot be started twice`() { + val state = FileDeleteDialogState().apply { open(report) } + + assertNotNull(state.begin()) + assertNull(state.begin()) + } + + private fun FileDeleteDialogState.beginOrFail(): FileDeleteRequest = assertNotNull(begin()) + + 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) = NextcloudFile( + path = path, + name = path.substringAfterLast('/'), + isDirectory = directory, + mimeType = if (directory) null else "text/plain", + size = 1, + lastModified = null, + fileId = null, + hasPreview = false, + etag = etag, + ) +} 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..577ec4848 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt @@ -0,0 +1,100 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +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. It + * runs in submission order on one background worker: a stalled lock occupies one thread instead of + * one per mutation, and later invalidations still apply after earlier ones. + */ +internal class DesktopFileMutationFollowUps( + private val scope: CoroutineScope, + private val completionWaitMillis: Long = DEFAULT_FILE_MUTATION_FOLLOW_UP_WAIT_MILLIS, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1), + private val recordDiagnostic: (accountId: String, SupportDiagnosticEventDraft) -> Unit = { _, _ -> }, +) { + init { + require(completionWaitMillis > 0L) + } + + /** + * Runs [work] after the server confirmed a mutation and waits at most [completionWaitMillis]. + * [work] returns the first local failure it recovered from, or null when every update applied. + */ + suspend fun afterConfirmedMutation(accountId: String, work: () -> Throwable?): FileMutationLocalFollowUp { + val pending = submit(accountId, work) + val followUp = try { + withTimeoutOrNull(completionWaitMillis) { pending.await() } + ?: FileMutationLocalFollowUp.StillRunning + } catch (cancelled: CancellationException) { + currentCoroutineContext().ensureActive() + // The service scope closed before the bookkeeping ran; the caller is still active. + FileMutationLocalFollowUp.Failed + } + if (followUp == FileMutationLocalFollowUp.StillRunning) { + recordOutcome(accountId, "still-running", "FILE_MUTATION_LOCAL_FOLLOW_UP_DELAYED") + } + return followUp + } + + /** Queues [work] after an unknown remote result without delaying the caller's failure. */ + fun afterUnknownResult(accountId: String, work: () -> Throwable?) { + submit(accountId, work) + } + + private fun submit( + accountId: String, + work: () -> Throwable?, + ): Deferred = scope.async(dispatcher) { + // Worker fault boundary: [work] is blocking and does not suspend, so it cannot observe + // cancellation; an unexpected failure is reported instead of escaping to the service scope. + val failure = try { + work() + } catch (failure: Exception) { + failure + } + if (failure == null) { + FileMutationLocalFollowUp.Completed + } else { + recordOutcome(accountId, "failed", "FILE_MUTATION_LOCAL_FOLLOW_UP_FAILED", failure) + FileMutationLocalFollowUp.Failed + } + } + + private fun recordOutcome(accountId: String, outcome: String, code: String, failure: Throwable? = null) { + try { + recordDiagnostic( + accountId, + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Files, + operation = "files.mutation-local-follow-up", + outcome = outcome, + code = code, + exception = failure?.toSupportDiagnosticExceptionDraft(), + ), + ) + } catch (diagnosticFailure: Exception) { + // Diagnostics must never change the reported mutation result. + failure?.addSuppressed(diagnosticFailure) + } + } +} + +/** + * 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 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 50184f8d4..faf0fc735 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1246,14 +1246,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, @@ -1262,12 +1263,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( @@ -1340,6 +1340,7 @@ 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) } private var backgroundFileSyncJob: Job? = null private val mutableFileSyncTraySnapshot = MutableStateFlow( DesktopFileSyncTraySnapshot( @@ -4832,13 +4833,11 @@ 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) - } - } + fun refreshAffectedLocalState(): Throwable? { + val sourceFailure = refreshRetainedFoldersAfterMutation(session, userId, accountId, spec.sourcePath, cacheProducer) + return spec.destinationPath?.let { destination -> + refreshRetainedFoldersAfterMutation(session, userId, accountId, destination, cacheProducer) + }.let { destinationFailure -> sourceFailure ?: destinationFailure } } val response = request( method = spec.method, @@ -4847,11 +4846,11 @@ class DesktopNextcloudServices( headers = headers, maxResponseBytes = 64 * 1024, mutationExecutor = fileMutationHttpExecutor, - onAmbiguousMutationResult = ::invalidateAffectedMetadata, + onAmbiguousMutationResult = { fileMutationFollowUps.afterUnknownResult(accountId, ::refreshAffectedLocalState) }, ) if (response.status !in 200..299) throw fileOperationException(response.status, spec.sourceIsDirectory) - invalidateAffectedMetadata() - NextcloudFileMutationResult(spec.destinationPath, response.etag) + val localFollowUp = fileMutationFollowUps.afterConfirmedMutation(accountId, ::refreshAffectedLocalState) + NextcloudFileMutationResult(spec.destinationPath, response.etag, localFollowUp) } 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..f69d2f199 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt @@ -0,0 +1,150 @@ +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.cancel +import kotlinx.coroutines.cancelAndJoin +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.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 is reported as completed`(): Unit = runBlocking { + val followUps = followUps() + + assertEquals(FileMutationLocalFollowUp.Completed, followUps.afterConfirmedMutation("account") { null }) + 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") { IllegalStateException("cache unavailable") } + val thrown = followUps.afterConfirmedMutation("account") { throw IllegalStateException("index locked") } + + 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") { + // 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 `work stays ordered behind a stalled update and unknown results never wait`(): Unit = runBlocking { + val followUps = followUps(waitMillis = 50L) + val release = latch() + val order = Collections.synchronizedList(mutableListOf()) + val laterRan = CountDownLatch(1) + + followUps.afterConfirmedMutation("account") { + release.awaitUninterruptibly() + order += "first" + null + } + withTimeout(1_000L) { + followUps.afterUnknownResult("account") { + order += "second" + laterRan.countDown() + null + } + } + + assertTrue(order.isEmpty(), "Later invalidations must not overtake an earlier one.") + release.countDown() + assertTrue(laterRan.await(5, TimeUnit.SECONDS)) + assertEquals(listOf("first", "second"), order.toList()) + } + + @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") { + 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") { null }) + } + + private fun followUps(waitMillis: Long = 5_000L) = DesktopFileMutationFollowUps( + scope = scope, + completionWaitMillis = waitMillis, + 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() + } +} From b0bf8896fd015f1607b1e0f733419c0b199fadfa Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:37:50 +0000 Subject: [PATCH 02/30] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 5976f9d17..24f98f2ec 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -107,6 +107,8 @@ "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/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", @@ -604,13 +606,15 @@ "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/FileDeleteDialog.kt": "b70e3b4fa9754b6af6bdae92e70f6d5392560e9e70c99794463b126cc33b307b", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "c864f7f1ccd1d683fd426f8021782fc2e857a8789051c9fac417216ebf792f74", "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/FileOfflineQueue.kt": "51fced0946f36654515a8a3b2a827f564e61e7626d4fdd5f194139d6aeaddaf9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueueSnapshot.kt": "1fb930db8f65e0e410af6eaacde0c4f3d071f4115c39f78a4f49a9532b9f7961", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "4df6de9cd0eb7f4ecb1e06a0f652d143f89b7d79c35444c508ce7857f7b36287", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "54aae5047f22f9fec79eb0d4e741d413a7e88a09fc0a58b946f8360a247755b2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileShareCreationFields.kt": "ae14656030b9c107907408209d07ed352a8307d577d02f912ee2f9ab8bbe330d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileShareDialog.kt": "0a0d071a79c18f12bb0bac2b026aed3901a3a68a13b8ee0d01b3dcd442cb2007", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileShareRecipientPicker.kt": "a1546f8d37ccae4bfbebcbb6c2776c4ba649eb6d49cc1b006fee013d5dbccfd2", @@ -736,7 +740,7 @@ "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": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "526914a022c8785935bfedde38d6df93deab0c3736e81f4612f893153d76463d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "39bb365faf668e6c93e6c5f63f78a2205ff4b2c8703e2ad00dee5e4134f2e51a", "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", From 374104f0dc3b3f0ea0187a0f702d09a30b3e9def Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 8 Oct 2026 19:12:17 +0200 Subject: [PATCH 03/30] fix(files): block stale, throttled, and overlapping deletes A 412 from an ETag-guarded delete now requires a folder refresh before another attempt, since resending the same precondition cannot succeed. HTTP 429 is a typed throttled file error. A throttled delete is not treated as an unknown result, starts no verification read or folder reload, and keeps retry blocked. File mutations do not expose Retry-After, so no delay is invented. Unresolved deletes are tracked per account and remote path outside the dialog. Closing and reopening Delete no longer allows a second request for the same item until the first reaches a verified outcome, and a reopened dialog for the same version receives that outcome. --- .../477-delete-dialog-completion.md | 2 +- tools/kotlin-file-size-baseline.txt | 2 +- .../nextcloudnative/app/FileDeleteFlow.kt | 98 ++++++++--- .../nextcloudnative/app/FileOperations.kt | 5 + .../nextcloudnative/app/NextcloudNativeApp.kt | 8 +- .../nextcloudnative/app/FileDeleteFlowTest.kt | 164 ++++++++++++++++-- .../nextcloudnative/app/FileOperationsTest.kt | 1 + 7 files changed, 237 insertions(+), 43 deletions(-) diff --git a/changes/unreleased/477-delete-dialog-completion.md b/changes/unreleased/477-delete-dialog-completion.md index 4a2cebfa8..ae2caed0c 100644 --- a/changes/unreleased/477-delete-dialog-completion.md +++ b/changes/unreleased/477-delete-dialog-completion.md @@ -4,4 +4,4 @@ pull: none platforms: android, desktop user-facing: yes -Files deletes always finish: stalled local virtual-file updates no longer hold the dialog, and interrupted responses are checked on the server instead of retried. +Files deletes always finish: stalled local virtual-file updates no longer hold the dialog, interrupted responses are checked on the server, and stale, throttled, or still-running deletes are never resent. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 5703b7027..22301395a 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -21,7 +21,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface. 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|11033 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|11031 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 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt index e906e8fc9..14be5db9d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative.app import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import kotlinx.coroutines.CancellationException @@ -24,8 +25,17 @@ internal sealed interface FileDeleteOutcome { /** The item is verified to be unchanged on the server, so trying again is safe. */ data class NotDeleted(val message: String) : FileDeleteOutcome - /** The outcome is unknown or the item changed. The folder must be refreshed before another try. */ + /** + * 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 } /** @@ -50,9 +60,15 @@ internal suspend fun deleteFileWithVerifiedOutcome( when (failure.error) { NextcloudFileOperationError.NotFound -> return FileDeleteOutcome.AlreadyGone 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, - NextcloudFileOperationError.Conflict, NextcloudFileOperationError.Locked, NextcloudFileOperationError.InsufficientStorage, -> return FileDeleteOutcome.NotDeleted(failure.message ?: "Could not delete ${target.name}.") @@ -88,8 +104,33 @@ private suspend fun verifyFileDeletePostcondition( } } +/** One remote resource in one account. Deletes for the same key never overlap. */ +internal data class FileDeleteResourceKey(val account: NextcloudAccountId, val path: String) + +/** + * Account-scoped Files deletes that have not reached a verified outcome. It outlives any dialog, + * so closing and reopening Delete cannot send a second request while the first is unresolved. + */ +internal class FileDeleteInFlight { + private val pending = mutableStateMapOf() + private var nextToken = 0L + + operator fun contains(key: FileDeleteResourceKey): Boolean = key in pending + + fun claim(key: FileDeleteResourceKey): Long? { + if (key in pending) return null + return (++nextToken).also { pending[key] = it } + } + + fun release(key: FileDeleteResourceKey, token: Long) { + if (pending[key] == token) pending.remove(key) + } +} + internal data class FileDeleteRequest( val generation: Long, + val key: FileDeleteResourceKey, + val token: Long, val file: NextcloudFile, val expectedEtag: String, ) { @@ -101,22 +142,28 @@ internal data class FileDeleteScreenEffect(val notice: String?, val reloadFolder /** * Main-thread owner of the Files delete dialog. Closing or replacing the dialog invalidates its - * pending completion, which can then only report a screen notice and refresh the folder. + * pending completion, which then only reports a screen notice and refreshes the folder, unless a + * reopened dialog shows the same version of the same item. */ -internal class FileDeleteDialogState { +internal class FileDeleteDialogState( + private val account: NextcloudAccountId, + private val inFlight: FileDeleteInFlight, +) { var target by mutableStateOf(null) private set - var running by mutableStateOf(false) - private set var error by mutableStateOf(null) private set var retryBlocked by mutableStateOf(false) private set private var generation = 0L + /** True while any delete of the shown item is unresolved, including one from a closed dialog. */ + val running: Boolean + get() = target?.let { key(it) in inFlight } == true + fun open(file: NextcloudFile) = reset(file) - /** The delete continues if it is running; its completion no longer changes this dialog. */ + /** The delete continues if it is running; it still blocks another delete of the same item. */ fun dismiss() = reset(null) fun begin(): FileDeleteRequest? { @@ -126,36 +173,40 @@ internal class FileDeleteDialogState { error = "Refresh the folder before deleting this item." return null } - running = true + val key = key(file) + val token = inFlight.claim(key) ?: return null error = null - return FileDeleteRequest(generation, file, etag) + return FileDeleteRequest(generation, key, token, file, etag) } fun complete(request: FileDeleteRequest, outcome: FileDeleteOutcome): FileDeleteScreenEffect { - val current = request.generation == generation + val shown = target + val current = request.generation == generation || + (shown != null && key(shown) == request.key && shown.etag == request.expectedEtag) if (current) { - running = false when (outcome) { is FileDeleteOutcome.Deleted, FileDeleteOutcome.AlreadyGone -> reset(null) is FileDeleteOutcome.NotDeleted -> error = outcome.message - is FileDeleteOutcome.RefreshRequired -> { - error = outcome.message - retryBlocked = true - } + is FileDeleteOutcome.RefreshRequired -> blockRetry(outcome.message) + is FileDeleteOutcome.Throttled -> blockRetry(outcome.message) } } return outcome.screenEffect(request.file.name, dialogShowsResult = current) } - /** Releases a request that ended without an outcome, such as a cancelled screen scope. */ - fun abandon(request: FileDeleteRequest) { - if (request.generation == generation) running = false + /** Releases the resource once the request ended, with or without an outcome. */ + fun release(request: FileDeleteRequest) = inFlight.release(request.key, request.token) + + private fun key(file: NextcloudFile) = FileDeleteResourceKey(account, file.path) + + private fun blockRetry(message: String) { + error = message + retryBlocked = true } private fun reset(file: NextcloudFile?) { generation += 1 target = file - running = false error = null retryBlocked = false } @@ -163,7 +214,7 @@ internal class FileDeleteDialogState { /** * Runs one delete for [request]. Cancellation propagates without changing the dialog beyond - * releasing its running state. + * releasing the resource. */ internal suspend fun FileDeleteDialogState.runDelete( request: FileDeleteRequest, @@ -185,7 +236,7 @@ internal suspend fun FileDeleteDialogState.runDelete( currentCoroutineContext().ensureActive() complete(request, outcome) } finally { - abandon(request) + release(request) } private fun FileDeleteOutcome.screenEffect(name: String, dialogShowsResult: Boolean): FileDeleteScreenEffect = @@ -210,4 +261,9 @@ private fun FileDeleteOutcome.screenEffect(name: String, dialogShowsResult: Bool notice = if (dialogShowsResult) null else message, reloadFolder = true, ) + // A folder reload is another request; leave it to the user after the rate limit. + is FileDeleteOutcome.Throttled -> FileDeleteScreenEffect( + notice = if (dialogShowsResult) null else "$name was not deleted. $message", + reloadFolder = false, + ) } 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 834ff2ab9..21ca24eb7 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt @@ -93,6 +93,9 @@ enum class NextcloudFileOperationError { Conflict, Locked, InsufficientStorage, + + /** The server rejected the request under its rate limit; nothing was changed. */ + Throttled, ServerFailure, } @@ -144,6 +147,7 @@ fun fileOperationException(status: Int, sourceIsDirectory: Boolean = false): Nex 404 -> NextcloudFileOperationError.NotFound 405, 409, 412 -> NextcloudFileOperationError.Conflict 423 -> NextcloudFileOperationError.Locked + 429 -> NextcloudFileOperationError.Throttled 507 -> NextcloudFileOperationError.InsufficientStorage else -> NextcloudFileOperationError.ServerFailure } @@ -157,6 +161,7 @@ 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) 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 aad6f9604..f180ce6f6 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -6491,7 +6491,8 @@ 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) } - val deleteDialog = remember(session, path, userId) { FileDeleteDialogState() } + val deletesInFlight = remember(session, userId) { FileDeleteInFlight() } + val deleteDialog = remember(session, path, userId) { FileDeleteDialogState(session.accountId, deletesInFlight) } 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) } @@ -7348,10 +7349,7 @@ internal fun FilesScreen( readFolder = { folder -> services.listFilesWithSource(session, currentUserId, folder) }, ) effect.notice?.let { mutationNotice = it } - if (effect.reloadFolder) { - files = null - loadAttempt += 1 - } + if (effect.reloadFolder) { files = null; loadAttempt += 1 } } }, ) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt index a59c80d03..d99b15ab3 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -19,7 +19,7 @@ class FileDeleteFlowTest { @Test fun `confirmed delete closes the dialog and refreshes the folder`(): Unit = runBlocking { - val state = FileDeleteDialogState().apply { open(report) } + val state = newState().apply { open(report) } val fake = FakeDeleteBoundary(deleteResult = { NextcloudFileMutationResult(null, null) }) val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) @@ -34,7 +34,7 @@ class FileDeleteFlowTest { @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 state = FileDeleteDialogState().apply { open(folder) } + val state = newState().apply { open(folder) } val fake = FakeDeleteBoundary( deleteResult = { throw IllegalStateException("connection reset") }, listing = { NextcloudFileListing(emptyList(), NextcloudFileListingSource.Network) }, @@ -55,7 +55,7 @@ class FileDeleteFlowTest { FileMutationLocalFollowUp.Failed to "Deleted report.txt. Local file status could not be updated; refresh if it still appears.", )) { - val state = FileDeleteDialogState().apply { open(report) } + val state = newState().apply { open(report) } val fake = FakeDeleteBoundary(deleteResult = { NextcloudFileMutationResult(null, null, followUp) }) val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) @@ -68,24 +68,151 @@ class FileDeleteFlowTest { @Test fun `definitive server rejection stays in the dialog and allows a safe retry`(): Unit = runBlocking { - val state = FileDeleteDialogState().apply { open(report) } - val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(412) }) + val state = newState().apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(423) }) val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) assertEquals(FileDeleteScreenEffect(null, reloadFolder = false), effect) assertEquals(report, state.target) assertFalse(state.running) - assertEquals("The file or destination changed. Refresh and try again.", state.error) + 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 state = newState().apply { open(item) } + val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(412, directory) }) + + val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) + + assertEquals(FileDeleteScreenEffect(null, reloadFolder = true), effect) + 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 state = newState().apply { open(report) } + val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(429) }) + + val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) + + 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 `throttled completion after the dialog closed reports without a reload`(): Unit = runBlocking { + val state = newState().apply { open(report) } + val release = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + release.await() + throw fileOperationException(429) + }) + val request = state.beginOrFail() + var effect: FileDeleteScreenEffect? = null + val job = launch(start = CoroutineStart.UNDISPATCHED) { effect = state.runDelete(request, fake::delete, fake::read) } + + state.dismiss() + release.complete(Unit) + job.join() + + assertEquals( + FileDeleteScreenEffect( + "report.txt was not deleted. The server is limiting requests. Wait a while, then refresh and try again.", + reloadFolder = false, + ), + effect, + ) + assertTrue(fake.reads.isEmpty()) + } + + @Test + fun `closing a running dialog keeps the item blocked until the delete is verified`(): Unit = runBlocking { + val inFlight = FileDeleteInFlight() + val state = newState(inFlight).apply { open(report) } + val release = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + release.await() + NextcloudFileMutationResult(null, null) + }) + val request = state.beginOrFail() + var effect: FileDeleteScreenEffect? = null + val job = launch(start = CoroutineStart.UNDISPATCHED) { effect = state.runDelete(request, 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) + job.join() + + assertEquals(FileDeleteScreenEffect("Deleted report.txt", reloadFolder = true), effect) + assertNull(state.target, "The reopened dialog for the same version receives the verified result.") + assertFalse(FileDeleteResourceKey(account, report.path) in inFlight) + assertEquals(1, fake.deletes.size) + } + + @Test + fun `verified rejection releases the item for a reopened dialog of another version`(): Unit = runBlocking { + val state = newState().apply { open(report) } + val release = CompletableDeferred() + val fake = FakeDeleteBoundary(deleteResult = { + release.await() + throw fileOperationException(423) + }) + val request = state.beginOrFail() + val job = launch(start = CoroutineStart.UNDISPATCHED) { state.runDelete(request, fake::delete, fake::read) } + + state.dismiss() + val refreshed = report.copy(etag = "v2") + state.open(refreshed) + assertTrue(state.running) + release.complete(Unit) + job.join() + + 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 `in-flight deletes are scoped to the account and the exact resource`() { + val inFlight = FileDeleteInFlight() + val first = newState(inFlight).apply { open(report) } + val otherAccount = newState(inFlight, NextcloudSession("https://example.invalid", "bob", "x").accountId) + .apply { open(report) } + val sibling = newState(inFlight).apply { open(file("Documents/other.txt")) } + + assertNotNull(first.begin()) + assertFalse(otherAccount.running) + assertNotNull(otherAccount.begin()) + assertNotNull(sibling.begin()) + } + @Test fun `missing item closes the dialog without claiming this request deleted it`(): Unit = runBlocking { - val state = FileDeleteDialogState().apply { open(report) } + val state = newState().apply { open(report) } val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(404) }) val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) @@ -96,7 +223,7 @@ class FileDeleteFlowTest { @Test fun `unknown result is verified by reading the folder and never resends the delete`(): Unit = runBlocking { - val state = FileDeleteDialogState().apply { open(report) } + val state = newState().apply { open(report) } val fake = FakeDeleteBoundary( deleteResult = { throw IllegalStateException("response interrupted") }, listing = { NextcloudFileListing(listOf(file("Documents/other.txt")), NextcloudFileListingSource.Network) }, @@ -118,7 +245,7 @@ class FileDeleteFlowTest { @Test fun `server failure is verified because a partial delete is possible`(): Unit = runBlocking { - val state = FileDeleteDialogState().apply { open(report) } + val state = newState().apply { open(report) } val fake = FakeDeleteBoundary( deleteResult = { throw fileOperationException(503) }, listing = { NextcloudFileListing(listOf(report), NextcloudFileListingSource.Network) }, @@ -139,7 +266,7 @@ class FileDeleteFlowTest { { NextcloudFileListing(listOf(report.copy(etag = "v2")), NextcloudFileListingSource.Network) }, ) for (listing in unverifiable) { - val state = FileDeleteDialogState().apply { open(report) } + val state = newState().apply { open(report) } val fake = FakeDeleteBoundary(deleteResult = { throw IllegalStateException("reset") }, listing = listing) val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) @@ -155,7 +282,7 @@ class FileDeleteFlowTest { @Test fun `cancellation propagates and releases the dialog without an error or effect`(): Unit = runBlocking { - val state = FileDeleteDialogState().apply { open(report) } + val state = newState().apply { open(report) } val started = CompletableDeferred() val fake = FakeDeleteBoundary(deleteResult = { started.complete(Unit) @@ -179,7 +306,7 @@ class FileDeleteFlowTest { @Test fun `cancelled noncooperative completion cannot change the dialog`(): Unit = runBlocking { - val state = FileDeleteDialogState().apply { open(report) } + val state = newState().apply { open(report) } val started = CompletableDeferred() val release = CompletableDeferred() val fake = FakeDeleteBoundary(deleteResult = { @@ -215,7 +342,7 @@ class FileDeleteFlowTest { reloadFolder = false, ), )) { - val state = FileDeleteDialogState().apply { open(report) } + val state = newState().apply { open(report) } val release = CompletableDeferred() val fake = FakeDeleteBoundary(deleteResult = { release.await() @@ -244,7 +371,7 @@ class FileDeleteFlowTest { @Test fun `delete without a current version asks for a refresh instead of sending`() { - val state = FileDeleteDialogState().apply { open(report.copy(etag = " ")) } + val state = newState().apply { open(report.copy(etag = " ")) } assertNull(state.begin()) assertEquals("Refresh the folder before deleting this item.", state.error) @@ -253,12 +380,19 @@ class FileDeleteFlowTest { @Test fun `a running delete cannot be started twice`() { - val state = FileDeleteDialogState().apply { open(report) } + val state = newState().apply { open(report) } assertNotNull(state.begin()) assertNull(state.begin()) } + private val account = NextcloudSession("https://example.invalid", "alice", "x").accountId + + private fun newState( + inFlight: FileDeleteInFlight = FileDeleteInFlight(), + accountId: NextcloudAccountId = account, + ) = FileDeleteDialogState(accountId, inFlight) + private fun FileDeleteDialogState.beginOrFail(): FileDeleteRequest = assertNotNull(begin()) private class FakeDeleteBoundary( 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 1dad7b05f..94e287fb4 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileOperationsTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileOperationsTest.kt @@ -102,6 +102,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) } From 042ee2664f821eb43580ae2c77463963bc0ea8e5 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:41:13 +0000 Subject: [PATCH 04/30] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 24f98f2ec..4a8afe67e 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -607,14 +607,14 @@ "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/FileDeleteDialog.kt": "b70e3b4fa9754b6af6bdae92e70f6d5392560e9e70c99794463b126cc33b307b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "c864f7f1ccd1d683fd426f8021782fc2e857a8789051c9fac417216ebf792f74", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "cffbc353d49c20342046b598e2eaead8cbf5b3d810739826dd120b2019e608eb", "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/FileOfflineQueue.kt": "51fced0946f36654515a8a3b2a827f564e61e7626d4fdd5f194139d6aeaddaf9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueueSnapshot.kt": "1fb930db8f65e0e410af6eaacde0c4f3d071f4115c39f78a4f49a9532b9f7961", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "54aae5047f22f9fec79eb0d4e741d413a7e88a09fc0a58b946f8360a247755b2", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "0f28e0b8446bdc81da464c205678b1df2342cd28b0461b943e042418236763fe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileShareCreationFields.kt": "ae14656030b9c107907408209d07ed352a8307d577d02f912ee2f9ab8bbe330d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileShareDialog.kt": "0a0d071a79c18f12bb0bac2b026aed3901a3a68a13b8ee0d01b3dcd442cb2007", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileShareRecipientPicker.kt": "a1546f8d37ccae4bfbebcbb6c2776c4ba649eb6d49cc1b006fee013d5dbccfd2", @@ -740,7 +740,7 @@ "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": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "39bb365faf668e6c93e6c5f63f78a2205ff4b2c8703e2ad00dee5e4134f2e51a", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "4fa92d4d0935d9f722d4a82d0b4c40d1aedc968207287adb381d7adc02fc4af6", "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", From b7a6555f5d5709a56e638df24f3ed0f9bb8efbdd Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 8 Oct 2026 20:30:09 +0200 Subject: [PATCH 05/30] fix(files): own deletes per account session and bound local follow-ups Files deletes now run in an account-session coordinator instead of the Files screen's coroutine scope. Leaving the dialog or the screen no longer cancels a delete or releases its resource before a verified outcome; a returning Files screen applies the finished outcome, and only account session teardown cancels unfinished work. While a delete is unverified, every remote write to that item, its folders, and its children is disabled in the file menu with a reason and rejected by the Files action handler. A parent listing that no longer contains the path after an unknown result is reported as no longer in this folder, not as deleted. When the same file ID appears under another name in that listing, the result requires a refresh. Desktop local follow-ups are coalesced per account and path behind one drain worker, and at most 256 paths wait behind a stalled refresh. Paths beyond that bound are reported as a local failure instead of growing an unbounded queue. --- .../477-delete-dialog-completion.md | 2 +- tools/kotlin-file-size-baseline.txt | 4 +- .../nextcloudnative/app/FileActionMenu.kt | 2 + .../nextcloudnative/app/FileActionPlanning.kt | 22 +- .../app/FileDeleteCoordinator.kt | 180 +++++++ .../nextcloudnative/app/FileDeleteDialog.kt | 32 ++ .../nextcloudnative/app/FileDeleteFlow.kt | 189 ++------ .../nextcloudnative/app/FilesWorkspace.kt | 6 + .../app/FilesWorkspaceInspector.kt | 2 + .../app/FilesWorkspaceItems.kt | 6 + .../nextcloudnative/app/NextcloudNativeApp.kt | 41 +- .../app/FileActionPlanningTest.kt | 24 + .../nextcloudnative/app/FileDeleteFlowTest.kt | 446 ++++++++++-------- .../app/captures/MarketingAppCapture.kt | 2 + .../app/DesktopFileMutationFollowUps.kt | 141 ++++-- .../app/DesktopNextcloudServices.kt | 12 +- .../app/DesktopFileMutationFollowUpsTest.kt | 101 +++- 17 files changed, 775 insertions(+), 437 deletions(-) create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt diff --git a/changes/unreleased/477-delete-dialog-completion.md b/changes/unreleased/477-delete-dialog-completion.md index ae2caed0c..fd8d7ccbc 100644 --- a/changes/unreleased/477-delete-dialog-completion.md +++ b/changes/unreleased/477-delete-dialog-completion.md @@ -4,4 +4,4 @@ pull: none platforms: android, desktop user-facing: yes -Files deletes always finish: stalled local virtual-file updates no longer hold the dialog, interrupted responses are checked on the server, and stale, throttled, or still-running deletes are never resent. +Files deletes always finish, even after leaving Files: stalled local updates no longer hold the dialog, interrupted responses are checked on the server, and other changes to the item wait until the delete is verified. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 22301395a..f8b6e0ab3 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -21,7 +21,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface. 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|11031 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|11026 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|884 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|6164 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..5d7c41736 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, + writesBlocked: Boolean = false, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, expanded: Boolean, @@ -37,6 +38,7 @@ internal fun FileActionMenu( discoverDocumentEditing = true, ), offlineState = offlineAvailability.toFileActionOfflineState(), + writesBlocked = writesBlocked, ).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..5e6081d07 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, + writesBlocked: Boolean = false, ): FileLongPressActionPlan { val plan = planFileActions(file, support, offlineState) return FileLongPressActionPlan( plan.actions.map { action -> - if (!action.enabled || action.action in filesScreenImplementedActions) { + if (writesBlocked && action.enabled && action.action in fileWriteActions) { + action.copy(enabled = false, disabledReason = "Wait until the delete of this item or its folder finishes.") + } 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..ec9747e98 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt @@ -0,0 +1,180 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch + +/** One remote resource in one account. */ +internal data class FileDeleteResourceKey(val account: NextcloudAccountId, val path: String) { + fun overlaps(other: FileDeleteResourceKey): Boolean = account == other.account && ( + path == other.path || path.startsWith("${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) } + + 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) + } +} + +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 session. A delete and its verification outlive the dialog and + * the Files screen, so leaving either never cancels the request or releases the resource without + * an outcome. Finished outcomes wait until a Files screen consumes them. Only [close], at account + * session teardown, cancels unfinished work. + */ +internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: CoroutineScope) { + val inFlight = FileDeleteInFlight() + private val job = SupervisorJob(parent.coroutineContext[Job]) + private val scope = CoroutineScope(parent.coroutineContext + job) + private val completions = mutableStateListOf() + + val nextCompletion: FileDeleteCompletion? get() = completions.firstOrNull() + + /** Other writes to [file] must wait while a delete of it, a parent, or a child is unverified. */ + fun blocksWrites(file: NextcloudFile): Boolean = inFlight.blocks(keyFor(file)) + + fun keyFor(file: NextcloudFile) = FileDeleteResourceKey(account, file.path) + + fun start( + request: FileDeleteRequest, + delete: suspend (NextcloudFileMutation.Delete) -> NextcloudFileMutationResult, + readFolder: suspend (String) -> NextcloudFileListing, + ): Job = scope.launch { + try { + val mutation = NextcloudFileMutation.Delete( + request.file.path, + request.expectedEtag, + sourceIsDirectory = request.file.isDirectory, + ) + val outcome = deleteFileWithVerifiedOutcome( + target = request.file, + expectedEtag = request.expectedEtag, + delete = { delete(mutation) }, + readParent = { readFolder(request.parentPath) }, + ) + // Reject even a non-cooperative service's completion after session teardown. + ensureActive() + completions += FileDeleteCompletion(request, outcome) + } finally { + inFlight.release(request.key, request.token) + } + } + + /** Claims [completion] for one screen; false when another screen already applied it. */ + fun consume(completion: FileDeleteCompletion): Boolean = completions.remove(completion) + + fun close() = job.cancel() +} + +@Composable +internal fun rememberFileDeleteCoordinator(session: NextcloudSession): FileDeleteCoordinator { + val parent = rememberCoroutineScope() + val coordinator = remember(session, parent) { FileDeleteCoordinator(session.accountId, parent) } + DisposableEffect(coordinator) { onDispose(coordinator::close) } + return coordinator +} + +/** + * 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 + var error by mutableStateOf(null) + private set + var retryBlocked by mutableStateOf(false) + private set + private var attachedToken: Long? = 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 { + error = "Refresh the folder before deleting this item." + return null + } + val key = deletes.keyFor(file) + val token = deletes.inFlight.claim(key) ?: run { + error = "Another delete in this location is still finishing. Try again when it completes." + return null + } + error = 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 -> error = outcome.message + is FileDeleteOutcome.RefreshRequired -> blockRetry(outcome.message) + is FileDeleteOutcome.Throttled -> blockRetry(outcome.message) + } + } + return outcome.screenEffect(request.file.name, dialogShowsResult = current) + } + + private fun blockRetry(message: String) { + error = message + retryBlocked = true + } + + private fun reset(file: NextcloudFile?) { + attachedToken = null + target = file + error = null + retryBlocked = 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 index 453a0371b..53d79977e 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt @@ -12,10 +12,42 @@ 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 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, + delete: suspend (NextcloudFileMutation.Delete) -> NextcloudFileMutationResult, + readFolder: suspend (String) -> NextcloudFileListing, + onEffect: (FileDeleteScreenEffect) -> Unit, +) { + val currentOnEffect by rememberUpdatedState(onEffect) + val completion = deletes.nextCompletion + LaunchedEffect(deletes, completion) { + if (completion != null && deletes.consume(completion)) currentOnEffect(dialog.complete(completion)) + } + 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( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt index 14be5db9d..e91042e74 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt @@ -1,26 +1,17 @@ package dev.obiente.nextcloudnative.app -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive /** 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 server confirmed the delete, or a fresh server listing showed the item is gone after - * an unknown result. [localFollowUp] is null when the local update was not reported. + * 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. */ - data class Deleted( - val confirmedByRead: Boolean, - val localFollowUp: FileMutationLocalFollowUp?, - ) : FileDeleteOutcome - - /** The server reported that the item no longer exists. */ - data object AlreadyGone : FileDeleteOutcome + data class NoLongerAtLocation(val afterUnknownResult: Boolean) : FileDeleteOutcome /** The item is verified to be unchanged on the server, so trying again is safe. */ data class NotDeleted(val message: String) : FileDeleteOutcome @@ -49,8 +40,7 @@ internal suspend fun deleteFileWithVerifiedOutcome( readParent: suspend () -> NextcloudFileListing, ): FileDeleteOutcome { val failure = try { - val result = delete() - return FileDeleteOutcome.Deleted(confirmedByRead = false, localFollowUp = result.localFollowUp) + return FileDeleteOutcome.Deleted(delete().localFollowUp) } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { @@ -58,7 +48,7 @@ internal suspend fun deleteFileWithVerifiedOutcome( } if (failure is NextcloudFileOperationException) { when (failure.error) { - NextcloudFileOperationError.NotFound -> return FileDeleteOutcome.AlreadyGone + NextcloudFileOperationError.NotFound -> return FileDeleteOutcome.NoLongerAtLocation(afterUnknownResult = false) NextcloudFileOperationError.ServerFailure -> Unit // Resending the same ETag precondition cannot succeed. NextcloudFileOperationError.Conflict -> return FileDeleteOutcome.RefreshRequired( @@ -94,7 +84,15 @@ private suspend fun verifyFileDeletePostcondition( } if (listing.source != NextcloudFileListingSource.Network) return unconfirmed val current = listing.files.firstOrNull { it.path == target.path } - ?: return FileDeleteOutcome.Deleted(confirmedByRead = true, localFollowUp = null) + 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) + } + } return if (current.isDirectory == target.isDirectory && current.etag == expectedEtag) { FileDeleteOutcome.NotDeleted("The delete did not finish. ${target.name} is still on the server.") } else { @@ -104,155 +102,30 @@ private suspend fun verifyFileDeletePostcondition( } } -/** One remote resource in one account. Deletes for the same key never overlap. */ -internal data class FileDeleteResourceKey(val account: NextcloudAccountId, val path: String) - -/** - * Account-scoped Files deletes that have not reached a verified outcome. It outlives any dialog, - * so closing and reopening Delete cannot send a second request while the first is unresolved. - */ -internal class FileDeleteInFlight { - private val pending = mutableStateMapOf() - private var nextToken = 0L - - operator fun contains(key: FileDeleteResourceKey): Boolean = key in pending - - fun claim(key: FileDeleteResourceKey): Long? { - if (key in pending) return null - return (++nextToken).also { pending[key] = it } - } - - fun release(key: FileDeleteResourceKey, token: Long) { - if (pending[key] == token) pending.remove(key) - } -} - -internal data class FileDeleteRequest( - val generation: Long, - val key: FileDeleteResourceKey, - val token: Long, - val file: NextcloudFile, - val expectedEtag: String, -) { - val parentPath: String get() = file.path.substringBeforeLast('/', missingDelimiterValue = "") -} - /** Screen effects of a finished delete. They apply even after its dialog was closed. */ internal data class FileDeleteScreenEffect(val notice: String?, val reloadFolder: Boolean) -/** - * Main-thread owner of the Files delete dialog. Closing or replacing the dialog invalidates its - * pending completion, which then only reports a screen notice and refreshes the folder, unless a - * reopened dialog shows the same version of the same item. - */ -internal class FileDeleteDialogState( - private val account: NextcloudAccountId, - private val inFlight: FileDeleteInFlight, -) { - var target by mutableStateOf(null) - private set - var error by mutableStateOf(null) - private set - var retryBlocked by mutableStateOf(false) - private set - private var generation = 0L - - /** True while any delete of the shown item is unresolved, including one from a closed dialog. */ - val running: Boolean - get() = target?.let { key(it) in inFlight } == true - - fun open(file: NextcloudFile) = reset(file) - - /** The delete continues if it is running; it still blocks another delete of the same 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 { - error = "Refresh the folder before deleting this item." - return null - } - val key = key(file) - val token = inFlight.claim(key) ?: return null - error = null - return FileDeleteRequest(generation, key, token, file, etag) - } - - fun complete(request: FileDeleteRequest, outcome: FileDeleteOutcome): FileDeleteScreenEffect { - val shown = target - val current = request.generation == generation || - (shown != null && key(shown) == request.key && shown.etag == request.expectedEtag) - if (current) { - when (outcome) { - is FileDeleteOutcome.Deleted, FileDeleteOutcome.AlreadyGone -> reset(null) - is FileDeleteOutcome.NotDeleted -> error = outcome.message - is FileDeleteOutcome.RefreshRequired -> blockRetry(outcome.message) - is FileDeleteOutcome.Throttled -> blockRetry(outcome.message) - } - } - return outcome.screenEffect(request.file.name, dialogShowsResult = current) - } - - /** Releases the resource once the request ended, with or without an outcome. */ - fun release(request: FileDeleteRequest) = inFlight.release(request.key, request.token) - - private fun key(file: NextcloudFile) = FileDeleteResourceKey(account, file.path) - - private fun blockRetry(message: String) { - error = message - retryBlocked = true - } - - private fun reset(file: NextcloudFile?) { - generation += 1 - target = file - error = null - retryBlocked = false - } -} - -/** - * Runs one delete for [request]. Cancellation propagates without changing the dialog beyond - * releasing the resource. - */ -internal suspend fun FileDeleteDialogState.runDelete( - request: FileDeleteRequest, - delete: suspend (NextcloudFileMutation.Delete) -> NextcloudFileMutationResult, - readFolder: suspend (String) -> NextcloudFileListing, -): FileDeleteScreenEffect = try { - val mutation = NextcloudFileMutation.Delete( - request.file.path, - request.expectedEtag, - sourceIsDirectory = request.file.isDirectory, - ) - val outcome = deleteFileWithVerifiedOutcome( - target = request.file, - expectedEtag = request.expectedEtag, - delete = { delete(mutation) }, - readParent = { readFolder(request.parentPath) }, - ) - // Reject even a non-cooperative service's completion after the caller is cancelled. - currentCoroutineContext().ensureActive() - complete(request, outcome) -} finally { - release(request) -} - -private fun FileDeleteOutcome.screenEffect(name: String, dialogShowsResult: Boolean): FileDeleteScreenEffect = +internal fun FileDeleteOutcome.screenEffect(name: String, dialogShowsResult: Boolean): FileDeleteScreenEffect = when (this) { is FileDeleteOutcome.Deleted -> FileDeleteScreenEffect( - notice = when { - confirmedByRead -> "Deleted $name. The server response was interrupted, so the folder was checked." - localFollowUp == FileMutationLocalFollowUp.StillRunning -> + notice = when (localFollowUp) { + FileMutationLocalFollowUp.StillRunning -> "Deleted $name. Local file status is still updating in the background." - localFollowUp == FileMutationLocalFollowUp.Failed -> + FileMutationLocalFollowUp.Failed -> "Deleted $name. Local file status could not be updated; refresh if it still appears." - else -> "Deleted $name" + FileMutationLocalFollowUp.Completed -> "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." }, reloadFolder = true, ) - FileDeleteOutcome.AlreadyGone -> FileDeleteScreenEffect("$name is no longer on the server.", true) is FileDeleteOutcome.NotDeleted -> FileDeleteScreenEffect( notice = if (dialogShowsResult) null else "$name was not deleted. $message", reloadFolder = false, 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..7db57ac72 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, + writesBlocked: (NextcloudFile) -> Boolean, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -115,6 +116,7 @@ internal fun NativeFilesWorkspace( layout = layout, offlineAvailability = offlineAvailability, offlineStorageSupported = offlineStorageSupported, + writesBlocked = writesBlocked, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -176,6 +178,7 @@ internal fun NativeFilesWorkspace( file = selectedFile, offlineAvailability = selectedFile?.let { offlineAvailability[it.path] }, offlineStorageSupported = offlineStorageSupported, + writesBlocked = writesBlocked, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -328,6 +331,7 @@ private fun FilesBrowserPane( layout: FileLayout, offlineAvailability: Map, offlineStorageSupported: Boolean, + writesBlocked: (NextcloudFile) -> Boolean, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -388,6 +392,7 @@ private fun FilesBrowserPane( files = files, offlineAvailability = offlineAvailability, offlineStorageSupported = offlineStorageSupported, + writesBlocked = writesBlocked, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -405,6 +410,7 @@ private fun FilesBrowserPane( compact = layout == FileLayout.Compact, offlineAvailability = offlineAvailability, offlineStorageSupported = offlineStorageSupported, + writesBlocked = writesBlocked, 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..f5f636d4d 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, + writesBlocked: (NextcloudFile) -> Boolean, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -104,6 +105,7 @@ internal fun FilesInspector( file = file, offlineAvailability = offlineAvailability ?: FileOfflineAvailability.OnlineOnly, offlineStorageSupported = offlineStorageSupported, + writesBlocked = writesBlocked(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..2b03e2740 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, + writesBlocked: (NextcloudFile) -> Boolean = { false }, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, selectedFile: NextcloudFile?, @@ -181,6 +182,7 @@ internal fun NativeFileWorkspaceList( file = file, offlineAvailability = availability, offlineStorageSupported = offlineStorageSupported, + writesBlocked = writesBlocked(file), fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, expanded = menuExpanded, @@ -200,6 +202,7 @@ internal fun NativeFileWorkspaceGrid( files: List, offlineAvailability: Map, offlineStorageSupported: Boolean, + writesBlocked: (NextcloudFile) -> Boolean = { false }, 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, + writesBlocked = writesBlocked, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -249,6 +253,7 @@ private fun NativeFileWorkspaceTile( selected: Boolean, availability: FileOfflineAvailability, offlineStorageSupported: Boolean, + writesBlocked: (NextcloudFile) -> Boolean, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -321,6 +326,7 @@ private fun NativeFileWorkspaceTile( file = file, offlineAvailability = availability, offlineStorageSupported = offlineStorageSupported, + writesBlocked = writesBlocked(file), fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, expanded = menuExpanded, 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 f180ce6f6..20f0ad4ea 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) var linkNavigationFailure by rememberSaveable( session.serverUrl, session.loginName, @@ -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, @@ -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,8 +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) } - val deletesInFlight = remember(session, userId) { FileDeleteInFlight() } - val deleteDialog = remember(session, path, userId) { FileDeleteDialogState(session.accountId, deletesInFlight) } + 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) } @@ -6684,6 +6686,10 @@ internal fun FilesScreen( action: FileMenuAction, loadedFiles: List, ) { + if (action.changesRemoteItem() && fileDeletes.blocksWrites(file)) { + mutationError = "Wait until the delete of ${file.name} or its folder finishes." + return + } when (action) { FileMenuAction.Rename -> { renameTarget = file @@ -7019,6 +7025,7 @@ internal fun FilesScreen( onLayoutChanged = onLayoutChanged, offlineAvailability = offlineAvailability, offlineStorageSupported = services.supportsFileOfflineStorage, + writesBlocked = fileDeletes::blocksWrites, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -7332,27 +7339,15 @@ internal fun FilesScreen( ) } - deleteDialog.target?.let { target -> - FileDeleteDialog( - target = target, - running = deleteDialog.running, - error = deleteDialog.error, - retryBlocked = deleteDialog.retryBlocked, - onDismiss = deleteDialog::dismiss, - onConfirm = confirm@{ - val currentUserId = userId ?: return@confirm - val request = deleteDialog.begin() ?: return@confirm - scope.launch { - val effect = deleteDialog.runDelete( - request, - delete = { mutation -> services.executeFileMutation(session, currentUserId, mutation) }, - readFolder = { folder -> services.listFilesWithSource(session, currentUserId, folder) }, - ) - effect.notice?.let { mutationNotice = it } - if (effect.reloadFolder) { files = null; loadAttempt += 1 } - } - }, - ) + userId?.let { uid -> + FileDeleteHost( + deleteDialog, fileDeletes, + 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 -> 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..54e9cacf6 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,30 @@ 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) + + val plan = planFilesScreenActions(file, support, writesBlocked = true) + + 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 for the delete.") + assertEquals("Wait until the delete of this item or its folder finishes.", action.disabledReason) + } + 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 index d99b15ab3..9e5ae53e7 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -1,12 +1,14 @@ package dev.obiente.nextcloudnative.app import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.launch +import kotlinx.coroutines.cancel import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -15,14 +17,16 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class FileDeleteFlowTest { - private val report = file("Documents/report.txt", etag = "v1") + 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 state = newState().apply { open(report) } + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } val fake = FakeDeleteBoundary(deleteResult = { NextcloudFileMutationResult(null, null) }) - val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) + val effect = deletes.finish(state, fake) assertEquals(FileDeleteScreenEffect("Deleted report.txt", reloadFolder = true), effect) assertNull(state.target) @@ -34,13 +38,14 @@ class FileDeleteFlowTest { @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 state = newState().apply { open(folder) } + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(folder) } val fake = FakeDeleteBoundary( deleteResult = { throw IllegalStateException("connection reset") }, listing = { NextcloudFileListing(emptyList(), NextcloudFileListingSource.Network) }, ) - state.runDelete(state.beginOrFail(), fake::delete, fake::read) + deletes.finish(state, fake) assertTrue(fake.deletes.single().sourceIsDirectory) assertEquals(listOf("Documents"), fake.reads) @@ -55,12 +60,11 @@ class FileDeleteFlowTest { FileMutationLocalFollowUp.Failed to "Deleted report.txt. Local file status could not be updated; refresh if it still appears.", )) { - val state = newState().apply { open(report) } + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } val fake = FakeDeleteBoundary(deleteResult = { NextcloudFileMutationResult(null, null, followUp) }) - val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) - - assertEquals(FileDeleteScreenEffect(notice, reloadFolder = true), effect) + 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.") } @@ -68,12 +72,11 @@ class FileDeleteFlowTest { @Test fun `definitive server rejection stays in the dialog and allows a safe retry`(): Unit = runBlocking { - val state = newState().apply { open(report) } + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(423) }) - val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) - - assertEquals(FileDeleteScreenEffect(null, reloadFolder = false), effect) + 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) @@ -87,12 +90,11 @@ class FileDeleteFlowTest { 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 state = newState().apply { open(item) } + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(item) } val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(412, directory) }) - val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) - - assertEquals(FileDeleteScreenEffect(null, reloadFolder = true), effect) + assertEquals(FileDeleteScreenEffect(null, reloadFolder = true), deletes.finish(state, fake)) assertEquals(item, state.target) assertTrue(state.retryBlocked) assertEquals(fileOperationException(412, directory).message, state.error) @@ -104,10 +106,11 @@ class FileDeleteFlowTest { @Test fun `throttled delete is not treated as unknown and does not allow an immediate retry`(): Unit = runBlocking { - val state = newState().apply { open(report) } + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } val fake = FakeDeleteBoundary(deleteResult = { throw fileOperationException(429) }) - val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) + 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) @@ -118,122 +121,30 @@ class FileDeleteFlowTest { } @Test - fun `throttled completion after the dialog closed reports without a reload`(): Unit = runBlocking { - val state = newState().apply { open(report) } - val release = CompletableDeferred() - val fake = FakeDeleteBoundary(deleteResult = { - release.await() - throw fileOperationException(429) - }) - val request = state.beginOrFail() - var effect: FileDeleteScreenEffect? = null - val job = launch(start = CoroutineStart.UNDISPATCHED) { effect = state.runDelete(request, fake::delete, fake::read) } - - state.dismiss() - release.complete(Unit) - job.join() - - assertEquals( - FileDeleteScreenEffect( - "report.txt was not deleted. The server is limiting requests. Wait a while, then refresh and try again.", - reloadFolder = false, - ), - effect, - ) - assertTrue(fake.reads.isEmpty()) - } - - @Test - fun `closing a running dialog keeps the item blocked until the delete is verified`(): Unit = runBlocking { - val inFlight = FileDeleteInFlight() - val state = newState(inFlight).apply { open(report) } - val release = CompletableDeferred() - val fake = FakeDeleteBoundary(deleteResult = { - release.await() - NextcloudFileMutationResult(null, null) - }) - val request = state.beginOrFail() - var effect: FileDeleteScreenEffect? = null - val job = launch(start = CoroutineStart.UNDISPATCHED) { effect = state.runDelete(request, 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) - job.join() - - assertEquals(FileDeleteScreenEffect("Deleted report.txt", reloadFolder = true), effect) - assertNull(state.target, "The reopened dialog for the same version receives the verified result.") - assertFalse(FileDeleteResourceKey(account, report.path) in inFlight) - assertEquals(1, fake.deletes.size) - } - - @Test - fun `verified rejection releases the item for a reopened dialog of another version`(): Unit = runBlocking { - val state = newState().apply { open(report) } - val release = CompletableDeferred() - val fake = FakeDeleteBoundary(deleteResult = { - release.await() - throw fileOperationException(423) - }) - val request = state.beginOrFail() - val job = launch(start = CoroutineStart.UNDISPATCHED) { state.runDelete(request, fake::delete, fake::read) } - - state.dismiss() - val refreshed = report.copy(etag = "v2") - state.open(refreshed) - assertTrue(state.running) - release.complete(Unit) - job.join() - - 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 `in-flight deletes are scoped to the account and the exact resource`() { - val inFlight = FileDeleteInFlight() - val first = newState(inFlight).apply { open(report) } - val otherAccount = newState(inFlight, NextcloudSession("https://example.invalid", "bob", "x").accountId) - .apply { open(report) } - val sibling = newState(inFlight).apply { open(file("Documents/other.txt")) } - - assertNotNull(first.begin()) - assertFalse(otherAccount.running) - assertNotNull(otherAccount.begin()) - assertNotNull(sibling.begin()) - } - - @Test - fun `missing item closes the dialog without claiming this request deleted it`(): Unit = runBlocking { - val state = newState().apply { open(report) } + 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) }) - val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) - - assertEquals(FileDeleteScreenEffect("report.txt is no longer on the server.", true), effect) + assertEquals(FileDeleteScreenEffect("report.txt is no longer in this folder.", true), deletes.finish(state, fake)) assertNull(state.target) } @Test - fun `unknown result is verified by reading the folder and never resends the delete`(): Unit = runBlocking { - val state = newState().apply { open(report) } + 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")), NextcloudFileListingSource.Network) }, + listing = { NextcloudFileListing(listOf(file("Documents/other.txt", fileId = 8L)), NextcloudFileListingSource.Network) }, ) - val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) + val effect = deletes.finish(state, fake) assertEquals( FileDeleteScreenEffect( - "Deleted report.txt. The server response was interrupted, so the folder was checked.", + "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, @@ -243,15 +154,31 @@ class FileDeleteFlowTest { assertEquals(listOf("Documents"), fake.reads) } + @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 state = newState().apply { open(report) } + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } val fake = FakeDeleteBoundary( deleteResult = { throw fileOperationException(503) }, listing = { NextcloudFileListing(listOf(report), NextcloudFileListingSource.Network) }, ) - state.runDelete(state.beginOrFail(), fake::delete, fake::read) + 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.") @@ -266,47 +193,104 @@ class FileDeleteFlowTest { { NextcloudFileListing(listOf(report.copy(etag = "v2")), NextcloudFileListingSource.Network) }, ) for (listing in unverifiable) { - val state = newState().apply { open(report) } + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } val fake = FakeDeleteBoundary(deleteResult = { throw IllegalStateException("reset") }, listing = listing) - val effect = state.runDelete(state.beginOrFail(), fake::delete, fake::read) - - assertEquals(FileDeleteScreenEffect(null, reloadFolder = true), effect) + assertEquals(FileDeleteScreenEffect(null, reloadFolder = true), deletes.finish(state, fake)) assertTrue(state.retryBlocked) assertNotNull(state.error) - assertFalse(state.running) assertNull(state.begin(), "An unknown delete result must not be retried blindly.") assertEquals(1, fake.deletes.size) } } @Test - fun `cancellation propagates and releases the dialog without an error or effect`(): Unit = runBlocking { - val state = newState().apply { open(report) } - val started = CompletableDeferred() - val fake = FakeDeleteBoundary(deleteResult = { - started.complete(Unit) - awaitCancellation() - }) - var effect: FileDeleteScreenEffect? = null - val request = state.beginOrFail() + 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) + } - val job = launch { effect = state.runDelete(request, fake::delete, fake::read) } - started.await() - assertTrue(state.running) - job.cancel() - job.join() + @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)) + } - assertNull(effect) - assertEquals(report, state.target) - assertFalse(state.running) - assertNull(state.error) - assertTrue(fake.reads.isEmpty(), "Cancellation is not an unknown result to verify.") - } + @Test + fun `account session teardown cancels the delete and releases the item without an outcome`(): 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() + job.join() + + assertTrue(job.isCancelled) + assertNull(deletes.nextCompletion) + assertFalse(state.running) + assertNull(state.error) + assertTrue(fake.reads.isEmpty(), "Cancellation is not an unknown result to verify.") + } @Test - fun `cancelled noncooperative completion cannot change the dialog`(): Unit = runBlocking { - val state = newState().apply { open(report) } + 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 = { @@ -316,19 +300,96 @@ class FileDeleteFlowTest { } NextcloudFileMutationResult(null, null) }) - val request = state.beginOrFail() - - val job = launch { state.runDelete(request, fake::delete, fake::read) } + val job = deletes.start(assertNotNull(state.begin()), fake::delete, fake::read) started.await() - job.cancel() + + deletes.close() release.complete(Unit) job.join() + assertNull(deletes.nextCompletion) + assertFalse(state.running) assertEquals(report, state.target) + } + + @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) - assertNull(state.error) + 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 { @@ -342,26 +403,22 @@ class FileDeleteFlowTest { reloadFolder = false, ), )) { - val state = newState().apply { open(report) } + val deletes = coordinator() + val state = FileDeleteDialogState(deletes).apply { open(report) } val release = CompletableDeferred() val fake = FakeDeleteBoundary(deleteResult = { release.await() result() }) - val request = state.beginOrFail() - var effect: FileDeleteScreenEffect? = null - val job = launch(start = CoroutineStart.UNDISPATCHED) { - effect = state.runDelete(request, fake::delete, fake::read) - } + 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) - job.join() - assertEquals(expected, effect) + assertEquals(expected, deletes.awaitCompletion(state)) assertEquals(other, state.target) assertFalse(state.running) assertNull(state.error) @@ -370,8 +427,8 @@ class FileDeleteFlowTest { } @Test - fun `delete without a current version asks for a refresh instead of sending`() { - val state = newState().apply { open(report.copy(etag = " ")) } + 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) @@ -379,21 +436,33 @@ class FileDeleteFlowTest { } @Test - fun `a running delete cannot be started twice`() { - val state = newState().apply { open(report) } + fun `a claimed delete cannot be started twice`(): Unit = runBlocking { + val state = FileDeleteDialogState(coordinator()).apply { open(report) } assertNotNull(state.begin()) assertNull(state.begin()) } - private val account = NextcloudSession("https://example.invalid", "alice", "x").accountId - - private fun newState( - inFlight: FileDeleteInFlight = FileDeleteInFlight(), - accountId: NextcloudAccountId = account, - ) = FileDeleteDialogState(accountId, inFlight) + /** The event loop stays the test's, but its job does not keep the test running. */ + private fun CoroutineScope.coordinator(accountId: NextcloudAccountId = account) = + FileDeleteCoordinator(accountId, CoroutineScope(coroutineContext.minusKey(Job))) + + /** 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 { + start(assertNotNull(state.begin()), fake::delete, fake::read).join() + return awaitCompletion(state) + } - private fun FileDeleteDialogState.beginOrFail(): FileDeleteRequest = assertNotNull(begin()) + private suspend fun FileDeleteCoordinator.awaitCompletion(state: FileDeleteDialogState): FileDeleteScreenEffect { + while (true) { + val completion = nextCompletion + if (completion != null && consume(completion)) return state.complete(completion) + yield() + } + } private class FakeDeleteBoundary( private val deleteResult: suspend () -> NextcloudFileMutationResult, @@ -413,15 +482,16 @@ class FileDeleteFlowTest { } } - private fun file(path: String, etag: String? = "e", directory: Boolean = false) = NextcloudFile( - path = path, - name = path.substringAfterLast('/'), - isDirectory = directory, - mimeType = if (directory) null else "text/plain", - size = 1, - lastModified = null, - fileId = null, - hasPreview = false, - etag = etag, - ) + 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/captures/MarketingAppCapture.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/captures/MarketingAppCapture.kt index 9225aa6cd..60789b002 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 @@ -87,6 +87,7 @@ fun NextcloudNativeMarketingCapture( FilesScreen( services = assets.services, session = marketingHomepageSession, + fileDeletes = rememberFileDeleteCoordinator(marketingHomepageSession), userId = marketingHomepageTalkUserId, fileSharing = nextcloudNativeMarketingFileShareFixture.capabilities, path = "", @@ -103,6 +104,7 @@ fun NextcloudNativeMarketingCapture( FilesScreen( services = assets.services, session = marketingHomepageSession, + fileDeletes = rememberFileDeleteCoordinator(marketingHomepageSession), userId = marketingHomepageTalkUserId, fileSharing = nextcloudNativeMarketingFileShareFixture.capabilities, path = "", diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt index 577ec4848..ce1648787 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt @@ -1,13 +1,15 @@ package dev.obiente.nextcloudnative.app import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull /** @@ -15,33 +17,64 @@ import kotlinx.coroutines.withTimeoutOrNull * * 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. It - * runs in submission order on one background worker: a stalled lock occupies one thread instead of - * one per mutation, and later invalidations still apply after earlier ones. + * 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. */ internal class DesktopFileMutationFollowUps( private val scope: CoroutineScope, private val completionWaitMillis: Long = DEFAULT_FILE_MUTATION_FOLLOW_UP_WAIT_MILLIS, - private val dispatcher: CoroutineDispatcher = Dispatchers.IO.limitedParallelism(1), + 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) + + private class Pending( + var refresh: () -> Throwable?, + val waiters: MutableList> = mutableListOf(), + ) + + private val lock = Any() + private val pending = LinkedHashMap() + 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 } + /** - * Runs [work] after the server confirmed a mutation and waits at most [completionWaitMillis]. - * [work] returns the first local failure it recovered from, or null when every update applied. + * Refreshes [paths] after the server confirmed a mutation and waits at most + * [completionWaitMillis]. [refresh] returns the first local failure it recovered from. */ - suspend fun afterConfirmedMutation(accountId: String, work: () -> Throwable?): FileMutationLocalFollowUp { - val pending = submit(accountId, work) + suspend fun afterConfirmedMutation( + accountId: String, + paths: List, + refresh: (path: String) -> Throwable?, + ): FileMutationLocalFollowUp { + val waiters = paths.distinct().map { path -> + val waiter = CompletableDeferred() + if (!submit(Key(accountId, path), { refresh(path) }, waiter)) { + waiter.complete(FileMutationLocalFollowUp.Failed) + } + waiter + } val followUp = try { - withTimeoutOrNull(completionWaitMillis) { pending.await() } - ?: FileMutationLocalFollowUp.StillRunning + withTimeoutOrNull(completionWaitMillis) { waiters.awaitAll() } + ?.let { results -> results.firstOrNull { it != FileMutationLocalFollowUp.Completed } } + ?: if (waiters.all { it.isCompleted }) FileMutationLocalFollowUp.Completed else FileMutationLocalFollowUp.StillRunning } catch (cancelled: CancellationException) { currentCoroutineContext().ensureActive() - // The service scope closed before the bookkeeping ran; the caller is still active. FileMutationLocalFollowUp.Failed + } finally { + synchronized(lock) { pending.values.forEach { entry -> entry.waiters.removeAll(waiters) } } } if (followUp == FileMutationLocalFollowUp.StillRunning) { recordOutcome(accountId, "still-running", "FILE_MUTATION_LOCAL_FOLLOW_UP_DELAYED") @@ -49,27 +82,72 @@ internal class DesktopFileMutationFollowUps( return followUp } - /** Queues [work] after an unknown remote result without delaying the caller's failure. */ - fun afterUnknownResult(accountId: String, work: () -> Throwable?) { - submit(accountId, work) + /** Queues a refresh after an unknown remote result without delaying the caller's failure. */ + fun afterUnknownResult(accountId: String, paths: List, refresh: (path: String) -> Throwable?) { + paths.distinct().forEach { path -> submit(Key(accountId, path), { refresh(path) }, waiter = null) } } private fun submit( - accountId: String, - work: () -> Throwable?, - ): Deferred = scope.async(dispatcher) { - // Worker fault boundary: [work] is blocking and does not suspend, so it cannot observe - // cancellation; an unexpected failure is reported instead of escaping to the service scope. - val failure = try { - work() - } catch (failure: Exception) { - failure + key: Key, + refresh: () -> Throwable?, + waiter: CompletableDeferred?, + ): Boolean { + val accepted = synchronized(lock) { + if (!scope.isActive) return@synchronized false + val entry = pending[key] + when { + entry != null -> entry.refresh = refresh + pending.size >= maximumPending -> return@synchronized false + else -> pending[key] = Pending(refresh) + } + waiter?.let { requireNotNull(pending[key]).waiters += it } + if (!draining) { + draining = true + startDrain() + } + true } - if (failure == null) { - FileMutationLocalFollowUp.Completed - } else { - recordOutcome(accountId, "failed", "FILE_MUTATION_LOCAL_FOLLOW_UP_FAILED", failure) - FileMutationLocalFollowUp.Failed + if (!accepted) recordOutcome(key.accountId, "dropped", "FILE_MUTATION_LOCAL_FOLLOW_UP_DROPPED") + return accepted + } + + private fun startDrain() { + scope.launch(dispatcher) { drain() }.invokeOnCompletion { cause -> + if (cause == null) return@invokeOnCompletion + // The service scope closed; nothing queued can run any more. + val abandoned = synchronized(lock) { + draining = false + pending.values.flatMap { it.waiters }.also { pending.clear() } + } + abandoned.forEach { it.complete(FileMutationLocalFollowUp.Failed) } + } + } + + private fun drain() { + while (true) { + 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 + } + // 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 + } + synchronized(lock) { entry.waiters.toList() }.forEach { it.complete(result) } } } @@ -98,3 +176,6 @@ internal class DesktopFileMutationFollowUps( * 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 faf0fc735..57702562a 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -4833,12 +4833,8 @@ class DesktopNextcloudServices( } } val (accountId, cacheProducer) = fileReadCache.producerFor(session) - fun refreshAffectedLocalState(): Throwable? { - val sourceFailure = refreshRetainedFoldersAfterMutation(session, userId, accountId, spec.sourcePath, cacheProducer) - return spec.destinationPath?.let { destination -> - refreshRetainedFoldersAfterMutation(session, userId, accountId, destination, cacheProducer) - }.let { destinationFailure -> sourceFailure ?: destinationFailure } - } + val affectedPaths = listOfNotNull(spec.sourcePath, spec.destinationPath) + fun refreshLocalState(path: String) = refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) val response = request( method = spec.method, url = buildNextcloudFileUrl(session.serverUrl, userId, spec.sourcePath), @@ -4846,10 +4842,10 @@ class DesktopNextcloudServices( headers = headers, maxResponseBytes = 64 * 1024, mutationExecutor = fileMutationHttpExecutor, - onAmbiguousMutationResult = { fileMutationFollowUps.afterUnknownResult(accountId, ::refreshAffectedLocalState) }, + onAmbiguousMutationResult = { fileMutationFollowUps.afterUnknownResult(accountId, affectedPaths, ::refreshLocalState) }, ) if (response.status !in 200..299) throw fileOperationException(response.status, spec.sourceIsDirectory) - val localFollowUp = fileMutationFollowUps.afterConfirmedMutation(accountId, ::refreshAffectedLocalState) + val localFollowUp = fileMutationFollowUps.afterConfirmedMutation(accountId, affectedPaths, ::refreshLocalState) NextcloudFileMutationResult(spec.destinationPath, response.etag, localFollowUp) } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt index f69d2f199..f506cae27 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -28,10 +29,17 @@ class DesktopFileMutationFollowUpsTest { } @Test - fun `completed local bookkeeping is reported as completed`(): Unit = runBlocking { + fun `completed local bookkeeping refreshes every affected path`(): Unit = runBlocking { val followUps = followUps() + val refreshed = Collections.synchronizedList(mutableListOf()) - assertEquals(FileMutationLocalFollowUp.Completed, followUps.afterConfirmedMutation("account") { null }) + 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()) } @@ -39,8 +47,8 @@ class DesktopFileMutationFollowUpsTest { fun `recovered or thrown local failure is reported separately from the server result`(): Unit = runBlocking { val followUps = followUps() - val recovered = followUps.afterConfirmedMutation("account") { IllegalStateException("cache unavailable") } - val thrown = followUps.afterConfirmedMutation("account") { throw IllegalStateException("index locked") } + 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) @@ -55,7 +63,7 @@ class DesktopFileMutationFollowUpsTest { val finished = CountDownLatch(1) val followUp = withTimeout(5_000L) { - followUps.afterConfirmedMutation("account") { + followUps.afterConfirmedMutation("account", listOf("a")) { // Stands in for a monitor wait that neither cancellation nor interruption can end. release.awaitUninterruptibly() finished.countDown() @@ -70,31 +78,67 @@ class DesktopFileMutationFollowUpsTest { } @Test - fun `work stays ordered behind a stalled update and unknown results never wait`(): Unit = runBlocking { - val followUps = followUps(waitMillis = 50L) + 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 order = Collections.synchronizedList(mutableListOf()) - val laterRan = CountDownLatch(1) - - followUps.afterConfirmedMutation("account") { + val started = CountDownLatch(1) + followUps.afterUnknownResult("account", listOf("stalled")) { + started.countDown() release.awaitUninterruptibly() - order += "first" null } - withTimeout(1_000L) { - followUps.afterUnknownResult("account") { - order += "second" - laterRan.countDown() + 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) } - assertTrue(order.isEmpty(), "Later invalidations must not overtake an earlier one.") + assertEquals(10, followUps.pendingCount, "Repeated paths must not grow the queue.") + assertTrue(runs.isEmpty(), "Nothing may overtake the stalled refresh.") release.countDown() - assertTrue(laterRan.await(5, TimeUnit.SECONDS)) - assertEquals(listOf("first", "second"), order.toList()) + 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 `caller cancellation propagates promptly while bookkeeping completes`(): Unit = runBlocking { val followUps = followUps(waitMillis = 60_000L) @@ -103,7 +147,7 @@ class DesktopFileMutationFollowUpsTest { val finished = CountDownLatch(1) val caller = launch(Dispatchers.Default) { - followUps.afterConfirmedMutation("account") { + followUps.afterConfirmedMutation("account", listOf("a")) { started.countDown() release.awaitUninterruptibly() finished.countDown() @@ -124,14 +168,19 @@ class DesktopFileMutationFollowUpsTest { val followUps = followUps() scope.cancel() - assertEquals(FileMutationLocalFollowUp.Failed, followUps.afterConfirmedMutation("account") { null }) + assertEquals( + FileMutationLocalFollowUp.Failed, + followUps.afterConfirmedMutation("account", listOf("a")) { null }, + ) } - private fun followUps(waitMillis: Long = 5_000L) = DesktopFileMutationFollowUps( - scope = scope, - completionWaitMillis = waitMillis, - recordDiagnostic = { account, event -> diagnostics += account to event }, - ) + 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) From 04ddf928807e6c590bae5e642213527da17e61ed Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:47:59 +0000 Subject: [PATCH 06/30] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 4a8afe67e..bffbfa34f 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -107,6 +107,7 @@ "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", @@ -599,15 +600,16 @@ "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": "cb47b3791da6d95560747a2dec717744600ea5314df74468b462c065cc5957e7", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanning.kt": "081a92d578eca1f42b2f1e48b1b5d78b7449c7670f9ae00a1359e8226493b709", "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/FileDeleteDialog.kt": "b70e3b4fa9754b6af6bdae92e70f6d5392560e9e70c99794463b126cc33b307b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "cffbc353d49c20342046b598e2eaead8cbf5b3d810739826dd120b2019e608eb", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt": "7709f06521ce6d49eef9812df4c033cd347bc8586ca8529d0b739fbe22d81905", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt": "6b110a2001ca86a8f6850311a208aa78d3c0b0cb6f1c089c8eb41891a660777e", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "bf164d0e0bbf4e4c7701003186d2ace67a25cd7ffe536baaf5220399a091fca1", "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", @@ -644,9 +646,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistorySection.kt": "b3205f33d9072dec6715b9a3e643dcc4c76fbebe166281d610f752938cb1d8c3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionRestoreHttpPolicy.kt": "2887700cfc59c51ebcc4ec87575b037c74bd7e47e33568f46ace10bf2a1fc2c5", "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": "983703f3568f08b0bca2f6978adcfbae34b0d5f3a4452a3ec748356cc52f311b", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceInspector.kt": "eb98f79e94f5ceb5f0b99eaaaa139337ffade1e35ccf54f59f0e4cc72becd654", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceItems.kt": "94ae37559403ef2ca1f93009ea12e652ba7bfa7ab6857caa9c8571393169a0b9", "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", @@ -740,7 +742,7 @@ "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": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "4fa92d4d0935d9f722d4a82d0b4c40d1aedc968207287adb381d7adc02fc4af6", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "6abf6591d35e69d9cb3d943eb2de2497f79c7a7c2ddf7992245a8afb295164cd", "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", @@ -952,7 +954,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": "7f40a93c4342e84260def956f28b7c8ae76d7e2cfe7823ce9951a315e8113b17", "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", From 75adac7ab46966c2c7fea3accf5662de472ac992 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 8 Oct 2026 21:50:13 +0200 Subject: [PATCH 07/30] fix(files): keep delete retry blocks and stop stale follow-ups Creating an item or moving one into a folder whose delete is still unverified now waits for that delete, like every other write to the folder and its contents. Throttled, stale-version, and rejected-credential or permission outcomes are recorded per resource in the account-session coordinator. Reopening the dialog no longer lifts them; only a server listing of the item's folder that started after the outcome does. HTTP 401 and 403 now block retry without a folder reload, while 423 and 507 still allow a retry because they can clear without changing the item. A desktop follow-up that drops one path now reports a local failure even while another path is still running. After service teardown the drain worker abandons every queued refresh as soon as the blocked one returns. --- tools/kotlin-file-size-baseline.txt | 2 +- .../app/FileDeleteCoordinator.kt | 71 +++++++--- .../nextcloudnative/app/FileDeleteFlow.kt | 22 ++- .../nextcloudnative/app/FileTransferDialog.kt | 4 +- .../nextcloudnative/app/NextcloudNativeApp.kt | 9 +- .../nextcloudnative/app/FileDeleteFlowTest.kt | 126 ++++++++++++++++++ .../app/DesktopFileMutationFollowUps.kt | 70 +++++----- .../app/DesktopFileMutationFollowUpsTest.kt | 57 ++++++++ 8 files changed, 304 insertions(+), 57 deletions(-) diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index f8b6e0ab3..af5c6d493 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -21,7 +21,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface. 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|11026 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|11025 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 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt index ec9747e98..f09e121d7 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt @@ -17,6 +17,8 @@ import kotlinx.coroutines.launch /** 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/") ) @@ -58,20 +60,48 @@ internal data class FileDeleteCompletion(val request: FileDeleteRequest, val out * the Files screen, so leaving either never cancels the request or releases the resource without * an outcome. Finished outcomes wait until a Files screen consumes them. Only [close], at account * session teardown, cancels unfinished work. + * + * 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. */ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: CoroutineScope) { + private class RetryBlock(val message: String, val recordedAt: Long) + val inFlight = FileDeleteInFlight() private val job = SupervisorJob(parent.coroutineContext[Job]) private val scope = CoroutineScope(parent.coroutineContext + job) private val completions = mutableStateListOf() + private val retryBlocks = mutableStateMapOf() + private var sequence = 0L val nextCompletion: FileDeleteCompletion? get() = completions.firstOrNull() /** Other writes to [file] must wait while a delete of it, a parent, or a child is unverified. */ fun blocksWrites(file: NextcloudFile): Boolean = inFlight.blocks(keyFor(file)) + /** Why a write that creates or changes [path] must wait, or null when it may proceed. */ + fun writeBlockedMessage(path: String): String? = + WRITE_BLOCKED_BY_DELETE.takeIf { inFlight.blocks(FileDeleteResourceKey(account, path)) } + + /** Why another delete of [file] must wait for a fresh folder listing, or null. */ + fun retryBlockFor(file: NextcloudFile): String? = retryBlocks[keyFor(file)]?.message + fun keyFor(file: NextcloudFile) = FileDeleteResourceKey(account, file.path) + /** + * 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. + */ + 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 + } + fun start( request: FileDeleteRequest, delete: suspend (NextcloudFileMutation.Delete) -> NextcloudFileMutationResult, @@ -91,6 +121,10 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co ) // Reject even a non-cooperative service's completion after session teardown. ensureActive() + when (val message = outcome.retryBlockMessage()) { + null -> retryBlocks.remove(request.key) + else -> retryBlocks[request.key] = RetryBlock(message, ++sequence) + } completions += FileDeleteCompletion(request, outcome) } finally { inFlight.release(request.key, request.token) @@ -103,6 +137,8 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co fun close() = job.cancel() } +internal const val WRITE_BLOCKED_BY_DELETE = "Wait until the delete of this item or its folder finishes." + @Composable internal fun rememberFileDeleteCoordinator(session: NextcloudSession): FileDeleteCoordinator { val parent = rememberCoroutineScope() @@ -118,12 +154,15 @@ internal fun rememberFileDeleteCoordinator(session: NextcloudSession): FileDelet internal class FileDeleteDialogState(private val deletes: FileDeleteCoordinator) { var target by mutableStateOf(null) private set - var error by mutableStateOf(null) - private set - var retryBlocked by mutableStateOf(false) - 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 @@ -137,15 +176,15 @@ internal class FileDeleteDialogState(private val deletes: FileDeleteCoordinator) val file = target ?: return null if (running || retryBlocked) return null val etag = file.etag?.takeIf(String::isNotBlank) ?: run { - error = "Refresh the folder before deleting this item." + localError = "Refresh the folder before deleting this item." return null } val key = deletes.keyFor(file) val token = deletes.inFlight.claim(key) ?: run { - error = "Another delete in this location is still finishing. Try again when it completes." + localError = "Another delete in this location is still finishing. Try again when it completes." return null } - error = null + localError = null attachedToken = token return FileDeleteRequest(key, token, file, etag) } @@ -158,23 +197,21 @@ internal class FileDeleteDialogState(private val deletes: FileDeleteCoordinator) if (current) { when (outcome) { is FileDeleteOutcome.Deleted, is FileDeleteOutcome.NoLongerAtLocation -> reset(null) - is FileDeleteOutcome.NotDeleted -> error = outcome.message - is FileDeleteOutcome.RefreshRequired -> blockRetry(outcome.message) - is FileDeleteOutcome.Throttled -> blockRetry(outcome.message) + 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 blockRetry(message: String) { - error = message - retryBlocked = true - } - private fun reset(file: NextcloudFile?) { attachedToken = null target = file - error = null - retryBlocked = false + localError = null + shownVersionBlocked = false } } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt index e91042e74..18d6402ec 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt @@ -27,6 +27,20 @@ internal sealed interface FileDeleteOutcome { * 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 } /** @@ -59,6 +73,8 @@ internal suspend fun deleteFileWithVerifiedOutcome( ) 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}.") @@ -134,9 +150,9 @@ internal fun FileDeleteOutcome.screenEffect(name: String, dialogShowsResult: Boo notice = if (dialogShowsResult) null else message, reloadFolder = true, ) - // A folder reload is another request; leave it to the user after the rate limit. - is FileDeleteOutcome.Throttled -> FileDeleteScreenEffect( - notice = if (dialogShowsResult) null else "$name was not deleted. $message", + // 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/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/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 20f0ad4ea..3029b3ccc 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -6563,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 @@ -6686,10 +6686,7 @@ internal fun FilesScreen( action: FileMenuAction, loadedFiles: List, ) { - if (action.changesRemoteItem() && fileDeletes.blocksWrites(file)) { - mutationError = "Wait until the delete of ${file.name} or its folder finishes." - return - } + if (action.changesRemoteItem()) fileDeletes.writeBlockedMessage(file.path)?.let { mutationError = it; return } when (action) { FileMenuAction.Rename -> { renameTarget = file @@ -7124,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 @@ -7294,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." diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt index 9e5ae53e7..7dbd5192b 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -4,6 +4,7 @@ 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 @@ -120,6 +121,131 @@ class FileDeleteFlowTest { 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(WRITE_BLOCKED_BY_DELETE, deletes.writeBlockedMessage("Documents/New folder")) + assertEquals(WRITE_BLOCKED_BY_DELETE, 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(WRITE_BLOCKED_BY_DELETE, deletes.writeBlockedMessage("Documents/report.txt")) + release.complete(Unit) + deletes.awaitCompletion(state) + } + @Test fun `missing item is reported as no longer in the folder, not as deleted`(): Unit = runBlocking { val deletes = coordinator() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt index ce1648787..f88f0ee3c 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt @@ -1,13 +1,10 @@ package dev.obiente.nextcloudnative.app -import kotlinx.coroutines.CancellationException +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.awaitAll -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull @@ -33,10 +30,18 @@ internal class DesktopFileMutationFollowUps( ) { private data class Key(val accountId: String, val path: String) - private class Pending( - var refresh: () -> Throwable?, - val waiters: MutableList> = mutableListOf(), - ) + /** 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()) private val lock = Any() private val pending = LinkedHashMap() @@ -60,22 +65,22 @@ internal class DesktopFileMutationFollowUps( refresh: (path: String) -> Throwable?, ): FileMutationLocalFollowUp { val waiters = paths.distinct().map { path -> - val waiter = CompletableDeferred() - if (!submit(Key(accountId, path), { refresh(path) }, waiter)) { - waiter.complete(FileMutationLocalFollowUp.Failed) + Waiter().also { waiter -> + if (!submit(Key(accountId, path), { refresh(path) }, waiter)) waiter.finish(FileMutationLocalFollowUp.Failed) } - waiter } - val followUp = try { - withTimeoutOrNull(completionWaitMillis) { waiters.awaitAll() } - ?.let { results -> results.firstOrNull { it != FileMutationLocalFollowUp.Completed } } - ?: if (waiters.all { it.isCompleted }) FileMutationLocalFollowUp.Completed else FileMutationLocalFollowUp.StillRunning - } catch (cancelled: CancellationException) { - currentCoroutineContext().ensureActive() - FileMutationLocalFollowUp.Failed + try { + withTimeoutOrNull(completionWaitMillis) { waiters.forEach { it.done.await() } } } finally { synchronized(lock) { pending.values.forEach { entry -> entry.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") } @@ -90,7 +95,7 @@ internal class DesktopFileMutationFollowUps( private fun submit( key: Key, refresh: () -> Throwable?, - waiter: CompletableDeferred?, + waiter: Waiter?, ): Boolean { val accepted = synchronized(lock) { if (!scope.isActive) return@synchronized false @@ -112,19 +117,24 @@ internal class DesktopFileMutationFollowUps( } private fun startDrain() { - scope.launch(dispatcher) { drain() }.invokeOnCompletion { cause -> - if (cause == null) return@invokeOnCompletion - // The service scope closed; nothing queued can run any more. - val abandoned = synchronized(lock) { - draining = false - pending.values.flatMap { it.waiters }.also { pending.clear() } - } - abandoned.forEach { it.complete(FileMutationLocalFollowUp.Failed) } + 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 { it.waiters }.also { pending.clear() } } + abandoned.forEach { it.finish(FileMutationLocalFollowUp.Failed) } } - private fun drain() { + 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) { @@ -147,7 +157,7 @@ internal class DesktopFileMutationFollowUps( recordOutcome(key.accountId, "failed", "FILE_MUTATION_LOCAL_FOLLOW_UP_FAILED", failure) FileMutationLocalFollowUp.Failed } - synchronized(lock) { entry.waiters.toList() }.forEach { it.complete(result) } + synchronized(lock) { entry.waiters.toList() }.forEach { it.finish(result) } } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt index f506cae27..8e04d6154 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt @@ -6,6 +6,7 @@ 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 @@ -139,6 +140,62 @@ class DesktopFileMutationFollowUpsTest { 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 `caller cancellation propagates promptly while bookkeeping completes`(): Unit = runBlocking { val followUps = followUps(waitMillis = 60_000L) From eafd337ab6fad86f74e931eb5dc1beac69d3ac8c Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:48:07 +0000 Subject: [PATCH 08/30] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index e6b9c767e..eeb20b17b 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", @@ -599,20 +602,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": "cb47b3791da6d95560747a2dec717744600ea5314df74468b462c065cc5957e7", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanning.kt": "081a92d578eca1f42b2f1e48b1b5d78b7449c7670f9ae00a1359e8226493b709", "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": "37158855714b93becc8d90ee569f0b1cd803c996f9d311d29fba29dece7ae6e0", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt": "6b110a2001ca86a8f6850311a208aa78d3c0b0cb6f1c089c8eb41891a660777e", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "a3333a7788f3cfc969ddf6b49c065e6bbf6a8fef22621e58612920f4520b909c", "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/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": "80a60e4a216f082c1694112bdfb1ba2ec75c39d747caae8c10b8298959fd7093", "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 +645,14 @@ "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/FileVersionRestoreHttpPolicy.kt": "2887700cfc59c51ebcc4ec87575b037c74bd7e47e33568f46ace10bf2a1fc2c5", "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": "983703f3568f08b0bca2f6978adcfbae34b0d5f3a4452a3ec748356cc52f311b", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceInspector.kt": "eb98f79e94f5ceb5f0b99eaaaa139337ffade1e35ccf54f59f0e4cc72becd654", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceItems.kt": "94ae37559403ef2ca1f93009ea12e652ba7bfa7ab6857caa9c8571393169a0b9", "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", @@ -740,7 +746,7 @@ "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/NextcloudNativeApp.kt": "2db797de74e313df73a8f2b1eb96408d5ff5d8df0586490a7763cb1f676847b1", "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", @@ -952,7 +958,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": "7f40a93c4342e84260def956f28b7c8ae76d7e2cfe7823ce9951a315e8113b17", "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", From d41fe6e914fbf3fae3387dbc99179413592ba01c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Mon, 12 Oct 2026 21:50:01 +0200 Subject: [PATCH 09/30] fix(files): keep writes blocked until refresh and report local follow-ups A delete outcome that needs a folder refresh (stale version, throttling, rejected credentials or permission, unverifiable result) now blocks every other write to that item, its folders, and its contents until a server listing of its folder starts after the outcome, including overlapping deletes. Renames, moves, and copies in Files, and moves, copies, and deletes in the media viewer, now report when local file status is still updating or could not be updated instead of announcing plain success. On desktop, sign-out and account removal drop that account's queued local refreshes and fail the waiters of its running one, so a late completion cannot report success. An interrupted mutation whose local reconciliation cannot be queued now says so instead of being dropped silently. --- .../477-delete-dialog-completion.md | 2 +- tools/kotlin-file-size-baseline.txt | 2 +- .../app/FileDeleteCoordinator.kt | 30 +++++- .../nextcloudnative/app/FileDeleteFlow.kt | 11 +-- .../nextcloudnative/app/FileOperations.kt | 7 ++ .../app/NextcloudMediaViewerActions.kt | 31 +++++- .../nextcloudnative/app/NextcloudNativeApp.kt | 12 +-- .../nextcloudnative/app/FileDeleteFlowTest.kt | 60 +++++++++++- .../nextcloudnative/app/FileOperationsTest.kt | 13 +++ .../app/DesktopFileMutationFollowUps.kt | 61 ++++++++++-- .../app/DesktopNextcloudServices.kt | 10 +- .../app/DesktopFileMutationFollowUpsTest.kt | 96 +++++++++++++++++++ .../app/MediaViewerLocalFollowUpSceneTest.kt | 96 +++++++++++++++++++ 13 files changed, 393 insertions(+), 38 deletions(-) create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt diff --git a/changes/unreleased/477-delete-dialog-completion.md b/changes/unreleased/477-delete-dialog-completion.md index fd8d7ccbc..fe8acdbf4 100644 --- a/changes/unreleased/477-delete-dialog-completion.md +++ b/changes/unreleased/477-delete-dialog-completion.md @@ -4,4 +4,4 @@ pull: none platforms: android, desktop user-facing: yes -Files deletes always finish, even after leaving Files: stalled local updates no longer hold the dialog, interrupted responses are checked on the server, and other changes to the item wait until the delete is verified. +Files deletes always finish, even after leaving Files. Other changes to the item wait until it is verified or refreshed, and renames, moves, copies, and deletes report incomplete local file status. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 514483979..17ba15fb0 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -21,7 +21,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface. 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|11025 +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 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt index f09e121d7..772643f25 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt @@ -63,6 +63,7 @@ internal data class FileDeleteCompletion(val request: FileDeleteRequest, val out * * 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. + * Until then it also blocks every other write to that item, its folders, and its contents. */ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: CoroutineScope) { private class RetryBlock(val message: String, val recordedAt: Long) @@ -76,12 +77,24 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co val nextCompletion: FileDeleteCompletion? get() = completions.firstOrNull() - /** Other writes to [file] must wait while a delete of it, a parent, or a child is unverified. */ - fun blocksWrites(file: NextcloudFile): Boolean = inFlight.blocks(keyFor(file)) + /** + * Other writes to [file] must wait while a delete of it, a parent, or a child is unverified, and + * until a folder refresh after a delete outcome that left that item's state unknown or stale. + */ + fun blocksWrites(file: NextcloudFile): Boolean = writeBlockedMessage(file.path) != null /** Why a write that creates or changes [path] must wait, or null when it may proceed. */ fun writeBlockedMessage(path: String): String? = - WRITE_BLOCKED_BY_DELETE.takeIf { inFlight.blocks(FileDeleteResourceKey(account, path)) } + WRITE_BLOCKED_BY_DELETE.takeIf { inFlight.blocks(FileDeleteResourceKey(account, path)) } ?: refreshBlockedMessage(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. + */ + fun refreshBlockedMessage(path: String): String? { + val key = FileDeleteResourceKey(account, path) + return retryBlocks.keys.firstOrNull { it.overlaps(key) }?.let { writeBlockedUntilRefresh(it.path) } + } /** Why another delete of [file] must wait for a fresh folder listing, or null. */ fun retryBlockFor(file: NextcloudFile): String? = retryBlocks[keyFor(file)]?.message @@ -139,6 +152,12 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co internal const val WRITE_BLOCKED_BY_DELETE = "Wait until the delete of this item or its folder finishes." +/** A delete of [blockedPath] needs a server listing of its folder before related writes may proceed. */ +internal fun writeBlockedUntilRefresh(blockedPath: String): String { + val folder = blockedPath.substringBeforeLast('/', missingDelimiterValue = "").ifEmpty { "your Nextcloud root" } + return "Refresh $folder before changing ${blockedPath.substringAfterLast('/')} or anything inside it." +} + @Composable internal fun rememberFileDeleteCoordinator(session: NextcloudSession): FileDeleteCoordinator { val parent = rememberCoroutineScope() @@ -180,6 +199,11 @@ internal class FileDeleteDialogState(private val deletes: FileDeleteCoordinator) return null } val key = deletes.keyFor(file) + // A parent or child whose delete left its state unknown must be refreshed first. + deletes.refreshBlockedMessage(file.path)?.let { + localError = it + 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 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt index 18d6402ec..9e486a98d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt @@ -123,16 +123,7 @@ internal data class FileDeleteScreenEffect(val notice: String?, val reloadFolder internal fun FileDeleteOutcome.screenEffect(name: String, dialogShowsResult: Boolean): FileDeleteScreenEffect = when (this) { - is FileDeleteOutcome.Deleted -> FileDeleteScreenEffect( - notice = when (localFollowUp) { - FileMutationLocalFollowUp.StillRunning -> - "Deleted $name. Local file status is still updating in the background." - FileMutationLocalFollowUp.Failed -> - "Deleted $name. Local file status could not be updated; refresh if it still appears." - FileMutationLocalFollowUp.Completed -> "Deleted $name" - }, - reloadFolder = true, - ) + 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 " + 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 d1203a377..5892d76f0 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt @@ -86,6 +86,13 @@ enum class FileMutationLocalFollowUp { 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, 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..7f0d9839f 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt @@ -156,9 +156,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}") }, @@ -218,7 +221,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 } @@ -246,8 +253,10 @@ private fun MediaDeleteDialog( ) { 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 scope = rememberCoroutineScope() + followUpNotice?.let { notice -> return MediaLocalFollowUpDialog(notice, onClose = onDeleted) } AlertDialog( onDismissRequest = { if (!running) onDismiss() }, title = { Text("Delete ${file.name}?") }, @@ -286,8 +295,10 @@ private fun MediaDeleteDialog( userId, NextcloudFileMutation.Delete(file.path, etag), ) - }.onSuccess { onDeleted() } - .onFailure { error = it.message ?: "Could not delete this file." } + }.onSuccess { result -> + if (result.localFollowUp == FileMutationLocalFollowUp.Completed) onDeleted() + else followUpNotice = result.localFollowUp.reportFor("Deleted ${file.name}") + }.onFailure { error = it.message ?: "Could not delete this file." } running = false } }, @@ -302,6 +313,20 @@ private fun MediaDeleteDialog( ) } +/** + * 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") } }, + ) +} + @Composable private fun MediaAlbumDialog( file: NextcloudFile, 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 afba09c9d..2a5f47972 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -7253,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 { @@ -7320,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 { diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt index 7dbd5192b..e57f382be 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -59,7 +59,7 @@ class FileDeleteFlowTest { 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 if it still appears.", + "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) } @@ -246,6 +246,64 @@ class FileDeleteFlowTest { 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 = "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("Refresh Documents before changing report.txt or anything inside it.", deletes.writeBlockedMessage("Documents/report.txt")) + assertEquals( + "Refresh your Nextcloud root before changing Documents or anything inside it.", + writeBlockedUntilRefresh("Documents"), + ) + } + @Test fun `missing item is reported as no longer in the folder, not as deleted`(): Unit = runBlocking { val deletes = coordinator() 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 8fc30fc4f..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( diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt index f88f0ee3c..c51bf61bd 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative.app +import java.io.IOException import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher @@ -19,7 +20,7 @@ import kotlinx.coroutines.withTimeoutOrNull * 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. + * instead of growing the queue, both after a confirmed change and after an unknown result. */ internal class DesktopFileMutationFollowUps( private val scope: CoroutineScope, @@ -45,6 +46,7 @@ internal class DesktopFileMutationFollowUps( private val lock = Any() private val pending = LinkedHashMap() + private var running: Pair? = null private var draining = false init { @@ -87,9 +89,46 @@ internal class DesktopFileMutationFollowUps( return followUp } - /** Queues a refresh after an unknown remote result without delaying the caller's failure. */ - fun afterUnknownResult(accountId: String, paths: List, refresh: (path: String) -> Throwable?) { - paths.distinct().forEach { path -> submit(Key(accountId, path), { refresh(path) }, waiter = null) } + /** + * 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 = + paths.distinct().map { path -> submit(Key(accountId, path), { refresh(path) }, waiter = null) }.all { it } + + /** + * Runs [mutation], which reports an interrupted exchange through its callback. When the + * reconciliation for that unknown result cannot be queued, the failure says so explicitly. + */ + inline fun reconcilingUnknownResult( + accountId: String, + paths: List, + noinline refresh: (path: String) -> Throwable?, + mutation: (onUnknownResult: () -> Unit) -> T, + ): T { + var queued = true + try { + return mutation { queued = afterUnknownResult(accountId, paths, refresh) } + } catch (unknown: DesktopFileSyncAmbiguousMutationException) { + if (queued) throw unknown + throw DesktopFileMutationReconciliationDroppedException(unknown) + } + } + + /** + * 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 keys = pending.keys.filter { it.accountId == accountId } + val current = running?.takeIf { (key, _) -> key.accountId == accountId }?.second?.waiters.orEmpty() + current.toList() + keys.flatMap { key -> requireNotNull(pending.remove(key)).waiters } + } + abandoned.forEach { it.finish(FileMutationLocalFollowUp.Failed) } } private fun submit( @@ -142,7 +181,7 @@ internal class DesktopFileMutationFollowUps( return } pending.remove(next.key) - next.key to next.value + (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. @@ -157,7 +196,10 @@ internal class DesktopFileMutationFollowUps( recordOutcome(key.accountId, "failed", "FILE_MUTATION_LOCAL_FOLLOW_UP_FAILED", failure) FileMutationLocalFollowUp.Failed } - synchronized(lock) { entry.waiters.toList() }.forEach { it.finish(result) } + synchronized(lock) { + running = null + entry.waiters.toList() + }.forEach { it.finish(result) } } } @@ -181,6 +223,13 @@ internal class DesktopFileMutationFollowUps( } } +/** The mutation's server result is unknown, and its local reconciliation could not be queued. */ +internal class DesktopFileMutationReconciliationDroppedException(cause: IOException) : IOException( + "The server response was interrupted, and local file status could not be scheduled for an update. " + + "Refresh the folder before trying again.", + cause, +) + /** * 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. 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 c99d4533f..39fa8c807 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3843,6 +3843,7 @@ class DesktopNextcloudServices( private fun fenceDesktopAccountPrivateCaches(accountStorageKey: String, fileCacheAccountId: String) { fileReadCache.retireAccount(fileCacheAccountId) + fileMutationFollowUps.abandonAccount(fileCacheAccountId) sharedJvmAuthenticatedAppReadSessions.retireAccount(accountStorageKey) dynamicDiscoveryCache.fenceAccount(accountStorageKey) } @@ -4834,18 +4835,17 @@ class DesktopNextcloudServices( val (accountId, cacheProducer) = fileReadCache.producerFor(session) val affectedPaths = listOfNotNull(spec.sourcePath, spec.destinationPath) fun refreshLocalState(path: String) = refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) - val response = request( + val response = fileMutationFollowUps.reconcilingUnknownResult(accountId, affectedPaths, ::refreshLocalState) { onUnknownResult -> request( method = spec.method, url = buildNextcloudFileUrl(session.serverUrl, userId, spec.sourcePath), session = session, headers = headers, maxResponseBytes = 64 * 1024, mutationExecutor = fileMutationHttpExecutor, - onAmbiguousMutationResult = { fileMutationFollowUps.afterUnknownResult(accountId, affectedPaths, ::refreshLocalState) }, - ) + onAmbiguousMutationResult = onUnknownResult, + ) } if (response.status !in 200..299) throw fileOperationException(response.status, spec.sourceIsDirectory) - val localFollowUp = fileMutationFollowUps.afterConfirmedMutation(accountId, affectedPaths, ::refreshLocalState) - NextcloudFileMutationResult(spec.destinationPath, response.etag, localFollowUp) + NextcloudFileMutationResult(spec.destinationPath, response.etag, fileMutationFollowUps.afterConfirmedMutation(accountId, affectedPaths, ::refreshLocalState)) } 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 index 8e04d6154..d356d81db 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt @@ -16,6 +16,9 @@ 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.assertSame import kotlin.test.assertTrue class DesktopFileMutationFollowUpsTest { @@ -196,6 +199,99 @@ class DesktopFileMutationFollowUpsTest { 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 = assertFailsWith { + followUps.reconcilingUnknownResult("account", listOf("source"), { null }) { onUnknown -> + onUnknown() + throw interrupted + } + } + val dropped = assertFailsWith { + followUps.reconcilingUnknownResult("account", listOf("source", "destination"), { null }) { onUnknown -> + onUnknown() + throw interrupted + } + } + + assertSame(interrupted, queued, "A queued reconciliation keeps the original unknown result.") + assertSame(interrupted, dropped.cause) + assertFalse(followUps.afterUnknownResult("account", listOf("other")) { null }) + assertEquals("ok", followUps.reconcilingUnknownResult("account", listOf("other"), { null }) { "ok" }) + } + @Test fun `caller cancellation propagates promptly while bookkeeping completes`(): Unit = runBlocking { val followUps = followUps(waitMillis = 60_000L) 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..4ab63da74 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt @@ -0,0 +1,96 @@ +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 `delete with incomplete local bookkeeping is reported before the viewer moves on`() { + val events = mutableListOf() + nativeSceneTest(800, 700, content = { dialog(MediaViewerAction.Delete, FileMutationLocalFollowUp.StillRunning, events) }) { + click("Delete") + + assertTrue(has("Deleted photo.jpg. Local file status is still updating in the background.")) + assertEquals(emptyList(), events, "The viewer must not move on as if everything finished.") + click("Close") + assertEquals(listOf("removed"), events) + } + } + + @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.Delete, "Delete", "removed"), + Triple(MediaViewerAction.Move, "Move", "removed"), + Triple(MediaViewerAction.Copy, "Copy", "dismissed"), + )) { + val events = mutableListOf() + nativeSceneTest(800, 700, content = { dialog(action, FileMutationLocalFollowUp.Completed, events) }) { + if (action != MediaViewerAction.Delete) replaceText("photo.jpg", "other.jpg") + click(button) + + assertEquals(listOf(expected), events) + assertFalse(has("Saved on the server")) + } + } + } + + @androidx.compose.runtime.Composable + private fun dialog(action: MediaViewerAction, followUp: FileMutationLocalFollowUp, events: MutableList) = + MediaViewerActionDialog( + action = action, + file = photo, + session = session, + userId = "alice", + services = services(followUp), + 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) + else -> error("Unexpected synthetic media operation: ${method.name}") + } + } as NextcloudPlatformServices +} From 63601b9b66bbce6e1944033d37ea492f9d2dc1d1 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:54:40 +0000 Subject: [PATCH 10/30] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index eeb20b17b..6bd0b55d1 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -609,16 +609,16 @@ "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": "37158855714b93becc8d90ee569f0b1cd803c996f9d311d29fba29dece7ae6e0", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt": "9e0dcb476ec638c5847f32064a660f2d49dac9bba84d87ddf03a1b0011bcbcc2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt": "6b110a2001ca86a8f6850311a208aa78d3c0b0cb6f1c089c8eb41891a660777e", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "a3333a7788f3cfc969ddf6b49c065e6bbf6a8fef22621e58612920f4520b909c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "62d53d9a93598826e943e615a64de6fd33650769593f98193d6b1222c12abc7f", "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/FileOfflineQueue.kt": "51fced0946f36654515a8a3b2a827f564e61e7626d4fdd5f194139d6aeaddaf9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineQueueSnapshot.kt": "1fb930db8f65e0e410af6eaacde0c4f3d071f4115c39f78a4f49a9532b9f7961", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "80a60e4a216f082c1694112bdfb1ba2ec75c39d747caae8c10b8298959fd7093", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "808e2f32ef313280c60f0f06ac4d6e7eb95746c20bdc3418deeb66cff6ea60ba", "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", @@ -745,8 +745,8 @@ "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": "2db797de74e313df73a8f2b1eb96408d5ff5d8df0586490a7763cb1f676847b1", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "9fffe98e8f1de3d13d48e9a7d51a7651c05beeead6dbfda3f375a6a9760b139c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "2f52652ff8179ca26c8d206b553adc6cdd3c0db1a55e9e76d1fdccf2f8da5bfa", "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", From d50ce090d0c264a2cab03854166f264314156d16 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Mon, 12 Oct 2026 22:44:48 +0200 Subject: [PATCH 11/30] fix(files): lift delete blocks from any view and reuse running refreshes A delete started from Favorites or search whose outcome needs a folder refresh now reads the item's own folder through the account-scoped delete coordinator, so the block lifts even when that folder is not the one shown. Only a listing that started after the outcome lifts it, and opening the folder still lifts it when that read fails. Disabled Files actions now show the typed reason: an unverified delete asks the user to wait, while a finished delete that needs a refresh names the folder to open or refresh. On desktop, the refresh queued when the server accepts a mutation and the wait after confirmation now share one entry per path. Confirmation attaches to that refresh while it is queued or running and reads its result once it finished, so a path is refreshed once and a full queue no longer reports a failure for a refresh that is already running. --- .../nextcloudnative/app/FileActionMenu.kt | 4 +- .../nextcloudnative/app/FileActionPlanning.kt | 6 +- .../app/FileDeleteCoordinator.kt | 72 +++++-- .../nextcloudnative/app/FileDeleteDialog.kt | 11 +- .../nextcloudnative/app/FilesWorkspace.kt | 12 +- .../app/FilesWorkspaceInspector.kt | 4 +- .../app/FilesWorkspaceItems.kt | 12 +- .../nextcloudnative/app/NextcloudNativeApp.kt | 6 +- .../app/FileActionPlanningTest.kt | 38 ++-- .../nextcloudnative/app/FileDeleteFlowTest.kt | 93 ++++++++- .../app/DesktopFileMutationFollowUps.kt | 183 +++++++++++------- .../app/DesktopNextcloudServices.kt | 6 +- .../app/DesktopFileMutationFollowUpsTest.kt | 126 +++++++++++- 13 files changed, 441 insertions(+), 132 deletions(-) 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 5d7c41736..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,7 +15,7 @@ internal fun FileActionMenu( file: NextcloudFile, offlineAvailability: FileOfflineAvailability, offlineStorageSupported: Boolean, - writesBlocked: Boolean = false, + writeBlock: FileWriteBlock? = null, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, expanded: Boolean, @@ -38,7 +38,7 @@ internal fun FileActionMenu( discoverDocumentEditing = true, ), offlineState = offlineAvailability.toFileActionOfflineState(), - writesBlocked = writesBlocked, + 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 5e6081d07..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,13 +213,13 @@ fun planFilesScreenActions( file: NextcloudFile, support: FileActionSupport = FileActionSupport(), offlineState: FileOfflineState = FileOfflineState.NotStored, - writesBlocked: Boolean = false, + writeBlock: FileWriteBlock? = null, ): FileLongPressActionPlan { val plan = planFileActions(file, support, offlineState) return FileLongPressActionPlan( plan.actions.map { action -> - if (writesBlocked && action.enabled && action.action in fileWriteActions) { - action.copy(enabled = false, disabledReason = "Wait until the delete of this item or its folder finishes.") + 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 { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt index 772643f25..931dee3fb 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -78,22 +79,54 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co val nextCompletion: FileDeleteCompletion? get() = completions.firstOrNull() /** - * Other writes to [file] must wait while a delete of it, a parent, or a child is unverified, and - * until a folder refresh after a delete outcome that left that item's state unknown or stale. + * 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 blocksWrites(file: NextcloudFile): Boolean = writeBlockedMessage(file.path) != null + fun writeBlock(file: NextcloudFile): FileWriteBlock? = writeBlockFor(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 writeBlockedMessage(path: String): String? = - WRITE_BLOCKED_BY_DELETE.takeIf { inFlight.blocks(FileDeleteResourceKey(account, path)) } ?: refreshBlockedMessage(path) + fun writeBlockFor(path: String): FileWriteBlock? = + FileWriteBlock.DeleteRunning.takeIf { inFlight.blocks(FileDeleteResourceKey(account, path)) } ?: refreshBlockFor(path) + + fun writeBlockedMessage(path: String): String? = writeBlockFor(path)?.message /** * 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. */ - fun refreshBlockedMessage(path: String): String? { + fun refreshBlockFor(path: String): FileWriteBlock.RefreshRequired? { val key = FileDeleteResourceKey(account, path) - return retryBlocks.keys.firstOrNull { it.overlaps(key) }?.let { writeBlockedUntilRefresh(it.path) } + return retryBlocks.keys.firstOrNull { it.overlaps(key) }?.let { FileWriteBlock.RefreshRequired(it.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 && completion.request.key in retryBlocks) { + 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. */ @@ -150,12 +183,23 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co fun close() = job.cancel() } -internal const val WRITE_BLOCKED_BY_DELETE = "Wait until the delete of this item or its folder finishes." +/** 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 = "") -/** A delete of [blockedPath] needs a server listing of its folder before related writes may proceed. */ -internal fun writeBlockedUntilRefresh(blockedPath: String): String { - val folder = blockedPath.substringBeforeLast('/', missingDelimiterValue = "").ifEmpty { "your Nextcloud root" } - return "Refresh $folder before changing ${blockedPath.substringAfterLast('/')} or anything inside it." + override val message: String + get() = "Open or refresh ${folder.ifEmpty { "your Nextcloud root" }} before changing " + + "${blockedPath.substringAfterLast('/')} or anything inside it." + } } @Composable @@ -200,8 +244,8 @@ internal class FileDeleteDialogState(private val deletes: FileDeleteCoordinator) } val key = deletes.keyFor(file) // A parent or child whose delete left its state unknown must be refreshed first. - deletes.refreshBlockedMessage(file.path)?.let { - localError = it + deletes.refreshBlockFor(file.path)?.let { + localError = it.message return null } val token = deletes.inFlight.claim(key) ?: run { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt index 53d79977e..a04e55e80 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt @@ -20,21 +20,26 @@ import androidx.compose.ui.unit.dp import dev.obiente.nextcloudnative.app.design.NextcloudSpacing /** - * Shows the delete dialog of one Files screen and applies finished deletes, including ones - * started before this screen was shown. The requests themselves belong to [deletes]. + * 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(dialog.complete(completion)) + if (completion != null && deletes.consume(completion)) { + currentOnEffect(deletes.applyCompletion(completion, dialog, currentShownFolder, currentReadFolder)) + } } dialog.target?.let { target -> FileDeleteDialog( 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 7db57ac72..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,7 +73,7 @@ internal fun NativeFilesWorkspace( onLayoutChanged: (FileLayout) -> Unit, offlineAvailability: Map, offlineStorageSupported: Boolean, - writesBlocked: (NextcloudFile) -> Boolean, + writeBlock: (NextcloudFile) -> FileWriteBlock?, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -116,7 +116,7 @@ internal fun NativeFilesWorkspace( layout = layout, offlineAvailability = offlineAvailability, offlineStorageSupported = offlineStorageSupported, - writesBlocked = writesBlocked, + writeBlock = writeBlock, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -178,7 +178,7 @@ internal fun NativeFilesWorkspace( file = selectedFile, offlineAvailability = selectedFile?.let { offlineAvailability[it.path] }, offlineStorageSupported = offlineStorageSupported, - writesBlocked = writesBlocked, + writeBlock = writeBlock, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -331,7 +331,7 @@ private fun FilesBrowserPane( layout: FileLayout, offlineAvailability: Map, offlineStorageSupported: Boolean, - writesBlocked: (NextcloudFile) -> Boolean, + writeBlock: (NextcloudFile) -> FileWriteBlock?, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -392,7 +392,7 @@ private fun FilesBrowserPane( files = files, offlineAvailability = offlineAvailability, offlineStorageSupported = offlineStorageSupported, - writesBlocked = writesBlocked, + writeBlock = writeBlock, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -410,7 +410,7 @@ private fun FilesBrowserPane( compact = layout == FileLayout.Compact, offlineAvailability = offlineAvailability, offlineStorageSupported = offlineStorageSupported, - writesBlocked = writesBlocked, + 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 f5f636d4d..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,7 +48,7 @@ internal fun FilesInspector( file: NextcloudFile?, offlineAvailability: FileOfflineAvailability?, offlineStorageSupported: Boolean, - writesBlocked: (NextcloudFile) -> Boolean, + writeBlock: (NextcloudFile) -> FileWriteBlock?, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -105,7 +105,7 @@ internal fun FilesInspector( file = file, offlineAvailability = offlineAvailability ?: FileOfflineAvailability.OnlineOnly, offlineStorageSupported = offlineStorageSupported, - writesBlocked = writesBlocked(file), + 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 2b03e2740..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,7 +60,7 @@ internal fun NativeFileWorkspaceList( compact: Boolean, offlineAvailability: Map, offlineStorageSupported: Boolean, - writesBlocked: (NextcloudFile) -> Boolean = { false }, + writeBlock: (NextcloudFile) -> FileWriteBlock? = { null }, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, selectedFile: NextcloudFile?, @@ -182,7 +182,7 @@ internal fun NativeFileWorkspaceList( file = file, offlineAvailability = availability, offlineStorageSupported = offlineStorageSupported, - writesBlocked = writesBlocked(file), + writeBlock = writeBlock(file), fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, expanded = menuExpanded, @@ -202,7 +202,7 @@ internal fun NativeFileWorkspaceGrid( files: List, offlineAvailability: Map, offlineStorageSupported: Boolean, - writesBlocked: (NextcloudFile) -> Boolean = { false }, + writeBlock: (NextcloudFile) -> FileWriteBlock? = { null }, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -228,7 +228,7 @@ internal fun NativeFileWorkspaceGrid( selected = selectedFile?.path == file.path, availability = offlineAvailability[file.path] ?: FileOfflineAvailability.OnlineOnly, offlineStorageSupported = offlineStorageSupported, - writesBlocked = writesBlocked, + writeBlock = writeBlock, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -253,7 +253,7 @@ private fun NativeFileWorkspaceTile( selected: Boolean, availability: FileOfflineAvailability, offlineStorageSupported: Boolean, - writesBlocked: (NextcloudFile) -> Boolean, + writeBlock: (NextcloudFile) -> FileWriteBlock?, fileSharing: NextcloudFileSharingCapabilities, externalHandoffCapability: ExternalFileHandoffCapability?, services: NextcloudPlatformServices, @@ -326,7 +326,7 @@ private fun NativeFileWorkspaceTile( file = file, offlineAvailability = availability, offlineStorageSupported = offlineStorageSupported, - writesBlocked = writesBlocked(file), + writeBlock = writeBlock(file), fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, expanded = menuExpanded, 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 2a5f47972..be576685d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -7022,7 +7022,7 @@ internal fun FilesScreen( onLayoutChanged = onLayoutChanged, offlineAvailability = offlineAvailability, offlineStorageSupported = services.supportsFileOfflineStorage, - writesBlocked = fileDeletes::blocksWrites, + writeBlock = fileDeletes::writeBlock, fileSharing = fileSharing, externalHandoffCapability = externalHandoffCapability, services = services, @@ -7336,8 +7336,8 @@ internal fun FilesScreen( userId?.let { uid -> FileDeleteHost( - deleteDialog, fileDeletes, - delete = { mutation -> services.executeFileMutation(session, uid, mutation) }, + deleteDialog, fileDeletes, path, + delete ={ mutation -> services.executeFileMutation(session, uid, mutation) }, readFolder = { folder -> services.listFilesWithSource(session, uid, folder) }, ) { effect -> effect.notice?.let { mutationNotice = it } 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 54e9cacf6..254ed4762 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanningTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanningTest.kt @@ -133,22 +133,30 @@ class FileActionPlanningTest { val file = file(name = "notes.txt", mimeType = "text/plain", size = 10L, etag = "v1") val support = FileActionSupport(sharing = true, platformEditor = true) - val plan = planFilesScreenActions(file, support, writesBlocked = true) - - 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 for the delete.") - assertEquals("Wait until the delete of this item or its folder finishes.", action.disabledReason) + 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(plan.action(FileMenuAction.Details).enabled) assertTrue(planFilesScreenActions(file, support).action(FileMenuAction.Rename).enabled) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt index e57f382be..9a1904243 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -9,6 +9,7 @@ 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 @@ -217,8 +218,8 @@ class FileDeleteFlowTest { })::delete, ) { error("not read") } - assertEquals(WRITE_BLOCKED_BY_DELETE, deletes.writeBlockedMessage("Documents/New folder")) - assertEquals(WRITE_BLOCKED_BY_DELETE, deletes.writeBlockedMessage("Documents/Sub/notes.md")) + 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")) @@ -241,7 +242,7 @@ class FileDeleteFlowTest { ) { error("not read") } assertNull(deletes.writeBlockedMessage("Documents/new.txt")) - assertEquals(WRITE_BLOCKED_BY_DELETE, deletes.writeBlockedMessage("Documents/report.txt")) + assertEquals(FileWriteBlock.DeleteRunning.message, deletes.writeBlockedMessage("Documents/report.txt")) release.complete(Unit) deletes.awaitCompletion(state) } @@ -258,7 +259,7 @@ class FileDeleteFlowTest { 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 = "Refresh Documents before changing Sub or anything inside it." + 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")) @@ -297,13 +298,82 @@ class FileDeleteFlowTest { releaseRead.complete(Unit) staleRead.await() - assertEquals("Refresh Documents before changing report.txt or anything inside it.", deletes.writeBlockedMessage("Documents/report.txt")) + assertEquals("Open or refresh Documents before changing report.txt or anything inside it.", deletes.writeBlockedMessage("Documents/report.txt")) assertEquals( - "Refresh your Nextcloud root before changing Documents or anything inside it.", - writeBlockedUntilRefresh("Documents"), + "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 `missing item is reported as no longer in the folder, not as deleted`(): Unit = runBlocking { val deletes = coordinator() @@ -640,6 +710,15 @@ class FileDeleteFlowTest { 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 { + start(assertNotNull(state.begin()), FakeDeleteBoundary(deleteResult = result)::delete) { error("not read") }.join() + return assertNotNull(nextCompletion).also { assertTrue(consume(it)) } + } + private suspend fun FileDeleteCoordinator.awaitCompletion(state: FileDeleteDialogState): FileDeleteScreenEffect { while (true) { val completion = nextCompletion diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt index c51bf61bd..1fa89bcf0 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt @@ -42,7 +42,81 @@ internal class DesktopFileMutationFollowUps( } } - private class Pending(var refresh: () -> Throwable?, val waiters: MutableList = mutableListOf()) + 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 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. + */ + inline fun reconcilingUnknownResult(mutation: (onAcceptedOrUnknownResult: () -> Unit) -> T): T { + var queued = true + try { + return mutation { queued = onAcceptedOrUnknownResult() } + } catch (unknown: DesktopFileSyncAmbiguousMutationException) { + if (queued) throw unknown + throw DesktopFileMutationReconciliationDroppedException(unknown) + } + } + + /** + * 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() @@ -57,63 +131,23 @@ internal class DesktopFileMutationFollowUps( /** Paths waiting behind the running refresh. Bounded by [maximumPending]. */ internal val pendingCount: Int get() = synchronized(lock) { pending.size } - /** - * Refreshes [paths] after the server confirmed a mutation and waits at most - * [completionWaitMillis]. [refresh] returns the first local failure it recovered from. - */ + /** 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 { - val waiters = paths.distinct().map { path -> - Waiter().also { waiter -> - if (!submit(Key(accountId, path), { refresh(path) }, waiter)) waiter.finish(FileMutationLocalFollowUp.Failed) - } - } - try { - withTimeoutOrNull(completionWaitMillis) { waiters.forEach { it.done.await() } } - } finally { - synchronized(lock) { pending.values.forEach { entry -> entry.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 - } + ): 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 = - paths.distinct().map { path -> submit(Key(accountId, path), { refresh(path) }, waiter = null) }.all { it } - - /** - * Runs [mutation], which reports an interrupted exchange through its callback. When the - * reconciliation for that unknown result cannot be queued, the failure says so explicitly. - */ - inline fun reconcilingUnknownResult( - accountId: String, - paths: List, - noinline refresh: (path: String) -> Throwable?, - mutation: (onUnknownResult: () -> Unit) -> T, - ): T { - var queued = true - try { - return mutation { queued = afterUnknownResult(accountId, paths, refresh) } - } catch (unknown: DesktopFileSyncAmbiguousMutationException) { - if (queued) throw unknown - throw DesktopFileMutationReconciliationDroppedException(unknown) - } - } + mutation(accountId, paths, refresh).onAcceptedOrUnknownResult() /** * Drops [accountId]'s queued refreshes at sign-out or account removal; their waiters and those @@ -124,35 +158,52 @@ internal class DesktopFileMutationFollowUps( */ fun abandonAccount(accountId: String) { val abandoned = synchronized(lock) { - val keys = pending.keys.filter { it.accountId == accountId } - val current = running?.takeIf { (key, _) -> key.accountId == accountId }?.second?.waiters.orEmpty() - current.toList() + keys.flatMap { key -> requireNotNull(pending.remove(key)).waiters } + 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?, - ): Boolean { - val accepted = synchronized(lock) { - if (!scope.isActive) return@synchronized false - val entry = pending[key] - when { - entry != null -> entry.refresh = refresh - pending.size >= maximumPending -> return@synchronized false - else -> pending[key] = Pending(refresh) + 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 } } - waiter?.let { requireNotNull(pending[key]).waiters += it } - if (!draining) { + if (finished == null) waiter?.let { entry.waiters += it } + if (!draining && pending.isNotEmpty()) { draining = true startDrain() } - true + entry } - if (!accepted) recordOutcome(key.accountId, "dropped", "FILE_MUTATION_LOCAL_FOLLOW_UP_DROPPED") - return accepted + finished?.let { result -> waiter?.finish(result) } + if (entry == null) recordOutcome(key.accountId, "dropped", "FILE_MUTATION_LOCAL_FOLLOW_UP_DROPPED") + return entry } private fun startDrain() { @@ -165,7 +216,7 @@ internal class DesktopFileMutationFollowUps( private fun abandonQueue() { val abandoned = synchronized(lock) { draining = false - pending.values.flatMap { it.waiters }.also { pending.clear() } + pending.values.flatMap { entry -> entry.abandon() }.also { pending.clear() } } abandoned.forEach { it.finish(FileMutationLocalFollowUp.Failed) } } @@ -196,8 +247,10 @@ internal class DesktopFileMutationFollowUps( 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) } } 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 39fa8c807..1a66a7ea5 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -4833,9 +4833,9 @@ class DesktopNextcloudServices( } } val (accountId, cacheProducer) = fileReadCache.producerFor(session) - val affectedPaths = listOfNotNull(spec.sourcePath, spec.destinationPath) fun refreshLocalState(path: String) = refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) - val response = fileMutationFollowUps.reconcilingUnknownResult(accountId, affectedPaths, ::refreshLocalState) { onUnknownResult -> request( + 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, @@ -4845,7 +4845,7 @@ class DesktopNextcloudServices( onAmbiguousMutationResult = onUnknownResult, ) } if (response.status !in 200..299) throw fileOperationException(response.status, spec.sourceIsDirectory) - NextcloudFileMutationResult(spec.destinationPath, response.etag, fileMutationFollowUps.afterConfirmedMutation(accountId, affectedPaths, ::refreshLocalState)) + 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 index d356d81db..d8f1adff3 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt @@ -274,13 +274,13 @@ class DesktopFileMutationFollowUpsTest { val interrupted = DesktopFileSyncAmbiguousMutationException(java.io.IOException("reset")) val queued = assertFailsWith { - followUps.reconcilingUnknownResult("account", listOf("source"), { null }) { onUnknown -> + followUps.mutation("account", listOf("source")) { null }.reconcilingUnknownResult { onUnknown -> onUnknown() throw interrupted } } val dropped = assertFailsWith { - followUps.reconcilingUnknownResult("account", listOf("source", "destination"), { null }) { onUnknown -> + followUps.mutation("account", listOf("source", "destination")) { null }.reconcilingUnknownResult { onUnknown -> onUnknown() throw interrupted } @@ -289,7 +289,127 @@ class DesktopFileMutationFollowUpsTest { assertSame(interrupted, queued, "A queued reconciliation keeps the original unknown result.") assertSame(interrupted, dropped.cause) assertFalse(followUps.afterUnknownResult("account", listOf("other")) { null }) - assertEquals("ok", followUps.reconcilingUnknownResult("account", listOf("other"), { null }) { "ok" }) + 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 From 21187c099ad7dee338f97ea7ae9e7d25abe5e5d3 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:51:43 +0000 Subject: [PATCH 12/30] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 6bd0b55d1..b4b31a3c8 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -602,15 +602,15 @@ "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": "cb47b3791da6d95560747a2dec717744600ea5314df74468b462c065cc5957e7", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileActionPlanning.kt": "081a92d578eca1f42b2f1e48b1b5d78b7449c7670f9ae00a1359e8226493b709", + "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": "9e0dcb476ec638c5847f32064a660f2d49dac9bba84d87ddf03a1b0011bcbcc2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt": "6b110a2001ca86a8f6850311a208aa78d3c0b0cb6f1c089c8eb41891a660777e", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt": "f0f1bbabf439a2aead3ab95b4acc751aac0029d5da2ed2246614ae4c99eb55b5", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt": "b0a52c940d5f4807293c77446fdbc22da9b4bef994328f570e4dbab6cf1651b5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "62d53d9a93598826e943e615a64de6fd33650769593f98193d6b1222c12abc7f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIdentityResolution.kt": "f8658c54cf14dec5b60037a27770ea3c2eb06b509bd28d3b9c97c228adfae83a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIntegrationPlanning.kt": "312ace8532d4f7aca78eb50ee5e35afd33bf77923b7729987c3906968b92fdda", @@ -650,9 +650,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistorySection.kt": "b3205f33d9072dec6715b9a3e643dcc4c76fbebe166281d610f752938cb1d8c3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionRestoreHttpPolicy.kt": "2887700cfc59c51ebcc4ec87575b037c74bd7e47e33568f46ace10bf2a1fc2c5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesRequestState.kt": "0e88380b24d51e1b9fb12931b471f9cc4c5e544714a9c0964eaded177905e5dc", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt": "983703f3568f08b0bca2f6978adcfbae34b0d5f3a4452a3ec748356cc52f311b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceInspector.kt": "eb98f79e94f5ceb5f0b99eaaaa139337ffade1e35ccf54f59f0e4cc72becd654", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceItems.kt": "94ae37559403ef2ca1f93009ea12e652ba7bfa7ab6857caa9c8571393169a0b9", + "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", @@ -746,7 +746,7 @@ "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": "9fffe98e8f1de3d13d48e9a7d51a7651c05beeead6dbfda3f375a6a9760b139c", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "2f52652ff8179ca26c8d206b553adc6cdd3c0db1a55e9e76d1fdccf2f8da5bfa", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "b40fe1f6059825fb6026cbd8a1235b90b14d4a5e2dcb449c36c1cb7848ae2326", "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", From 77ed1c0349677a54b18a8bba4c523f72f43e3027 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Mon, 12 Oct 2026 23:39:59 +0200 Subject: [PATCH 13/30] fix(files): fence every in-app file edit and report Android cache failures The account-scoped delete fence is now a narrow FileWriteFence that the media viewer, photo editing entry, text editor saves, file version restore, and the Files folder picker consult before writing. While a delete of the item or its folder is unverified, or its outcome needs a refresh, these surfaces refuse the change, send nothing, and show the typed reason. On Android, a failed local cache invalidation after a confirmed rename, move, copy, or delete is now reported as incomplete local file status with its cause in diagnostics, instead of plain success. The invalidation and its diagnostic are shared JVM code used by Android and desktop. --- .../AndroidNextcloudServices.kt | 9 +- .../AndroidFileMutationLocalFollowUpTest.kt | 93 ++++++++++++++++++ .../477-delete-dialog-completion.md | 2 +- tools/kotlin-file-size-baseline.txt | 2 +- .../app/FileDeleteCoordinator.kt | 22 ++++- .../app/FileVersionHistorySection.kt | 8 ++ .../app/NextcloudMediaViewer.kt | 14 +-- .../app/NextcloudMediaViewerActions.kt | 36 +++++++ .../nextcloudnative/app/NextcloudNativeApp.kt | 14 +-- .../app/RemoteFolderPickerOperations.kt | 7 +- .../nextcloudnative/app/TextEditorScreen.kt | 4 +- .../nextcloudnative/app/TextEditorState.kt | 7 ++ .../app/TextEditorStateTest.kt | 29 ++++++ .../app/DesktopFileMutationFollowUps.kt | 12 +-- .../app/FileWriteFenceSurfacesTest.kt | 98 +++++++++++++++++++ .../app/MediaViewerLocalFollowUpSceneTest.kt | 40 +++++++- .../app/TextEditorScreenInteractionTest.kt | 8 +- .../preview/NativeTiffMarketingCapture.kt | 3 +- .../preview/RawMediaMarketingCapture.kt | 3 +- .../app/JvmFileMutationLocalFollowUp.kt | 49 ++++++++++ 20 files changed, 418 insertions(+), 42 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileMutationLocalFollowUpTest.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceSurfacesTest.kt create mode 100644 ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmFileMutationLocalFollowUp.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 9a1c60e3f..0d455f62e 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 @@ -2731,11 +2732,9 @@ internal class AndroidNextcloudServices( ) 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) + val localFollowUp = invalidateAfterConfirmedFileMutation(listOfNotNull(spec.sourcePath, spec.destinationPath), + { event -> recordSupportDiagnosticForAccountIdentity(accountId, event) }) { path -> fileReadCache.invalidate(accountId, path) } + NextcloudFileMutationResult(spec.destinationPath, response.etag, localFollowUp) } } } 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..2f9e0c053 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileMutationLocalFollowUpTest.kt @@ -0,0 +1,93 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileMutationLocalFollowUp +import dev.obiente.nextcloudnative.app.NextcloudFile +import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft +import dev.obiente.nextcloudnative.app.invalidateAfterConfirmedFileMutation +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException + +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) + } + + 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/changes/unreleased/477-delete-dialog-completion.md b/changes/unreleased/477-delete-dialog-completion.md index fe8acdbf4..92ebf975b 100644 --- a/changes/unreleased/477-delete-dialog-completion.md +++ b/changes/unreleased/477-delete-dialog-completion.md @@ -4,4 +4,4 @@ pull: none platforms: android, desktop user-facing: yes -Files deletes always finish, even after leaving Files. Other changes to the item wait until it is verified or refreshed, and renames, moves, copies, and deletes report incomplete local file status. +Files deletes always finish, even after leaving Files. Edits from Files, the media viewer, the text editor, and version history wait until a covering delete is verified or refreshed, and file changes report incomplete local status. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 17ba15fb0..b55fd6300 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|4128 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 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt index 931dee3fb..3d3e8b0c6 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt @@ -66,7 +66,7 @@ internal data class FileDeleteCompletion(val request: FileDeleteRequest, val out * permission) stays recorded per resource until a server listing of its folder starts after it. * Until then it also blocks every other write to that item, its folders, and its contents. */ -internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: CoroutineScope) { +internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: CoroutineScope) : FileWriteFence { private class RetryBlock(val message: String, val recordedAt: Long) val inFlight = FileDeleteInFlight() @@ -90,6 +90,8 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co fun writeBlockFor(path: String): FileWriteBlock? = FileWriteBlock.DeleteRunning.takeIf { inFlight.blocks(FileDeleteResourceKey(account, path)) } ?: refreshBlockFor(path) + override fun writeBlock(path: String): FileWriteBlock? = writeBlockFor(path) + fun writeBlockedMessage(path: String): String? = writeBlockFor(path)?.message /** @@ -183,6 +185,24 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co fun close() = job.cancel() } +/** + * 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. + */ +fun interface FileWriteFence { + /** Why a write that creates or changes [path] must wait, or null when it may proceed. */ + fun writeBlock(path: String): FileWriteBlock? + + companion object { + /** For surfaces outside an account session that owns deletes, such as previews. */ + val None = FileWriteFence { null } + } +} + +/** 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 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/NextcloudMediaViewer.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt index ee1812ae7..5d5103b71 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt @@ -82,6 +82,7 @@ fun NextcloudMediaViewer( session: NextcloudSession, userId: String, services: NextcloudPlatformServices, + writeFence: FileWriteFence, taggingAvailable: Boolean, memoriesLivePhotoCapability: MemoriesLivePhotoCapability, sharingCapabilities: NextcloudFileSharingCapabilities, @@ -175,6 +176,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 -> @@ -766,7 +768,7 @@ fun NextcloudMediaViewer( highDetailSourceFile = fullQuality?.source?.file, ) ) { - { editing = true } + { writeBlockNotice = writeFence.writeBlock(selected.path); if (writeBlockNotice == null) editing = true } } else { null }, @@ -849,17 +851,15 @@ fun NextcloudMediaViewer( ) } + writeBlockNotice?.let { block -> MediaWriteBlockedDialog(block) { writeBlockNotice = null } } viewerAction?.let { action -> MediaViewerActionDialog( action = action, file = selected, - session = session, - userId = userId, - services = services, + session = session, userId = userId, + services = services, writeFence = writeFence, 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 7f0d9839f..bbc45d369 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt @@ -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,6 +101,7 @@ internal fun MediaViewerActionDialog( session = session, userId = userId, services = services, + writeFence = writeFence, onDismiss = onDismiss, onMoved = onSourceRemoved, onMutationRunningChanged = onMutationRunningChanged, @@ -107,6 +111,7 @@ internal fun MediaViewerActionDialog( session = session, userId = userId, services = services, + writeFence = writeFence, onDismiss = onDismiss, onDeleted = onSourceRemoved, onMutationRunningChanged = onMutationRunningChanged, @@ -145,6 +150,7 @@ private fun MediaTransferDialog( session: NextcloudSession, userId: String, services: NextcloudPlatformServices, + writeFence: FileWriteFence, onDismiss: () -> Unit, onMoved: () -> Unit, onMutationRunningChanged: (Boolean) -> Unit, @@ -212,6 +218,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 { @@ -247,6 +258,7 @@ private fun MediaDeleteDialog( session: NextcloudSession, userId: String, services: NextcloudPlatformServices, + writeFence: FileWriteFence, onDismiss: () -> Unit, onDeleted: () -> Unit, onMutationRunningChanged: (Boolean) -> Unit, @@ -286,6 +298,10 @@ private fun MediaDeleteDialog( error = "Refresh the media item before deleting it." return@Button } + writeFence.writeBlock(file.path)?.let { block -> + error = block.message + return@Button + } running = true error = null scope.launch { @@ -313,6 +329,26 @@ private fun MediaDeleteDialog( ) } +/** 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.Delete, + 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. 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 be576685d..c71e62d02 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -1888,8 +1888,8 @@ private fun AuthenticatedApp( selected = snapshot.selected, session = session, userId = serverInfo?.userId.orEmpty(), - services = services, - taggingAvailable = serverInfo?.apps?.any { it.id == "memories" } == true, + services = services, writeFence = fileDeletes, + taggingAvailable =serverInfo?.apps?.any { it.id == "memories" } == true, memoriesLivePhotoCapability = memoriesLivePhotoCapability, sharingCapabilities = serverInfo?.fileSharing ?: NextcloudFileSharingCapabilities.Unavailable, @@ -1917,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, @@ -1941,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, @@ -7280,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, @@ -10448,7 +10448,7 @@ internal enum class MediaTileLayout { @Composable private fun FileInfoScreen( - services: NextcloudPlatformServices, + services: NextcloudPlatformServices, writeFence: FileWriteFence, session: NextcloudSession, userId: String, file: NextcloudFile, @@ -10498,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/RemoteFolderPickerOperations.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt index 0d7bb8b4c..847397473 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,22 @@ internal suspend fun RemoteFolderPickerOperations.createAndConfirmDestination( } } +/** [writeFence] refuses creating a folder inside one whose delete is unverified or needs a refresh. */ fun remoteFolderPickerOperations( services: NextcloudPlatformServices, session: NextcloudSession, userId: String, + writeFence: FileWriteFence = FileWriteFence.None, ): 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) + }, ) } 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/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/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt index 1fa89bcf0..500f62a4f 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt @@ -258,17 +258,7 @@ internal class DesktopFileMutationFollowUps( private fun recordOutcome(accountId: String, outcome: String, code: String, failure: Throwable? = null) { try { - recordDiagnostic( - accountId, - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Warning, - component = SupportDiagnosticComponent.Files, - operation = "files.mutation-local-follow-up", - outcome = outcome, - code = code, - exception = failure?.toSupportDiagnosticExceptionDraft(), - ), - ) + recordDiagnostic(accountId, fileMutationLocalFollowUpDiagnostic(outcome, code, failure)) } catch (diagnosticFailure: Exception) { // Diagnostics must never change the reported mutation result. failure?.addSuppressed(diagnosticFailure) 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/MediaViewerLocalFollowUpSceneTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt index 4ab63da74..df195a01f 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt @@ -65,14 +65,51 @@ class MediaViewerLocalFollowUpSceneTest { } } + @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.Delete, + 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) = + private fun dialog( + action: MediaViewerAction, + followUp: FileMutationLocalFollowUp, + events: MutableList, + fence: FileWriteFence = FileWriteFence.None, + ) = MediaViewerActionDialog( action = action, file = photo, session = session, userId = "alice", services = services(followUp), + writeFence = fence, sharingCapabilities = NextcloudFileSharingCapabilities(), onDismiss = { events += "dismissed" }, onSourceRemoved = { events += "removed" }, @@ -90,6 +127,7 @@ class MediaViewerLocalFollowUpSceneTest { "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/TextEditorScreenInteractionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TextEditorScreenInteractionTest.kt index fabf68d6b..7dc614b7d 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, {}, FileWriteFence.None) }) { 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, {}, FileWriteFence.None, 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, {}, FileWriteFence.None) } }) { 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, {}, FileWriteFence.None) }) { 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..97da325fe 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.FileWriteFence 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, writeFence = FileWriteFence.None, taggingAvailable = false, memoriesLivePhotoCapability = MemoriesLivePhotoCapability.NotAdvertised, sharingCapabilities = NextcloudFileSharingCapabilities.Unavailable, 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..800de5555 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.FileWriteFence 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, writeFence = FileWriteFence.None, taggingAvailable = false, memoriesLivePhotoCapability = MemoriesLivePhotoCapability.NotAdvertised, sharingCapabilities = NextcloudFileSharingCapabilities.Unavailable, 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..c7b99f3bf --- /dev/null +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmFileMutationLocalFollowUp.kt @@ -0,0 +1,49 @@ +package dev.obiente.nextcloudnative.app + +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 +} From 877efae2b1170dfa9468f0957ea120b6e1dd671a Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:46:44 +0000 Subject: [PATCH 14/30] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index b4b31a3c8..a33decdf2 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -609,7 +609,7 @@ "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": "f0f1bbabf439a2aead3ab95b4acc751aac0029d5da2ed2246614ae4c99eb55b5", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt": "5982912369ad2fbee0549eac9eaee5b92106b094ec30283d8cf71d8748910770", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt": "b0a52c940d5f4807293c77446fdbc22da9b4bef994328f570e4dbab6cf1651b5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "62d53d9a93598826e943e615a64de6fd33650769593f98193d6b1222c12abc7f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIdentityResolution.kt": "f8658c54cf14dec5b60037a27770ea3c2eb06b509bd28d3b9c97c228adfae83a", @@ -647,7 +647,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncUploadOwnership.kt": "b3dbc9fa663592e783991faaa9eea0b43bf368886b6ecc9834a6ffab019b6f1a", "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/FilesRequestState.kt": "0e88380b24d51e1b9fb12931b471f9cc4c5e544714a9c0964eaded177905e5dc", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt": "9af2699cb3665f9cb83c73ccf9ed1dcd32c6d698b63f815f1f16477e3eee5912", @@ -744,9 +744,9 @@ "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": "9fffe98e8f1de3d13d48e9a7d51a7651c05beeead6dbfda3f375a6a9760b139c", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "b40fe1f6059825fb6026cbd8a1235b90b14d4a5e2dcb449c36c1cb7848ae2326", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "9148a987e38edebe35047138916afa0d6a526e069aa9fcedff31e661ec2f981e", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "481719be3fc0806131fad80661d3547a2fa266d13fcce6c856011f6b415bf15a", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "7aac70ea1947af92cb1099ec8cf283f83aa682bbb7cd4f00a554f8538dc1a15f", "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", @@ -796,7 +796,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": "144e1df2752f922a6b1e011b4f5d774900f6b3235734c8caf99e2ecc3673be19", "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", @@ -815,8 +815,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", @@ -989,10 +989,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": "17e98ea6c1f872430ea3d9da33c33c9b030d699d45463606fef60006ee044458", "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": "6dc8e11662a5d35e79420fcb01d74d7dd305c04c698b45030d4c973073c6424f", "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", From 642e4ad40b04a90a3cbf73258f7872121ea759e8 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 13 Oct 2026 04:53:05 +0200 Subject: [PATCH 15/30] fix(files): share delete write blocks across activities through a process-wide fence Files delete blocks now live in a process-level FileWriteFenceRegistry, keyed by the non-secret local account identity and owned by the desktop services and the Android Application. The Files delete coordinator records and lifts blocks there, so surfaces outside its composition see them too. Every remote folder picker requires an explicit fence: creating a folder and choosing a destination are refused inside a blocked folder, including folder-sync setup. The Android share upload resolves the fence for the account it uploads into, from the same owner as the main activity, and checks it when a destination is chosen, when the upload is queued, and again in the queued upload worker. Blocks stay in memory only, so a new process starts unblocked. Sign-out and account removal drop that account's blocks with its other private state. The architecture notes and changelog name the surfaces that are not paused by a pending delete. --- ADAPTER_ARCHITECTURE.md | 27 +++ .../AndroidAccountOwnedStateCleanup.kt | 3 + .../nextcloudnative/AndroidFileWriteFences.kt | 20 +++ .../AndroidIncomingShareUploadWorker.kt | 6 + .../AndroidNextcloudServices.kt | 5 +- .../AndroidShareUploadActivity.kt | 50 +++--- .../NextcloudNativeApplication.kt | 3 + .../AndroidShareUploadWriteFenceTest.kt | 90 ++++++++++ .../477-delete-dialog-completion.md | 2 +- .../477-delete-write-fence-scope.md | 7 + tools/kotlin-file-size-baseline.txt | 2 +- .../app/FileDeleteCoordinator.kt | 132 +++------------ .../app/FileOfflineCenterScreen.kt | 6 +- .../nextcloudnative/app/FileWriteFence.kt | 157 ++++++++++++++++++ .../app/IncomingShareUploadScreen.kt | 32 ++++ .../nextcloudnative/app/NextcloudNativeApp.kt | 6 +- .../nextcloudnative/app/NextcloudPlatform.kt | 2 +- .../app/RemoteFolderPickerOperations.kt | 16 +- .../nextcloudnative/app/FileDeleteFlowTest.kt | 2 +- .../app/captures/MarketingAppCapture.kt | 5 +- .../app/DesktopNextcloudServices.kt | 6 +- .../app/FileWriteFenceRegistryTest.kt | 150 +++++++++++++++++ .../app/MediaViewerLocalFollowUpSceneTest.kt | 2 +- .../app/TestFileWriteFences.kt | 4 + .../app/TextEditorScreenInteractionTest.kt | 8 +- .../preview/NativeTiffMarketingCapture.kt | 4 +- .../preview/RawMediaMarketingCapture.kt | 4 +- 27 files changed, 586 insertions(+), 165 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileWriteFences.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt create mode 100644 changes/unreleased/477-delete-write-fence-scope.md create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileWriteFence.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt diff --git a/ADAPTER_ARCHITECTURE.md b/ADAPTER_ARCHITECTURE.md index cb892ca2b..1520a9084 100644 --- a/ADAPTER_ARCHITECTURE.md +++ b/ADAPTER_ARCHITECTURE.md @@ -362,6 +362,33 @@ 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 + +A Files delete and its verification are owned by the account session's +`FileDeleteCoordinator`, but the resulting write blocks 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. + +- 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`. 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 is refused while a block applies but does not itself register a block. + ### Groupware (CardDAV and CalDAV) **Multiget requests** 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/AndroidIncomingShareUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt index f02be0886..f62e6347d 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,11 @@ 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. + if (incomingShareUploadBlock(services.fileWriteFences.account(session.accountId), requireNotNull(request.destinationPath)) != null) { + return Result.retry() + } AndroidNotificationCoordinator(applicationContext).ensureChannels() var foregroundPromotionAvailable = setForegroundIfAvailable(request) request = store.beginUpload(requestId) ?: return Result.success() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 0d455f62e..48dfe88c4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -687,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, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt index 4631255f7..1dd6abe2f 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 diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index 2c0c55f2e..5de4d207b 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,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch class NextcloudNativeApplication : Application() { + /** The process's account-scoped write fences, shared by the main activity and the share activity. */ + internal val fileWriteFences = FileWriteFenceRegistry() private val startupRecoveryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var accountCleanupListener: SharedPreferences.OnSharedPreferenceChangeListener? = null 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..5ccd482f3 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt @@ -0,0 +1,90 @@ +package dev.obiente.nextcloudnative + +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.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.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +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().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().account(session.accountId), "Inbox/Old")) + } + + 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 index 92ebf975b..0321359d0 100644 --- a/changes/unreleased/477-delete-dialog-completion.md +++ b/changes/unreleased/477-delete-dialog-completion.md @@ -4,4 +4,4 @@ pull: none platforms: android, desktop user-facing: yes -Files deletes always finish, even after leaving Files. Edits from Files, the media viewer, the text editor, and version history wait until a covering delete is verified or refreshed, and file changes report incomplete local status. +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 b55fd6300..aceb0c9fc 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|4128 +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 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt index 3d3e8b0c6..fb7e2e0af 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -16,35 +15,6 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch -/** 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/") - ) -} - -/** 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) } - - 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) - } -} - internal data class FileDeleteRequest( val key: FileDeleteResourceKey, val token: Long, @@ -62,19 +32,18 @@ internal data class FileDeleteCompletion(val request: FileDeleteRequest, val out * an outcome. Finished outcomes wait until a Files screen consumes them. Only [close], at account * session teardown, cancels unfinished work. * - * 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. - * Until then it also blocks every other write to that item, its folders, and its contents. + * The resulting write blocks live in the account's process-level [fence], so surfaces outside this + * session's composition see them too. 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. */ -internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: CoroutineScope) : FileWriteFence { - private class RetryBlock(val message: String, val recordedAt: Long) - - val inFlight = FileDeleteInFlight() +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() - private val retryBlocks = mutableStateMapOf() - private var sequence = 0L val nextCompletion: FileDeleteCompletion? get() = completions.firstOrNull() @@ -82,26 +51,16 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co * 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? = writeBlockFor(file.path) + 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? = - FileWriteBlock.DeleteRunning.takeIf { inFlight.blocks(FileDeleteResourceKey(account, path)) } ?: refreshBlockFor(path) + fun writeBlockFor(path: String): FileWriteBlock? = fence.writeBlock(path) - override fun writeBlock(path: String): FileWriteBlock? = writeBlockFor(path) + fun writeBlockedMessage(path: String): String? = fence.writeBlock(path)?.message - fun writeBlockedMessage(path: String): String? = writeBlockFor(path)?.message - - /** - * 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. - */ - fun refreshBlockFor(path: String): FileWriteBlock.RefreshRequired? { - val key = FileDeleteResourceKey(account, path) - return retryBlocks.keys.firstOrNull { it.overlaps(key) }?.let { FileWriteBlock.RefreshRequired(it.path) } - } + 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 @@ -117,7 +76,7 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co ): FileDeleteScreenEffect { val effect = dialog.complete(completion) val folder = completion.request.parentPath - if (effect.reloadFolder && folder != shownFolder && completion.request.key in retryBlocks) { + if (effect.reloadFolder && folder != shownFolder && fence.hasRetryBlock(completion.request.key)) { scope.launch { try { readFolder(folder) { read(folder) } @@ -132,23 +91,13 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co } /** Why another delete of [file] must wait for a fresh folder listing, or null. */ - fun retryBlockFor(file: NextcloudFile): String? = retryBlocks[keyFor(file)]?.message + fun retryBlockFor(file: NextcloudFile): String? = fence.retryBlockMessage(keyFor(file)) fun keyFor(file: NextcloudFile) = FileDeleteResourceKey(account, file.path) - /** - * 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. - */ - 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 - } + /** 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, @@ -169,10 +118,7 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co ) // Reject even a non-cooperative service's completion after session teardown. ensureActive() - when (val message = outcome.retryBlockMessage()) { - null -> retryBlocks.remove(request.key) - else -> retryBlocks[request.key] = RetryBlock(message, ++sequence) - } + fence.recordOutcome(request.key, outcome.retryBlockMessage()) completions += FileDeleteCompletion(request, outcome) } finally { inFlight.release(request.key, request.token) @@ -185,51 +131,15 @@ internal class FileDeleteCoordinator(val account: NextcloudAccountId, parent: Co fun close() = job.cancel() } -/** - * 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. - */ -fun interface FileWriteFence { - /** Why a write that creates or changes [path] must wait, or null when it may proceed. */ - fun writeBlock(path: String): FileWriteBlock? - - companion object { - /** For surfaces outside an account session that owns deletes, such as previews. */ - val None = FileWriteFence { null } - } -} - -/** 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." - } -} - @Composable -internal fun rememberFileDeleteCoordinator(session: NextcloudSession): FileDeleteCoordinator { +internal fun rememberFileDeleteCoordinator(session: NextcloudSession, fences: FileWriteFenceRegistry): FileDeleteCoordinator { val parent = rememberCoroutineScope() - val coordinator = remember(session, parent) { FileDeleteCoordinator(session.accountId, parent) } + val coordinator = remember(session, fences, parent) { FileDeleteCoordinator(fences.account(session.accountId), parent) } DisposableEffect(coordinator) { onDispose(coordinator::close) } return coordinator } + /** * 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. 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/FileWriteFence.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileWriteFence.kt new file mode 100644 index 000000000..326c12a91 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileWriteFence.kt @@ -0,0 +1,157 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.mutableStateMapOf + +/** + * 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. + */ +class AccountFileWriteFence internal constructor(val account: NextcloudAccountId) : FileWriteFence { + private class RetryBlock(val message: String, val recordedAt: Long) + + internal val inFlight = FileDeleteInFlight() + private val retryBlocks = mutableStateMapOf() + private var sequence = 0L + + 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. Sign-out and account removal call + * [removeAccount], which drops that account's blocks with its other private state. + */ +class FileWriteFenceRegistry { + private val lock = DynamicNativeMemoryCacheLock() + private val accounts = mutableMapOf() + + fun account(account: NextcloudAccountId): AccountFileWriteFence = lock.withLock { + accounts.getOrPut(account.storageKey) { AccountFileWriteFence(account) } + } + + fun removeAccount(accountStorageKey: String) { + lock.withLock { accounts.remove(accountStorageKey) } + } +} 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..a376147cc 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingShareUploadScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingShareUploadScreen.kt @@ -576,3 +576,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/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index c71e62d02..808838b88 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -684,7 +684,7 @@ private fun AuthenticatedApp( val certificateScope = rememberCoroutineScope() var groupwareMutationInProgress by remember(session) { mutableStateOf(false) } val inlineEditorNavigation = remember(session) { NativeInlineEditorNavigation() } - val fileDeletes = rememberFileDeleteCoordinator(session) + val fileDeletes = rememberFileDeleteCoordinator(session, services.fileWriteFences) var linkNavigationFailure by rememberSaveable( session.serverUrl, session.loginName, @@ -1534,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 }, @@ -1600,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, 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 847397473..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,12 +55,15 @@ internal suspend fun RemoteFolderPickerOperations.createAndConfirmDestination( } } -/** [writeFence] refuses creating a folder inside one whose delete is unverified or needs a refresh. */ +/** + * [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 = FileWriteFence.None, + writeFence: FileWriteFence, ): RemoteFolderPickerOperations { require(userId.isNotBlank()) return RemoteFolderPickerOperations( @@ -71,6 +74,10 @@ fun remoteFolderPickerOperations( 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 + }, ) } @@ -79,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/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt index 9a1904243..db9a0bcd6 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -699,7 +699,7 @@ class FileDeleteFlowTest { /** The event loop stays the test's, but its job does not keep the test running. */ private fun CoroutineScope.coordinator(accountId: NextcloudAccountId = account) = - FileDeleteCoordinator(accountId, CoroutineScope(coroutineContext.minusKey(Job))) + FileDeleteCoordinator(FileWriteFenceRegistry().account(accountId), CoroutineScope(coroutineContext.minusKey(Job))) /** Starts the open dialog's delete and applies its outcome as a Files screen would. */ private suspend fun FileDeleteCoordinator.finish( 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 60789b002..ddbb0bcab 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,7 @@ 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 dev.obiente.nextcloudnative.app.design.NextcloudAppBackground import dev.obiente.nextcloudnative.app.design.NextcloudDestination import dev.obiente.nextcloudnative.app.design.NextcloudNativeTheme @@ -87,7 +88,7 @@ fun NextcloudNativeMarketingCapture( FilesScreen( services = assets.services, session = marketingHomepageSession, - fileDeletes = rememberFileDeleteCoordinator(marketingHomepageSession), + fileDeletes = rememberFileDeleteCoordinator(marketingHomepageSession, remember { FileWriteFenceRegistry() }), userId = marketingHomepageTalkUserId, fileSharing = nextcloudNativeMarketingFileShareFixture.capabilities, path = "", @@ -104,7 +105,7 @@ fun NextcloudNativeMarketingCapture( FilesScreen( services = assets.services, session = marketingHomepageSession, - fileDeletes = rememberFileDeleteCoordinator(marketingHomepageSession), + fileDeletes = rememberFileDeleteCoordinator(marketingHomepageSession, remember { FileWriteFenceRegistry() }), userId = marketingHomepageTalkUserId, fileSharing = nextcloudNativeMarketingFileShareFixture.capabilities, path = "", 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 1a66a7ea5..8e5fda5c5 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1340,6 +1340,7 @@ class DesktopNextcloudServices( 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() private var backgroundFileSyncJob: Job? = null private val mutableFileSyncTraySnapshot = MutableStateFlow( DesktopFileSyncTraySnapshot( @@ -3258,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) @@ -3844,6 +3843,7 @@ class DesktopNextcloudServices( private fun fenceDesktopAccountPrivateCaches(accountStorageKey: String, fileCacheAccountId: String) { fileReadCache.retireAccount(fileCacheAccountId) fileMutationFollowUps.abandonAccount(fileCacheAccountId) + fileWriteFences.removeAccount(accountStorageKey) sharedJvmAuthenticatedAppReadSessions.retireAccount(accountStorageKey) dynamicDiscoveryCache.fenceAccount(accountStorageKey) } 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..de674f3ec --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt @@ -0,0 +1,150 @@ +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 kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +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 = FileWriteFenceRegistry() + 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) + job.join() + assertNull(incomingShareUploadBlock(registry.account(alice.accountId), "Projects/Old"), "A verified delete lifts it.") + deletes.close() + } + + @Test + fun `sign-out or removal drops that account's blocks and keeps other accounts'`(): Unit = runBlocking { + val registry = FileWriteFenceRegistry() + 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 = FileWriteFenceRegistry() + 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 `the registry hands out one fence per account`() { + val registry = FileWriteFenceRegistry() + + 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) } + deletes.start(assertNotNull(dialog.begin()), { throw fileOperationException(412) }) { error("not read") }.join() + deletes.close() + } + + private fun CoroutineScope.coordinator(registry: FileWriteFenceRegistry, session: NextcloudSession) = + FileDeleteCoordinator(registry.account(session.accountId), 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/MediaViewerLocalFollowUpSceneTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt index df195a01f..f93b786e9 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt @@ -101,7 +101,7 @@ class MediaViewerLocalFollowUpSceneTest { action: MediaViewerAction, followUp: FileMutationLocalFollowUp, events: MutableList, - fence: FileWriteFence = FileWriteFence.None, + fence: FileWriteFence = NoFileWriteBlocksForTests, ) = MediaViewerActionDialog( action = action, 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..3e38e5375 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt @@ -0,0 +1,4 @@ +package dev.obiente.nextcloudnative.app + +/** Test-only fence for scenes that do not exercise deletes; production callers always pass a real fence. */ +internal val NoFileWriteBlocksForTests = FileWriteFence { null } 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 7dc614b7d..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, {}, FileWriteFence.None) + 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, {}, FileWriteFence.None, + 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, {}, FileWriteFence.None) + 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, {}, FileWriteFence.None) + 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 97da325fe..cab12fb39 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,7 +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.FileWriteFence +import dev.obiente.nextcloudnative.app.NoFileWriteBlocksForTests 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 @@ -52,7 +52,7 @@ internal class NativeTiffMarketingCapture private constructor( selected = tiffFile, session = fixtureSession, userId = FIXTURE_USER_ID, - services = services, writeFence = FileWriteFence.None, + services = services, writeFence = NoFileWriteBlocksForTests, taggingAvailable = false, memoriesLivePhotoCapability = MemoriesLivePhotoCapability.NotAdvertised, sharingCapabilities = NextcloudFileSharingCapabilities.Unavailable, 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 800de5555..b01a8de7a 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,7 +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.FileWriteFence +import dev.obiente.nextcloudnative.app.NoFileWriteBlocksForTests 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 @@ -63,7 +63,7 @@ internal class RawMediaMarketingCapture private constructor( selected = rawFile, session = session, userId = FIXTURE_USER_ID, - services = services, writeFence = FileWriteFence.None, + services = services, writeFence = NoFileWriteBlocksForTests, taggingAvailable = false, memoriesLivePhotoCapability = MemoriesLivePhotoCapability.NotAdvertised, sharingCapabilities = NextcloudFileSharingCapabilities.Unavailable, From 4a8ab4ddc76dcdf55098262b90846ff8481cd29e Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:00:19 +0000 Subject: [PATCH 16/30] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index a33decdf2..c952b394d 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -147,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", @@ -609,13 +610,13 @@ "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": "5982912369ad2fbee0549eac9eaee5b92106b094ec30283d8cf71d8748910770", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt": "bcd844d0b1af044a39fedf7b147f0251fc414f3c40b1f17c06bba71a5ee8e24d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt": "b0a52c940d5f4807293c77446fdbc22da9b4bef994328f570e4dbab6cf1651b5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "62d53d9a93598826e943e615a64de6fd33650769593f98193d6b1222c12abc7f", "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": "808e2f32ef313280c60f0f06ac4d6e7eb95746c20bdc3418deeb66cff6ea60ba", @@ -649,6 +650,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistory.kt": "7d01527acc00eafe092885d19f318734263597b4c2c5c09cf39159d174a371dd", "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": "126190b25c73a26a31e9f041184eb30ae525abd17ea141dad53a9d574eebe6e1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesRequestState.kt": "0e88380b24d51e1b9fb12931b471f9cc4c5e544714a9c0964eaded177905e5dc", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt": "9af2699cb3665f9cb83c73ccf9ed1dcd32c6d698b63f815f1f16477e3eee5912", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceInspector.kt": "a1f9032f4086100492f64d7e596c45f8b06320bf2cb02c97f7404c793b941398", @@ -683,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": "3af54afe00ec00e21f0ff8a98f56fbbb26d25f87e74605589b6fddd8fcb8bc93", "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", @@ -746,12 +748,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "d1a9fbf7d82b48ed227708221eec71c27dcc9828fb12467d132bde9a6a00f6f8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "9148a987e38edebe35047138916afa0d6a526e069aa9fcedff31e661ec2f981e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "481719be3fc0806131fad80661d3547a2fa266d13fcce6c856011f6b415bf15a", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "7aac70ea1947af92cb1099ec8cf283f83aa682bbb7cd4f00a554f8538dc1a15f", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "8b5ba6e2238ed41cdb60e0fe396e6ed0f2470fac78cd0c9c5984775a7c5078ee", "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", @@ -796,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": "144e1df2752f922a6b1e011b4f5d774900f6b3235734c8caf99e2ecc3673be19", + "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", @@ -958,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": "7f40a93c4342e84260def956f28b7c8ae76d7e2cfe7823ce9951a315e8113b17", + "ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/captures/MarketingAppCapture.kt": "26c70530ffd28a0369ec1336ae3de9b3f074a3bbf945cb1cb086cb28dc97e28a", "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", @@ -989,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": "17e98ea6c1f872430ea3d9da33c33c9b030d699d45463606fef60006ee044458", + "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NativeTiffMarketingCapture.kt": "f323dbb0da0eadbfb117578df3566c7a4808b051589099e6891faa5741a21285", "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": "6dc8e11662a5d35e79420fcb01d74d7dd305c04c698b45030d4c973073c6424f", + "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/RawMediaMarketingCapture.kt": "26bdcde616b0c3087086ff79f18050eb50446d5c75c70b9000ab73e1ff3deef1", "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", From 909ff81b738c1e773266bdee43402bf005618ff0 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 13 Oct 2026 06:20:28 +0200 Subject: [PATCH 17/30] fix(files): run Files deletes in the process-level account owner Files deletes and their verification now run in each account's process-level fence owner, under a main-thread scope held by the desktop services and the Android Application. A composition starts a delete and observes its state; leaving Files or recreating the activity no longer cancels the request or releases its block, and a recreated screen sees the same block and receives the pending outcome. A delete cancelled before its request could be sent releases the item unchanged. Cancelled after it may have been sent, it records a refresh-required block, so an item is never released without an outcome. Sign-out and account removal cancel that account's deletes and drop its fence object, so a late completion cannot reach a later incarnation of the same account. --- ADAPTER_ARCHITECTURE.md | 17 +++- .../NextcloudNativeApplication.kt | 8 +- .../AndroidShareUploadWriteFenceTest.kt | 6 +- .../app/FileDeleteCoordinator.kt | 53 +++++++---- .../nextcloudnative/app/FileDeleteFlow.kt | 4 +- .../nextcloudnative/app/FileWriteFence.kt | 21 +++-- .../nextcloudnative/app/FileDeleteFlowTest.kt | 27 +++++- .../app/captures/MarketingAppCapture.kt | 5 +- .../app/DesktopNextcloudServices.kt | 2 +- .../app/FileWriteFenceRegistryTest.kt | 94 +++++++++++++++++-- 10 files changed, 186 insertions(+), 51 deletions(-) diff --git a/ADAPTER_ARCHITECTURE.md b/ADAPTER_ARCHITECTURE.md index 1520a9084..cb7a15676 100644 --- a/ADAPTER_ARCHITECTURE.md +++ b/ADAPTER_ARCHITECTURE.md @@ -364,12 +364,23 @@ keeps the source ETag and no-overwrite preconditions. ### Files delete write fence -A Files delete and its verification are owned by the account session's -`FileDeleteCoordinator`, but the resulting write blocks live in an +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. +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. + +- 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, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index 5de4d207b..186bc65fd 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt @@ -18,8 +18,12 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch class NextcloudNativeApplication : Application() { - /** The process's account-scoped write fences, shared by the main activity and the share activity. */ - internal val fileWriteFences = FileWriteFenceRegistry() + /** + * 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/AndroidShareUploadWriteFenceTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt index 5ccd482f3..ad205add2 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt @@ -15,6 +15,8 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.runBlocking class AndroidShareUploadWriteFenceTest { @@ -51,7 +53,7 @@ class AndroidShareUploadWriteFenceTest { @Test fun folderPermissionsStillApplyWhenTheFenceAllows(): Unit = runBlocking { val operations = androidIncomingShareFolderPickerOperations( - remoteFolderPickerOperations(services(mutableListOf()), session, "alice", FileWriteFenceRegistry().account(session.accountId)), + remoteFolderPickerOperations(services(mutableListOf()), session, "alice", FileWriteFenceRegistry(CoroutineScope(Job())).account(session.accountId)), ) { access(canCreateFiles = false, canCreateDirectories = false) } assertEquals( @@ -65,7 +67,7 @@ class AndroidShareUploadWriteFenceTest { fun queueingIntoABlockedFolderIsRefusedAndAnUnblockedAccountIsNot() { assertEquals(block, incomingShareUploadBlock(fence, "Inbox/Old")) assertNull(incomingShareUploadBlock(fence, "Inbox")) - assertNull(incomingShareUploadBlock(FileWriteFenceRegistry().account(session.accountId), "Inbox/Old")) + assertNull(incomingShareUploadBlock(FileWriteFenceRegistry(CoroutineScope(Job())).account(session.accountId), "Inbox/Old")) } private fun access(canCreateFiles: Boolean = false, canCreateDirectories: Boolean = false) = diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt index fb7e2e0af..9dc2831a2 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt @@ -1,17 +1,17 @@ package dev.obiente.nextcloudnative.app import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope 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 @@ -27,16 +27,18 @@ internal data class FileDeleteRequest( internal data class FileDeleteCompletion(val request: FileDeleteRequest, val outcome: FileDeleteOutcome) /** - * Owns Files deletes for one account session. A delete and its verification outlive the dialog and - * the Files screen, so leaving either never cancels the request or releases the resource without - * an outcome. Finished outcomes wait until a Files screen consumes them. Only [close], at account - * session teardown, cancels unfinished work. + * 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 the account's process-level [fence], so surfaces outside this - * session's composition see them too. 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. + * 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 @@ -103,7 +105,10 @@ internal class FileDeleteCoordinator(val fence: AccountFileWriteFence, parent: C request: FileDeleteRequest, delete: suspend (NextcloudFileMutation.Delete) -> NextcloudFileMutationResult, readFolder: suspend (String) -> NextcloudFileListing, - ): Job = scope.launch { + ): 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, @@ -113,14 +118,22 @@ internal class FileDeleteCoordinator(val fence: AccountFileWriteFence, parent: C val outcome = deleteFileWithVerifiedOutcome( target = request.file, expectedEtag = request.expectedEtag, - delete = { delete(mutation) }, + delete = { + currentCoroutineContext().ensureActive() + maySendRequest = true + delete(mutation) + }, readParent = { readFolder(request.parentPath) }, ) - // Reject even a non-cooperative service's completion after session teardown. + // 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) } } @@ -131,13 +144,13 @@ internal class FileDeleteCoordinator(val fence: AccountFileWriteFence, parent: C 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 { - val parent = rememberCoroutineScope() - val coordinator = remember(session, fences, parent) { FileDeleteCoordinator(fences.account(session.accountId), parent) } - DisposableEffect(coordinator) { onDispose(coordinator::close) } - return coordinator -} +internal fun rememberFileDeleteCoordinator(session: NextcloudSession, fences: FileWriteFenceRegistry): FileDeleteCoordinator = + remember(session, fences) { fences.account(session.accountId).deletes } /** diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt index 9e486a98d..707726561 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt @@ -88,9 +88,7 @@ private suspend fun verifyFileDeletePostcondition( expectedEtag: String, readParent: suspend () -> NextcloudFileListing, ): FileDeleteOutcome { - val unconfirmed = FileDeleteOutcome.RefreshRequired( - "Could not confirm whether ${target.name} was deleted. Refresh the folder before trying again.", - ) + val unconfirmed = FileDeleteOutcome.RefreshRequired(unconfirmedDeleteMessage(target.name)) val listing = try { readParent() } catch (cancelled: CancellationException) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileWriteFence.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileWriteFence.kt index 326c12a91..d6eab0c51 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileWriteFence.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileWriteFence.kt @@ -1,6 +1,7 @@ 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, @@ -82,14 +83,20 @@ internal class FileDeleteInFlight { * 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) : FileWriteFence { +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) @@ -140,18 +147,20 @@ interface FileWriteFencePlatformServices { /** * 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. Sign-out and account removal call - * [removeAccount], which drops that account's blocks with its other private state. + * 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 { +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) } + accounts.getOrPut(account.storageKey) { AccountFileWriteFence(account, scope) } } fun removeAccount(accountStorageKey: String) { - lock.withLock { accounts.remove(accountStorageKey) } + lock.withLock { accounts.remove(accountStorageKey) }?.deletes?.close() } } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt index db9a0bcd6..59b1b27db 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -519,7 +519,7 @@ class FileDeleteFlowTest { } @Test - fun `account session teardown cancels the delete and releases the item without an outcome`(): Unit = + 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) } @@ -537,10 +537,28 @@ class FileDeleteFlowTest { assertTrue(job.isCancelled) assertNull(deletes.nextCompletion) assertFalse(state.running) - assertNull(state.error) - assertTrue(fake.reads.isEmpty(), "Cancellation is not an unknown result to verify.") + 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() + 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() @@ -564,6 +582,7 @@ class FileDeleteFlowTest { assertNull(deletes.nextCompletion) assertFalse(state.running) assertEquals(report, state.target) + assertTrue(state.retryBlocked, "The unpublished result leaves the item waiting for a refresh.") } @Test @@ -699,7 +718,7 @@ class FileDeleteFlowTest { /** The event loop stays the test's, but its job does not keep the test running. */ private fun CoroutineScope.coordinator(accountId: NextcloudAccountId = account) = - FileDeleteCoordinator(FileWriteFenceRegistry().account(accountId), CoroutineScope(coroutineContext.minusKey(Job))) + 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( 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 ddbb0bcab..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 @@ -4,6 +4,7 @@ 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 @@ -88,7 +89,7 @@ fun NextcloudNativeMarketingCapture( FilesScreen( services = assets.services, session = marketingHomepageSession, - fileDeletes = rememberFileDeleteCoordinator(marketingHomepageSession, remember { FileWriteFenceRegistry() }), + fileDeletes = rememberFileDeleteCoordinator(marketingHomepageSession, rememberCoroutineScope().let { scope -> remember(scope) { FileWriteFenceRegistry(scope) } }), userId = marketingHomepageTalkUserId, fileSharing = nextcloudNativeMarketingFileShareFixture.capabilities, path = "", @@ -105,7 +106,7 @@ fun NextcloudNativeMarketingCapture( FilesScreen( services = assets.services, session = marketingHomepageSession, - fileDeletes = rememberFileDeleteCoordinator(marketingHomepageSession, remember { FileWriteFenceRegistry() }), + 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/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 8e5fda5c5..9bea6750d 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1340,7 +1340,7 @@ class DesktopNextcloudServices( 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() + override val fileWriteFences = FileWriteFenceRegistry(CoroutineScope(serviceScope.coroutineContext + Dispatchers.Main)) private var backgroundFileSyncJob: Job? = null private val mutableFileSyncTraySnapshot = MutableStateFlow( DesktopFileSyncTraySnapshot( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt index de674f3ec..f04a07f0c 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt @@ -7,9 +7,14 @@ 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.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.withContext import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking @@ -21,7 +26,7 @@ class FileWriteFenceRegistryTest { @Test fun `a Files delete blocks the sync-center picker and the share upload of that account only`(): Unit = runBlocking { - val registry = FileWriteFenceRegistry() + val registry = registry() val release = CompletableDeferred() val deletes = coordinator(registry, alice) val dialog = FileDeleteDialogState(deletes).apply { open(folder) } @@ -55,12 +60,11 @@ class FileWriteFenceRegistryTest { release.complete(Unit) job.join() assertNull(incomingShareUploadBlock(registry.account(alice.accountId), "Projects/Old"), "A verified delete lifts it.") - deletes.close() } @Test fun `sign-out or removal drops that account's blocks and keeps other accounts'`(): Unit = runBlocking { - val registry = FileWriteFenceRegistry() + val registry = registry() refreshRequired(registry, alice) refreshRequired(registry, bob) @@ -72,7 +76,7 @@ class FileWriteFenceRegistryTest { @Test fun `a refresh block lifts only through a listing that started after the outcome`(): Unit = runBlocking { - val registry = FileWriteFenceRegistry() + val registry = registry() val fence = registry.account(alice.accountId) val readStarted = CompletableDeferred() val releaseRead = CompletableDeferred() @@ -100,9 +104,82 @@ class FileWriteFenceRegistryTest { assertEquals(RemoteFolderSelectionAccess.Allowed, picker.selectionAccess("Projects/Old")) } + @Test + fun `disposing the composition that started a delete neither cancels it nor releases its block`() { + val registry = FileWriteFenceRegistry(CoroutineScope(SupervisorJob() + Dispatchers.Default)) + 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 } + } + + // 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) + runBlocking { assertNotNull(job).join() } + 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) + 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() + val registry = FileWriteFenceRegistry(CoroutineScope(Job())) assertTrue(registry.account(alice.accountId) === registry.account(alice.accountId)) assertTrue(registry.account(alice.accountId) !== registry.account(bob.accountId)) @@ -113,11 +190,12 @@ class FileWriteFenceRegistryTest { val deletes = coordinator(registry, session) val dialog = FileDeleteDialogState(deletes).apply { open(folder) } deletes.start(assertNotNull(dialog.begin()), { throw fileOperationException(412) }) { error("not read") }.join() - deletes.close() } - private fun CoroutineScope.coordinator(registry: FileWriteFenceRegistry, session: NextcloudSession) = - FileDeleteCoordinator(registry.account(session.accountId), CoroutineScope(coroutineContext.minusKey(Job))) + 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, From 2aad0b209e0f99381039b26b871ad2a4f2b744f4 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:25:15 +0000 Subject: [PATCH 18/30] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index c952b394d..5e258c050 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -610,9 +610,9 @@ "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": "bcd844d0b1af044a39fedf7b147f0251fc414f3c40b1f17c06bba71a5ee8e24d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt": "34bebcb6c0d433fdd91f0c1d69cb7d074b145944b9ead7198d5bece548b99cbc", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt": "b0a52c940d5f4807293c77446fdbc22da9b4bef994328f570e4dbab6cf1651b5", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "62d53d9a93598826e943e615a64de6fd33650769593f98193d6b1222c12abc7f", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "aa07fcf95b79e9482105882548c65b67b838a9a73360cd0a88acfa000638b50c", "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", @@ -650,7 +650,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileVersionHistory.kt": "7d01527acc00eafe092885d19f318734263597b4c2c5c09cf39159d174a371dd", "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": "126190b25c73a26a31e9f041184eb30ae525abd17ea141dad53a9d574eebe6e1", + "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": "9af2699cb3665f9cb83c73ccf9ed1dcd32c6d698b63f815f1f16477e3eee5912", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceInspector.kt": "a1f9032f4086100492f64d7e596c45f8b06320bf2cb02c97f7404c793b941398", @@ -960,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": "26c70530ffd28a0369ec1336ae3de9b3f074a3bbf945cb1cb086cb28dc97e28a", + "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", From 428e3e8730a76c922917b4d73b3d288ff9a21763 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 13 Oct 2026 07:22:33 +0200 Subject: [PATCH 19/30] fix(files): verify delete identity, skip verification for unsent requests, pause share uploads per file After an unknown delete result, a retry is offered only for positively the same item: when both file IDs are known they must match, so a replacement at the same path requires a refresh even when its type and ETag match. A delete that provably never left the device is now a plain retryable failure without a verification read. The account-session guard on both platforms and, on desktop, a connection, name-resolution, or TLS failure before the mutation executor saw a network exchange raise FileMutationNotSentException. Other failures stay ambiguous and are verified. A 404 now runs the same local follow-up as a confirmed delete on desktop and Android and reports an incomplete one. Android share uploads check the delete fence before each file. A block pauses the upload before the next file, keeps finished files, and the next run resumes at the first unfinished one. --- ADAPTER_ARCHITECTURE.md | 14 ++++ .../AndroidAccountOperationGuard.kt | 3 +- .../AndroidIncomingShareUploadWorker.kt | 55 ++++++++++-- .../AndroidNextcloudServices.kt | 6 +- .../AndroidShareUploadWriteFenceTest.kt | 67 +++++++++++++++ .../nextcloudnative/app/FileDeleteFlow.kt | 32 +++++-- .../nextcloudnative/app/FileOperations.kt | 17 +++- .../nextcloudnative/app/FileDeleteFlowTest.kt | 83 +++++++++++++++++++ .../app/DesktopAccountOperationGuard.kt | 4 +- .../app/DesktopFileMutationFollowUps.kt | 13 ++- .../app/DesktopNextcloudServices.kt | 2 +- .../app/DesktopFileMutationFollowUpsTest.kt | 50 +++++++++++ 12 files changed, 320 insertions(+), 26 deletions(-) diff --git a/ADAPTER_ARCHITECTURE.md b/ADAPTER_ARCHITECTURE.md index cb7a15676..4ba34c5dc 100644 --- a/ADAPTER_ARCHITECTURE.md +++ b/ADAPTER_ARCHITECTURE.md @@ -375,6 +375,20 @@ 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 when both are known, otherwise matching type + and ETag. A differing ID at the same path 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. +- Android share uploads check the fence before each file; a block pauses the + upload before the next file, keeps finished files, and the next run resumes + at the first unfinished one. - 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. 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/AndroidIncomingShareUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt index f62e6347d..b7fa28603 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt @@ -119,9 +119,9 @@ internal class AndroidIncomingShareUploadWorker( } // 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. - if (incomingShareUploadBlock(services.fileWriteFences.account(session.accountId), requireNotNull(request.destinationPath)) != null) { - return Result.retry() - } + val destinationFence = services.fileWriteFences.account(session.accountId) + val destinationBlocked = { incomingShareUploadBlock(destinationFence, requireNotNull(request.destinationPath)) != null } + if (destinationBlocked()) return Result.retry() AndroidNotificationCoordinator(applicationContext).ensureChannels() var foregroundPromotionAvailable = setForegroundIfAvailable(request) request = store.beginUpload(requestId) ?: return Result.success() @@ -147,11 +147,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, @@ -160,11 +160,23 @@ internal class AndroidIncomingShareUploadWorker( throw CancellationException("Incoming share upload canceled") } mutationInFlight = inFlight - } - if (foregroundPromotionAvailable) { - foregroundPromotionAvailable = setForegroundIfAvailable(request) + }.also { published -> + if (foregroundPromotionAvailable) { + foregroundPromotionAvailable = setForegroundIfAvailable(published) + } } } + request = uploaded + if (!allPublished) { + // Finished files stay completed; the rest continue from completedFiles on the next run. + store.transition( + id = requestId, + expected = setOf(AndroidIncomingShareState.Uploading), + target = AndroidIncomingShareState.Queued, + message = INCOMING_SHARE_PAUSED_FOR_DELETE, + ) ?: return Result.success() + return Result.retry() + } scheduleIncomingShareCleanup(applicationContext, request.id) request = store.transition( id = requestId, @@ -353,3 +365,28 @@ 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." + +/** + * 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/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 48dfe88c4..1bb57218e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -2729,11 +2729,11 @@ internal class AndroidNextcloudServices( maxResponseBytes = 64 * 1024, accountMutationSerialized = accountMutationSerialized, ) - if (response.status !in 200..299) throw fileOperationException(response.status, spec.sourceIsDirectory) val accountId = NextcloudDocumentIds.accountKey(currentSession) - val localFollowUp = invalidateAfterConfirmedFileMutation(listOfNotNull(spec.sourcePath, spec.destinationPath), + fun invalidateLocal() = invalidateAfterConfirmedFileMutation(listOfNotNull(spec.sourcePath, spec.destinationPath), { event -> recordSupportDiagnosticForAccountIdentity(accountId, event) }) { path -> fileReadCache.invalidate(accountId, path) } - NextcloudFileMutationResult(spec.destinationPath, response.etag, localFollowUp) + 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/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt index ad205add2..3378ac1ee 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt @@ -1,5 +1,6 @@ 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 @@ -13,6 +14,7 @@ import java.lang.reflect.Proxy import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.CoroutineScope @@ -70,6 +72,71 @@ class AndroidShareUploadWriteFenceTest { 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 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) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt index 707726561..28179ed20 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt @@ -9,11 +9,18 @@ internal sealed interface 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. + * ([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) : FileDeleteOutcome + data class NoLongerAtLocation( + val afterUnknownResult: Boolean, + val localFollowUp: FileMutationLocalFollowUp = FileMutationLocalFollowUp.Completed, + ) : FileDeleteOutcome - /** The item is verified to be unchanged on the server, so trying again is safe. */ + /** + * 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 /** @@ -60,9 +67,16 @@ internal suspend fun deleteFileWithVerifiedOutcome( } 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) + 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( @@ -107,6 +121,12 @@ private suspend fun verifyFileDeletePostcondition( FileDeleteOutcome.NoLongerAtLocation(afterUnknownResult = true) } } + // Retrying is safe only for positively the same item. A differing stable file ID means a + // replacement now holds the path, even when its type and ETag match. Without an ID on either + // side, the type and ETag are the only identity evidence the listing offers. + if (target.fileId != null && current.fileId != null && current.fileId != target.fileId) { + return FileDeleteOutcome.RefreshRequired("${target.name} was replaced on the server. 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 { @@ -126,8 +146,10 @@ internal fun FileDeleteOutcome.screenEffect(name: String, dialogShowsResult: Boo 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 { + } else if (localFollowUp == FileMutationLocalFollowUp.Completed) { "$name is no longer in this folder." + } else { + localFollowUp.reportFor("$name is no longer in this folder") }, reloadFolder = true, ) 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 5892d76f0..0db4e570d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt @@ -110,8 +110,17 @@ 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) + fun NextcloudFileMutation.toWebDavMutationSpec(): FileWebDavMutationSpec { val safeSource = requireSafeFilePath(sourcePath, allowRoot = false) val safeEtag = expectedEtag.trim().also { @@ -147,7 +156,11 @@ 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 @@ -171,7 +184,7 @@ fun fileOperationException(status: Int, sourceIsDirectory: Boolean = false): Nex 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/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt index 59b1b27db..24066a92a 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -374,6 +374,89 @@ class FileDeleteFlowTest { } } + @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."), + // Without an ID on one side, type and ETag are the only identity evidence. + Triple(null, 8L, "The delete did not finish. report.txt is still on the server."), + Triple(7L, 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 != null && listedId != null && shownId != listedId, state.retryBlocked) + 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() 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 index 500f62a4f..afea24fc3 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt @@ -69,9 +69,14 @@ internal class DesktopFileMutationFollowUps( }.all { it } /** - * Runs [mutation], which 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. + * 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]. */ inline fun reconcilingUnknownResult(mutation: (onAcceptedOrUnknownResult: () -> Unit) -> T): T { var queued = true @@ -80,6 +85,8 @@ internal class DesktopFileMutationFollowUps( } catch (unknown: DesktopFileSyncAmbiguousMutationException) { if (queued) throw unknown throw DesktopFileMutationReconciliationDroppedException(unknown) + } catch (unsent: IOException) { + throw FileMutationNotSentException("Could not reach the server, so nothing was changed. Try again.", unsent) } } 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 9bea6750d..2c2a8c1e3 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -4844,7 +4844,7 @@ class DesktopNextcloudServices( mutationExecutor = fileMutationHttpExecutor, onAmbiguousMutationResult = onUnknownResult, ) } - if (response.status !in 200..299) throw fileOperationException(response.status, spec.sourceIsDirectory) + 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()) } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt index d8f1adff3..ed80f59e1 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt @@ -18,6 +18,7 @@ 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 @@ -412,6 +413,55 @@ class DesktopFileMutationFollowUpsTest { 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 = assertFailsWith { + 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 } + + assertFailsWith { + mutation.reconcilingUnknownResult { onUnknown -> executor.execute(request, onUnknown) { it.code } } + } + } + } + + @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) From 6555a5124c5a84d8c7859c0f682270df43dd4a8d Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:27:42 +0000 Subject: [PATCH 20/30] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 5e258c050..516392c93 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -612,14 +612,14 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDavQueries.kt": "656cda7579abbb4825d32d7383679dcf1af963e604b7152924f2b8a81713721c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt": "34bebcb6c0d433fdd91f0c1d69cb7d074b145944b9ead7198d5bece548b99cbc", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt": "b0a52c940d5f4807293c77446fdbc22da9b4bef994328f570e4dbab6cf1651b5", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "aa07fcf95b79e9482105882548c65b67b838a9a73360cd0a88acfa000638b50c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "243ea1f7d1e628c2d73f93d33fa707b35d43e0422cc9da109df769ef7572b2f2", "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": "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": "808e2f32ef313280c60f0f06ac4d6e7eb95746c20bdc3418deeb66cff6ea60ba", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt": "1c0c95c4ea63f5b92832003fe4769bbc5bf137bf7ea8a879e8452c134a3ce5b1", "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", From d94984a9739477266c54d40bb139581d186a68de Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 13 Oct 2026 18:11:16 +0200 Subject: [PATCH 21/30] fix(files): route media viewer deletes through the shared delete coordinator The media viewer's delete now starts in the account's shared FileDeleteCoordinator, the same owner as a Files delete. It registers the same in-flight and refresh blocks for every screen, verifies an unknown result, follows the same retry, throttling, credential, and identity rules, reports incomplete local follow-up, and is fenced by account, item, and generation. The viewer hosts the delete for its shown item, so an outcome that arrives after the dialog closed, or after the viewer was recreated, is still applied. The item leaves the viewer only when the server confirmed the delete or the item is no longer at its path; any other outcome keeps it and shows the reason. The separate media delete dialog is removed. --- ADAPTER_ARCHITECTURE.md | 12 +- tools/kotlin-file-size-baseline.txt | 2 +- .../app/FileDeleteCoordinator.kt | 3 + .../app/NextcloudMediaViewer.kt | 16 +- .../app/NextcloudMediaViewerActions.kt | 139 ++++++------ .../nextcloudnative/app/NextcloudNativeApp.kt | 6 +- .../app/MediaViewerDeleteSceneTest.kt | 197 ++++++++++++++++++ .../app/MediaViewerLocalFollowUpSceneTest.kt | 18 +- .../app/TestFileWriteFences.kt | 11 + .../preview/NativeTiffMarketingCapture.kt | 4 +- .../preview/RawMediaMarketingCapture.kt | 4 +- 11 files changed, 297 insertions(+), 115 deletions(-) create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt diff --git a/ADAPTER_ARCHITECTURE.md b/ADAPTER_ARCHITECTURE.md index 4ba34c5dc..f1df1b619 100644 --- a/ADAPTER_ARCHITECTURE.md +++ b/ADAPTER_ARCHITECTURE.md @@ -400,7 +400,9 @@ receives the pending outcome. 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`. Files actions, the media +- 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) @@ -411,8 +413,12 @@ receives the pending outcome. - 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 is refused while a block applies but does not itself register a block. + 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 drops the item only when the delete was confirmed or + the item is no longer at its path, and applies an outcome that arrives after + its dialog closed or the viewer was recreated. ### Groupware (CardDAV and CalDAV) diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index aceb0c9fc..30baf9d66 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -20,7 +20,7 @@ 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/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 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt index 9dc2831a2..1b4fb32bf 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt @@ -138,6 +138,9 @@ internal class FileDeleteCoordinator(val fence: AccountFileWriteFence, parent: C } } + /** 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 } + /** Claims [completion] for one screen; false when another screen already applied it. */ fun consume(completion: FileDeleteCompletion): Boolean = completions.remove(completion) 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 5d5103b71..9df8b52d4 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt @@ -74,15 +74,16 @@ 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. */ @Composable -fun NextcloudMediaViewer( +internal fun NextcloudMediaViewer( media: List, selected: NextcloudFile, session: NextcloudSession, userId: String, services: NextcloudPlatformServices, - writeFence: FileWriteFence, + fileDeletes: FileDeleteCoordinator, taggingAvailable: Boolean, memoriesLivePhotoCapability: MemoriesLivePhotoCapability, sharingCapabilities: NextcloudFileSharingCapabilities, @@ -565,11 +566,7 @@ fun NextcloudMediaViewer( }.getOrNull() } } - DisposableEffect(playbackRangeSource) { - onDispose { - playbackRangeSource?.close() - } - } + DisposableEffect(playbackRangeSource) { onDispose { playbackRangeSource?.close() } } if (editing && displayImage != null) { NextcloudPhotoEditor( image = displayImage, @@ -768,7 +765,7 @@ fun NextcloudMediaViewer( highDetailSourceFile = fullQuality?.source?.file, ) ) { - { writeBlockNotice = writeFence.writeBlock(selected.path); if (writeBlockNotice == null) editing = true } + { writeBlockNotice = fileDeletes.writeBlock(selected.path); if (writeBlockNotice == null) editing = true } } else { null }, @@ -852,12 +849,13 @@ fun NextcloudMediaViewer( } writeBlockNotice?.let { block -> MediaWriteBlockedDialog(block) { writeBlockNotice = null } } + MediaDeleteHost(selected, session, userId, services, fileDeletes, viewerAction == MediaViewerAction.Delete, onClose = { viewerAction = null }) { viewerAction = null; onSourceRemoved(selected) } viewerAction?.let { action -> MediaViewerActionDialog( action = action, file = selected, session = session, userId = userId, - services = services, writeFence = writeFence, + services = services, writeFence = fileDeletes, sharingCapabilities = sharingCapabilities, onDismiss = { if (activeActionMutations == 0) viewerAction = null }, onSourceRemoved = { 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 bbc45d369..4c1bfa2c8 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 @@ -106,16 +106,8 @@ internal fun MediaViewerActionDialog( onMoved = onSourceRemoved, onMutationRunningChanged = onMutationRunningChanged, ) - MediaViewerAction.Delete -> MediaDeleteDialog( - file = file, - session = session, - userId = userId, - services = services, - writeFence = writeFence, - 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, @@ -252,88 +244,77 @@ private fun MediaTransferDialog( ) } +/** + * The media viewer's delete. It runs 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, so a delete whose dialog was closed is still applied here. The item leaves the viewer + * only when the server confirmed the delete or the item is no longer at its path; any other + * outcome keeps it and shows the reason. + */ @Composable -private fun MediaDeleteDialog( +internal fun MediaDeleteHost( file: NextcloudFile, session: NextcloudSession, userId: String, services: NextcloudPlatformServices, - writeFence: FileWriteFence, - onDismiss: () -> Unit, - onDeleted: () -> Unit, - onMutationRunningChanged: (Boolean) -> Unit, + deletes: FileDeleteCoordinator, + open: Boolean, + onClose: () -> Unit, + onRemoved: () -> Unit, ) { - 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 scope = rememberCoroutineScope() - followUpNotice?.let { notice -> return MediaLocalFollowUpDialog(notice, onClose = onDeleted) } - 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 - } - writeFence.writeBlock(file.path)?.let { block -> - error = block.message - return@Button - } - running = true - error = null - scope.launch { - runCatching { - services.executeFileMutation( - session, - userId, - NextcloudFileMutation.Delete(file.path, etag), - ) - }.onSuccess { result -> - if (result.localFollowUp == FileMutationLocalFollowUp.Completed) onDeleted() - else followUpNotice = result.localFollowUp.reportFor("Deleted ${file.name}") - }.onFailure { error = it.message ?: "Could not delete this file." } - running = false + val dialog = remember(deletes, file.path) { FileDeleteDialogState(deletes) } + var removalNotice by remember(deletes, file.path) { mutableStateOf(null) } + var outcomeNotice by remember(deletes, file.path) { mutableStateOf(null) } + val currentOnRemoved by rememberUpdatedState(onRemoved) + val completion = deletes.completionFor(deletes.keyFor(file)) + LaunchedEffect(deletes, completion) { + if (completion == null || !deletes.consume(completion)) return@LaunchedEffect + val effect = dialog.complete(completion) + when (val outcome = completion.outcome) { + is FileDeleteOutcome.Deleted -> + if (outcome.localFollowUp == FileMutationLocalFollowUp.Completed) currentOnRemoved() else removalNotice = effect.notice + is FileDeleteOutcome.NoLongerAtLocation -> removalNotice = effect.notice + // An open dialog shows the reason itself; otherwise it is reported here. + else -> outcomeNotice = effect.notice + } + } + LaunchedEffect(open) { if (open && dialog.target == null) dialog.open(file) } + removalNotice?.let { notice -> return MediaLocalFollowUpDialog(notice, onClose = { removalNotice = null; currentOnRemoved() }) } + outcomeNotice?.let { notice -> + return AlertDialog( + onDismissRequest = { outcomeNotice = null }, + title = { Text("${file.name} was not deleted") }, + text = { Text(notice) }, + confirmButton = { TextButton(onClick = { outcomeNotice = null }) { 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() + }, + ) + } } /** 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.Delete, MediaViewerAction.ShareNextcloud, MediaViewerAction.AddToAlbum, ) 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 808838b88..ff45ac3dd 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -1888,8 +1888,8 @@ private fun AuthenticatedApp( selected = snapshot.selected, session = session, userId = serverInfo?.userId.orEmpty(), - services = services, writeFence = fileDeletes, - taggingAvailable =serverInfo?.apps?.any { it.id == "memories" } == true, + services = services, fileDeletes = fileDeletes, + taggingAvailable = serverInfo?.apps?.any { it.id == "memories" } == true, memoriesLivePhotoCapability = memoriesLivePhotoCapability, sharingCapabilities = serverInfo?.fileSharing ?: NextcloudFileSharingCapabilities.Unavailable, @@ -7337,7 +7337,7 @@ internal fun FilesScreen( userId?.let { uid -> FileDeleteHost( deleteDialog, fileDeletes, path, - delete ={ mutation -> services.executeFileMutation(session, uid, mutation) }, + delete = { mutation -> services.executeFileMutation(session, uid, mutation) }, readFolder = { folder -> services.listFilesWithSource(session, uid, folder) }, ) { effect -> effect.notice?.let { mutationNotice = it } 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..b6459c82f --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt @@ -0,0 +1,197 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import java.lang.reflect.Proxy +import java.util.Collections +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.intercepted +import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +/** 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 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 `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 photo.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 = { Host(fake, events) }) { + click("Delete") + settleUntil { events.isNotEmpty() || (notice != null && has(notice)) } + + if (notice == null) { + assertEquals(listOf("removed"), events) + } else { + assertTrue(has(notice)) + assertEquals(emptyList(), events, "The viewer does not move on as if everything finished.") + click("Close") + assertEquals(listOf("removed"), events) + } + 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 } } + Host(fake, events, owned.account(alice.accountId).deletes) + }) { + click("Delete") + val fence = assertNotNull(registry).account(alice.accountId) + + assertEquals(FileWriteBlock.DeleteRunning, fence.writeBlock("Photos/photo.jpg")) + assertEquals(FileWriteBlock.DeleteRunning, fence.writeBlock("Photos"), "Its folder waits too.") + assertNull(assertNotNull(registry).account(bob.accountId).writeBlock("Photos/photo.jpg")) + val files = FileDeleteDialogState(fence.deletes).apply { open(photo) } + assertNull(files.begin(), "A Files delete of the same item must wait.") + + release.complete(Unit) + settleUntil { events.isNotEmpty() } + assertEquals(listOf("removed"), events) + assertNull(fence.writeBlock("Photos/photo.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 = { Host(fake, events) }) { + click("Delete") + settleUntil { 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 = { Host(fake, events) }) { + click("Delete") + settleUntil { has(unconfirmedDeleteMessage("photo.jpg")) } + + click("Delete") + assertEquals(listOf("delete", "list:Photos"), fake.calls, "The unknown result is verified, never resent.") + assertTrue(events.isEmpty()) + } + } + + @Test + fun `closing the viewer mid-delete keeps the block, and a later viewer applies the outcome`() { + val registry = FileWriteFenceRegistry(CoroutineScope(SupervisorJob() + Dispatchers.Default)) + 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 = { Host(fake, firstEvents, deletes) }) { + click("Delete") + } + + assertEquals(FileWriteBlock.DeleteRunning, registry.account(alice.accountId).writeBlock("Photos/photo.jpg")) + release.complete(Unit) + runBlocking { withTimeout(5_000L) { while (deletes.completionFor(deletes.keyFor(photo)) == null) delay(10L) } } + assertNull(registry.account(alice.accountId).writeBlock("Photos/photo.jpg"), "The verified delete lifts it.") + assertTrue(firstEvents.isEmpty()) + + val laterEvents = mutableListOf() + nativeSceneTest(800, 700, content = { Host(fake, laterEvents, deletes, open = false) }) { + settleUntil { laterEvents.isNotEmpty() } + assertEquals(listOf("removed"), laterEvents, "A recreated viewer applies the pending outcome.") + } + } + + @Composable + private fun Host( + fake: FakeMediaServices, + events: MutableList, + deletes: FileDeleteCoordinator = rememberFileDeletesForTests(alice), + open: Boolean = true, + ) = MediaDeleteHost(photo, alice, "alice", fake.services, deletes, open, onClose = { events += "closed" }) { + events += "removed" + } + + 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()) + + @Suppress("UNCHECKED_CAST") + 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)) + kotlin.coroutines.intrinsics.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 index f93b786e9..b376c9fd2 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerLocalFollowUpSceneTest.kt @@ -20,19 +20,6 @@ class MediaViewerLocalFollowUpSceneTest { etag = "v1", ) - @Test - fun `delete with incomplete local bookkeeping is reported before the viewer moves on`() { - val events = mutableListOf() - nativeSceneTest(800, 700, content = { dialog(MediaViewerAction.Delete, FileMutationLocalFollowUp.StillRunning, events) }) { - click("Delete") - - assertTrue(has("Deleted photo.jpg. Local file status is still updating in the background.")) - assertEquals(emptyList(), events, "The viewer must not move on as if everything finished.") - click("Close") - assertEquals(listOf("removed"), events) - } - } - @Test fun `move whose local bookkeeping failed is reported, then removes the source`() { val events = mutableListOf() @@ -50,13 +37,12 @@ class MediaViewerLocalFollowUpSceneTest { @Test fun `completed local bookkeeping finishes without an extra step`() { for ((action, button, expected) in listOf( - Triple(MediaViewerAction.Delete, "Delete", "removed"), Triple(MediaViewerAction.Move, "Move", "removed"), Triple(MediaViewerAction.Copy, "Copy", "dismissed"), )) { val events = mutableListOf() nativeSceneTest(800, 700, content = { dialog(action, FileMutationLocalFollowUp.Completed, events) }) { - if (action != MediaViewerAction.Delete) replaceText("photo.jpg", "other.jpg") + replaceText("photo.jpg", "other.jpg") click(button) assertEquals(listOf(expected), events) @@ -68,7 +54,7 @@ class MediaViewerLocalFollowUpSceneTest { @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.Delete, + MediaViewerAction.Move, MediaViewerAction.Copy, MediaViewerAction.ShareNextcloud, MediaViewerAction.AddToAlbum, )) { val events = mutableListOf() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt index 3e38e5375..5eb9f08c6 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt @@ -1,4 +1,15 @@ package dev.obiente.nextcloudnative.app +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope + /** 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 } +} 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 cab12fb39..3baead84a 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,7 +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.NoFileWriteBlocksForTests +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 @@ -52,7 +52,7 @@ internal class NativeTiffMarketingCapture private constructor( selected = tiffFile, session = fixtureSession, userId = FIXTURE_USER_ID, - services = services, writeFence = NoFileWriteBlocksForTests, + services = services, fileDeletes = rememberFileDeletesForTests(fixtureSession), taggingAvailable = false, memoriesLivePhotoCapability = MemoriesLivePhotoCapability.NotAdvertised, sharingCapabilities = NextcloudFileSharingCapabilities.Unavailable, 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 b01a8de7a..42f950eff 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,7 +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.NoFileWriteBlocksForTests +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 @@ -63,7 +63,7 @@ internal class RawMediaMarketingCapture private constructor( selected = rawFile, session = session, userId = FIXTURE_USER_ID, - services = services, writeFence = NoFileWriteBlocksForTests, + services = services, fileDeletes = rememberFileDeletesForTests(session), taggingAvailable = false, memoriesLivePhotoCapability = MemoriesLivePhotoCapability.NotAdvertised, sharingCapabilities = NextcloudFileSharingCapabilities.Unavailable, From 2340bcaa58194837d67e92ab090688a5583513f5 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:18:07 +0000 Subject: [PATCH 22/30] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 516392c93..7a7bc6a0a 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -610,7 +610,7 @@ "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": "34bebcb6c0d433fdd91f0c1d69cb7d074b145944b9ead7198d5bece548b99cbc", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt": "f2d6294fad65f1a4d4893d9a6d921ab958e48e2228f745812ae2530757cfc95b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteDialog.kt": "b0a52c940d5f4807293c77446fdbc22da9b4bef994328f570e4dbab6cf1651b5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "243ea1f7d1e628c2d73f93d33fa707b35d43e0422cc9da109df769ef7572b2f2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIdentityResolution.kt": "f8658c54cf14dec5b60037a27770ea3c2eb06b509bd28d3b9c97c228adfae83a", @@ -746,9 +746,9 @@ "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": "9148a987e38edebe35047138916afa0d6a526e069aa9fcedff31e661ec2f981e", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "481719be3fc0806131fad80661d3547a2fa266d13fcce6c856011f6b415bf15a", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "8b5ba6e2238ed41cdb60e0fe396e6ed0f2470fac78cd0c9c5984775a7c5078ee", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "dd116150fd50fcd7972bbc4595ddda85a78d3c19c038feb5e970fb2e7320aec5", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "75da82471e322f223d6227a7c3a24c1be31ccae9a67cdc04847784ee7845de48", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "95f738ed3f9d3c649857dce5ad3afcf96d42998280ef8461eb3dc614f30833c4", "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", @@ -991,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": "f323dbb0da0eadbfb117578df3566c7a4808b051589099e6891faa5741a21285", + "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/NativeTiffMarketingCapture.kt": "3d08a35d9793bde972d78dadb714a0a616469d19edbc354af6a9a756e285483c", "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": "26bdcde616b0c3087086ff79f18050eb50446d5c75c70b9000ab73e1ff3deef1", + "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/preview/RawMediaMarketingCapture.kt": "53bdc666621b58a4a04bc80fcfe273ad3c3412a94a9e6546bc70a3ef4d25eb18", "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", From 2eab6909fb8c1b189466a16c1a130a223bae41f6 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 13 Oct 2026 20:27:19 +0200 Subject: [PATCH 23/30] fix(files): apply media deletes for every viewer item and bound delete test waits The media viewer now watches delete outcomes for every item in its list, not only the shown one. A confirmed or no-longer-at-location outcome hides that item whenever it arrives, even after the dialog was closed and the user moved on; when the shown item goes, the next item, or else the previous one, is selected, and the viewer closes after its last item. Any other outcome keeps the item and shows its reason once that item is shown. Swiping is never blocked. Delete tests that need work to outlive a scene now run the delete owner on a dedicated test thread instead of the shared default dispatcher, and every wait on a delete, completion, or block is bounded and fails with a message. --- ADAPTER_ARCHITECTURE.md | 10 +- .../app/FileDeleteCoordinator.kt | 3 + .../app/NextcloudMediaViewer.kt | 8 +- .../app/NextcloudMediaViewerActions.kt | 82 +++++-- .../nextcloudnative/app/FileDeleteFlowTest.kt | 28 ++- .../app/FileWriteFenceRegistryTest.kt | 19 +- .../app/MediaViewerDeleteSceneTest.kt | 202 +++++++++++++----- .../app/TestFileWriteFences.kt | 32 +++ 8 files changed, 286 insertions(+), 98 deletions(-) diff --git a/ADAPTER_ARCHITECTURE.md b/ADAPTER_ARCHITECTURE.md index f1df1b619..5335244c3 100644 --- a/ADAPTER_ARCHITECTURE.md +++ b/ADAPTER_ARCHITECTURE.md @@ -416,9 +416,13 @@ receives the pending outcome. 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 drops the item only when the delete was confirmed or - the item is no longer at its path, and applies an outcome that arrives after - its dialog closed or the viewer was recreated. + retry rules. The viewer watches outcomes for every item in its list, not only + the shown one. It drops an item only when its delete was confirmed or the + item is no longer at its path, moving the selection to a neighbor or closing + when none is left, and applies an outcome that arrives after its dialog + closed, after the user moved to another item, or after the viewer was + recreated. Any other outcome keeps the item and shows its reason when that + item is shown. ### Groupware (CardDAV and CalDAV) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt index 1b4fb32bf..5507ded00 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteCoordinator.kt @@ -141,6 +141,9 @@ internal class FileDeleteCoordinator(val fence: AccountFileWriteFence, parent: C /** 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) 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 9df8b52d4..65709c11b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt @@ -98,9 +98,8 @@ internal fun NextcloudMediaViewer( onStateObserved: (MediaViewerStateObservation) -> Unit = {}, modifier: Modifier = Modifier, ) { - val items = remember(media, selected) { - if (mediaViewerNavigationIndex(media, selected) >= 0) media else listOf(selected) - } + val removedPaths = remember { androidx.compose.runtime.mutableStateListOf() } + val items = remember(media, selected, removedPaths.toList()) { mediaViewerItems(media, selected, removedPaths) } val sourcePlan = remember(sourceMembers, selected) { planMediaSources(sourceMembers, selected) } @@ -849,7 +848,8 @@ internal fun NextcloudMediaViewer( } writeBlockNotice?.let { block -> MediaWriteBlockedDialog(block) { writeBlockNotice = null } } - MediaDeleteHost(selected, session, userId, services, fileDeletes, viewerAction == MediaViewerAction.Delete, onClose = { viewerAction = null }) { viewerAction = null; onSourceRemoved(selected) } + MediaDeleteHost(items, selected, session, userId, services, fileDeletes, viewerAction == MediaViewerAction.Delete, onClose = { viewerAction = null }) { removed -> + viewerAction = null; mediaViewerRemove(removed, items, selected, removedPaths::add, onSelect) { onSourceRemoved(selected) } } viewerAction?.let { action -> MediaViewerActionDialog( action = action, 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 4c1bfa2c8..373436480 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt @@ -245,48 +245,61 @@ private fun MediaTransferDialog( } /** - * The media viewer's delete. It runs in the account's shared [deletes] owner exactly like a Files + * 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, so a delete whose dialog was closed is still applied here. The item leaves the viewer - * only when the server confirmed the delete or the item is no longer at its path; any other - * outcome keeps it and shows the reason. + * viewer and watches every item in [items], not only [selected], so a delete whose dialog was + * closed is applied even after the user moved to another item 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 internal fun MediaDeleteHost( - file: NextcloudFile, + items: List, + selected: NextcloudFile, session: NextcloudSession, userId: String, services: NextcloudPlatformServices, deletes: FileDeleteCoordinator, open: Boolean, onClose: () -> Unit, - onRemoved: () -> Unit, + onRemoved: (NextcloudFile) -> Unit, ) { - val dialog = remember(deletes, file.path) { FileDeleteDialogState(deletes) } - var removalNotice by remember(deletes, file.path) { mutableStateOf(null) } - var outcomeNotice by remember(deletes, file.path) { mutableStateOf(null) } + 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 completion = deletes.completionFor(deletes.keyFor(file)) + val paths = remember(items, selected) { items.mapTo(HashSet()) { it.path } + selected.path } + val completion = deletes.completionFor(paths) LaunchedEffect(deletes, completion) { if (completion == null || !deletes.consume(completion)) return@LaunchedEffect - val effect = dialog.complete(completion) + 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() else removalNotice = effect.notice - is FileDeleteOutcome.NoLongerAtLocation -> removalNotice = effect.notice - // An open dialog shows the reason itself; otherwise it is reported here. - else -> outcomeNotice = effect.notice + 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) } } - LaunchedEffect(open) { if (open && dialog.target == null) dialog.open(file) } - removalNotice?.let { notice -> return MediaLocalFollowUpDialog(notice, onClose = { removalNotice = null; currentOnRemoved() }) } - outcomeNotice?.let { notice -> + keptNotices[selected.path]?.let { notice -> return AlertDialog( - onDismissRequest = { outcomeNotice = null }, - title = { Text("${file.name} was not deleted") }, + onDismissRequest = { keptNotices = keptNotices - selected.path }, + title = { Text("${selected.name} was not deleted") }, text = { Text(notice) }, - confirmButton = { TextButton(onClick = { outcomeNotice = null }) { Text("Close") } }, + confirmButton = { TextButton(onClick = { keptNotices = keptNotices - selected.path }) { Text("Close") } }, ) } dialog.target?.let { target -> @@ -311,6 +324,33 @@ internal fun MediaDeleteHost( } } +/** The viewer's items: [media] without items whose delete was applied, or only [selected] when it is not listed. */ +internal fun mediaViewerItems(media: List, selected: NextcloudFile, removedPaths: Collection): List { + val visible = if (removedPaths.isEmpty()) media else media.filterNot { it.path in removedPaths } + return if (mediaViewerNavigationIndex(visible, selected) >= 0) visible else listOf(selected) +} + +/** + * Drops [removed] from the viewer. Another item is simply hidden. When the shown item goes, the + * next item, or else the previous one, is selected; when none is left, [closeLast] closes the viewer. + */ +internal fun mediaViewerRemove( + removed: NextcloudFile, + items: List, + selected: NextcloudFile, + markRemoved: (String) -> Unit, + select: (NextcloudFile) -> Unit, + closeLast: () -> Unit, +) { + if (removed.path != selected.path) return markRemoved(removed.path) + val remaining = items.filterNot { it.path == removed.path } + val index = items.indexOfFirst { it.path == removed.path } + val neighbor = remaining.getOrNull(index.coerceAtLeast(0)) ?: remaining.lastOrNull() + if (neighbor == null) return closeLast() + markRemoved(removed.path) + select(neighbor) +} + /** Actions that change the item, create files next to it, or bind it to a share or album. */ private val mediaWriteActions = setOf( MediaViewerAction.Move, diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt index 24066a92a..00617c9ab 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -615,7 +615,7 @@ class FileDeleteFlowTest { started.await() deletes.close() - job.join() + withTimeout(5_000L) { job.join() } assertTrue(job.isCancelled) assertNull(deletes.nextCompletion) @@ -634,7 +634,7 @@ class FileDeleteFlowTest { val job = deletes.start(assertNotNull(state.begin()), fake::delete, fake::read) deletes.close() - job.join() + withTimeout(5_000L) { job.join() } assertTrue(fake.deletes.isEmpty()) assertFalse(state.running) @@ -660,7 +660,7 @@ class FileDeleteFlowTest { deletes.close() release.complete(Unit) - job.join() + withTimeout(5_000L) { job.join() } assertNull(deletes.nextCompletion) assertFalse(state.running) @@ -808,7 +808,8 @@ class FileDeleteFlowTest { state: FileDeleteDialogState, fake: FakeDeleteBoundary, ): FileDeleteScreenEffect { - start(assertNotNull(state.begin()), fake::delete, fake::read).join() + val job = start(assertNotNull(state.begin()), fake::delete, fake::read) + withTimeout(5_000L) { job.join() } return awaitCompletion(state) } @@ -817,17 +818,22 @@ class FileDeleteFlowTest { state: FileDeleteDialogState, result: suspend () -> NextcloudFileMutationResult, ): FileDeleteCompletion { - start(assertNotNull(state.begin()), FakeDeleteBoundary(deleteResult = result)::delete) { error("not read") }.join() + 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)) } } - private suspend fun FileDeleteCoordinator.awaitCompletion(state: FileDeleteDialogState): FileDeleteScreenEffect { - while (true) { - val completion = nextCompletion - if (completion != null && consume(completion)) return state.complete(completion) - yield() + /** 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, diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt index f04a07f0c..01593c94e 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/FileWriteFenceRegistryTest.kt @@ -10,11 +10,10 @@ import kotlin.test.assertTrue import androidx.compose.runtime.LaunchedEffect import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking @@ -58,7 +57,7 @@ class FileWriteFenceRegistryTest { assertEquals(listOf("createDirectoryIfAbsent:Projects/Old/Synced"), calls, "Only the other account wrote.") release.complete(Unit) - job.join() + withTimeout(5_000L) { job.join() } assertNull(incomingShareUploadBlock(registry.account(alice.accountId), "Projects/Old"), "A verified delete lifts it.") } @@ -105,8 +104,10 @@ class FileWriteFenceRegistryTest { } @Test - fun `disposing the composition that started a delete neither cancels it nor releases its block`() { - val registry = FileWriteFenceRegistry(CoroutineScope(SupervisorJob() + Dispatchers.Default)) + 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 @@ -123,6 +124,7 @@ class FileWriteFenceRegistryTest { }) { 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.") @@ -132,7 +134,7 @@ class FileWriteFenceRegistryTest { } release.complete(Unit) - runBlocking { assertNotNull(job).join() } + 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.") @@ -161,7 +163,7 @@ class FileWriteFenceRegistryTest { registry.removeAccount(alice.accountId.storageKey) val next = registry.account(alice.accountId) release.complete(Unit) - job.join() + withTimeout(5_000L) { job.join() } assertTrue(job.isCancelled) assertTrue(next !== removed, "Signing in again starts a new incarnation.") @@ -189,7 +191,8 @@ class FileWriteFenceRegistryTest { private suspend fun CoroutineScope.refreshRequired(registry: FileWriteFenceRegistry, session: NextcloudSession) { val deletes = coordinator(registry, session) val dialog = FileDeleteDialogState(deletes).apply { open(folder) } - deletes.start(assertNotNull(dialog.begin()), { throw fileOperationException(412) }) { error("not read") }.join() + 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 diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt index b6459c82f..127d3b5a9 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt @@ -1,62 +1,56 @@ 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.mutableStateListOf +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 -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeout /** 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 photo = NextcloudFile( - path = "Photos/photo.jpg", - name = "photo.jpg", - isDirectory = false, - mimeType = "image/jpeg", - size = 1, - lastModified = null, - fileId = 7L, - hasPreview = true, - etag = "v1", - ) + 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 photo.jpg. Local file status is still updating in the background.", + 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 = { Host(fake, events) }) { + nativeSceneTest(800, 700, content = { Viewer(fake, listOf(first, second), events) }) { click("Delete") settleUntil { events.isNotEmpty() || (notice != null && has(notice)) } - if (notice == null) { - assertEquals(listOf("removed"), events) - } else { + if (notice != null) { assertTrue(has(notice)) assertEquals(emptyList(), events, "The viewer does not move on as if everything finished.") click("Close") - assertEquals(listOf("removed"), events) } + assertEquals(listOf("removed:first.jpg"), events) + assertTrue(has("showing second.jpg of 1"), "The next item is selected.") assertEquals(listOf("delete"), fake.calls) } } @@ -71,21 +65,22 @@ class MediaViewerDeleteSceneTest { nativeSceneTest(800, 700, content = { val scope = rememberCoroutineScope() val owned = remember { FileWriteFenceRegistry(scope).also { registry = it } } - Host(fake, events, owned.account(alice.accountId).deletes) + 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/photo.jpg")) + assertEquals(FileWriteBlock.DeleteRunning, fence.writeBlock("Photos/first.jpg")) assertEquals(FileWriteBlock.DeleteRunning, fence.writeBlock("Photos"), "Its folder waits too.") - assertNull(assertNotNull(registry).account(bob.accountId).writeBlock("Photos/photo.jpg")) - val files = FileDeleteDialogState(fence.deletes).apply { open(photo) } + 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"), events) - assertNull(fence.writeBlock("Photos/photo.jpg")) + assertEquals(listOf("removed:first.jpg"), events) + assertNull(fence.writeBlock("Photos/first.jpg")) } } @@ -93,10 +88,11 @@ class MediaViewerDeleteSceneTest { 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 = { Host(fake, events) }) { + 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.") @@ -110,10 +106,11 @@ class MediaViewerDeleteSceneTest { delete = { throw IllegalStateException("response interrupted") }, listing = { throw IllegalStateException("offline") }, ) - nativeSceneTest(800, 700, content = { Host(fake, events) }) { + nativeSceneTest(800, 700, content = { Viewer(fake, listOf(first, second), events) }) { click("Delete") - settleUntil { has(unconfirmedDeleteMessage("photo.jpg")) } + 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()) @@ -121,46 +118,149 @@ class MediaViewerDeleteSceneTest { } @Test - fun `closing the viewer mid-delete keeps the block, and a later viewer applies the outcome`() { - val registry = FileWriteFenceRegistry(CoroutineScope(SupervisorJob() + Dispatchers.Default)) - val deletes = registry.account(alice.accountId).deletes + 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) }) - val firstEvents = mutableListOf() - nativeSceneTest(800, 700, content = { Host(fake, firstEvents, deletes) }) { + 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()) - assertEquals(FileWriteBlock.DeleteRunning, registry.account(alice.accountId).writeBlock("Photos/photo.jpg")) - release.complete(Unit) - runBlocking { withTimeout(5_000L) { while (deletes.completionFor(deletes.keyFor(photo)) == null) delay(10L) } } - assertNull(registry.account(alice.accountId).writeBlock("Photos/photo.jpg"), "The verified delete lifts it.") - 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.") + } + } - val laterEvents = mutableListOf() - nativeSceneTest(800, 700, content = { Host(fake, laterEvents, deletes, open = false) }) { - settleUntil { laterEvents.isNotEmpty() } - assertEquals(listOf("removed"), laterEvents, "A recreated viewer applies the pending outcome.") + @Test + fun `the selection rule picks the next item, then the previous one, and closes on the last`() { + val items = listOf(first, second, third) + fun removeFrom(removed: NextcloudFile, selected: NextcloudFile): List { + val events = mutableListOf() + mediaViewerRemove(removed, items, selected, { events += "hide:$it" }, { events += "select:${it.name}" }) { events += "close" } + return events } + + assertEquals(listOf("hide:Photos/third.jpg"), removeFrom(third, first), "Another item is only hidden.") + assertEquals(listOf("hide:Photos/second.jpg", "select:third.jpg"), removeFrom(second, second)) + assertEquals(listOf("hide:Photos/third.jpg", "select:second.jpg"), removeFrom(third, third)) + val events = mutableListOf() + mediaViewerRemove(first, listOf(first), first, { events += "hide" }, { events += "select" }) { events += "close" } + assertEquals(listOf("close"), events) + assertEquals(listOf(second, third), mediaViewerItems(items, second, listOf("Photos/first.jpg"))) + assertEquals(listOf(first), mediaViewerItems(items, first, listOf("Photos/first.jpg")), "A shown item is never hidden from itself.") } + /** A minimal viewer: the shared delete host, the viewer's list rule, and next/previous selection. */ @Composable - private fun Host( + private fun Viewer( fake: FakeMediaServices, + media: List, events: MutableList, deletes: FileDeleteCoordinator = rememberFileDeletesForTests(alice), open: Boolean = true, - ) = MediaDeleteHost(photo, alice, "alice", fake.services, deletes, open, onClose = { events += "closed" }) { - events += "removed" + ) { + var selected by remember { mutableStateOf(media.first()) } + var deleteOpen by remember { mutableStateOf(open) } + val removedPaths = remember { mutableStateListOf() } + val items = mediaViewerItems(media, selected, removedPaths) + val index = items.indexOfFirst { it.path == selected.path } + Column { + Text("showing ${selected.name} of ${items.size}") + TextButton(onClick = { items.getOrNull(index + 1)?.let { selected = it } }) { Text("Next") } + TextButton(onClick = { items.getOrNull(index - 1)?.let { selected = it } }) { Text("Previous") } + } + MediaDeleteHost(items, selected, alice, "alice", fake.services, deletes, deleteOpen, onClose = { + deleteOpen = false + events += "closed" + }) { removed -> + events += "removed:${removed.name}" + mediaViewerRemove(removed, items, selected, removedPaths::add, { selected = it }) { 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()) - @Suppress("UNCHECKED_CAST") val services = Proxy.newProxyInstance( NextcloudPlatformServices::class.java.classLoader, arrayOf(NextcloudPlatformServices::class.java), @@ -190,7 +290,7 @@ class MediaViewerDeleteSceneTest { } catch (failure: Exception) { // Dispatched, so the caller resumes after this call has returned suspended. caller.intercepted().resumeWith(Result.failure(failure)) - kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED + COROUTINE_SUSPENDED } } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt index 5eb9f08c6..ff686fe92 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/TestFileWriteFences.kt @@ -3,6 +3,12 @@ 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 } @@ -13,3 +19,29 @@ internal fun rememberFileDeletesForTests(session: NextcloudSession): FileDeleteC 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) + } +} From a073b532056cfd9e2e1543ff39f6baa901a7ddf6 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:33:40 +0000 Subject: [PATCH 24/30] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 7a7bc6a0a..9991df32d 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -610,7 +610,7 @@ "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": "f2d6294fad65f1a4d4893d9a6d921ab958e48e2228f745812ae2530757cfc95b", + "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": "243ea1f7d1e628c2d73f93d33fa707b35d43e0422cc9da109df769ef7572b2f2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileIdentityResolution.kt": "f8658c54cf14dec5b60037a27770ea3c2eb06b509bd28d3b9c97c228adfae83a", @@ -746,8 +746,8 @@ "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": "dd116150fd50fcd7972bbc4595ddda85a78d3c19c038feb5e970fb2e7320aec5", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "75da82471e322f223d6227a7c3a24c1be31ccae9a67cdc04847784ee7845de48", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "2272597fb85f22b7d3c93d33afebad3b8fedacb54f81150bdccae7505137c76d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "0492db40b4c8b0fd9669ac04051867ee2822e4bf51eedc815ea24b6b50c94a64", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "95f738ed3f9d3c649857dce5ad3afcf96d42998280ef8461eb3dc614f30833c4", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "85bcc0e5bb26cd7a77982094b65972c212b7fb92e1e60bb690444f40b4f0f075", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", From 7e92f8914e571ebadffe7dc5a9cd44b795dc5095 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 13 Oct 2026 21:59:28 +0200 Subject: [PATCH 25/30] fix(files): require a refresh when only one side of a delete has a file ID After an unknown delete result, the verification listing now offers a retry only for positively the same item: matching stable file IDs, or matching type and ETag only when neither the shown item nor the listed one has an ID. A differing ID, or an ID on only one side, requires a refresh, so a replacement at the same path is never deleted by a retry. The rule is shared by Files and media viewer deletes. --- ADAPTER_ARCHITECTURE.md | 5 +++-- .../nextcloudnative/app/FileDeleteFlow.kt | 17 +++++++++++----- .../nextcloudnative/app/FileDeleteFlowTest.kt | 10 ++++++---- .../app/MediaViewerDeleteSceneTest.kt | 20 +++++++++++++++++++ 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/ADAPTER_ARCHITECTURE.md b/ADAPTER_ARCHITECTURE.md index 5335244c3..e2d1622af 100644 --- a/ADAPTER_ARCHITECTURE.md +++ b/ADAPTER_ARCHITECTURE.md @@ -376,8 +376,9 @@ 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 when both are known, otherwise matching type - and ETag. A differing ID at the same path requires a refresh. + 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 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt index 28179ed20..99fc6b6af 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt @@ -121,11 +121,18 @@ private suspend fun verifyFileDeletePostcondition( FileDeleteOutcome.NoLongerAtLocation(afterUnknownResult = true) } } - // Retrying is safe only for positively the same item. A differing stable file ID means a - // replacement now holds the path, even when its type and ETag match. Without an ID on either - // side, the type and ETag are the only identity evidence the listing offers. - if (target.fileId != null && current.fileId != null && current.fileId != target.fileId) { - return FileDeleteOutcome.RefreshRequired("${target.name} was replaced on the server. Refresh the folder.") + // 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.") diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt index 00617c9ab..a06086702 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -381,9 +381,11 @@ class FileDeleteFlowTest { 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."), - // Without an ID on one side, type and ETag are the only identity evidence. - Triple(null, 8L, "The delete did not finish. report.txt is still on the server."), - Triple(7L, null, "The delete did not finish. report.txt is still on the server."), + // 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) @@ -399,7 +401,7 @@ class FileDeleteFlowTest { deletes.finish(state, fake) assertEquals(message, state.error, "shown=$shownId listed=$listedId") - assertEquals(shownId != null && listedId != null && shownId != listedId, state.retryBlocked) + assertEquals(shownId != listedId, state.retryBlocked, "Only positively the same item may be retried.") assertEquals(1, fake.deletes.size) } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt index 127d3b5a9..fc7936070 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt @@ -117,6 +117,26 @@ class MediaViewerDeleteSceneTest { } } + @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() From 0c8d1cfdfe87c8921c2b3f11c54c668967bd78f9 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:05:44 +0000 Subject: [PATCH 26/30] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 9991df32d..cddae0ee2 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -612,7 +612,7 @@ "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": "243ea1f7d1e628c2d73f93d33fa707b35d43e0422cc9da109df769ef7572b2f2", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt": "8ab175088ca9b2ebd3909fbc6e66b5112b5fabd07d6e9ad49e152e2b4e8b7484", "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", From 4305e6e61d2e411942580013795a1d413735d366 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 13 Oct 2026 23:48:17 +0200 Subject: [PATCH 27/30] fix(files): report reconciliation after unknown deletes and keep viewer removals An item found gone after an unknown delete result now reports the local reconciliation queued for that result: the desktop follow-up, including still running, failed, or dropped, and the Android cache invalidation. Media viewer removals are applied to the process-level navigation route, so a recreated viewer does not show a deleted item again. Each removal issues a new route key, so the viewer updates even when the selected index stays the same. --- ADAPTER_ARCHITECTURE.md | 11 +- .../AndroidNextcloudServices.kt | 10 +- .../AndroidFileMutationLocalFollowUpTest.kt | 47 +++++++ .../nextcloudnative/app/FileDeleteFlow.kt | 23 ++-- .../nextcloudnative/app/FileOperations.kt | 14 ++ .../app/MediaViewerNavigation.kt | 52 ++++++++ .../app/NextcloudMediaViewer.kt | 10 +- .../app/NextcloudMediaViewerActions.kt | 29 +---- .../nextcloudnative/app/NextcloudNativeApp.kt | 14 +- .../nextcloudnative/app/FileDeleteFlowTest.kt | 27 ++++ .../app/DesktopFileMutationFollowUps.kt | 10 +- .../app/DesktopFileMutationFollowUpsTest.kt | 86 ++++++++++--- .../app/MediaViewerDeleteSceneTest.kt | 121 +++++++++++++++--- .../preview/NativeTiffMarketingCapture.kt | 1 + .../preview/RawMediaMarketingCapture.kt | 1 + .../app/JvmFileMutationLocalFollowUp.kt | 14 ++ 16 files changed, 370 insertions(+), 100 deletions(-) diff --git a/ADAPTER_ARCHITECTURE.md b/ADAPTER_ARCHITECTURE.md index e2d1622af..4eb254f33 100644 --- a/ADAPTER_ARCHITECTURE.md +++ b/ADAPTER_ARCHITECTURE.md @@ -386,7 +386,10 @@ receives the pending outcome. 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. + 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 each file; a block pauses the upload before the next file, keeps finished files, and the next run resumes at the first unfinished one. @@ -419,8 +422,10 @@ receives the pending outcome. registers the same blocks, verifies an unknown result, and follows the same retry rules. The viewer watches outcomes for every item in its list, not only the shown one. It drops an item only when its delete was confirmed or the - item is no longer at its path, moving the selection to a neighbor or closing - when none is left, and applies an outcome that arrives after its dialog + item 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; the selection moves to a neighbor, or the viewer closes when + none is left. It applies an outcome that arrives after its dialog closed, after the user moved to another item, or after the viewer was recreated. Any other outcome keeps the item and shows its reason when that item is shown. diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 1bb57218e..375eb78a2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -2721,17 +2721,17 @@ 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, - ) - val accountId = NextcloudDocumentIds.accountKey(currentSession) - fun invalidateLocal() = invalidateAfterConfirmedFileMutation(listOfNotNull(spec.sourcePath, spec.destinationPath), - { event -> recordSupportDiagnosticForAccountIdentity(accountId, event) }) { path -> fileReadCache.invalidate(accountId, path) } + ) } 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/test/kotlin/dev/obiente/nextcloudnative/AndroidFileMutationLocalFollowUpTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileMutationLocalFollowUpTest.kt index 2f9e0c053..a2c5c35d1 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileMutationLocalFollowUpTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileMutationLocalFollowUpTest.kt @@ -1,17 +1,22 @@ 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 @@ -66,6 +71,48 @@ class AndroidFileMutationLocalFollowUpTest { 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 { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt index 99fc6b6af..f9ef6d605 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlow.kt @@ -94,12 +94,15 @@ internal suspend fun deleteFileWithVerifiedOutcome( -> return FileDeleteOutcome.NotDeleted(failure.message ?: "Could not delete ${target.name}.") } } - return verifyFileDeletePostcondition(target, expectedEtag, readParent) + // 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)) @@ -118,7 +121,7 @@ private suspend fun verifyFileDeletePostcondition( return if (renamedInPlace) { FileDeleteOutcome.RefreshRequired("${target.name} was renamed on the server. Refresh the folder.") } else { - FileDeleteOutcome.NoLongerAtLocation(afterUnknownResult = true) + FileDeleteOutcome.NoLongerAtLocation(afterUnknownResult = true, localFollowUp = unknownFollowUp) } } // Retrying is safe only for positively the same item. Matching stable file IDs prove it; a @@ -150,14 +153,14 @@ internal fun FileDeleteOutcome.screenEffect(name: String, dialogShowsResult: Boo 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 if (localFollowUp == FileMutationLocalFollowUp.Completed) { - "$name is no longer in this folder." - } else { - localFollowUp.reportFor("$name is no longer in this folder") - }, + 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( 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 0db4e570d..569f9a407 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOperations.kt @@ -121,6 +121,20 @@ class NextcloudFileOperationException( */ 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 { 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..32559ff95 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,58 @@ internal class MediaViewerNavigationRepository private constructor( } } + /** + * Removes [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. Returns the route for the item to show: + * the same selection, or for a removed selection 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) + val media = content.media.filterNot { mediaViewerFileIdentity(it) == removedIdentity } + if (media.isEmpty()) { + routes.remove(route.key) + return@withLock null + } + val shownNavigation = content.media.getOrNull(route.selectedIndex) + val shownSource = content.sourceMembers.getOrNull(route.selectedSourceIndex) + val retained = media.mapTo(mutableSetOf(), ::mediaViewerFileIdentity) + val navigationIdentities = content.navigationIdentityBySourceIdentity + .filter { (source, navigation) -> source != removedIdentity && navigation in retained } + val sources = content.sourceMembers.filter { source -> + val identity = mediaViewerFileIdentity(source) + identity != removedIdentity && (identity in retained || identity in navigationIdentities) + } + routes.remove(route.key) + val key = "media-${nextRouteId++}" + routes[key] = content.copy( + media = media, + sourceMembers = sources, + navigationIdentityBySourceIdentity = navigationIdentities, + ) + val selectionSurvives = shownNavigation != null && shownSource != null && + mediaViewerFileIdentity(shownNavigation) in retained && + sources.any { mediaViewerFileIdentity(it) == mediaViewerFileIdentity(shownSource) } + val nextNavigation = if (selectionSurvives) requireNotNull(shownNavigation) else media.getOrNull(route.selectedIndex) ?: media.last() + val nextSource = if (selectionSurvives) requireNotNull(shownSource) else nextNavigation + val navigationIndex = media.indexOfFirst { mediaViewerFileIdentity(it) == mediaViewerFileIdentity(nextNavigation) } + val sourceIndex = sources.indexOfFirst { mediaViewerFileIdentity(it) == mediaViewerFileIdentity(nextSource) } + if (navigationIndex < 0 || sourceIndex < 0) { + routes.remove(key) + return@withLock null + } + 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 65709c11b..6747b4bc2 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt @@ -74,7 +74,8 @@ 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. + * 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 internal fun NextcloudMediaViewer( @@ -89,6 +90,7 @@ internal fun NextcloudMediaViewer( sharingCapabilities: NextcloudFileSharingCapabilities, onSelect: (NextcloudFile) -> Unit, onSourceRemoved: (NextcloudFile) -> Unit, + onItemRemoved: (NextcloudFile) -> Unit, onClose: () -> Unit, navigationRequest: NextcloudPendingNavigationRequest? = null, onNavigationConfirmed: (NextcloudPendingNavigationRequest) -> Unit = {}, @@ -98,8 +100,7 @@ internal fun NextcloudMediaViewer( onStateObserved: (MediaViewerStateObservation) -> Unit = {}, modifier: Modifier = Modifier, ) { - val removedPaths = remember { androidx.compose.runtime.mutableStateListOf() } - val items = remember(media, selected, removedPaths.toList()) { mediaViewerItems(media, selected, removedPaths) } + val items = remember(media, selected) { mediaViewerItems(media, selected) } val sourcePlan = remember(sourceMembers, selected) { planMediaSources(sourceMembers, selected) } @@ -848,8 +849,7 @@ internal fun NextcloudMediaViewer( } writeBlockNotice?.let { block -> MediaWriteBlockedDialog(block) { writeBlockNotice = null } } - MediaDeleteHost(items, selected, session, userId, services, fileDeletes, viewerAction == MediaViewerAction.Delete, onClose = { viewerAction = null }) { removed -> - viewerAction = null; mediaViewerRemove(removed, items, selected, removedPaths::add, onSelect) { onSourceRemoved(selected) } } + MediaDeleteHost(items, selected, session, userId, services, fileDeletes, viewerAction == MediaViewerAction.Delete, onClose = { viewerAction = null }) { removed -> viewerAction = null; onItemRemoved(removed) } viewerAction?.let { action -> MediaViewerActionDialog( action = action, 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 373436480..e66a18b21 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt @@ -324,32 +324,9 @@ internal fun MediaDeleteHost( } } -/** The viewer's items: [media] without items whose delete was applied, or only [selected] when it is not listed. */ -internal fun mediaViewerItems(media: List, selected: NextcloudFile, removedPaths: Collection): List { - val visible = if (removedPaths.isEmpty()) media else media.filterNot { it.path in removedPaths } - return if (mediaViewerNavigationIndex(visible, selected) >= 0) visible else listOf(selected) -} - -/** - * Drops [removed] from the viewer. Another item is simply hidden. When the shown item goes, the - * next item, or else the previous one, is selected; when none is left, [closeLast] closes the viewer. - */ -internal fun mediaViewerRemove( - removed: NextcloudFile, - items: List, - selected: NextcloudFile, - markRemoved: (String) -> Unit, - select: (NextcloudFile) -> Unit, - closeLast: () -> Unit, -) { - if (removed.path != selected.path) return markRemoved(removed.path) - val remaining = items.filterNot { it.path == removed.path } - val index = items.indexOfFirst { it.path == removed.path } - val neighbor = remaining.getOrNull(index.coerceAtLeast(0)) ?: remaining.lastOrNull() - if (neighbor == null) return closeLast() - markRemoved(removed.path) - select(neighbor) -} +/** 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( 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 ff45ac3dd..34ed7624b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -1883,6 +1883,7 @@ 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, @@ -1901,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, diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt index a06086702..e164094a8 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/FileDeleteFlowTest.kt @@ -493,6 +493,33 @@ class FileDeleteFlowTest { 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() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt index afea24fc3..4ebbc170a 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUps.kt @@ -78,13 +78,14 @@ internal class DesktopFileMutationFollowUps( * the request could leave this device (name resolution, connection, or TLS setup), so it * becomes [FileMutationNotSentException]. */ - inline fun reconcilingUnknownResult(mutation: (onAcceptedOrUnknownResult: () -> Unit) -> T): T { + suspend inline fun reconcilingUnknownResult(mutation: (onAcceptedOrUnknownResult: () -> Unit) -> T): T { var queued = true try { return mutation { queued = onAcceptedOrUnknownResult() } } catch (unknown: DesktopFileSyncAmbiguousMutationException) { - if (queued) throw unknown - throw DesktopFileMutationReconciliationDroppedException(unknown) + 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) } @@ -274,10 +275,11 @@ internal class DesktopFileMutationFollowUps( } /** The mutation's server result is unknown, and its local reconciliation could not be queued. */ -internal class DesktopFileMutationReconciliationDroppedException(cause: IOException) : IOException( +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, ) /** diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt index ed80f59e1..7e2115fbb 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileMutationFollowUpsTest.kt @@ -274,21 +274,27 @@ class DesktopFileMutationFollowUpsTest { assertTrue(started.await(5, TimeUnit.SECONDS)) val interrupted = DesktopFileSyncAmbiguousMutationException(java.io.IOException("reset")) - val queued = assertFailsWith { - followUps.mutation("account", listOf("source")) { null }.reconcilingUnknownResult { onUnknown -> - onUnknown() - throw interrupted - } - } - val dropped = assertFailsWith { - followUps.mutation("account", listOf("source", "destination")) { null }.reconcilingUnknownResult { onUnknown -> - onUnknown() - throw interrupted - } - } + 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, "A queued reconciliation keeps the original unknown result.") + 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" }) } @@ -422,9 +428,9 @@ class DesktopFileMutationFollowUpsTest { val runs = Collections.synchronizedList(mutableListOf()) val mutation = followUps().mutation("account", listOf("a.txt")) { path -> runs += path; null } - val unsent = assertFailsWith { - mutation.reconcilingUnknownResult { onUnknown -> executor.execute(request, onUnknown) { it.code } } - } + val unsent = assertIs( + failureOf { mutation.reconcilingUnknownResult { onUnknown -> executor.execute(request, onUnknown) { it.code } } }, + ) assertIs(unsent.cause) delay(100L) @@ -443,12 +449,54 @@ class DesktopFileMutationFollowUpsTest { .method("DELETE", null).build() val mutation = followUps().mutation("account", listOf("a.txt")) { null } - assertFailsWith { - mutation.reconcilingUnknownResult { onUnknown -> executor.execute(request, onUnknown) { it.code } } - } + 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() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt index fc7936070..13ca55ab2 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt @@ -4,7 +4,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton 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.rememberCoroutineScope @@ -19,6 +18,7 @@ import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -217,25 +217,79 @@ class MediaViewerDeleteSceneTest { } @Test - fun `the selection rule picks the next item, then the previous one, and closes on the last`() { - val items = listOf(first, second, third) - fun removeFrom(removed: NextcloudFile, selected: NextcloudFile): List { - val events = mutableListOf() - mediaViewerRemove(removed, items, selected, { events += "hide:$it" }, { events += "select:${it.name}" }) { events += "close" } - return events + 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) + } - assertEquals(listOf("hide:Photos/third.jpg"), removeFrom(third, first), "Another item is only hidden.") - assertEquals(listOf("hide:Photos/second.jpg", "select:third.jpg"), removeFrom(second, second)) - assertEquals(listOf("hide:Photos/third.jpg", "select:second.jpg"), removeFrom(third, third)) + @Test + fun `a non-confirmed outcome keeps the item after the viewer is recreated`() { val events = mutableListOf() - mediaViewerRemove(first, listOf(first), first, { events += "hide" }, { events += "select" }) { events += "close" } - assertEquals(listOf("close"), events) - assertEquals(listOf(second, third), mediaViewerItems(items, second, listOf("Photos/first.jpg"))) - assertEquals(listOf(first), mediaViewerItems(items, first, listOf("Photos/first.jpg")), "A shown item is never hidden from itself.") + 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 `the route drops a removed item, selects the next or the previous one, and ends on the last`() { + fun removeFrom(removed: NextcloudFile, shown: NextcloudFile): Pair { + val repository = MediaViewerNavigationRepository() + val route = assertNotNull(repository.register(alice.accountId, listOf(first, second, third), shown)) + val next = assertNotNull(repository.remove(alice.accountId, route, removed)) + val snapshot = assertNotNull(repository.resolve(alice.accountId, next)) + assertTrue(snapshot.media.none { 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.accountId, listOf(first), first)) + assertNull(repository.remove(alice.accountId, only, first), "Removing the last item ends the route.") + assertNull(repository.resolve(alice.accountId, only), "The ended route cannot be restored.") + val shown = assertNotNull(repository.register(alice.accountId, listOf(first, second), first)) + val kept = assertNotNull(repository.remove(alice.accountId, shown, first)) + assertNotEquals(shown, kept, "A removal changes the route even when the next item takes the same index.") + assertNull(repository.resolve(alice.accountId, shown), "A stale copy of the old route no longer resolves.") + val other = assertNotNull(repository.register(alice.accountId, listOf(first, second), first)) + assertNull(repository.remove(bob.accountId, other, first), "Another account cannot change the route.") + assertEquals(2, assertNotNull(repository.resolve(alice.accountId, other)).media.size) + assertEquals(listOf(first), mediaViewerItems(listOf(second, third), first), "A shown item outside the list stays shown.") + } + + /** The viewer's navigable sequence, held by a route repository that outlives any composition. */ + private inner class ViewerRoute(media: List) { + val repository = MediaViewerNavigationRepository() + var route: MediaViewerNavigationRoute? = repository.register(alice.accountId, media, media.first()) } - /** A minimal viewer: the shared delete host, the viewer's list rule, and next/previous selection. */ @Composable private fun Viewer( fake: FakeMediaServices, @@ -243,23 +297,48 @@ class MediaViewerDeleteSceneTest { 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 selected by remember { mutableStateOf(media.first()) } + var route by remember { mutableStateOf(state.route) } var deleteOpen by remember { mutableStateOf(open) } - val removedPaths = remember { mutableStateListOf() } - val items = mediaViewerItems(media, selected, removedPaths) + 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 = { items.getOrNull(index + 1)?.let { selected = it } }) { Text("Next") } - TextButton(onClick = { items.getOrNull(index - 1)?.let { selected = it } }) { Text("Previous") } + TextButton(onClick = { move(items.getOrNull(index + 1)) }) { Text("Next") } + TextButton(onClick = { move(items.getOrNull(index - 1)) }) { Text("Previous") } } MediaDeleteHost(items, selected, alice, "alice", fake.services, deletes, deleteOpen, onClose = { deleteOpen = false events += "closed" }) { removed -> events += "removed:${removed.name}" - mediaViewerRemove(removed, items, selected, removedPaths::add, { selected = it }) { events += "viewer closed" } + val next = state.repository.remove(alice.accountId, current, removed) + show(next) + if (next == null) events += "viewer closed" } } 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 3baead84a..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 @@ -60,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 42f950eff..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 @@ -75,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 index c7b99f3bf..9c80a1eee 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmFileMutationLocalFollowUp.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmFileMutationLocalFollowUp.kt @@ -1,5 +1,6 @@ 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. */ @@ -47,3 +48,16 @@ fun invalidateAfterConfirmedFileMutation( } 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()) + } From 296a5743d68996e9eb3873ba5db41b7de78106cd Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:52:58 +0000 Subject: [PATCH 28/30] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index cddae0ee2..a4d207197 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -612,14 +612,14 @@ "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": "8ab175088ca9b2ebd3909fbc6e66b5112b5fabd07d6e9ad49e152e2b4e8b7484", + "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": "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": "1c0c95c4ea63f5b92832003fe4769bbc5bf137bf7ea8a879e8452c134a3ce5b1", + "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", @@ -708,7 +708,7 @@ "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": "6fbde5719e3a5c6c8415bec3d3ea00fbf9274bbd77a7e4511cca03bddd9b12ee", "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", @@ -746,9 +746,9 @@ "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": "2272597fb85f22b7d3c93d33afebad3b8fedacb54f81150bdccae7505137c76d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "0492db40b4c8b0fd9669ac04051867ee2822e4bf51eedc815ea24b6b50c94a64", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "95f738ed3f9d3c649857dce5ad3afcf96d42998280ef8461eb3dc614f30833c4", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "1123671288fc7524c433e3272368851123c6a6d4a8d0d85d5efc49c2b5929da0", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "7ff55bf533cf7c67036d1e2f6f935c0cb03616e26e48cb70216fcb4e787b5384", + "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", @@ -991,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": "3d08a35d9793bde972d78dadb714a0a616469d19edbc354af6a9a756e285483c", + "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": "53bdc666621b58a4a04bc80fcfe273ad3c3412a94a9e6546bc70a3ef4d25eb18", + "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", From a4b6fe3b8b3a216c1e1b7cf37fe5a22fa5a5b6ff Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 14 Oct 2026 03:44:21 +0200 Subject: [PATCH 29/30] fix(files): keep surviving stack members and explain delete-blocked share uploads The media viewer now watches deletes for every RAW and rendered stack member, not only the navigation covers. Deleting a stack cover rebuilds the stack in place around a surviving member using the existing stacking rule; an item leaves only when no member survives. A queued Android share upload blocked by a destination delete, at start or between files, records a typed wait on the durable request. Its progress asks the user to open or refresh the destination folder instead of describing a network wait, and starting the upload clears it. --- ADAPTER_ARCHITECTURE.md | 29 ++-- .../AndroidIncomingSharePersistence.kt | 2 + .../AndroidIncomingShareUploadWorker.kt | 25 +++- .../AndroidIncomingShareUploads.kt | 10 +- .../AndroidShareUploadActivity.kt | 1 + .../AndroidShareUploadWriteFenceTest.kt | 50 +++++++ .../app/IncomingShareUploadScreen.kt | 19 ++- .../nextcloudnative/app/MediaStacking.kt | 2 +- .../app/MediaViewerNavigation.kt | 74 +++++---- .../app/NextcloudMediaViewer.kt | 2 +- .../app/NextcloudMediaViewerActions.kt | 8 +- .../app/MediaViewerRouteRemovalTest.kt | 141 ++++++++++++++++++ .../app/MediaViewerDeleteSceneTest.kt | 66 ++++---- 13 files changed, 343 insertions(+), 86 deletions(-) create mode 100644 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerRouteRemovalTest.kt diff --git a/ADAPTER_ARCHITECTURE.md b/ADAPTER_ARCHITECTURE.md index 4eb254f33..2a9984690 100644 --- a/ADAPTER_ARCHITECTURE.md +++ b/ADAPTER_ARCHITECTURE.md @@ -390,9 +390,11 @@ receives the pending outcome. 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 each file; a block pauses the - upload before the next file, keeps finished files, and the next run resumes - at the first unfinished one. +- 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. @@ -420,15 +422,18 @@ receives the pending outcome. 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, not only - the shown one. It drops an item only when its delete was confirmed or the - item 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; the selection moves to a neighbor, or the viewer closes when - none is left. It applies an outcome that arrives after its dialog - closed, after the user moved to another item, or after the viewer was - recreated. Any other outcome keeps the item and shows its reason when that - item is shown. + 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) 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 b7fa28603..190b13875 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt @@ -121,7 +121,10 @@ internal class AndroidIncomingShareUploadWorker( // 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()) return Result.retry() + 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() @@ -169,12 +172,7 @@ internal class AndroidIncomingShareUploadWorker( request = uploaded if (!allPublished) { // Finished files stay completed; the rest continue from completedFiles on the next run. - store.transition( - id = requestId, - expected = setOf(AndroidIncomingShareState.Uploading), - target = AndroidIncomingShareState.Queued, - message = INCOMING_SHARE_PAUSED_FOR_DELETE, - ) ?: return Result.success() + store.pauseForDestinationDelete(requestId) ?: return Result.success() return Result.retry() } scheduleIncomingShareCleanup(applicationContext, request.id) @@ -369,6 +367,19 @@ internal fun Throwable.incomingShareRetryNotBeforeEpochMillis(nowEpochMillis: Lo 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 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/AndroidShareUploadActivity.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt index 1dd6abe2f..cb8580db9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt @@ -493,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/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt index 3378ac1ee..5d7642298 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadWriteFenceTest.kt @@ -8,6 +8,7 @@ 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 @@ -15,11 +16,13 @@ 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") @@ -107,6 +110,53 @@ class AndroidShareUploadWriteFenceTest { 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") 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 a376147cc..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 }, 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 32559ff95..e34b44f5b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt @@ -202,12 +202,16 @@ internal class MediaViewerNavigationRepository private constructor( } /** - * Removes [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. Returns the route for the item to show: - * the same selection, or for a removed selection 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. + * 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, @@ -217,38 +221,48 @@ internal class MediaViewerNavigationRepository private constructor( lock.withLock { val content = routes[route.key]?.takeIf { it.accountId == accountId } ?: return@withLock null val removedIdentity = mediaViewerFileIdentity(removed) - val media = content.media.filterNot { mediaViewerFileIdentity(it) == removedIdentity } + // 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 shownNavigation = content.media.getOrNull(route.selectedIndex) - val shownSource = content.sourceMembers.getOrNull(route.selectedSourceIndex) val retained = media.mapTo(mutableSetOf(), ::mediaViewerFileIdentity) - val navigationIdentities = content.navigationIdentityBySourceIdentity - .filter { (source, navigation) -> source != removedIdentity && navigation in retained } - val sources = content.sourceMembers.filter { source -> + val navigationIdentities = survivingSources.mapNotNull { source -> val identity = mediaViewerFileIdentity(source) - identity != removedIdentity && (identity in retained || identity in navigationIdentities) - } + 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, - ) - val selectionSurvives = shownNavigation != null && shownSource != null && - mediaViewerFileIdentity(shownNavigation) in retained && - sources.any { mediaViewerFileIdentity(it) == mediaViewerFileIdentity(shownSource) } - val nextNavigation = if (selectionSurvives) requireNotNull(shownNavigation) else media.getOrNull(route.selectedIndex) ?: media.last() - val nextSource = if (selectionSurvives) requireNotNull(shownSource) else nextNavigation - val navigationIndex = media.indexOfFirst { mediaViewerFileIdentity(it) == mediaViewerFileIdentity(nextNavigation) } - val sourceIndex = sources.indexOfFirst { mediaViewerFileIdentity(it) == mediaViewerFileIdentity(nextSource) } - if (navigationIndex < 0 || sourceIndex < 0) { - routes.remove(key) - return@withLock null - } + routes[key] = content.copy(media = media, sourceMembers = sources, navigationIdentityBySourceIdentity = navigationIdentities) MediaViewerNavigationRoute(key, selectedIndex = navigationIndex, selectedSourceIndex = sourceIndex) } } 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 6747b4bc2..e9c0c261c 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt @@ -849,7 +849,7 @@ internal fun NextcloudMediaViewer( } writeBlockNotice?.let { block -> MediaWriteBlockedDialog(block) { writeBlockNotice = null } } - MediaDeleteHost(items, selected, session, userId, services, fileDeletes, viewerAction == MediaViewerAction.Delete, onClose = { viewerAction = null }) { removed -> viewerAction = null; onItemRemoved(removed) } + 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, 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 e66a18b21..ff8272601 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt @@ -248,8 +248,9 @@ 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], not only [selected], so a delete whose dialog was - * closed is applied even after the user moved to another item or the viewer was recreated. + * 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 @@ -258,6 +259,7 @@ private fun MediaTransferDialog( @Composable internal fun MediaDeleteHost( items: List, + sources: List, selected: NextcloudFile, session: NextcloudSession, userId: String, @@ -271,7 +273,7 @@ internal fun MediaDeleteHost( var removalNotices by remember(deletes) { mutableStateOf(emptyList>()) } var keptNotices by remember(deletes) { mutableStateOf(emptyMap()) } val currentOnRemoved by rememberUpdatedState(onRemoved) - val paths = remember(items, selected) { items.mapTo(HashSet()) { it.path } + selected.path } + 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 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/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt index 13ca55ab2..4dbb586ef 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerDeleteSceneTest.kt @@ -18,7 +18,6 @@ import kotlin.coroutines.intrinsics.startCoroutineUninterceptedOrReturn import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -257,37 +256,47 @@ class MediaViewerDeleteSceneTest { } @Test - fun `the route drops a removed item, selects the next or the previous one, and ends on the last`() { - fun removeFrom(removed: NextcloudFile, shown: NextcloudFile): Pair { - val repository = MediaViewerNavigationRepository() - val route = assertNotNull(repository.register(alice.accountId, listOf(first, second, third), shown)) - val next = assertNotNull(repository.remove(alice.accountId, route, removed)) - val snapshot = assertNotNull(repository.resolve(alice.accountId, next)) - assertTrue(snapshot.media.none { it.path == removed.path }, "The removed item is not navigable.") - return snapshot.selected.name to snapshot.media.size + 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 }) - 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.accountId, listOf(first), first)) - assertNull(repository.remove(alice.accountId, only, first), "Removing the last item ends the route.") - assertNull(repository.resolve(alice.accountId, only), "The ended route cannot be restored.") - val shown = assertNotNull(repository.register(alice.accountId, listOf(first, second), first)) - val kept = assertNotNull(repository.remove(alice.accountId, shown, first)) - assertNotEquals(shown, kept, "A removal changes the route even when the next item takes the same index.") - assertNull(repository.resolve(alice.accountId, shown), "A stale copy of the old route no longer resolves.") - val other = assertNotNull(repository.register(alice.accountId, listOf(first, second), first)) - assertNull(repository.remove(bob.accountId, other, first), "Another account cannot change the route.") - assertEquals(2, assertNotNull(repository.resolve(alice.accountId, other)).media.size) - assertEquals(listOf(first), mediaViewerItems(listOf(second, third), first), "A shown item outside the list stays shown.") + 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(media: List) { + 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, media, media.first()) + 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 @@ -330,8 +339,11 @@ class MediaViewerDeleteSceneTest { 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, selected, alice, "alice", fake.services, deletes, deleteOpen, onClose = { + MediaDeleteHost(items, snapshot.sourceMembers, selected, alice, "alice", fake.services, deletes, deleteOpen, onClose = { deleteOpen = false events += "closed" }) { removed -> From 90208dd6e442bc21531098934c9b1511d5e51f23 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:50:02 +0000 Subject: [PATCH 30/30] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index a4d207197..688b5773e 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -685,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": "3af54afe00ec00e21f0ff8a98f56fbbb26d25f87e74605589b6fddd8fcb8bc93", + "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", @@ -703,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": "6fbde5719e3a5c6c8415bec3d3ea00fbf9274bbd77a7e4511cca03bddd9b12ee", + "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", @@ -746,8 +746,8 @@ "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": "1123671288fc7524c433e3272368851123c6a6d4a8d0d85d5efc49c2b5929da0", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "7ff55bf533cf7c67036d1e2f6f935c0cb03616e26e48cb70216fcb4e787b5384", + "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",