Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e812916
fix(files): always finish the delete dialog after the server result
veryCrunchy Oct 7, 2026
b0bf889
chore(website): refresh marketing captures
obiente-automations[bot] Oct 8, 2026
374104f
fix(files): block stale, throttled, and overlapping deletes
veryCrunchy Oct 8, 2026
042ee26
chore(website): refresh marketing captures
obiente-automations[bot] Oct 8, 2026
b7a6555
fix(files): own deletes per account session and bound local follow-ups
veryCrunchy Oct 8, 2026
04ddf92
chore(website): refresh marketing captures
obiente-automations[bot] Oct 8, 2026
75adac7
fix(files): keep delete retry blocks and stop stale follow-ups
veryCrunchy Oct 8, 2026
51efc03
Merge remote-tracking branch 'origin/main' into fix/windows-delete-di…
veryCrunchy Oct 9, 2026
eafd337
chore(website): refresh marketing captures
obiente-automations[bot] Oct 12, 2026
d41fe6e
fix(files): keep writes blocked until refresh and report local follow…
veryCrunchy Oct 12, 2026
63601b9
chore(website): refresh marketing captures
obiente-automations[bot] Oct 12, 2026
d50ce09
fix(files): lift delete blocks from any view and reuse running refreshes
veryCrunchy Oct 12, 2026
21187c0
chore(website): refresh marketing captures
obiente-automations[bot] Oct 12, 2026
77ed1c0
fix(files): fence every in-app file edit and report Android cache fai…
veryCrunchy Oct 12, 2026
877efae
chore(website): refresh marketing captures
obiente-automations[bot] Oct 12, 2026
642e4ad
fix(files): share delete write blocks across activities through a pro…
veryCrunchy Oct 13, 2026
4a8ab4d
chore(website): refresh marketing captures
obiente-automations[bot] Oct 13, 2026
909ff81
fix(files): run Files deletes in the process-level account owner
veryCrunchy Oct 13, 2026
2aad0b2
chore(website): refresh marketing captures
obiente-automations[bot] Oct 13, 2026
428e3e8
fix(files): verify delete identity, skip verification for unsent requ…
veryCrunchy Oct 13, 2026
6555a51
chore(website): refresh marketing captures
obiente-automations[bot] Oct 13, 2026
d94984a
fix(files): route media viewer deletes through the shared delete coor…
veryCrunchy Oct 13, 2026
2340bca
chore(website): refresh marketing captures
obiente-automations[bot] Oct 13, 2026
2eab690
fix(files): apply media deletes for every viewer item and bound delet…
veryCrunchy Oct 13, 2026
a073b53
chore(website): refresh marketing captures
obiente-automations[bot] Oct 13, 2026
7e92f89
fix(files): require a refresh when only one side of a delete has a fi…
veryCrunchy Oct 13, 2026
0c8d1cf
chore(website): refresh marketing captures
obiente-automations[bot] Oct 13, 2026
4305e6e
fix(files): report reconciliation after unknown deletes and keep view…
veryCrunchy Oct 13, 2026
296a574
chore(website): refresh marketing captures
obiente-automations[bot] Oct 13, 2026
a4b6fe3
fix(files): keep surviving stack members and explain delete-blocked s…
veryCrunchy Oct 14, 2026
90208dd
chore(website): refresh marketing captures
obiente-automations[bot] Oct 14, 2026
732b7ce
Merge remote-tracking branch 'origin/main' into fix/windows-delete-di…
veryCrunchy Oct 14, 2026
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
73 changes: 73 additions & 0 deletions ADAPTER_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,79 @@ dialog owns only the temporary name and destination choice. The parent captures
an immutable validated destination for the typed mutation, and the transport
keeps the source ETag and no-overwrite preconditions.

### Files delete write fence

Each account's write blocks and its `FileDeleteCoordinator` live in an
`AccountFileWriteFence` held by the process-level `FileWriteFenceRegistry`,
keyed by the non-secret local account identity. Desktop services own one
registry; on Android the `Application` owns it, so the main activity, the share
upload activity, and workers of the same process see the same blocks. The
registry runs deletes and their verification in a main-thread scope that lives
as long as that owner, so a composition only starts a delete and observes its
state: leaving Files or recreating an activity neither cancels the request nor
releases its block, and a recreated screen shows the same typed reason and
receives the pending outcome.

