Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions ADAPTER_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

### Local follow-up after file writes

The server result of a file write is authoritative. Folder and text file
creation, text saves, favorites, version restores, and rename, move, copy, and
delete return it with a separate `FileMutationLocalFollowUp`, and surfaces
show an incomplete one with the shared `reportFor` copy instead of plain
success.

- Desktop writes go through `DesktopFileWrites`. Local cache and virtual-file
bookkeeping can wait on the virtual-file provider lock, which Windows Cloud
Files activation, recovery, or storage summaries can hold for a long time,
so it never runs on the caller's coroutine. `DesktopFileMutationFollowUps` queues it when the server
accepts the request or its result becomes unknown, refreshes one path at a
time, and coalesces repeated paths. The caller waits a bounded time and
receives `StillRunning` after that; caller cancellation leaves the queued
refresh running.
- A full queue reports `Failed`, whose copy asks for a folder refresh, and an
unknown result whose reconciliation could not be queued says so. Folder sync
uploads and Linux mount writes have no waiting caller: they queue the same
refresh without blocking, and refresh on their own thread when the queue is
full rather than dropping it.
- Sign-out and account removal abandon the account's queued refreshes; waiting
callers see `Failed`, and a refresh that was already running cannot change
that result when it finishes.
- Android invalidates its read cache after the confirmed write and reports a
failed invalidation the same way.

### Files delete write fence

Each account's write blocks and its `FileDeleteCoordinator` live in an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import dev.obiente.nextcloudnative.app.AcquiredContractKind
import dev.obiente.nextcloudnative.app.DeckAttachment
import dev.obiente.nextcloudnative.app.DeckAttachmentOpenTarget
import dev.obiente.nextcloudnative.app.DeckCardDraftKey
import dev.obiente.nextcloudnative.app.DirectoryCreation
import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult
import dev.obiente.nextcloudnative.app.DurableUploadScope
import dev.obiente.nextcloudnative.app.DurableUploadStatus
Expand Down Expand Up @@ -1397,8 +1398,12 @@ internal class AndroidNextcloudServices(
check(response.status == 200 || response.status == 207 && fileFavoriteUpdateSucceeded(response.body)) {
"The server did not confirm the favorite change."
}
runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(session), safePath) }
Unit
invalidateAfterConfirmedWrite(session, safePath)
}

/** Invalidates cached content after a confirmed write of [paths] and reports whether that succeeded. */
private fun invalidateAfterConfirmedWrite(session: NextcloudSession, vararg paths: String?) = NextcloudDocumentIds.accountKey(session).let { id ->
invalidateAfterConfirmedFileMutation(paths.filterNotNull(), { recordSupportDiagnosticForAccountIdentity(id, it) }) { fileReadCache.invalidate(id, it) }
}

override suspend fun loadFileOfflineAvailability(
Expand Down Expand Up @@ -2565,7 +2570,7 @@ internal class AndroidNextcloudServices(
userId: String,
file: NextcloudFile,
version: NextcloudFileVersion,
): Unit = withContext(Dispatchers.IO) {
) = withContext(Dispatchers.IO) {
val specification = fileVersionRestoreRequest(userId, file, version)
val response = request(
method = specification.method,
Expand All @@ -2579,7 +2584,7 @@ internal class AndroidNextcloudServices(
client = noRedirectHttpClient,
)
when (val result = classifyFileVersionRestoreHttpResponse(response.status)) {
FileVersionRestoreHttpResult.Restored -> Unit
FileVersionRestoreHttpResult.Restored -> invalidateAfterConfirmedWrite(session, file.path)
is FileVersionRestoreHttpResult.Rejected -> error(result.message)
}
}
Expand Down Expand Up @@ -2649,8 +2654,7 @@ internal class AndroidNextcloudServices(
if (failure is CancellationException) throw failure
null
}
runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(currentSession), path) }
SavedTextFile(etag, confirmation.created)
SavedTextFile(etag, confirmation.created, invalidateAfterConfirmedWrite(currentSession, path))
}
}
}
Expand All @@ -2676,12 +2680,11 @@ internal class AndroidNextcloudServices(
if (response.status == 412) return@withAndroidAuthenticatedFileMutation SavedTextFile(null, false)
check(response.status in 200..299) { "Creating the text file failed (HTTP ${response.status})." }
check(response.status == 201) { "The server did not confirm that a new text file was created." }
runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(currentSession), path) }
SavedTextFile(response.etag, wasCreated = true)
SavedTextFile(response.etag, wasCreated = true, invalidateAfterConfirmedWrite(currentSession, path))
}
}