- After an unknown result, a retry is offered only for positively the same
item: matching stable file IDs, or matching type and ETag only when neither
side has an ID. A differing ID, or an ID on only one side, requires a
refresh.
- A request that provably never left the device is a plain retryable failure
without a verification read: the account-session guard and, on desktop, a
connection, name-resolution, or TLS failure before the mutation executor saw
a network exchange raise `FileMutationNotSentException`. Android's file
mutation transport does not track the exchange, so its I/O failures stay
ambiguous and are verified.
- A 404 runs the same local follow-up as a confirmed delete and reports an
incomplete one. An item found gone after an unknown result reports the
local reconciliation run for that result: on desktop the queued follow-up,
including still running, failed, or dropped; on Android its cache
invalidation.
- Android share uploads check the fence before the first file and before each
later one; a block pauses the upload, keeps finished files, and the next run
resumes at the first unfinished one. The paused request records a typed
delete wait, so its progress asks the user to open or refresh the destination
folder instead of describing a network wait; starting the upload clears it.
- Cancellation before the request can be sent releases the item unchanged.
Cancellation after it may have been sent records a refresh-required block,
so the item is never released without an outcome.
- Sign-out and account removal cancel that account's deletes and drop its
fence object. A late completion lands only in the dropped object, never in a
later incarnation of the same account.

- An item is blocked while a delete of it, a parent, or a child is unverified,
and after an outcome that leaves its state unknown or stale (stale version,
throttling, rejected credentials or permission) until a server listing of its
folder starts after that outcome.
- Surfaces receive only the narrow `FileWriteFence`, except Files and the
media viewer, which receive the account's delete coordinator to start
deletes. Files actions, the media
viewer, photo editing, text editor saves, version restore, every remote
folder picker (create and destination choice, including folder-sync setup),
and Android share uploads (destination, queueing, and the queued worker)
refuse a blocked write, send nothing, and show the typed `FileWriteBlock`
reason.
- Blocks are in memory only. A new process starts unblocked, and sign-out or
account removal drops that account's blocks with its other private state.
- Not fenced: Memories tags (they target the file ID), Deck attachments and
dynamic-app uploads (application APIs, not Files paths), and the background
folder-sync, media-backup, virtual-file, and Android documents-provider
engines, which keep their own ETag and conflict preconditions.
- A media viewer delete runs in the same coordinator as a Files delete, so it
registers the same blocks, verifies an unknown result, and follows the same
retry rules. The viewer watches outcomes for every item in its list and every
member of their RAW and rendered stacks, not only the shown one. It drops a
file only when its delete was confirmed or the file is no longer at its path,
by removing it from the viewer's navigation route in the process-level route
repository, so a recreated viewer never shows it again. Only that file
leaves: a stack whose cover was deleted is rebuilt in place by the stacking
rule around a surviving member, and an item leaves only when none of its
members survive. The selection moves to the rebuilt stack or a neighbor, or
the viewer closes when nothing is left. It applies an outcome that arrives
after its dialog closed, after the user moved to another item or member, or
after the viewer was recreated. Any other outcome keeps the file and shows
its reason when that file is shown.

### Groupware (CardDAV and CalDAV)