override suspend fun createDirectoryIfAbsent(session: NextcloudSession, userId: String, path: String): Boolean =
override suspend fun createDirectoryIfAbsent(session: NextcloudSession, userId: String, path: String): DirectoryCreation =
withContext(Dispatchers.IO) {
withAndroidAuthenticatedFileMutation(
accountMutationLeaseHeld, session, accountCredentials::loadSession,
Expand All @@ -2694,11 +2697,10 @@ internal class AndroidNextcloudServices(
maxResponseBytes = 64 * 1024,
accountMutationSerialized = accountMutationSerialized,
)
if (response.status in setOf(405, 412)) return@withAndroidAuthenticatedFileMutation false
if (response.status in setOf(405, 412)) return@withAndroidAuthenticatedFileMutation DirectoryCreation(false)
if (response.status !in 200..299) throw fileOperationException(response.status)
check(response.status == 201) { "The server did not confirm that a new folder was created." }
runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(currentSession), path) }
true
DirectoryCreation(wasCreated = true, invalidateAfterConfirmedWrite(currentSession, path))
}
}

Expand All @@ -2721,9 +2723,7 @@ internal class AndroidNextcloudServices(
put("Overwrite", if (spec.overwrite) "T" else "F")
}
}
val accountId = NextcloudDocumentIds.accountKey(currentSession)
fun invalidateLocal() = invalidateAfterConfirmedFileMutation(listOfNotNull(spec.sourcePath, spec.destinationPath),
{ event -> recordSupportDiagnosticForAccountIdentity(accountId, event) }) { path -> fileReadCache.invalidate(accountId, path) }
fun invalidateLocal() = invalidateAfterConfirmedWrite(currentSession, spec.sourcePath, spec.destinationPath)
val response = dev.obiente.nextcloudnative.app.reportingUnknownFileMutationResult(::invalidateLocal) { request(
method = spec.method,
url = buildNextcloudFileUrl(currentSession.serverUrl, userId, spec.sourcePath),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dev.obiente.nextcloudnative

import dev.obiente.nextcloudnative.app.DirectoryCreation
import dev.obiente.nextcloudnative.app.FileMutationNotSentException
import dev.obiente.nextcloudnative.app.FileWriteBlock
import dev.obiente.nextcloudnative.app.FileWriteBlockedException
Expand Down Expand Up @@ -201,7 +202,7 @@ class AndroidShareUploadWriteFenceTest {
"equals" -> proxy === args?.singleOrNull()
"createDirectoryIfAbsent" -> {
calls += "createDirectoryIfAbsent:${args!![2]}"
true
DirectoryCreation(wasCreated = true)
}
else -> error("Unexpected synthetic operation: ${method.name}")
}
Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/477-desktop-write-local-follow-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: fix
issue: 477
pull: 522
platforms: linux, windows
user-facing: yes

Creating folders and files, saving text, favorites, version restores, sync uploads, and Linux mount writes no longer wait for local file status after the server confirmed them, and incomplete local updates are reported.
6 changes: 3 additions & 3 deletions tools/kotlin-file-size-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ 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|1047
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|11008
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|11007
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
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1711
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoEditing.kt|847
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoFolderBrowsing.kt|895
ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoTimelinePaging.kt|860
Expand All @@ -48,7 +48,7 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAppUpdates.kt|9
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt|1536
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt|876
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|857
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6115
ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|5987
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|1023
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,22 @@ internal fun FileMutationLocalFollowUp.reportFor(done: String): String = when (t
FileMutationLocalFollowUp.Failed -> "$done. Local file status could not be updated; refresh the folder if it looks out of date."
}

/** A text file write the server confirmed. [localFollowUp] reports the local bookkeeping separately. */
data class SavedTextFile(
val etag: String?,
val wasCreated: Boolean,
val localFollowUp: FileMutationLocalFollowUp = FileMutationLocalFollowUp.Completed,
)

/**
* The result of creating a folder only when nothing exists at its path. [localFollowUp] reports
* the local bookkeeping after a confirmed creation; nothing changed when [wasCreated] is false.
*/
data class DirectoryCreation(
val wasCreated: Boolean,
val localFollowUp: FileMutationLocalFollowUp = FileMutationLocalFollowUp.Completed,
)

enum class NextcloudFileOperationError {
AuthenticationRequired,
PermissionDenied,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,8 @@ internal fun FileVersionHistorySection(
}
is FileVersionConfirmation.Restore -> runCatching {
services.restoreFileVersion(session, userId, file, version)
}.onSuccess {
actionMessage = "Version restored. Refreshing the current file..."
}.onSuccess { localFollowUp ->
actionMessage = fileVersionRestoredMessage(localFollowUp)
runCatching { services.listFileVersions(session, userId, file) }
.onSuccess { refreshed ->
history = refreshed
Expand Down Expand Up @@ -470,6 +470,11 @@ internal fun canRestoreFileVersion(
requireRestorableFileVersion(file, version)
}.isSuccess

/** What to say once the server restored a version; incomplete local bookkeeping is never hidden. */
internal fun fileVersionRestoredMessage(localFollowUp: FileMutationLocalFollowUp): String =
if (localFollowUp == FileMutationLocalFollowUp.Completed) "Version restored. Refreshing the current file..."
else localFollowUp.reportFor("Version restored")

private fun ExternalFileHandoffResult.fileVersionActionMessage(): String = when (this) {
is ExternalFileHandoffResult.Launched -> "A detached historical copy is ready in the platform export sheet."
is ExternalFileHandoffResult.Cancelled -> "Export cancelled."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6703,13 +6703,13 @@ internal fun FilesScreen(
scope.launch {
filesRequest {
services.setFileFavorite(session, requireNotNull(userId), file, favorite)
}.onSuccess {
}.onSuccess { localFollowUp ->
favorites.succeeded(file.path)
mutationNotice = if (favorite) {
mutationNotice = localFollowUp.reportFor(if (favorite) {
"${file.name} added to favorites"
} else {
"${file.name} removed from favorites"
}
})
}.onFailure {
favorites.failed(file.path)?.let { previous ->
files = files.withFavorite(file.path, previous)
Expand Down Expand Up @@ -7129,10 +7129,9 @@ internal fun FilesScreen(
filesRequest {
when (plan.kind) {
FileCreationKind.Folder -> {
check(services.createDirectoryIfAbsent(session, requireNotNull(userId), plan.path)) {
"An item named ${plan.name} already exists."
}
null
val created = services.createDirectoryIfAbsent(session, requireNotNull(userId), plan.path)
check(created.wasCreated) { "An item named ${plan.name} already exists." }
null to created.localFollowUp
}
FileCreationKind.Markdown, FileCreationKind.Text -> {
val result = services.createTextFileIfAbsent(
Expand All @@ -7157,13 +7156,13 @@ internal fun FilesScreen(
hasPreview = false,
etag = result.etag,
permissions = "WDNVR",
)
) to result.localFollowUp
}
}
}.onSuccess { createdDocument ->
}.onSuccess { (createdDocument, localFollowUp) ->
creationKind = null
creationName = ""
mutationNotice = "Created ${plan.name}"
mutationNotice = localFollowUp.reportFor("Created ${plan.name}")
if (createdDocument == null) {
files = null
loadAttempt += 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,11 +187,6 @@ data class NextcloudFileContent(
val etag: String?,
)

data class SavedTextFile(
val etag: String?,
val wasCreated: Boolean,
)

enum class NextcloudApiMethod {
GET,
POST,
Expand Down Expand Up @@ -785,13 +780,13 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa
scopePath: String = "",
): List<NextcloudFile> = listFiles(session, userId, scopePath).filter(NextcloudFile::favorite)

/** Updates the server-backed favorite property and returns only after DAV confirms the write. */
/** Returns only after DAV confirms the favorite write; local bookkeeping is reported separately. */
suspend fun setFileFavorite(
session: NextcloudSession,
userId: String,
file: NextcloudFile,
favorite: Boolean,
) {
): FileMutationLocalFollowUp {
error("File favorites are not supported on this platform.")
}

Expand Down Expand Up @@ -1275,7 +1270,7 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa
userId: String,
file: NextcloudFile,
version: NextcloudFileVersion,
) {
): FileMutationLocalFollowUp {
error("File version restoration is not supported on this platform.")
}

Expand Down Expand Up @@ -1316,7 +1311,7 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa
session: NextcloudSession,
userId: String,
path: String,
): Boolean
): DirectoryCreation

suspend fun executeFileMutation(
session: NextcloudSession,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,12 @@ internal class TextEditorState(
originalText = submitted
etag = saved.etag?.takeIf(String::isNotBlank)
verificationRequired = etag == null
savedMessage = if (verificationRequired) "Saved. Verify the server version before saving again." else "Saved to Nextcloud"
// Verifying reads the server version, so only a plain success reports local status.
savedMessage = if (verificationRequired) {
"Saved. Verify the server version before saving again."
} else {
saved.localFollowUp.reportFor("Saved to Nextcloud")
}
} catch (cancelled: CancellationException) {
etag = null
throw cancelled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ class FileVersionHistoryTest {
)
}

@Test
fun `a restored version reports incomplete local status instead of plain success`() {
assertEquals(
"Version restored. Refreshing the current file...",
fileVersionRestoredMessage(FileMutationLocalFollowUp.Completed),
)
assertEquals(
"Version restored. Local file status is still updating in the background.",
fileVersionRestoredMessage(FileMutationLocalFollowUp.StillRunning),
)
assertEquals(
"Version restored. Local file status could not be updated; refresh the folder if it looks out of date.",
fileVersionRestoredMessage(FileMutationLocalFollowUp.Failed),
)
}

@Test
fun `plans official bounded versions DAV discovery`() {
val request = fileVersionHistoryRequest("person@example.test", 42L)
Expand Down
Loading
Loading