**Multiget requests**
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.FileMutationNotSentException
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
Expand Down Expand Up @@ -181,7 +182,7 @@ internal suspend fun <Result> 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,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -116,6 +117,14 @@ internal class AndroidIncomingShareUploadWorker(
}
return failUnavailableAccount(store, requestId)
}
// A delete of the destination or a parent that is unverified in this process finishes first;
// the queued upload is retried later and nothing is sent meanwhile.
val destinationFence = services.fileWriteFences.account(session.accountId)
val destinationBlocked = { incomingShareUploadBlock(destinationFence, requireNotNull(request.destinationPath)) != null }
if (destinationBlocked()) {
store.pauseForDestinationDelete(requestId) ?: return Result.success()
return Result.retry()
}
AndroidNotificationCoordinator(applicationContext).ensureChannels()
var foregroundPromotionAvailable = setForegroundIfAvailable(request)
request = store.beginUpload(requestId) ?: return Result.success()
Expand All @@ -141,11 +150,11 @@ internal class AndroidIncomingShareUploadWorker(
addAll(request.uploadedNames)
}
val transfer = AndroidIncomingShareFileTransfer(store, remote, requestCancellation)
for (index in request.completedFiles until request.files.size) {
val (uploaded, allPublished) = uploadIncomingShareFilesUntilBlocked(request, destinationBlocked) { current, index ->
ensureNotCanceled(requestId, store)
request = transfer.upload(
transfer.upload(
requestId,
request,
current,
index,
occupiedNames,
destinationSnapshot.complete,
Expand All @@ -154,10 +163,17 @@ internal class AndroidIncomingShareUploadWorker(
throw CancellationException("Incoming share upload canceled")
}
mutationInFlight = inFlight
}.also { published ->
if (foregroundPromotionAvailable) {
foregroundPromotionAvailable = setForegroundIfAvailable(published)
}
}
if (foregroundPromotionAvailable) {
foregroundPromotionAvailable = setForegroundIfAvailable(request)
}
}
request = uploaded
if (!allPublished) {
// Finished files stay completed; the rest continue from completedFiles on the next run.
store.pauseForDestinationDelete(requestId) ?: return Result.success()
return Result.retry()
}
scheduleIncomingShareCleanup(applicationContext, request.id)
request = store.transition(
Expand Down Expand Up @@ -347,3 +363,41 @@ internal fun Throwable.incomingShareRetryNotBeforeEpochMillis(nowEpochMillis: Lo
?: return null
return nowEpochMillis + retryAfterSeconds * 1_000L
}

internal const val INCOMING_SHARE_PAUSED_FOR_DELETE =
"Upload paused while a delete in the destination folder finishes. Finished files are kept."

/**
* Queues [current] again, before its first file or between files, recording as a typed state that
* a delete covering the destination must be verified or refreshed first. Starting the upload clears
* it. Returns null when the request was canceled or finished meanwhile.
*/
internal fun pauseIncomingShareRequestForDestinationDelete(current: AndroidIncomingShareRequest): AndroidIncomingShareRequest? =
transitionIncomingShareRequest(
current,
expected = setOf(AndroidIncomingShareState.Queued, AndroidIncomingShareState.Uploading),
target = AndroidIncomingShareState.Queued,
message = INCOMING_SHARE_PAUSED_FOR_DELETE,
)?.copy(waitingForDestinationDelete = true)

/**
* Publishes the remaining files of [request] in order, from its completed count. Before each file
* it consults [blocked], the account's write fence for the destination, and stops before sending
* that file when a covering delete is unverified or needs a refresh. Returns the updated request
* and whether every file was published; a later run resumes at the first unpublished file, so
* finished files are never sent again.
*/
internal suspend fun uploadIncomingShareFilesUntilBlocked(
request: AndroidIncomingShareRequest,
blocked: () -> Boolean,
publish: suspend (AndroidIncomingShareRequest, Int) -> AndroidIncomingShareRequest,
): Pair<AndroidIncomingShareRequest, Boolean> {
var current = request
while (current.completedFiles < current.files.size) {
if (blocked()) return current to false
val index = current.completedFiles
current = publish(current, index)
Comment thread
veryCrunchy marked this conversation as resolved.
check(current.completedFiles > index) { "A published file must advance the completed count." }
}
return current to true
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -609,6 +616,7 @@ internal fun prepareIncomingShareRequestForQueue(
automaticTransferAttempts = 0,
retryNotBeforeEpochMillis = null,
message = null,
waitingForDestinationDelete = false,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -686,9 +687,8 @@ internal class AndroidNextcloudServices(

override suspend fun clearSupportDiagnostics(): Boolean = supportDiagnostics.clear()

override fun recordSupportDiagnostic(event: SupportDiagnosticEventDraft) {
supportDiagnostics.record(event)
}
override fun recordSupportDiagnostic(event: SupportDiagnosticEventDraft) = supportDiagnostics.record(event)
override val fileWriteFences get() = androidFileWriteFences(appContext)

internal fun recordSupportDiagnosticForAccountIdentity(
accountIdentity: String,
Expand Down Expand Up @@ -2721,21 +2721,19 @@ internal class AndroidNextcloudServices(
put("Overwrite", if (spec.overwrite) "T" else "F")
}
}
val response = request(
val accountId = NextcloudDocumentIds.accountKey(currentSession)
fun invalidateLocal() = invalidateAfterConfirmedFileMutation(listOfNotNull(spec.sourcePath, spec.destinationPath),
{ event -> recordSupportDiagnosticForAccountIdentity(accountId, event) }) { path -> fileReadCache.invalidate(accountId, path) }
val response = dev.obiente.nextcloudnative.app.reportingUnknownFileMutationResult(::invalidateLocal) { request(
method = spec.method,
url = buildNextcloudFileUrl(currentSession.serverUrl, userId, spec.sourcePath),
session = currentSession,
headers = headers,
maxResponseBytes = 64 * 1024,
accountMutationSerialized = accountMutationSerialized,
)
if (response.status !in 200..299) throw fileOperationException(response.status, spec.sourceIsDirectory)
val accountId = NextcloudDocumentIds.accountKey(currentSession)
runCatching { fileReadCache.invalidate(accountId, spec.sourcePath) }
spec.destinationPath?.let { destination ->
runCatching { fileReadCache.invalidate(accountId, destination) }
}
NextcloudFileMutationResult(spec.destinationPath, response.etag)
) }
if (response.status !in 200..299) throw fileOperationException(response.status, spec.sourceIsDirectory, if (response.status == 404) invalidateLocal() else null)
NextcloudFileMutationResult(spec.destinationPath, response.etag, invalidateLocal())
}
}
}
Expand Down
Loading
Loading