From c37a6f5d929b189d26dcd7e1d750b130feb13568 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 23:15:36 +0200 Subject: [PATCH 01/53] fix(android): preserve queued upload scheduling --- .../AndroidDurableMultipartUploads.kt | 45 +++++++++---- ...AndroidDurableMultipartUploadPolicyTest.kt | 63 +++++++++++++++++++ .../durable-multipart-scheduling-recovery.md | 7 +++ 3 files changed, 104 insertions(+), 11 deletions(-) create mode 100644 changes/unreleased/durable-multipart-scheduling-recovery.md diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 18308c0cc..e8508f07d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -38,8 +38,7 @@ internal class AndroidDurableMultipartUploads(context: Context) { ): DurableUploadEnqueueResult { val accountId = NextcloudDocumentIds.accountKey(session) val picker = AndroidLocalUploadPicker(appContext) - var storedJob: AndroidDurableMultipartUploadJob? = null - return runCatching { + return try { val safeRequest = request.requireSafe() picker.requirePersisted(safeRequest.file) val job = AndroidDurableMultipartUploadJob( @@ -51,15 +50,18 @@ internal class AndroidDurableMultipartUploads(context: Context) { state = DurableUploadState.Queued, message = null, ) - store.add(job) - storedJob = job - schedule(job).await() - DurableUploadEnqueueResult.Queued(job.status()) - }.getOrElse { error -> - storedJob?.let { job -> - runCatching { store.remove(job.id) } - } - if (!store.hasActiveSelection(request.file.selectionId)) { + persistAndScheduleDurableUpload( + job = job, + persist = store::add, + schedule = { queued -> schedule(queued).await() }, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + val selectionIsDefinitelyInactive = runCatching { + !store.hasActiveSelection(request.file.selectionId) + }.getOrNull() == true + if (selectionIsDefinitelyInactive) { picker.release(request.file) } DurableUploadEnqueueResult.Rejected( @@ -211,6 +213,27 @@ internal suspend fun keepRetryingQueuedDurableUploadScheduling( } } +/** + * Persists the upload before asking WorkManager to schedule it. WorkManager acceptance and its + * completion signal are not atomic, so a scheduling failure after persistence is ambiguous: the + * durable queued job must remain authoritative and can be scheduled again after process restart. + */ +internal suspend fun persistAndScheduleDurableUpload( + job: AndroidDurableMultipartUploadJob, + persist: (AndroidDurableMultipartUploadJob) -> Unit, + schedule: suspend (AndroidDurableMultipartUploadJob) -> Unit, +): DurableUploadEnqueueResult.Queued { + persist(job) + try { + schedule(job) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // The scheduler may already own this work. Keep the journal and retry scheduling later. + } + return DurableUploadEnqueueResult.Queued(job.status()) +} + internal sealed interface DurableUploadAccountResolution { data class Available(val session: NextcloudSession) : DurableUploadAccountResolution data object RegistryUnavailable : DurableUploadAccountResolution diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 2f07b1280..ff5cb6368 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult import dev.obiente.nextcloudnative.app.DurableUploadScope import dev.obiente.nextcloudnative.app.DurableUploadState import dev.obiente.nextcloudnative.app.NextcloudApiMethod @@ -15,6 +16,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue import org.json.JSONArray @@ -409,6 +411,67 @@ class AndroidDurableMultipartUploadPolicyTest { ) } + @Test + fun `ambiguous scheduling keeps the durable job queued across restart`() = runBlocking { + val job = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val persisted = mutableListOf() + val acceptedWork = mutableSetOf() + + val result = persistAndScheduleDurableUpload( + job = job, + persist = persisted::add, + schedule = { queued -> + acceptedWork += queued.id + throw IOException("The scheduler completion signal was lost") + }, + ) + + assertIs(result) + assertEquals(listOf(job), persisted) + assertEquals(setOf(job.id), acceptedWork) + + val workRecoveredAfterRestart = persisted + .filter { queued -> queued.state == DurableUploadState.Queued } + .map(AndroidDurableMultipartUploadJob::id) + assertEquals(listOf(job.id), workRecoveredAfterRestart) + } + + @Test + fun `cancellation after persistence propagates without discarding restart state`() { + val job = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val persisted = mutableListOf() + + assertFailsWith { + runBlocking { + persistAndScheduleDurableUpload( + job = job, + persist = persisted::add, + schedule = { throw CancellationException("Owner stopped") }, + ) + } + } + + assertEquals(listOf(job), persisted) + } + + @Test + fun `persistence failure never reaches the scheduler`() { + val job = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + var scheduled = false + + assertFailsWith { + runBlocking { + persistAndScheduleDurableUpload( + job = job, + persist = { throw IOException("Queue storage is unavailable") }, + schedule = { scheduled = true }, + ) + } + } + + assertFalse(scheduled) + } + @Test fun `background upload resolves the queued account instead of the active account`() { val queuedSession = fixtureSession("alice") diff --git a/changes/unreleased/durable-multipart-scheduling-recovery.md b/changes/unreleased/durable-multipart-scheduling-recovery.md new file mode 100644 index 000000000..bf9d5b6aa --- /dev/null +++ b/changes/unreleased/durable-multipart-scheduling-recovery.md @@ -0,0 +1,7 @@ +category: fix +issue: 52 +pull: none +platforms: android +user-facing: yes + +Keep a durably queued attachment upload recoverable when Android accepts its background work but the scheduling completion signal is interrupted. From 017ac03d42cb01cdc0d5bf48ecf6fab504e0dbcb Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 23:17:36 +0200 Subject: [PATCH 02/53] chore(changelog): link upload scheduling fix --- changes/unreleased/durable-multipart-scheduling-recovery.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/unreleased/durable-multipart-scheduling-recovery.md b/changes/unreleased/durable-multipart-scheduling-recovery.md index bf9d5b6aa..66492dc91 100644 --- a/changes/unreleased/durable-multipart-scheduling-recovery.md +++ b/changes/unreleased/durable-multipart-scheduling-recovery.md @@ -1,6 +1,6 @@ category: fix issue: 52 -pull: none +pull: 439 platforms: android user-facing: yes From 4706996991561a6aca17d00d72cb017fac7429cf Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 2 Sep 2026 00:31:22 +0200 Subject: [PATCH 03/53] fix(android): restore queued uploads at startup --- changes/unreleased/durable-multipart-scheduling-recovery.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/unreleased/durable-multipart-scheduling-recovery.md b/changes/unreleased/durable-multipart-scheduling-recovery.md index 66492dc91..9a041a598 100644 --- a/changes/unreleased/durable-multipart-scheduling-recovery.md +++ b/changes/unreleased/durable-multipart-scheduling-recovery.md @@ -4,4 +4,4 @@ pull: 439 platforms: android user-facing: yes -Keep a durably queued attachment upload recoverable when Android accepts its background work but the scheduling completion signal is interrupted. +Restore every durably queued attachment upload at Android startup when background scheduling is interrupted. From f6c447dbde3578a05c8135778f74d3c3e5dc9689 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 00:52:06 +0200 Subject: [PATCH 04/53] fix(android): retain startup upload retries --- .../AndroidDurableMultipartUploads.kt | 95 ---------------- .../AndroidDurableUploadScheduling.kt | 101 ++++++++++++++++++ ...AndroidDurableMultipartUploadPolicyTest.kt | 28 +++++ 3 files changed, 129 insertions(+), 95 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index e8508f07d..82b85f65b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -139,101 +139,6 @@ internal class AndroidDurableMultipartUploads(context: Context) { internal fun durableUploadWorkName(jobId: String) = "deck-attachment-$jobId" -internal suspend fun reconcileQueuedDurableUploads( - jobs: List, - schedule: suspend (AndroidDurableMultipartUploadJob) -> Unit, -): Boolean { - var allScheduled = true - jobs.filter { job -> job.state == DurableUploadState.Queued }.forEach { job -> - try { - schedule(job) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - allScheduled = false - } - } - return allScheduled -} - -internal suspend fun constructAndReconcileQueuedDurableUploads( - createReconciler: () -> suspend () -> Boolean, -): Boolean { - val reconcile = try { - createReconciler() - } catch (cancelled: CancellationException) { - throw cancelled - } catch (failure: Exception) { - throw AndroidDurableMultipartUploadRecoveryException(failure) - } - return reconcile() -} - -internal suspend fun retryQueuedDurableUploadScheduling( - retryDelaysMillis: List = listOf(1_000L, 5_000L), - reconcile: suspend () -> Boolean, - wait: suspend (Long) -> Unit, -): Boolean { - if (reconcile()) return true - retryDelaysMillis.forEach { delayMillis -> - require(delayMillis >= 0L) - wait(delayMillis) - if (reconcile()) return true - } - return false -} - -internal suspend fun keepRetryingQueuedDurableUploadScheduling( - retryDelaysMillis: List = listOf(1_000L, 5_000L), - followUpDelayMillis: Long = 60_000L, - reconcile: suspend () -> Boolean, - wait: suspend (Long) -> Unit, - recordRecoveryFailure: () -> Unit = {}, -) { - require(followUpDelayMillis > 0L) - var recoveryFailureReported = false - while (true) { - val recovered = try { - retryQueuedDurableUploadScheduling(retryDelaysMillis, reconcile, wait) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (failure: AndroidDurableMultipartUploadRecoveryException) { - if (failure.disposition == DurableUploadQueueRecoveryDisposition.Quarantine) { - if (!recoveryFailureReported) runCatching(recordRecoveryFailure) - return - } - false - } - if (recovered) return - if (!recoveryFailureReported) { - runCatching(recordRecoveryFailure) - recoveryFailureReported = true - } - wait(followUpDelayMillis) - } -} - -/** - * Persists the upload before asking WorkManager to schedule it. WorkManager acceptance and its - * completion signal are not atomic, so a scheduling failure after persistence is ambiguous: the - * durable queued job must remain authoritative and can be scheduled again after process restart. - */ -internal suspend fun persistAndScheduleDurableUpload( - job: AndroidDurableMultipartUploadJob, - persist: (AndroidDurableMultipartUploadJob) -> Unit, - schedule: suspend (AndroidDurableMultipartUploadJob) -> Unit, -): DurableUploadEnqueueResult.Queued { - persist(job) - try { - schedule(job) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - // The scheduler may already own this work. Keep the journal and retry scheduling later. - } - return DurableUploadEnqueueResult.Queued(job.status()) -} - internal sealed interface DurableUploadAccountResolution { data class Available(val session: NextcloudSession) : DurableUploadAccountResolution data object RegistryUnavailable : DurableUploadAccountResolution diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt new file mode 100644 index 000000000..9d88e9f74 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -0,0 +1,101 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult +import dev.obiente.nextcloudnative.app.DurableUploadState +import kotlinx.coroutines.CancellationException + +internal suspend fun reconcileQueuedDurableUploads( + jobs: List, + schedule: suspend (AndroidDurableMultipartUploadJob) -> Unit, +): Boolean { + var allScheduled = true + jobs.filter { job -> job.state == DurableUploadState.Queued }.forEach { job -> + try { + schedule(job) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + allScheduled = false + } + } + return allScheduled +} + +internal suspend fun constructAndReconcileQueuedDurableUploads( + createReconciler: () -> suspend () -> Boolean, +): Boolean { + val reconcile = try { + createReconciler() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + throw AndroidDurableMultipartUploadRecoveryException(failure) + } + return reconcile() +} + +internal suspend fun retryQueuedDurableUploadScheduling( + retryDelaysMillis: List = listOf(1_000L, 5_000L), + reconcile: suspend () -> Boolean, + wait: suspend (Long) -> Unit, +): Boolean { + if (reconcile()) return true + retryDelaysMillis.forEach { delayMillis -> + require(delayMillis >= 0L) + wait(delayMillis) + if (reconcile()) return true + } + return false +} + +internal suspend fun keepRetryingQueuedDurableUploadScheduling( + retryDelaysMillis: List = listOf(1_000L, 5_000L), + followUpDelayMillis: Long = 60_000L, + reconcile: suspend () -> Boolean, + wait: suspend (Long) -> Unit, + recordRecoveryFailure: () -> Unit = {}, +) { + require(followUpDelayMillis > 0L) + var recoveryFailureReported = false + while (true) { + val recovered = try { + retryQueuedDurableUploadScheduling(retryDelaysMillis, reconcile, wait) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: AndroidDurableMultipartUploadRecoveryException) { + if (failure.disposition == DurableUploadQueueRecoveryDisposition.Quarantine) { + if (!recoveryFailureReported) runCatching(recordRecoveryFailure) + return + } + false + } + if (recovered) { + recoveryFailureReported = false + } else if (!recoveryFailureReported) { + runCatching(recordRecoveryFailure) + recoveryFailureReported = true + } + wait(followUpDelayMillis) + } +} + +/** + * Persists the upload before asking WorkManager to schedule it. WorkManager acceptance and its + * completion signal are not atomic, so a scheduling failure after persistence is ambiguous: the + * durable queued job must remain authoritative and can be scheduled again after process restart. + */ +internal suspend fun persistAndScheduleDurableUpload( + job: AndroidDurableMultipartUploadJob, + persist: (AndroidDurableMultipartUploadJob) -> Unit, + schedule: suspend (AndroidDurableMultipartUploadJob) -> Unit, +): DurableUploadEnqueueResult.Queued { + persist(job) + try { + schedule(job) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // The scheduler may already own this work. Keep the journal and retry scheduling later. + } + return DurableUploadEnqueueResult.Queued(job.status()) +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index ff5cb6368..7d0e2eee2 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -436,6 +436,34 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(listOf(job.id), workRecoveredAfterRestart) } + @Test + fun `startup scheduling keeps polling after success for later enqueue failures`() { + var attempts = 0 + val waits = mutableListOf() + + assertFailsWith { + runBlocking { + keepRetryingQueuedDurableUploadScheduling( + retryDelaysMillis = listOf(10L, 20L), + followUpDelayMillis = 100L, + reconcile = { + attempts += 1 + when (attempts) { + 1 -> true + 2 -> false + 3 -> true + else -> throw CancellationException("Lifecycle stopped") + } + }, + wait = waits::add, + ) + } + } + + assertEquals(4, attempts) + assertEquals(listOf(100L, 10L, 100L), waits) + } + @Test fun `cancellation after persistence propagates without discarding restart state`() { val job = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) From 508eb699d000063654cf66ba75d0aa267c3c29fd Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 04:02:36 +0200 Subject: [PATCH 05/53] fix(android): retain upload reconciliation after journal errors --- ...AndroidDurableMultipartUploadPolicyTest.kt | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 7d0e2eee2..0ffb47186 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -464,6 +464,37 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(listOf(100L, 10L, 100L), waits) } + @Test + fun `startup scheduling keeps polling after a transient journal read failure`() { + var attempts = 0 + val waits = mutableListOf() + var diagnostics = 0 + + assertFailsWith { + runBlocking { + keepRetryingQueuedDurableUploadScheduling( + followUpDelayMillis = 100L, + reconcile = { + attempts += 1 + when (attempts) { + 1 -> throw AndroidDurableMultipartUploadRecoveryException( + IOException("Synthetic unreadable journal"), + ) + 2 -> true + else -> throw CancellationException("Lifecycle stopped") + } + }, + wait = waits::add, + recordRecoveryFailure = { diagnostics += 1 }, + ) + } + } + + assertEquals(3, attempts) + assertEquals(listOf(100L, 100L), waits) + assertEquals(1, diagnostics) + } + @Test fun `cancellation after persistence propagates without discarding restart state`() { val job = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) From 1ab984793e1b009fe39724d1617650665b964529 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 05:29:23 +0200 Subject: [PATCH 06/53] fix(android): defer uploads during credential recovery --- .../AndroidDurableMultipartUploads.kt | 72 ------------------ .../AndroidDurableUploadAccountResolution.kt | 76 +++++++++++++++++++ .../AndroidDurableUploadScheduling.kt | 31 ++++---- ...AndroidDurableMultipartUploadPolicyTest.kt | 31 ++++++++ 4 files changed, 122 insertions(+), 88 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolution.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 82b85f65b..8f46fa7ed 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -16,8 +16,6 @@ import dev.obiente.nextcloudnative.app.DurableUploadStatus import dev.obiente.nextcloudnative.app.LocalUploadFile import dev.obiente.nextcloudnative.app.MAX_DURABLE_UPLOAD_MESSAGE_CHARACTERS import dev.obiente.nextcloudnative.app.MultipartTextField -import dev.obiente.nextcloudnative.app.NextcloudAccountId -import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import dev.obiente.nextcloudnative.app.NextcloudApiMethod import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest import dev.obiente.nextcloudnative.app.NextcloudSession @@ -139,76 +137,6 @@ internal class AndroidDurableMultipartUploads(context: Context) { internal fun durableUploadWorkName(jobId: String) = "deck-attachment-$jobId" -internal sealed interface DurableUploadAccountResolution { - data class Available(val session: NextcloudSession) : DurableUploadAccountResolution - data object RegistryUnavailable : DurableUploadAccountResolution - data object CredentialUnavailable : DurableUploadAccountResolution - data object DeferAccountActivation : DurableUploadAccountResolution - data object AccountUnavailable : DurableUploadAccountResolution -} - -internal sealed interface DurableUploadAccountRegistry { - data class Available( - val accounts: List, - val activeAccountId: NextcloudAccountId? = null, - ) : DurableUploadAccountRegistry - - data object Unavailable : DurableUploadAccountRegistry -} - -internal fun queuedDurableUploadsForAccount( - jobs: List, - accountId: String, -): List = jobs.filter { job -> - job.accountId == accountId && job.state == DurableUploadState.Queued -} - -internal fun resolveDurableUploadSession( - expectedAccountId: String, - registry: DurableUploadAccountRegistry, - loadSession: (NextcloudAccountId) -> NextcloudSession?, -): DurableUploadAccountResolution { - val availableRegistry = when (registry) { - is DurableUploadAccountRegistry.Available -> registry - DurableUploadAccountRegistry.Unavailable -> return DurableUploadAccountResolution.RegistryUnavailable - } - val account = availableRegistry.accounts.singleOrNull { record -> - NextcloudDocumentIds.accountKey(record.serverUrl, record.loginName) == expectedAccountId - } ?: return DurableUploadAccountResolution.AccountUnavailable - val session = loadSession(account.id) - ?.takeIf { loaded -> NextcloudDocumentIds.accountKey(loaded) == expectedAccountId } - ?: return if (account.id == availableRegistry.activeAccountId) { - DurableUploadAccountResolution.CredentialUnavailable - } else { - DurableUploadAccountResolution.DeferAccountActivation - } - return DurableUploadAccountResolution.Available(session) -} - -internal fun resolveDurableUploadSessionWithRegistryRecovery( - expectedAccountId: String, - readRegistry: () -> DurableUploadAccountRegistry, - recoverRegistry: () -> NextcloudSession?, - loadSession: (NextcloudAccountId) -> NextcloudSession?, -): DurableUploadAccountResolution { - val initial = readRegistry() - val recoveryRequired = when (initial) { - DurableUploadAccountRegistry.Unavailable -> true - is DurableUploadAccountRegistry.Available -> initial.accounts.none { account -> - NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == expectedAccountId - } - } - if (!recoveryRequired) return resolveDurableUploadSession(expectedAccountId, initial, loadSession) - val recoveredSession = recoverRegistry() - if ( - recoveredSession != null && - NextcloudDocumentIds.accountKey(recoveredSession) == expectedAccountId - ) { - return DurableUploadAccountResolution.Available(recoveredSession) - } - return resolveDurableUploadSession(expectedAccountId, readRegistry(), loadSession) -} - internal data class AndroidDurableMultipartUploadJob( val id: String, val accountId: String, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolution.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolution.kt new file mode 100644 index 000000000..89b3ab8c5 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolution.kt @@ -0,0 +1,76 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.DurableUploadState +import dev.obiente.nextcloudnative.app.NextcloudAccountId +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal sealed interface DurableUploadAccountResolution { + data class Available(val session: NextcloudSession) : DurableUploadAccountResolution + data object RegistryUnavailable : DurableUploadAccountResolution + data object CredentialUnavailable : DurableUploadAccountResolution + data object DeferAccountActivation : DurableUploadAccountResolution + data object AccountUnavailable : DurableUploadAccountResolution +} + +internal sealed interface DurableUploadAccountRegistry { + data class Available( + val accounts: List, + val activeAccountId: NextcloudAccountId? = null, + ) : DurableUploadAccountRegistry + + data object Unavailable : DurableUploadAccountRegistry +} + +internal fun queuedDurableUploadsForAccount( + jobs: List, + accountId: String, +): List = jobs.filter { job -> + job.accountId == accountId && job.state == DurableUploadState.Queued +} + +internal fun resolveDurableUploadSession( + expectedAccountId: String, + registry: DurableUploadAccountRegistry, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): DurableUploadAccountResolution { + val availableRegistry = when (registry) { + is DurableUploadAccountRegistry.Available -> registry + DurableUploadAccountRegistry.Unavailable -> return DurableUploadAccountResolution.RegistryUnavailable + } + val account = availableRegistry.accounts.singleOrNull { record -> + NextcloudDocumentIds.accountKey(record.serverUrl, record.loginName) == expectedAccountId + } ?: return DurableUploadAccountResolution.AccountUnavailable + val session = loadSession(account.id) + ?.takeIf { loaded -> NextcloudDocumentIds.accountKey(loaded) == expectedAccountId } + ?: return if (account.id == availableRegistry.activeAccountId) { + DurableUploadAccountResolution.CredentialUnavailable + } else { + DurableUploadAccountResolution.DeferAccountActivation + } + return DurableUploadAccountResolution.Available(session) +} + +internal fun resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId: String, + readRegistry: () -> DurableUploadAccountRegistry, + recoverRegistry: () -> NextcloudSession?, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): DurableUploadAccountResolution { + val initial = readRegistry() + val recoveryRequired = when (initial) { + DurableUploadAccountRegistry.Unavailable -> true + is DurableUploadAccountRegistry.Available -> initial.accounts.none { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == expectedAccountId + } + } + if (!recoveryRequired) return resolveDurableUploadSession(expectedAccountId, initial, loadSession) + val recoveredSession = recoverRegistry() + if ( + recoveredSession != null && + NextcloudDocumentIds.accountKey(recoveredSession) == expectedAccountId + ) { + return DurableUploadAccountResolution.Available(recoveredSession) + } + return resolveDurableUploadSession(expectedAccountId, readRegistry(), loadSession) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index 9d88e9f74..05cea2f5b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -4,6 +4,19 @@ import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult import dev.obiente.nextcloudnative.app.DurableUploadState import kotlinx.coroutines.CancellationException +internal suspend fun constructAndReconcileQueuedDurableUploads( + createReconciler: () -> suspend () -> Boolean, +): Boolean { + val reconcile = try { + createReconciler() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + throw AndroidDurableMultipartUploadRecoveryException(failure) + } + return reconcile() +} + internal suspend fun reconcileQueuedDurableUploads( jobs: List, schedule: suspend (AndroidDurableMultipartUploadJob) -> Unit, @@ -21,19 +34,6 @@ internal suspend fun reconcileQueuedDurableUploads( return allScheduled } -internal suspend fun constructAndReconcileQueuedDurableUploads( - createReconciler: () -> suspend () -> Boolean, -): Boolean { - val reconcile = try { - createReconciler() - } catch (cancelled: CancellationException) { - throw cancelled - } catch (failure: Exception) { - throw AndroidDurableMultipartUploadRecoveryException(failure) - } - return reconcile() -} - internal suspend fun retryQueuedDurableUploadScheduling( retryDelaysMillis: List = listOf(1_000L, 5_000L), reconcile: suspend () -> Boolean, @@ -69,9 +69,8 @@ internal suspend fun keepRetryingQueuedDurableUploadScheduling( } false } - if (recovered) { - recoveryFailureReported = false - } else if (!recoveryFailureReported) { + if (recovered) return + if (!recoveryFailureReported) { runCatching(recordRecoveryFailure) recoveryFailureReported = true } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 0ffb47186..0f73b2705 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -495,6 +495,37 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(1, diagnostics) } + @Test + fun `startup scheduling retries when uploader construction is temporarily unavailable`() { + var constructions = 0 + val waits = mutableListOf() + var diagnostics = 0 + + assertFailsWith { + runBlocking { + keepRetryingQueuedDurableUploadScheduling( + followUpDelayMillis = 100L, + reconcile = { + constructAndReconcileQueuedDurableUploads { + constructions += 1 + when (constructions) { + 1 -> throw IOException("Synthetic keystore initialization failure") + 2 -> suspend { true } + else -> suspend { throw CancellationException("Lifecycle stopped") } + } + } + }, + wait = waits::add, + recordRecoveryFailure = { diagnostics += 1 }, + ) + } + } + + assertEquals(3, constructions) + assertEquals(listOf(100L, 100L), waits) + assertEquals(1, diagnostics) + } + @Test fun `cancellation after persistence propagates without discarding restart state`() { val job = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) From 291f79684957d8b1acb3246d85bd05d3a343c74e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 10:39:04 +0200 Subject: [PATCH 07/53] fix(uploads): skip WorkManager-owned recovery jobs --- .../AndroidDurableMultipartUploads.kt | 18 +++++++++++++----- .../AndroidDurableUploadScheduling.kt | 3 ++- .../AndroidDurableMultipartUploadPolicyTest.kt | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 8f46fa7ed..4f6d9f64a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -22,12 +22,14 @@ import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.localUploadFile import java.util.UUID import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first import org.json.JSONArray import org.json.JSONObject internal class AndroidDurableMultipartUploads(context: Context) { private val appContext = context.applicationContext private val store = AndroidDurableMultipartUploadStore(appContext) + private val workManager = WorkManager.getInstance(appContext) suspend fun enqueue( session: NextcloudSession, @@ -94,10 +96,16 @@ internal class AndroidDurableMultipartUploads(context: Context) { } } - suspend fun reconcileQueuedUploads(): Boolean = reconcileQueuedDurableUploads( - jobs = store.list(), - schedule = { job -> schedule(job).await() }, - ) + suspend fun reconcileQueuedUploads(): Boolean = + reconcileQueuedDurableUploads( + jobs = store.list(), + schedulerOwns = { job -> + workManager.getWorkInfosForUniqueWorkFlow(durableUploadWorkName(job.id)) + .first() + .any { work -> !work.state.isFinished } + }, + schedule = { job -> schedule(job).await() }, + ) fun dismiss(session: NextcloudSession, scope: DurableUploadScope, uploadId: String): Boolean { val job = store.find(uploadId) ?: return false @@ -117,7 +125,7 @@ internal class AndroidDurableMultipartUploads(context: Context) { job: AndroidDurableMultipartUploadJob, policy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP, ): Operation = - WorkManager.getInstance(appContext).enqueueUniqueWork( + workManager.enqueueUniqueWork( durableUploadWorkName(job.id), policy, OneTimeWorkRequestBuilder() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index 05cea2f5b..338ed97dc 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -19,12 +19,13 @@ internal suspend fun constructAndReconcileQueuedDurableUploads( internal suspend fun reconcileQueuedDurableUploads( jobs: List, + schedulerOwns: suspend (AndroidDurableMultipartUploadJob) -> Boolean = { false }, schedule: suspend (AndroidDurableMultipartUploadJob) -> Unit, ): Boolean { var allScheduled = true jobs.filter { job -> job.state == DurableUploadState.Queued }.forEach { job -> try { - schedule(job) + if (!schedulerOwns(job)) schedule(job) } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 0f73b2705..5f8a3e943 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -436,6 +436,22 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(listOf(job.id), workRecoveredAfterRestart) } + @Test + fun `startup reconciliation skips queued uploads already owned by WorkManager`() = runBlocking { + val owned = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val missing = fixtureJob(index = 2, account = ACCOUNT_B, cardId = 43) + val attempted = mutableListOf() + + val allScheduled = reconcileQueuedDurableUploads( + jobs = listOf(owned, missing), + schedulerOwns = { job -> job == owned }, + schedule = { job -> attempted += job.id }, + ) + + assertTrue(allScheduled) + assertEquals(listOf(missing.id), attempted) + } + @Test fun `startup scheduling keeps polling after success for later enqueue failures`() { var attempts = 0 From f6f1ca910d818a8adc294628d32889264e68a99b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 16:51:15 +0200 Subject: [PATCH 08/53] fix(uploads): bound credential recovery retries --- .../AndroidDurableUploadAccountResolution.kt | 25 +++++++++++ .../AndroidDurableUploadWorker.kt | 39 ++++++++++++++--- ...AndroidDurableMultipartUploadPolicyTest.kt | 43 +++++++++++++++++++ 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolution.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolution.kt index 89b3ab8c5..aa87b096a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolution.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolution.kt @@ -22,6 +22,29 @@ internal sealed interface DurableUploadAccountRegistry { data object Unavailable : DurableUploadAccountRegistry } +internal enum class DurableUploadCredentialDisposition { + Retry, + Fail, +} + +internal fun durableUploadCredentialDisposition(runAttemptCount: Int): DurableUploadCredentialDisposition { + require(runAttemptCount >= 0) + return if (runAttemptCount < MAX_DURABLE_UPLOAD_CREDENTIAL_RETRIES) { + DurableUploadCredentialDisposition.Retry + } else { + DurableUploadCredentialDisposition.Fail + } +} + +internal fun failDurableUploadAfterCredentialRetries( + transitionToFailed: () -> AndroidDurableMultipartUploadJob?, + releaseCapability: (AndroidDurableMultipartUploadJob) -> Unit, +): Boolean { + val failed = transitionToFailed() ?: return false + releaseCapability(failed) + return true +} + internal fun queuedDurableUploadsForAccount( jobs: List, accountId: String, @@ -74,3 +97,5 @@ internal fun resolveDurableUploadSessionWithRegistryRecovery( } return resolveDurableUploadSession(expectedAccountId, readRegistry(), loadSession) } + +internal const val MAX_DURABLE_UPLOAD_CREDENTIAL_RETRIES = 8 diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index 825a9cc0d..a9eb5e82a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -71,15 +71,10 @@ internal class DeckAttachmentUploadWorker( ) val session = when (accountResolution) { is DurableUploadAccountResolution.Available -> accountResolution.session - DurableUploadAccountResolution.RegistryUnavailable, - DurableUploadAccountResolution.CredentialUnavailable, - -> { + DurableUploadAccountResolution.RegistryUnavailable -> { recordUploadDiagnostic( severity = SupportDiagnosticSeverity.Warning, - outcome = when (accountResolution) { - DurableUploadAccountResolution.RegistryUnavailable -> "account-registry-unavailable" - else -> "account-resolution-deferred" - }, + outcome = "account-registry-unavailable", accountId = initial.accountId, jobId = jobId, ) @@ -94,6 +89,36 @@ internal class DeckAttachmentUploadWorker( ) return Result.success() } + DurableUploadAccountResolution.CredentialUnavailable -> { + if (durableUploadCredentialDisposition(runAttemptCount) == DurableUploadCredentialDisposition.Retry) { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-resolution-deferred", + accountId = initial.accountId, + jobId = jobId, + ) + return Result.retry() + } + val failureCommitted = failDurableUploadAfterCredentialRetries( + transitionToFailed = { + store.transition( + jobId, + expected = DurableUploadState.Queued, + target = DurableUploadState.Failed, + message = "Sign in to this account again, then select the file again to retry.", + ) + }, + releaseCapability = { job -> picker.release(job.request.file) }, + ) + if (!failureCommitted) return Result.success() + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-credential-unavailable", + accountId = initial.accountId, + jobId = jobId, + ) + return Result.failure() + } DurableUploadAccountResolution.AccountUnavailable -> { return failQueuedDurableUploadForUnavailableAccount( transitionToFailed = { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 5f8a3e943..d8be0bedb 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -394,6 +394,49 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(listOf("fail", "release", "diagnose"), events) } + @Test + fun `credential recovery retries become terminal at the durable bound`() { + assertEquals( + DurableUploadCredentialDisposition.Retry, + durableUploadCredentialDisposition(runAttemptCount = 0), + ) + assertEquals( + DurableUploadCredentialDisposition.Retry, + durableUploadCredentialDisposition(MAX_DURABLE_UPLOAD_CREDENTIAL_RETRIES - 1), + ) + assertEquals( + DurableUploadCredentialDisposition.Fail, + durableUploadCredentialDisposition(MAX_DURABLE_UPLOAD_CREDENTIAL_RETRIES), + ) + assertFailsWith { + durableUploadCredentialDisposition(runAttemptCount = -1) + } + } + + @Test + fun `terminal credential recovery releases capability after durable failure`() { + val job = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val events = mutableListOf() + + assertTrue( + failDurableUploadAfterCredentialRetries( + transitionToFailed = { + events += "fail-job" + job.copy(state = DurableUploadState.Failed) + }, + releaseCapability = { failed -> events += "release:${failed.request.file.selectionId}" }, + ), + ) + + assertEquals(listOf("fail-job", "release:${job.request.file.selectionId}"), events) + assertFalse( + failDurableUploadAfterCredentialRetries( + transitionToFailed = { null }, + releaseCapability = { events += "unexpected-release" }, + ), + ) + } + @Test fun `account activation resumes only its queued uploads`() { val queuedForA = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) From 01a43e84ac7ed382c19df3f612619e9240e18352 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 17:41:56 +0200 Subject: [PATCH 09/53] fix(uploads): recover registry before worker rejection --- ...AndroidDurableMultipartUploadPolicyTest.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index d8be0bedb..562b19fb5 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -886,6 +886,26 @@ class AndroidDurableMultipartUploadPolicyTest { assertTrue(events.isEmpty()) } + @Test + fun `unsupported registry remains unavailable after credential recovery attempt`() { + var recoveryAttempts = 0 + var credentialReads = 0 + + val resolved = resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId = NextcloudDocumentIds.accountKey(fixtureSession("alice")), + readRegistry = { DurableUploadAccountRegistry.Unavailable }, + recoverRegistry = { recoveryAttempts += 1 }, + loadSession = { + credentialReads += 1 + fixtureSession("alice") + }, + ) + + assertEquals(DurableUploadAccountResolution.RegistryUnavailable, resolved) + assertEquals(1, recoveryAttempts) + assertEquals(0, credentialReads) + } + @Test fun `background upload never substitutes another account on the same server path`() { val queuedSession = fixtureSession("alice") From a75a06bc692a0dd662a3ef1e40d8be4655a667be Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 20:30:11 +0200 Subject: [PATCH 10/53] fix(uploads): keep credential recovery deferred --- .../AndroidDurableMultipartUploads.kt | 10 +- .../AndroidDurableUploadAccountResolution.kt | 25 ----- .../AndroidDurableUploadScheduling.kt | 53 ++++++++++ .../AndroidDurableUploadWorker.kt | 39 ++----- ...AndroidDurableMultipartUploadPolicyTest.kt | 100 ++++++++++++------ 5 files changed, 138 insertions(+), 89 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 4f6d9f64a..b237802ef 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -87,7 +87,13 @@ internal class AndroidDurableMultipartUploads(context: Context) { suspend fun resumeQueuedForAccount(accountId: String) { queuedDurableUploadsForAccount(store.list(), accountId).forEach { job -> try { - schedule(job, ExistingWorkPolicy.APPEND_OR_REPLACE).await() + replaceDeferredDurableUploadWork( + expected = job, + load = store::find, + replace = { queued -> + schedule(queued, DURABLE_UPLOAD_ACCOUNT_RECOVERY_WORK_POLICY).await() + }, + ) } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { @@ -143,6 +149,8 @@ internal class AndroidDurableMultipartUploads(context: Context) { } } +internal val DURABLE_UPLOAD_ACCOUNT_RECOVERY_WORK_POLICY = ExistingWorkPolicy.REPLACE + internal fun durableUploadWorkName(jobId: String) = "deck-attachment-$jobId" internal data class AndroidDurableMultipartUploadJob( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolution.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolution.kt index aa87b096a..89b3ab8c5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolution.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolution.kt @@ -22,29 +22,6 @@ internal sealed interface DurableUploadAccountRegistry { data object Unavailable : DurableUploadAccountRegistry } -internal enum class DurableUploadCredentialDisposition { - Retry, - Fail, -} - -internal fun durableUploadCredentialDisposition(runAttemptCount: Int): DurableUploadCredentialDisposition { - require(runAttemptCount >= 0) - return if (runAttemptCount < MAX_DURABLE_UPLOAD_CREDENTIAL_RETRIES) { - DurableUploadCredentialDisposition.Retry - } else { - DurableUploadCredentialDisposition.Fail - } -} - -internal fun failDurableUploadAfterCredentialRetries( - transitionToFailed: () -> AndroidDurableMultipartUploadJob?, - releaseCapability: (AndroidDurableMultipartUploadJob) -> Unit, -): Boolean { - val failed = transitionToFailed() ?: return false - releaseCapability(failed) - return true -} - internal fun queuedDurableUploadsForAccount( jobs: List, accountId: String, @@ -97,5 +74,3 @@ internal fun resolveDurableUploadSessionWithRegistryRecovery( } return resolveDurableUploadSession(expectedAccountId, readRegistry(), loadSession) } - -internal const val MAX_DURABLE_UPLOAD_CREDENTIAL_RETRIES = 8 diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index 338ed97dc..f297e1f62 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -3,6 +3,59 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult import dev.obiente.nextcloudnative.app.DurableUploadState import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class AndroidDurableUploadStartCoordinator { + private val monitor = Any() + private val jobLeases = mutableMapOf() + + suspend fun withJob(jobId: String, action: suspend () -> Result): Result { + require(jobId.isNotBlank()) + val lease = synchronized(monitor) { + jobLeases.getOrPut(jobId) { JobLease() }.also { it.references += 1 } + } + return try { + lease.mutex.withLock { action() } + } finally { + synchronized(monitor) { + lease.references -= 1 + if (lease.references == 0) jobLeases.remove(jobId, lease) + } + } + } + + private class JobLease( + val mutex: Mutex = Mutex(), + var references: Int = 0, + ) +} + +private val ANDROID_DURABLE_UPLOAD_START_COORDINATOR = AndroidDurableUploadStartCoordinator() + +internal suspend fun claimQueuedDurableUploadForExecution( + jobId: String, + coordinator: AndroidDurableUploadStartCoordinator = ANDROID_DURABLE_UPLOAD_START_COORDINATOR, + claim: suspend () -> AndroidDurableMultipartUploadJob?, +): AndroidDurableMultipartUploadJob? = coordinator.withJob(jobId, claim) + +internal suspend fun replaceDeferredDurableUploadWork( + expected: AndroidDurableMultipartUploadJob, + load: (String) -> AndroidDurableMultipartUploadJob?, + replace: suspend (AndroidDurableMultipartUploadJob) -> Unit, + coordinator: AndroidDurableUploadStartCoordinator = ANDROID_DURABLE_UPLOAD_START_COORDINATOR, +): Boolean = coordinator.withJob(expected.id) { + val current = load(expected.id) + if ( + current == null || + current.accountId != expected.accountId || + current.state != DurableUploadState.Queued + ) { + return@withJob false + } + replace(current) + true +} internal suspend fun constructAndReconcileQueuedDurableUploads( createReconciler: () -> suspend () -> Boolean, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index a9eb5e82a..5cf4cedfe 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -90,34 +90,13 @@ internal class DeckAttachmentUploadWorker( return Result.success() } DurableUploadAccountResolution.CredentialUnavailable -> { - if (durableUploadCredentialDisposition(runAttemptCount) == DurableUploadCredentialDisposition.Retry) { - recordUploadDiagnostic( - severity = SupportDiagnosticSeverity.Warning, - outcome = "account-resolution-deferred", - accountId = initial.accountId, - jobId = jobId, - ) - return Result.retry() - } - val failureCommitted = failDurableUploadAfterCredentialRetries( - transitionToFailed = { - store.transition( - jobId, - expected = DurableUploadState.Queued, - target = DurableUploadState.Failed, - message = "Sign in to this account again, then select the file again to retry.", - ) - }, - releaseCapability = { job -> picker.release(job.request.file) }, - ) - if (!failureCommitted) return Result.success() recordUploadDiagnostic( severity = SupportDiagnosticSeverity.Warning, - outcome = "account-credential-unavailable", + outcome = "account-resolution-deferred", accountId = initial.accountId, jobId = jobId, ) - return Result.failure() + return Result.retry() } DurableUploadAccountResolution.AccountUnavailable -> { return failQueuedDurableUploadForUnavailableAccount( @@ -162,12 +141,14 @@ internal class DeckAttachmentUploadWorker( ) return Result.failure() } - val started = store.transition( - jobId, - expected = DurableUploadState.Queued, - target = DurableUploadState.Uploading, - message = null, - ) ?: return Result.success() + val started = claimQueuedDurableUploadForExecution(jobId) { + store.transition( + jobId, + expected = DurableUploadState.Queued, + target = DurableUploadState.Uploading, + message = null, + ) + } ?: return Result.success() val uploadServices = AndroidNextcloudServices( applicationContext, localUploadPicker = picker, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 562b19fb5..8844c23e9 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import androidx.work.ExistingWorkPolicy import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult import dev.obiente.nextcloudnative.app.DurableUploadScope import dev.obiente.nextcloudnative.app.DurableUploadState @@ -11,7 +12,10 @@ import dev.obiente.nextcloudnative.app.afterProcessRecovery import dev.obiente.nextcloudnative.app.localUploadFile import java.io.IOException import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -395,46 +399,74 @@ class AndroidDurableMultipartUploadPolicyTest { } @Test - fun `credential recovery retries become terminal at the durable bound`() { - assertEquals( - DurableUploadCredentialDisposition.Retry, - durableUploadCredentialDisposition(runAttemptCount = 0), - ) - assertEquals( - DurableUploadCredentialDisposition.Retry, - durableUploadCredentialDisposition(MAX_DURABLE_UPLOAD_CREDENTIAL_RETRIES - 1), - ) - assertEquals( - DurableUploadCredentialDisposition.Fail, - durableUploadCredentialDisposition(MAX_DURABLE_UPLOAD_CREDENTIAL_RETRIES), - ) - assertFailsWith { - durableUploadCredentialDisposition(runAttemptCount = -1) - } + fun `account recovery uses replacement only for deferred worker backoff`() { + assertEquals(ExistingWorkPolicy.REPLACE, DURABLE_UPLOAD_ACCOUNT_RECOVERY_WORK_POLICY) } @Test - fun `terminal credential recovery releases capability after durable failure`() { - val job = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) - val events = mutableListOf() + fun `account recovery never replaces a worker after it starts its upload`() = runBlocking { + val queued = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + var current = queued + val claimEntered = CompletableDeferred() + val allowClaim = CompletableDeferred() + var replacementScheduled = false + + val claim = async { + claimQueuedDurableUploadForExecution(queued.id) { + claimEntered.complete(Unit) + allowClaim.await() + current = queued.copy(state = DurableUploadState.Uploading) + current + } + } + claimEntered.await() + val recovery = async { + replaceDeferredDurableUploadWork( + expected = queued, + load = { current }, + replace = { replacementScheduled = true }, + ) + } + yield() - assertTrue( - failDurableUploadAfterCredentialRetries( - transitionToFailed = { - events += "fail-job" - job.copy(state = DurableUploadState.Failed) + assertFalse(recovery.isCompleted) + allowClaim.complete(Unit) + assertEquals(DurableUploadState.Uploading, claim.await()?.state) + assertFalse(recovery.await()) + assertFalse(replacementScheduled) + } + + @Test + fun `slow account recovery does not block an unrelated upload claim`() = runBlocking { + val recovering = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val unrelated = fixtureJob(index = 2, account = ACCOUNT_B, cardId = 43) + val coordinator = AndroidDurableUploadStartCoordinator() + val replacementEntered = CompletableDeferred() + val releaseReplacement = CompletableDeferred() + val recovery = async { + replaceDeferredDurableUploadWork( + expected = recovering, + load = { recovering }, + replace = { + replacementEntered.complete(Unit) + releaseReplacement.await() }, - releaseCapability = { failed -> events += "release:${failed.request.file.selectionId}" }, - ), - ) + coordinator = coordinator, + ) + } + replacementEntered.await() - assertEquals(listOf("fail-job", "release:${job.request.file.selectionId}"), events) - assertFalse( - failDurableUploadAfterCredentialRetries( - transitionToFailed = { null }, - releaseCapability = { events += "unexpected-release" }, - ), - ) + val claimed = claimQueuedDurableUploadForExecution( + jobId = unrelated.id, + coordinator = coordinator, + ) { + unrelated.copy(state = DurableUploadState.Uploading) + } + + assertEquals(DurableUploadState.Uploading, claimed?.state) + assertFalse(recovery.isCompleted) + releaseReplacement.complete(Unit) + assertTrue(recovery.await()) } @Test From 8969213b0d813e29c15b54a366a3784cfd25f6df Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 00:14:53 +0200 Subject: [PATCH 11/53] fix(uploads): bound startup recovery diagnostics --- .../AndroidDurableMultipartUploadPolicyTest.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 8844c23e9..d90444287 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -568,10 +568,10 @@ class AndroidDurableMultipartUploadPolicyTest { reconcile = { attempts += 1 when (attempts) { - 1 -> throw AndroidDurableMultipartUploadRecoveryException( + 1, 2 -> throw AndroidDurableMultipartUploadRecoveryException( IOException("Synthetic unreadable journal"), ) - 2 -> true + 3 -> true else -> throw CancellationException("Lifecycle stopped") } }, @@ -581,8 +581,8 @@ class AndroidDurableMultipartUploadPolicyTest { } } - assertEquals(3, attempts) - assertEquals(listOf(100L, 100L), waits) + assertEquals(4, attempts) + assertEquals(listOf(100L, 100L, 100L), waits) assertEquals(1, diagnostics) } @@ -926,7 +926,10 @@ class AndroidDurableMultipartUploadPolicyTest { val resolved = resolveDurableUploadSessionWithRegistryRecovery( expectedAccountId = NextcloudDocumentIds.accountKey(fixtureSession("alice")), readRegistry = { DurableUploadAccountRegistry.Unavailable }, - recoverRegistry = { recoveryAttempts += 1 }, + recoverRegistry = { + recoveryAttempts += 1 + null + }, loadSession = { credentialReads += 1 fixtureSession("alice") From e2c08061288d8ea9a24dd8f94cb3f47a6469d239 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 01:22:41 +0200 Subject: [PATCH 12/53] fix(uploads): wake failed scheduling recovery --- .../AndroidDurableMultipartUploads.kt | 3 +- .../AndroidDurableUploadScheduling.kt | 35 +++++- .../NextcloudNativeApplication.kt | 40 +++--- ...AndroidDurableMultipartUploadPolicyTest.kt | 117 ++++++++---------- 4 files changed, 110 insertions(+), 85 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index b237802ef..f497a6a70 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -77,6 +77,7 @@ internal class AndroidDurableMultipartUploads(context: Context) { .onEach { job -> if (job.state == DurableUploadState.Queued) { runCatching { schedule(job) } + .onFailure { requestQueuedDurableUploadSchedulingRecovery() } } } .sortedByDescending(AndroidDurableMultipartUploadJob::updatedAtEpochMillis) @@ -97,7 +98,7 @@ internal class AndroidDurableMultipartUploads(context: Context) { } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { - // The queue stays authoritative; status refresh or a later activation can retry. + requestQueuedDurableUploadSchedulingRecovery() } } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index f297e1f62..2842fc4c0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -3,6 +3,7 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult import dev.obiente.nextcloudnative.app.DurableUploadState import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -33,6 +34,36 @@ internal class AndroidDurableUploadStartCoordinator { private val ANDROID_DURABLE_UPLOAD_START_COORDINATOR = AndroidDurableUploadStartCoordinator() +internal class AndroidDurableUploadSchedulingRecoverySignal { + private val requests = Channel(Channel.CONFLATED) + + fun request() { + requests.trySend(Unit) + } + + suspend fun await() { + requests.receive() + } +} + +private val ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL = + AndroidDurableUploadSchedulingRecoverySignal() + +internal fun requestQueuedDurableUploadSchedulingRecovery() { + ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL.request() +} + +internal suspend fun monitorQueuedDurableUploadScheduling( + recover: suspend () -> Unit, + recoverySignal: AndroidDurableUploadSchedulingRecoverySignal = + ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL, +) { + while (true) { + recover() + recoverySignal.await() + } +} + internal suspend fun claimQueuedDurableUploadForExecution( jobId: String, coordinator: AndroidDurableUploadStartCoordinator = ANDROID_DURABLE_UPLOAD_START_COORDINATOR, @@ -141,14 +172,16 @@ internal suspend fun persistAndScheduleDurableUpload( job: AndroidDurableMultipartUploadJob, persist: (AndroidDurableMultipartUploadJob) -> Unit, schedule: suspend (AndroidDurableMultipartUploadJob) -> Unit, + requestRecovery: () -> Unit = ::requestQueuedDurableUploadSchedulingRecovery, ): DurableUploadEnqueueResult.Queued { persist(job) try { schedule(job) } catch (cancelled: CancellationException) { + runCatching(requestRecovery) throw cancelled } catch (_: Exception) { - // The scheduler may already own this work. Keep the journal and retry scheduling later. + runCatching(requestRecovery) } return DurableUploadEnqueueResult.Queued(job.status()) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index 37310ca46..dd7589a1e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt @@ -40,25 +40,29 @@ class NextcloudNativeApplication : Application() { runAndroidDurableUploadStartupRecovery( recover = { var uploads: AndroidDurableMultipartUploads? = null - keepRetryingQueuedDurableUploadScheduling( - reconcile = { - constructAndReconcileQueuedDurableUploads { - val accountPreferences = getSharedPreferences( - ANDROID_ACCOUNT_PREFERENCES_NAME, - Context.MODE_PRIVATE, - ) - if (accountPreferences.durableUploadAccountResolutionAvailable()) { - val available = uploads ?: AndroidDurableMultipartUploads( - this@NextcloudNativeApplication, - ).also { uploads = it } - available::reconcileQueuedUploads - } else { - suspend { true } - } - } + monitorQueuedDurableUploadScheduling( + recover = { + keepRetryingQueuedDurableUploadScheduling( + reconcile = { + constructAndReconcileQueuedDurableUploads { + val accountPreferences = getSharedPreferences( + ANDROID_ACCOUNT_PREFERENCES_NAME, + Context.MODE_PRIVATE, + ) + if (accountPreferences.durableUploadAccountResolutionAvailable()) { + val available = uploads ?: AndroidDurableMultipartUploads( + this@NextcloudNativeApplication, + ).also { uploads = it } + available::reconcileQueuedUploads + } else { + suspend { true } + } + } + }, + wait = { delayMillis -> delay(delayMillis) }, + recordRecoveryFailure = recordRecoveryFailure, + ) }, - wait = { delayMillis -> delay(delayMillis) }, - recordRecoveryFailure = recordRecoveryFailure, ) }, recordRecoveryFailure = recordRecoveryFailure, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index d90444287..86da9aa20 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -491,6 +491,7 @@ class AndroidDurableMultipartUploadPolicyTest { val job = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) val persisted = mutableListOf() val acceptedWork = mutableSetOf() + var recoveryRequests = 0 val result = persistAndScheduleDurableUpload( job = job, @@ -499,11 +500,13 @@ class AndroidDurableMultipartUploadPolicyTest { acceptedWork += queued.id throw IOException("The scheduler completion signal was lost") }, + requestRecovery = { recoveryRequests += 1 }, ) assertIs(result) assertEquals(listOf(job), persisted) assertEquals(setOf(job.id), acceptedWork) + assertEquals(1, recoveryRequests) val workRecoveredAfterRestart = persisted .filter { queued -> queued.state == DurableUploadState.Queued } @@ -528,92 +531,73 @@ class AndroidDurableMultipartUploadPolicyTest { } @Test - fun `startup scheduling keeps polling after success for later enqueue failures`() { - var attempts = 0 - val waits = mutableListOf() + fun `a recovery request wakes the idle scheduling monitor without polling`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + var recoveryRuns = 0 + recoverySignal.request() assertFailsWith { - runBlocking { - keepRetryingQueuedDurableUploadScheduling( - retryDelaysMillis = listOf(10L, 20L), - followUpDelayMillis = 100L, - reconcile = { - attempts += 1 - when (attempts) { - 1 -> true - 2 -> false - 3 -> true - else -> throw CancellationException("Lifecycle stopped") - } - }, - wait = waits::add, - ) - } + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + if (recoveryRuns == 2) throw CancellationException("Lifecycle stopped") + }, + recoverySignal = recoverySignal, + ) } - assertEquals(4, attempts) - assertEquals(listOf(100L, 10L, 100L), waits) + assertEquals(2, recoveryRuns) } @Test - fun `startup scheduling keeps polling after a transient journal read failure`() { + fun `startup scheduling retries until a transient journal read failure clears`() = runBlocking { var attempts = 0 val waits = mutableListOf() var diagnostics = 0 - assertFailsWith { - runBlocking { - keepRetryingQueuedDurableUploadScheduling( - followUpDelayMillis = 100L, - reconcile = { - attempts += 1 - when (attempts) { - 1, 2 -> throw AndroidDurableMultipartUploadRecoveryException( - IOException("Synthetic unreadable journal"), - ) - 3 -> true - else -> throw CancellationException("Lifecycle stopped") - } - }, - wait = waits::add, - recordRecoveryFailure = { diagnostics += 1 }, - ) - } - } + keepRetryingQueuedDurableUploadScheduling( + followUpDelayMillis = 100L, + reconcile = { + attempts += 1 + when (attempts) { + 1, 2 -> throw AndroidDurableMultipartUploadRecoveryException( + IOException("Synthetic unreadable journal"), + ) + else -> true + } + }, + wait = waits::add, + recordRecoveryFailure = { diagnostics += 1 }, + ) - assertEquals(4, attempts) - assertEquals(listOf(100L, 100L, 100L), waits) + assertEquals(3, attempts) + assertEquals(listOf(100L, 100L), waits) assertEquals(1, diagnostics) } @Test - fun `startup scheduling retries when uploader construction is temporarily unavailable`() { + fun `startup scheduling retries when uploader construction is temporarily unavailable`() = runBlocking { var constructions = 0 val waits = mutableListOf() var diagnostics = 0 - assertFailsWith { - runBlocking { - keepRetryingQueuedDurableUploadScheduling( - followUpDelayMillis = 100L, - reconcile = { - constructAndReconcileQueuedDurableUploads { - constructions += 1 - when (constructions) { - 1 -> throw IOException("Synthetic keystore initialization failure") - 2 -> suspend { true } - else -> suspend { throw CancellationException("Lifecycle stopped") } - } - } - }, - wait = waits::add, - recordRecoveryFailure = { diagnostics += 1 }, - ) - } - } + keepRetryingQueuedDurableUploadScheduling( + followUpDelayMillis = 100L, + reconcile = { + constructAndReconcileQueuedDurableUploads { + constructions += 1 + when (constructions) { + 1 -> throw IOException("Synthetic keystore initialization failure") + else -> suspend { true } + } + } + }, + wait = waits::add, + recordRecoveryFailure = { diagnostics += 1 }, + ) - assertEquals(3, constructions) - assertEquals(listOf(100L, 100L), waits) + assertEquals(2, constructions) + assertEquals(listOf(100L), waits) assertEquals(1, diagnostics) } @@ -621,6 +605,7 @@ class AndroidDurableMultipartUploadPolicyTest { fun `cancellation after persistence propagates without discarding restart state`() { val job = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) val persisted = mutableListOf() + var recoveryRequests = 0 assertFailsWith { runBlocking { @@ -628,11 +613,13 @@ class AndroidDurableMultipartUploadPolicyTest { job = job, persist = persisted::add, schedule = { throw CancellationException("Owner stopped") }, + requestRecovery = { recoveryRequests += 1 }, ) } } assertEquals(listOf(job), persisted) + assertEquals(1, recoveryRequests) } @Test From 984982c8396a3cbcf03020cb7b59d6170c7c1b84 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 02:07:16 +0200 Subject: [PATCH 13/53] fix(uploads): wake recovery after worker failure --- .../AndroidDurableUploadWorker.kt | 29 +++++++++--- ...AndroidDurableMultipartUploadPolicyTest.kt | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index 5cf4cedfe..e972e9da6 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -20,10 +20,16 @@ internal class DeckAttachmentUploadWorker( params: WorkerParameters, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + runDurableUploadWorkerWithRecoverySignal { + executeDurableUploadWork() + } + } + + private suspend fun executeDurableUploadWork(): Result { val jobId = inputData.getString(KEY_JOB_ID)?.takeIf(String::isNotBlank) - ?: return@withContext Result.failure() + ?: return Result.failure() val store = AndroidDurableMultipartUploadStore(applicationContext) - val initial = store.find(jobId) ?: return@withContext Result.success() + val initial = store.find(jobId) ?: return Result.success() val picker = AndroidLocalUploadPicker(applicationContext) if (initial.state.afterProcessRecovery() != initial.state) { store.transition( @@ -39,11 +45,11 @@ internal class DeckAttachmentUploadWorker( accountId = initial.accountId, jobId = jobId, ) - return@withContext Result.success() + return Result.success() } - if (initial.state != DurableUploadState.Queued) return@withContext Result.success() + if (initial.state != DurableUploadState.Queued) return Result.success() - return@withContext uploadQueuedJob(store, initial, picker, jobId) + return uploadQueuedJob(store, initial, picker, jobId) } private suspend fun uploadQueuedJob( @@ -255,6 +261,19 @@ internal fun failQueuedDurableUploadForUnavailableAccount( return failureResult } +internal suspend fun runDurableUploadWorkerWithRecoverySignal( + requestRecovery: () -> Unit = ::requestQueuedDurableUploadSchedulingRecovery, + work: suspend () -> WorkResult, +): WorkResult = try { + work() +} catch (cancelled: CancellationException) { + runCatching(requestRecovery) + throw cancelled +} catch (failure: Exception) { + runCatching(requestRecovery) + throw failure +} + internal suspend fun captureDurableUploadRequestOutcome( request: suspend () -> Result, ): kotlin.Result = try { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 86da9aa20..dc84283f0 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -25,6 +25,51 @@ import kotlin.test.assertTrue import org.json.JSONArray class AndroidDurableMultipartUploadPolicyTest { + @Test + fun `successful worker execution does not request scheduling recovery`() = runBlocking { + var recoveryRequests = 0 + + val result = runDurableUploadWorkerWithRecoverySignal( + requestRecovery = { recoveryRequests += 1 }, + work = { "completed" }, + ) + + assertEquals("completed", result) + assertEquals(0, recoveryRequests) + } + + @Test + fun `worker failure requests scheduling recovery before preserving the failure`() = runBlocking { + var recoveryRequests = 0 + val expected = IOException("journal read failed") + + val actual = assertFailsWith { + runDurableUploadWorkerWithRecoverySignal( + requestRecovery = { recoveryRequests += 1 }, + work = { throw expected }, + ) + } + + assertTrue(actual === expected) + assertEquals(1, recoveryRequests) + } + + @Test + fun `worker cancellation requests scheduling recovery before preserving cancellation`() = runBlocking { + var recoveryRequests = 0 + val expected = CancellationException("worker stopped") + + val actual = assertFailsWith { + runDurableUploadWorkerWithRecoverySignal( + requestRecovery = { recoveryRequests += 1 }, + work = { throw expected }, + ) + } + + assertTrue(actual === expected) + assertEquals(1, recoveryRequests) + } + @Test fun `worker cancellation does not become a terminal upload outcome`() = runBlocking { assertFailsWith { From 6feb0d9ea4f10abbe018c323650edd049d176b91 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 02:30:25 +0200 Subject: [PATCH 14/53] fix(uploads): close recovery wakeup races --- .../AndroidDurableMultipartUploads.kt | 16 +- .../AndroidDurableUploadScheduling.kt | 93 +++++++++- .../AndroidDurableUploadWorker.kt | 8 +- .../NextcloudNativeApplication.kt | 14 ++ ...AndroidDurableMultipartUploadPolicyTest.kt | 159 ++++++++++++++++++ 5 files changed, 280 insertions(+), 10 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index f497a6a70..ffc78acfe 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -21,6 +21,7 @@ import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.localUploadFile import java.util.UUID +import java.util.concurrent.Executor import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first import org.json.JSONArray @@ -76,8 +77,17 @@ internal class AndroidDurableMultipartUploads(context: Context) { .asSequence() .onEach { job -> if (job.state == DurableUploadState.Queued) { - runCatching { schedule(job) } - .onFailure { requestQueuedDurableUploadSchedulingRecovery() } + runCatching { + val operation = schedule(job) + observeDurableUploadSchedulingResult( + result = operation.result, + addListener = { listener -> + operation.result.addListener(listener, DIRECT_COMPLETION_EXECUTOR) + }, + ) + }.onFailure { + requestQueuedDurableUploadSchedulingRecovery() + } } } .sortedByDescending(AndroidDurableMultipartUploadJob::updatedAtEpochMillis) @@ -96,6 +106,7 @@ internal class AndroidDurableMultipartUploads(context: Context) { }, ) } catch (cancelled: CancellationException) { + runCatching { requestQueuedDurableUploadSchedulingRecovery() } throw cancelled } catch (_: Exception) { requestQueuedDurableUploadSchedulingRecovery() @@ -147,6 +158,7 @@ internal class AndroidDurableMultipartUploads(context: Context) { private companion object { const val MAX_VISIBLE_UPLOADS_PER_RESOURCE = 12 + val DIRECT_COMPLETION_EXECUTOR = Executor(Runnable::run) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index 2842fc4c0..8a18feed8 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -2,6 +2,8 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult import dev.obiente.nextcloudnative.app.DurableUploadState +import java.util.UUID +import java.util.concurrent.Future import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.sync.Mutex @@ -34,15 +36,42 @@ internal class AndroidDurableUploadStartCoordinator { private val ANDROID_DURABLE_UPLOAD_START_COORDINATOR = AndroidDurableUploadStartCoordinator() +internal data class AndroidDurableUploadSchedulingRecoveryBatch( + val immediate: Boolean, + val workIdsToAwait: List, +) + internal class AndroidDurableUploadSchedulingRecoverySignal { - private val requests = Channel(Channel.CONFLATED) + private val monitor = Any() + private val wakeups = Channel(Channel.CONFLATED) + private var immediatePending = false + private val workIdsToAwait = linkedSetOf() fun request() { - requests.trySend(Unit) + synchronized(monitor) { + immediatePending = true + } + wakeups.trySend(Unit) } - suspend fun await() { - requests.receive() + fun requestAfterWorkStopsRunning(workId: UUID) { + synchronized(monitor) { + workIdsToAwait += workId + } + wakeups.trySend(Unit) + } + + suspend fun await(): AndroidDurableUploadSchedulingRecoveryBatch { + wakeups.receive() + return synchronized(monitor) { + AndroidDurableUploadSchedulingRecoveryBatch( + immediate = immediatePending, + workIdsToAwait = workIdsToAwait.toList(), + ).also { + immediatePending = false + workIdsToAwait.clear() + } + } } } @@ -53,14 +82,66 @@ internal fun requestQueuedDurableUploadSchedulingRecovery() { ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL.request() } +internal fun requestQueuedDurableUploadSchedulingRecoveryAfterWorkStopsRunning(workId: UUID) { + ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL.requestAfterWorkStopsRunning(workId) +} + internal suspend fun monitorQueuedDurableUploadScheduling( recover: suspend () -> Unit, + awaitWorkStopsRunning: suspend (UUID) -> Unit = {}, recoverySignal: AndroidDurableUploadSchedulingRecoverySignal = ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL, ) { + recover() while (true) { - recover() - recoverySignal.await() + val requests = recoverySignal.await() + if (requests.immediate) recover() + if (requests.workIdsToAwait.isNotEmpty()) { + requests.workIdsToAwait.forEach { workId -> awaitWorkStopsRunning(workId) } + recover() + } + } +} + +internal suspend fun awaitDurableUploadWorkToStopRunning( + workId: UUID, + retryDelayMillis: Long = 1_000L, + awaitWorkStopsRunning: suspend (UUID) -> Unit, + wait: suspend (Long) -> Unit, +) { + require(retryDelayMillis > 0L) + while (true) { + try { + awaitWorkStopsRunning(workId) + return + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + wait(retryDelayMillis) + } + } +} + +internal fun observeDurableUploadSchedulingResult( + result: Future<*>, + addListener: (Runnable) -> Unit, + requestRecovery: () -> Unit = ::requestQueuedDurableUploadSchedulingRecovery, +) { + val listener = Runnable { + if (!result.isDone) { + runCatching(requestRecovery) + return@Runnable + } + try { + result.get() + } catch (_: Exception) { + runCatching(requestRecovery) + } + } + try { + addListener(listener) + } catch (_: Exception) { + runCatching(requestRecovery) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index e972e9da6..54ab1f0bb 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -19,8 +19,12 @@ internal class DeckAttachmentUploadWorker( appContext: Context, params: WorkerParameters, ) : CoroutineWorker(appContext, params) { - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - runDurableUploadWorkerWithRecoverySignal { + override suspend fun doWork(): Result = runDurableUploadWorkerWithRecoverySignal( + requestRecovery = { + requestQueuedDurableUploadSchedulingRecoveryAfterWorkStopsRunning(id) + }, + ) { + withContext(Dispatchers.IO) { executeDurableUploadWork() } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index dd7589a1e..a856cf593 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt @@ -3,6 +3,8 @@ package dev.obiente.nextcloudnative import android.app.Application import android.content.Context import android.content.SharedPreferences +import androidx.work.WorkInfo +import androidx.work.WorkManager import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity @@ -11,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch class NextcloudNativeApplication : Application() { @@ -63,6 +66,17 @@ class NextcloudNativeApplication : Application() { recordRecoveryFailure = recordRecoveryFailure, ) }, + awaitWorkStopsRunning = { workId -> + awaitDurableUploadWorkToStopRunning( + workId = workId, + awaitWorkStopsRunning = { requestedWorkId -> + WorkManager.getInstance(this@NextcloudNativeApplication) + .getWorkInfoByIdFlow(requestedWorkId) + .first { work -> work == null || work.state != WorkInfo.State.RUNNING } + }, + wait = { retryDelayMillis -> delay(retryDelayMillis) }, + ) + }, ) }, recordRecoveryFailure = recordRecoveryFailure, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index dc84283f0..8641e9e3a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -11,6 +11,8 @@ import dev.obiente.nextcloudnative.app.accountRecord import dev.obiente.nextcloudnative.app.afterProcessRecovery import dev.obiente.nextcloudnative.app.localUploadFile import java.io.IOException +import java.util.UUID +import java.util.concurrent.CompletableFuture import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async @@ -594,6 +596,163 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(2, recoveryRuns) } + @Test + fun `worker failure wake waits for WorkManager to relinquish ownership`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val workId = UUID.randomUUID() + val initialRecoveryFinished = CompletableDeferred() + val readinessEntered = CompletableDeferred() + val workStoppedRunning = CompletableDeferred() + var workManagerOwnsJob = true + var recoveryRuns = 0 + + val monitor = async { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + if (recoveryRuns == 1) { + initialRecoveryFinished.complete(Unit) + } else { + assertFalse(workManagerOwnsJob) + throw CancellationException("Test completed") + } + }, + awaitWorkStopsRunning = { requestedWorkId -> + assertEquals(workId, requestedWorkId) + readinessEntered.complete(Unit) + workStoppedRunning.await() + }, + recoverySignal = recoverySignal, + ) + } + + initialRecoveryFinished.await() + recoverySignal.requestAfterWorkStopsRunning(workId) + readinessEntered.await() + yield() + assertEquals(1, recoveryRuns) + + workManagerOwnsJob = false + workStoppedRunning.complete(Unit) + assertFailsWith { monitor.await() } + assertEquals(2, recoveryRuns) + } + + @Test + fun `recovery signal conflates immediate requests and deduplicates worker ids`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val firstWorkId = UUID.randomUUID() + val secondWorkId = UUID.randomUUID() + + recoverySignal.request() + recoverySignal.request() + recoverySignal.requestAfterWorkStopsRunning(firstWorkId) + recoverySignal.requestAfterWorkStopsRunning(firstWorkId) + recoverySignal.requestAfterWorkStopsRunning(secondWorkId) + + assertEquals( + AndroidDurableUploadSchedulingRecoveryBatch( + immediate = true, + workIdsToAwait = listOf(firstWorkId, secondWorkId), + ), + recoverySignal.await(), + ) + } + + @Test + fun `worker readiness retries a transient state query for the same work id`() = runBlocking { + val workId = UUID.randomUUID() + val requestedWorkIds = mutableListOf() + val waits = mutableListOf() + + awaitDurableUploadWorkToStopRunning( + workId = workId, + retryDelayMillis = 25L, + awaitWorkStopsRunning = { requestedWorkId -> + requestedWorkIds += requestedWorkId + if (requestedWorkIds.size == 1) { + throw IOException("Synthetic WorkManager database failure") + } + }, + wait = waits::add, + ) + + assertEquals(listOf(workId, workId), requestedWorkIds) + assertEquals(listOf(25L), waits) + } + + @Test + fun `worker readiness cancellation propagates without retrying`() = runBlocking { + val workId = UUID.randomUUID() + val expected = CancellationException("Recovery owner stopped") + val waits = mutableListOf() + + val actual = assertFailsWith { + awaitDurableUploadWorkToStopRunning( + workId = workId, + awaitWorkStopsRunning = { requestedWorkId -> + assertEquals(workId, requestedWorkId) + throw expected + }, + wait = waits::add, + ) + } + + assertTrue(actual === expected) + assertTrue(waits.isEmpty()) + } + + @Test + fun `asynchronous scheduling failure requests recovery`() { + val schedulingResult = CompletableFuture() + var recoveryRequests = 0 + + observeDurableUploadSchedulingResult( + result = schedulingResult, + addListener = { listener -> + schedulingResult.whenComplete { _, _ -> listener.run() } + }, + requestRecovery = { recoveryRequests += 1 }, + ) + assertEquals(0, recoveryRequests) + + schedulingResult.completeExceptionally(IOException("WorkManager rejected the request")) + + assertEquals(1, recoveryRequests) + } + + @Test + fun `cancelled scheduling result requests recovery without blocking`() { + val schedulingResult = CompletableFuture() + var recoveryRequests = 0 + + observeDurableUploadSchedulingResult( + result = schedulingResult, + addListener = { listener -> + schedulingResult.whenComplete { _, _ -> listener.run() } + }, + requestRecovery = { recoveryRequests += 1 }, + ) + + assertTrue(schedulingResult.cancel(false)) + assertEquals(1, recoveryRequests) + } + + @Test + fun `premature scheduling listener requests recovery instead of blocking`() { + val schedulingResult = CompletableFuture() + var recoveryRequests = 0 + + observeDurableUploadSchedulingResult( + result = schedulingResult, + addListener = Runnable::run, + requestRecovery = { recoveryRequests += 1 }, + ) + + assertFalse(schedulingResult.isDone) + assertEquals(1, recoveryRequests) + } + @Test fun `startup scheduling retries until a transient journal read failure clears`() = runBlocking { var attempts = 0 From 3b0c3f8e3e5fb867230cd0c8be9d8edab9aea41b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 02:40:09 +0200 Subject: [PATCH 15/53] fix(uploads): centralize queued status recovery --- .../AndroidDurableMultipartUploads.kt | 32 +++++------ .../AndroidDurableUploadScheduling.kt | 24 --------- ...AndroidDurableMultipartUploadPolicyTest.kt | 54 +++++++------------ 3 files changed, 32 insertions(+), 78 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index ffc78acfe..b519ff358 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -21,7 +21,6 @@ import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.localUploadFile import java.util.UUID -import java.util.concurrent.Executor import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first import org.json.JSONArray @@ -72,28 +71,15 @@ internal class AndroidDurableMultipartUploads(context: Context) { } } - fun statuses(session: NextcloudSession, scope: DurableUploadScope): List = - store.list(NextcloudDocumentIds.accountKey(session), scope) - .asSequence() - .onEach { job -> - if (job.state == DurableUploadState.Queued) { - runCatching { - val operation = schedule(job) - observeDurableUploadSchedulingResult( - result = operation.result, - addListener = { listener -> - operation.result.addListener(listener, DIRECT_COMPLETION_EXECUTOR) - }, - ) - }.onFailure { - requestQueuedDurableUploadSchedulingRecovery() - } - } - } + fun statuses(session: NextcloudSession, scope: DurableUploadScope): List { + val jobs = store.list(NextcloudDocumentIds.accountKey(session), scope) + requestDurableUploadSchedulingRecoveryForQueuedStatuses(jobs) + return jobs.asSequence() .sortedByDescending(AndroidDurableMultipartUploadJob::updatedAtEpochMillis) .take(MAX_VISIBLE_UPLOADS_PER_RESOURCE) .map(AndroidDurableMultipartUploadJob::status) .toList() + } suspend fun resumeQueuedForAccount(accountId: String) { queuedDurableUploadsForAccount(store.list(), accountId).forEach { job -> @@ -158,7 +144,6 @@ internal class AndroidDurableMultipartUploads(context: Context) { private companion object { const val MAX_VISIBLE_UPLOADS_PER_RESOURCE = 12 - val DIRECT_COMPLETION_EXECUTOR = Executor(Runnable::run) } } @@ -166,6 +151,13 @@ internal val DURABLE_UPLOAD_ACCOUNT_RECOVERY_WORK_POLICY = ExistingWorkPolicy.RE internal fun durableUploadWorkName(jobId: String) = "deck-attachment-$jobId" +internal fun requestDurableUploadSchedulingRecoveryForQueuedStatuses( + jobs: List, + requestRecovery: () -> Unit = ::requestQueuedDurableUploadSchedulingRecovery, +) { + if (jobs.any { job -> job.state == DurableUploadState.Queued }) requestRecovery() +} + internal data class AndroidDurableMultipartUploadJob( val id: String, val accountId: String, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index 8a18feed8..c480ecb4e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -3,7 +3,6 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult import dev.obiente.nextcloudnative.app.DurableUploadState import java.util.UUID -import java.util.concurrent.Future import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.sync.Mutex @@ -122,29 +121,6 @@ internal suspend fun awaitDurableUploadWorkToStopRunning( } } -internal fun observeDurableUploadSchedulingResult( - result: Future<*>, - addListener: (Runnable) -> Unit, - requestRecovery: () -> Unit = ::requestQueuedDurableUploadSchedulingRecovery, -) { - val listener = Runnable { - if (!result.isDone) { - runCatching(requestRecovery) - return@Runnable - } - try { - result.get() - } catch (_: Exception) { - runCatching(requestRecovery) - } - } - try { - addListener(listener) - } catch (_: Exception) { - runCatching(requestRecovery) - } -} - internal suspend fun claimQueuedDurableUploadForExecution( jobId: String, coordinator: AndroidDurableUploadStartCoordinator = ANDROID_DURABLE_UPLOAD_START_COORDINATOR, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 8641e9e3a..6a67f8575 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -12,7 +12,6 @@ import dev.obiente.nextcloudnative.app.afterProcessRecovery import dev.obiente.nextcloudnative.app.localUploadFile import java.io.IOException import java.util.UUID -import java.util.concurrent.CompletableFuture import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async @@ -703,54 +702,41 @@ class AndroidDurableMultipartUploadPolicyTest { } @Test - fun `asynchronous scheduling failure requests recovery`() { - val schedulingResult = CompletableFuture() + fun `queued status snapshot requests one scheduling recovery`() { + val first = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val second = fixtureJob(index = 2, account = ACCOUNT_A, cardId = 43) var recoveryRequests = 0 - observeDurableUploadSchedulingResult( - result = schedulingResult, - addListener = { listener -> - schedulingResult.whenComplete { _, _ -> listener.run() } - }, + requestDurableUploadSchedulingRecoveryForQueuedStatuses( + jobs = listOf(first, second), requestRecovery = { recoveryRequests += 1 }, ) - assertEquals(0, recoveryRequests) - - schedulingResult.completeExceptionally(IOException("WorkManager rejected the request")) assertEquals(1, recoveryRequests) } @Test - fun `cancelled scheduling result requests recovery without blocking`() { - val schedulingResult = CompletableFuture() - var recoveryRequests = 0 - - observeDurableUploadSchedulingResult( - result = schedulingResult, - addListener = { listener -> - schedulingResult.whenComplete { _, _ -> listener.run() } - }, - requestRecovery = { recoveryRequests += 1 }, + fun `terminal status snapshot does not request scheduling recovery`() { + val completed = fixtureJob( + index = 1, + account = ACCOUNT_A, + cardId = 42, + state = DurableUploadState.Completed, + ) + val failed = fixtureJob( + index = 2, + account = ACCOUNT_A, + cardId = 43, + state = DurableUploadState.Failed, ) - - assertTrue(schedulingResult.cancel(false)) - assertEquals(1, recoveryRequests) - } - - @Test - fun `premature scheduling listener requests recovery instead of blocking`() { - val schedulingResult = CompletableFuture() var recoveryRequests = 0 - observeDurableUploadSchedulingResult( - result = schedulingResult, - addListener = Runnable::run, + requestDurableUploadSchedulingRecoveryForQueuedStatuses( + jobs = listOf(completed, failed), requestRecovery = { recoveryRequests += 1 }, ) - assertFalse(schedulingResult.isDone) - assertEquals(1, recoveryRequests) + assertEquals(0, recoveryRequests) } @Test From 428514b55eba58b7584cac283d63e6369e394a0a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 06:41:34 +0200 Subject: [PATCH 16/53] fix(uploads): back off worker recovery --- .../AndroidDurableUploadScheduling.kt | 12 +++-- .../NextcloudNativeApplication.kt | 1 + ...AndroidDurableMultipartUploadPolicyTest.kt | 44 ++++++++++--------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index c480ecb4e..e42ad5707 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -35,6 +35,8 @@ internal class AndroidDurableUploadStartCoordinator { private val ANDROID_DURABLE_UPLOAD_START_COORDINATOR = AndroidDurableUploadStartCoordinator() +internal const val ANDROID_DURABLE_UPLOAD_SCHEDULING_FOLLOW_UP_DELAY_MILLIS = 60_000L + internal data class AndroidDurableUploadSchedulingRecoveryBatch( val immediate: Boolean, val workIdsToAwait: List, @@ -88,17 +90,21 @@ internal fun requestQueuedDurableUploadSchedulingRecoveryAfterWorkStopsRunning(w internal suspend fun monitorQueuedDurableUploadScheduling( recover: suspend () -> Unit, awaitWorkStopsRunning: suspend (UUID) -> Unit = {}, + wait: suspend (Long) -> Unit, + workerFailureFollowUpDelayMillis: Long = + ANDROID_DURABLE_UPLOAD_SCHEDULING_FOLLOW_UP_DELAY_MILLIS, recoverySignal: AndroidDurableUploadSchedulingRecoverySignal = ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL, ) { + require(workerFailureFollowUpDelayMillis > 0L) recover() while (true) { val requests = recoverySignal.await() - if (requests.immediate) recover() if (requests.workIdsToAwait.isNotEmpty()) { requests.workIdsToAwait.forEach { workId -> awaitWorkStopsRunning(workId) } - recover() + wait(workerFailureFollowUpDelayMillis) } + if (requests.immediate || requests.workIdsToAwait.isNotEmpty()) recover() } } @@ -192,7 +198,7 @@ internal suspend fun retryQueuedDurableUploadScheduling( internal suspend fun keepRetryingQueuedDurableUploadScheduling( retryDelaysMillis: List = listOf(1_000L, 5_000L), - followUpDelayMillis: Long = 60_000L, + followUpDelayMillis: Long = ANDROID_DURABLE_UPLOAD_SCHEDULING_FOLLOW_UP_DELAY_MILLIS, reconcile: suspend () -> Boolean, wait: suspend (Long) -> Unit, recordRecoveryFailure: () -> Unit = {}, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index a856cf593..ef812718f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt @@ -77,6 +77,7 @@ class NextcloudNativeApplication : Application() { wait = { retryDelayMillis -> delay(retryDelayMillis) }, ) }, + wait = { retryDelayMillis -> delay(retryDelayMillis) }, ) }, recordRecoveryFailure = recordRecoveryFailure, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 6a67f8575..77fb25e8d 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -588,6 +588,7 @@ class AndroidDurableMultipartUploadPolicyTest { recoveryRuns += 1 if (recoveryRuns == 2) throw CancellationException("Lifecycle stopped") }, + wait = { error("an immediate wake must not wait") }, recoverySignal = recoverySignal, ) } @@ -596,45 +597,46 @@ class AndroidDurableMultipartUploadPolicyTest { } @Test - fun `worker failure wake waits for WorkManager to relinquish ownership`() = runBlocking { + fun `repeated worker failure wakes wait for ownership and follow up delay`() = runBlocking { val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() val workId = UUID.randomUUID() - val initialRecoveryFinished = CompletableDeferred() - val readinessEntered = CompletableDeferred() - val workStoppedRunning = CompletableDeferred() + val expectedCancellation = CancellationException("recovery owner stopped") var workManagerOwnsJob = true var recoveryRuns = 0 + var delayRuns = 0 + recoverySignal.request() + recoverySignal.requestAfterWorkStopsRunning(workId) - val monitor = async { + val actual = assertFailsWith { monitorQueuedDurableUploadScheduling( recover = { recoveryRuns += 1 - if (recoveryRuns == 1) { - initialRecoveryFinished.complete(Unit) - } else { - assertFalse(workManagerOwnsJob) - throw CancellationException("Test completed") + when (recoveryRuns) { + 1 -> Unit + 2 -> { + workManagerOwnsJob = true + recoverySignal.requestAfterWorkStopsRunning(workId) + } + else -> error("worker recovery bypassed its follow-up delay") } }, awaitWorkStopsRunning = { requestedWorkId -> assertEquals(workId, requestedWorkId) - readinessEntered.complete(Unit) - workStoppedRunning.await() + assertTrue(workManagerOwnsJob) + workManagerOwnsJob = false + }, + wait = { delayMillis -> + assertEquals(ANDROID_DURABLE_UPLOAD_SCHEDULING_FOLLOW_UP_DELAY_MILLIS, delayMillis) + assertFalse(workManagerOwnsJob) + if (++delayRuns == 2) throw expectedCancellation }, recoverySignal = recoverySignal, ) } - initialRecoveryFinished.await() - recoverySignal.requestAfterWorkStopsRunning(workId) - readinessEntered.await() - yield() - assertEquals(1, recoveryRuns) - - workManagerOwnsJob = false - workStoppedRunning.complete(Unit) - assertFailsWith { monitor.await() } + assertTrue(actual === expectedCancellation) assertEquals(2, recoveryRuns) + assertEquals(2, delayRuns) } @Test From 19aa454c7f87ff064ac5c0450b70aabf10f5fe3e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 08:37:07 +0200 Subject: [PATCH 17/53] fix(uploads): defer transient source failures --- .../AndroidDurableUploadWorker.kt | 83 +++++++++--- .../AndroidLocalUploadPicker.kt | 37 ++++- ...AndroidDurableUploadSourcePreflightTest.kt | 126 ++++++++++++++++++ 3 files changed, 220 insertions(+), 26 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index 54ab1f0bb..7edfb0c79 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import dev.obiente.nextcloudnative.app.DurableUploadState +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticFieldDraft @@ -131,26 +132,48 @@ internal class DeckAttachmentUploadWorker( ) } } - val capabilityReady = runCatching { - picker.requirePersisted(initial.request.file) - picker.open(initial.request.file).use { } - }.isSuccess - if (!capabilityReady) { - store.transition( - jobId, - expected = DurableUploadState.Queued, - target = DurableUploadState.Failed, - message = "The selected file is no longer available. Select it again to retry.", - ) - picker.release(initial.request.file) - recordUploadDiagnostic( - severity = SupportDiagnosticSeverity.Warning, - outcome = "source-unavailable", - accountId = initial.accountId, - jobId = jobId, - ) - return Result.failure() - } + return processQueuedDurableUploadSource( + requireCapability = { picker.requirePersisted(initial.request.file) }, + openSource = { picker.open(initial.request.file).use { } }, + onCapabilityUnavailable = { + store.transition( + jobId, + expected = DurableUploadState.Queued, + target = DurableUploadState.Failed, + message = "The selected file is no longer available. Select it again to retry.", + ) + picker.release(initial.request.file) + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "source-unavailable", + accountId = initial.accountId, + jobId = jobId, + ) + Result.failure() + }, + onProviderUnavailable = { failure -> + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "source-open-deferred", + accountId = initial.accountId, + jobId = jobId, + failure = failure, + ) + Result.retry() + }, + onReady = { + uploadReadyQueuedJob(store, initial, picker, jobId, session) + }, + ) + } + + private suspend fun uploadReadyQueuedJob( + store: AndroidDurableMultipartUploadStore, + initial: AndroidDurableMultipartUploadJob, + picker: AndroidLocalUploadPicker, + jobId: String, + session: NextcloudSession, + ): Result { val started = claimQueuedDurableUploadForExecution(jobId) { store.transition( jobId, @@ -265,6 +288,26 @@ internal fun failQueuedDurableUploadForUnavailableAccount( return failureResult } +internal suspend fun processQueuedDurableUploadSource( + requireCapability: () -> Unit, + openSource: () -> Unit, + onCapabilityUnavailable: suspend () -> Result, + onProviderUnavailable: suspend (Exception) -> Result, + onReady: suspend () -> Result, +): Result { + try { + requireCapability() + openSource() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: AndroidLocalUploadCapabilityUnavailableException) { + return onCapabilityUnavailable() + } catch (failure: Exception) { + return onProviderUnavailable(failure) + } + return onReady() +} + internal suspend fun runDurableUploadWorkerWithRecoverySignal( requestRecovery: () -> Unit = ::requestQueuedDurableUploadSchedulingRecovery, work: suspend () -> WorkResult, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index b7dc27a62..432b86785 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -15,6 +15,7 @@ import java.io.InputStream import java.util.UUID import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.suspendCancellableCoroutine import org.json.JSONObject import kotlin.coroutines.resume @@ -125,9 +126,7 @@ internal class AndroidLocalUploadPicker(context: Context) { } fun requirePersisted(file: LocalUploadFile) { - val source = load(file.selectionId) - ?: error("The local file selection was not durably saved.") - require(source.file == file) { "The persisted local file metadata changed." } + requiredSource(file, useCachedSource = false) } fun release(file: LocalUploadFile): Boolean { @@ -165,9 +164,30 @@ internal class AndroidLocalUploadPicker(context: Context) { } private fun persistedSource(file: LocalUploadFile): SelectedSource { - val source = selections[file.selectionId] ?: load(file.selectionId) - ?: error("The local file selection has expired.") - require(source.file == file) { "The local file selection metadata changed." } + return requiredSource(file, useCachedSource = true) + } + + private fun requiredSource( + file: LocalUploadFile, + useCachedSource: Boolean, + ): SelectedSource { + val source = try { + selections[file.selectionId].takeIf { useCachedSource } ?: load(file.selectionId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + throw AndroidLocalUploadCapabilityUnavailableException( + "The local file selection metadata could not be read.", + failure, + ) + } ?: throw AndroidLocalUploadCapabilityUnavailableException( + "The local file selection was not durably saved.", + ) + if (source.file != file) { + throw AndroidLocalUploadCapabilityUnavailableException( + "The persisted local file metadata changed.", + ) + } return source } @@ -205,6 +225,11 @@ internal class AndroidLocalUploadPicker(context: Context) { } } +internal class AndroidLocalUploadCapabilityUnavailableException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) + /** * Acquires a durable picker capability without exposing an interval where a successful selection * can be reported before its metadata reaches app-private storage. diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt new file mode 100644 index 000000000..af01d743e --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt @@ -0,0 +1,126 @@ +package dev.obiente.nextcloudnative + +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidDurableUploadSourcePreflightTest { + @Test + fun `missing or mismatched private metadata terminally fails and releases`() = runBlocking { + listOf("missing", "mismatched").forEach { reason -> + var providerOpened = false + var queued = true + var retained = true + + val result = processQueuedDurableUploadSource( + requireCapability = { + throw AndroidLocalUploadCapabilityUnavailableException(reason) + }, + openSource = { providerOpened = true }, + onCapabilityUnavailable = { + queued = false + retained = false + "failed" + }, + onProviderUnavailable = { "retried" }, + onReady = { "started" }, + ) + + assertEquals("failed", result) + assertFalse(providerOpened) + assertFalse(queued) + assertFalse(retained) + } + } + + @Test + fun `transient provider failure leaves queued capability retained`() = runBlocking { + var queued = true + var retained = true + var starts = 0 + + val result = processQueuedDurableUploadSource( + requireCapability = { }, + openSource = { throw IOException("provider unavailable") }, + onCapabilityUnavailable = { + queued = false + retained = false + "failed" + }, + onProviderUnavailable = { "retried" }, + onReady = { + starts += 1 + "started" + }, + ) + + assertEquals("retried", result) + assertTrue(queued) + assertTrue(retained) + assertEquals(0, starts) + } + + @Test + fun `later provider success starts exactly once`() = runBlocking { + var providerAttempts = 0 + var starts = 0 + + suspend fun attempt(): String = processQueuedDurableUploadSource( + requireCapability = { }, + openSource = { + providerAttempts += 1 + if (providerAttempts == 1) throw IOException("provider restarting") + }, + onCapabilityUnavailable = { "failed" }, + onProviderUnavailable = { "retried" }, + onReady = { + starts += 1 + "started" + }, + ) + + assertEquals("retried", attempt()) + assertEquals(0, starts) + assertEquals("started", attempt()) + assertEquals(1, starts) + } + + @Test + fun `cancellation is preserved without running a disposition`() = runBlocking { + listOf(true, false).forEach { cancelDuringCapabilityRead -> + var dispositions = 0 + val expected = CancellationException("worker stopped") + + val actual = assertFailsWith { + processQueuedDurableUploadSource( + requireCapability = { + if (cancelDuringCapabilityRead) throw expected + }, + openSource = { + if (!cancelDuringCapabilityRead) throw expected + }, + onCapabilityUnavailable = { + dispositions += 1 + Unit + }, + onProviderUnavailable = { + dispositions += 1 + Unit + }, + onReady = { + dispositions += 1 + Unit + }, + ) + } + + assertTrue(actual === expected) + assertEquals(0, dispositions) + } + } +} From 4b51467cca738cd94a5d5de4e8aaf03579c1421d Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 09:31:03 +0200 Subject: [PATCH 18/53] fix(uploads): fail permanently unavailable sources --- .../AndroidDurableUploadWorker.kt | 5 +++ ...AndroidDurableUploadSourcePreflightTest.kt | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index 7edfb0c79..504b1fd96 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -12,6 +12,7 @@ import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity import dev.obiente.nextcloudnative.app.SupportDiagnosticValuePrivacy import dev.obiente.nextcloudnative.app.afterProcessRecovery import dev.obiente.nextcloudnative.app.toSupportDiagnosticExceptionDraft +import java.io.FileNotFoundException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -302,6 +303,10 @@ internal suspend fun processQueuedDurableUploadSource( throw cancelled } catch (_: AndroidLocalUploadCapabilityUnavailableException) { return onCapabilityUnavailable() + } catch (_: FileNotFoundException) { + return onCapabilityUnavailable() + } catch (_: SecurityException) { + return onCapabilityUnavailable() } catch (failure: Exception) { return onProviderUnavailable(failure) } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt index af01d743e..b29dd4836 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import java.io.FileNotFoundException import java.io.IOException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking @@ -38,6 +39,38 @@ class AndroidDurableUploadSourcePreflightTest { } } + @Test + fun `permanently unavailable provider source terminally fails and releases`() = runBlocking { + listOf( + FileNotFoundException("document removed"), + SecurityException("grant revoked"), + ).forEach { failure -> + var queued = true + var retained = true + var starts = 0 + + val result = processQueuedDurableUploadSource( + requireCapability = { }, + openSource = { throw failure }, + onCapabilityUnavailable = { + queued = false + retained = false + "failed" + }, + onProviderUnavailable = { "retried" }, + onReady = { + starts += 1 + "started" + }, + ) + + assertEquals("failed", result) + assertFalse(queued) + assertFalse(retained) + assertEquals(0, starts) + } + } + @Test fun `transient provider failure leaves queued capability retained`() = runBlocking { var queued = true From adfea3737930b274e54d8d79f947a4302f851d60 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 09:39:56 +0200 Subject: [PATCH 19/53] fix(uploads): release cancelled unowned selections --- .../AndroidDurableMultipartUploads.kt | 45 +++++++-- .../AndroidNextcloudServices.kt | 2 +- ...oidDurableUploadEnqueueCancellationTest.kt | 93 +++++++++++++++++++ 3 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadEnqueueCancellationTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index b519ff358..93418230b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -22,7 +22,9 @@ import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.localUploadFile import java.util.UUID import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject @@ -58,12 +60,7 @@ internal class AndroidDurableMultipartUploads(context: Context) { } catch (cancelled: CancellationException) { throw cancelled } catch (error: Exception) { - val selectionIsDefinitelyInactive = runCatching { - !store.hasActiveSelection(request.file.selectionId) - }.getOrNull() == true - if (selectionIsDefinitelyInactive) { - picker.release(request.file) - } + releaseIfUnowned(request.file) DurableUploadEnqueueResult.Rejected( error.message?.take(MAX_DURABLE_UPLOAD_MESSAGE_CHARACTERS) ?: "The background upload could not be scheduled.", @@ -71,6 +68,20 @@ internal class AndroidDurableMultipartUploads(context: Context) { } } + fun releaseIfUnowned(file: LocalUploadFile): Boolean = releaseUnownedDurableUploadSelection( + selectionId = file.selectionId, + hasActiveSelection = store::hasActiveSelection, + releaseSelection = { AndroidLocalUploadPicker(appContext).release(file) }, + ) + + suspend fun runEnqueueWithCancellationCleanup( + file: LocalUploadFile, + enqueue: suspend () -> Result, + ): Result = runDurableUploadEnqueueWithCancellationCleanup( + enqueue = { withContext(Dispatchers.IO) { enqueue() } }, + releaseUnownedSelection = { releaseIfUnowned(file) }, + ) + fun statuses(session: NextcloudSession, scope: DurableUploadScope): List { val jobs = store.list(NextcloudDocumentIds.accountKey(session), scope) requestDurableUploadSchedulingRecoveryForQueuedStatuses(jobs) @@ -147,6 +158,28 @@ internal class AndroidDurableMultipartUploads(context: Context) { } } +internal fun releaseUnownedDurableUploadSelection( + selectionId: String, + hasActiveSelection: (String) -> Boolean, + releaseSelection: () -> Boolean, +): Boolean = synchronized(AndroidDurableMultipartUploadStore.LOCK) { + val selectionIsDefinitelyInactive = runCatching { + !hasActiveSelection(selectionId) + }.getOrNull() == true + if (!selectionIsDefinitelyInactive) return@synchronized false + runCatching(releaseSelection).getOrDefault(false) +} + +internal suspend fun runDurableUploadEnqueueWithCancellationCleanup( + enqueue: suspend () -> Result, + releaseUnownedSelection: () -> Unit, +): Result = try { + enqueue() +} catch (cancelled: CancellationException) { + runCatching(releaseUnownedSelection) + throw cancelled +} + internal val DURABLE_UPLOAD_ACCOUNT_RECOVERY_WORK_POLICY = ExistingWorkPolicy.REPLACE internal fun durableUploadWorkName(jobId: String) = "deck-attachment-$jobId" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 1121b7bff..b726ca66d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -2937,7 +2937,7 @@ internal class AndroidNextcloudServices( session: NextcloudSession, scope: DurableUploadScope, request: NextcloudMultipartUploadRequest, - ): DurableUploadEnqueueResult = withContext(Dispatchers.IO) { + ): DurableUploadEnqueueResult = durableMultipartUploads.runEnqueueWithCancellationCleanup(request.file) { ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( expectedSession = session, resolveSession = ::loadSession, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadEnqueueCancellationTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadEnqueueCancellationTest.kt new file mode 100644 index 000000000..1c99e704a --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadEnqueueCancellationTest.kt @@ -0,0 +1,93 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidDurableUploadEnqueueCancellationTest { + @Test + fun `cancellation releases a selection with no durable owner`() = runBlocking { + val expected = CancellationException("screen closed") + var releases = 0 + + val actual = assertFailsWith { + runDurableUploadEnqueueWithCancellationCleanup( + enqueue = { throw expected }, + releaseUnownedSelection = { + assertTrue( + releaseUnownedDurableUploadSelection( + selectionId = "selection-123456", + hasActiveSelection = { false }, + releaseSelection = { + releases += 1 + true + }, + ), + ) + }, + ) + } + + assertTrue(actual === expected) + assertEquals(1, releases) + } + + @Test + fun `cancellation retains a selection owned by a queued job`() = runBlocking { + var releases = 0 + + assertFailsWith { + runDurableUploadEnqueueWithCancellationCleanup( + enqueue = { throw CancellationException("scheduling cancelled") }, + releaseUnownedSelection = { + assertFalse( + releaseUnownedDurableUploadSelection( + selectionId = "selection-123456", + hasActiveSelection = { true }, + releaseSelection = { + releases += 1 + true + }, + ), + ) + }, + ) + } + + assertEquals(0, releases) + } + + @Test + fun `unreadable ownership state retains the selection`() { + var releases = 0 + + val released = releaseUnownedDurableUploadSelection( + selectionId = "selection-123456", + hasActiveSelection = { error("journal unavailable") }, + releaseSelection = { + releases += 1 + true + }, + ) + + assertFalse(released) + assertEquals(0, releases) + } + + @Test + fun `successful enqueue does not run cancellation cleanup`() = runBlocking { + var cleanupCalls = 0 + + val result = runDurableUploadEnqueueWithCancellationCleanup( + enqueue = { "queued" }, + releaseUnownedSelection = { cleanupCalls += 1 }, + ) + + assertEquals("queued", result) + assertEquals(0, cleanupCalls) + } +} From b413b94e556a63fc939aa0d18007062c929bb462 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 09:42:23 +0200 Subject: [PATCH 20/53] fix(uploads): retry terminal capability cleanup --- .../AndroidDurableUploadWorker.kt | 44 ++++++++++---- ...AndroidDurableMultipartUploadPolicyTest.kt | 6 +- ...roidDurableUploadTerminalCapabilityTest.kt | 59 +++++++++++++++++++ 3 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadTerminalCapabilityTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index 504b1fd96..56eeb492e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -44,16 +44,25 @@ internal class DeckAttachmentUploadWorker( target = DurableUploadState.OutcomeUnknown, message = "The app restarted while this upload was in progress. Check the card before uploading again.", ) - picker.release(initial.request.file) recordUploadDiagnostic( severity = SupportDiagnosticSeverity.Warning, outcome = "process-recovery", accountId = initial.accountId, jobId = jobId, ) - return Result.success() + return resultAfterDurableUploadCapabilityRelease( + releaseCapability = { picker.release(initial.request.file) }, + releasedResult = Result.success(), + retainedResult = Result.retry(), + ) + } + if (initial.state != DurableUploadState.Queued) { + return resultAfterDurableUploadCapabilityRelease( + releaseCapability = { picker.release(initial.request.file) }, + releasedResult = Result.success(), + retainedResult = Result.retry(), + ) } - if (initial.state != DurableUploadState.Queued) return Result.success() return uploadQueuedJob(store, initial, picker, jobId) } @@ -130,6 +139,7 @@ internal class DeckAttachmentUploadWorker( ) }, failureResult = Result.failure(), + retryResult = Result.retry(), ) } } @@ -143,14 +153,17 @@ internal class DeckAttachmentUploadWorker( target = DurableUploadState.Failed, message = "The selected file is no longer available. Select it again to retry.", ) - picker.release(initial.request.file) recordUploadDiagnostic( severity = SupportDiagnosticSeverity.Warning, outcome = "source-unavailable", accountId = initial.accountId, jobId = jobId, ) - Result.failure() + resultAfterDurableUploadCapabilityRelease( + releaseCapability = { picker.release(initial.request.file) }, + releasedResult = Result.failure(), + retainedResult = Result.retry(), + ) }, onProviderUnavailable = { failure -> recordUploadDiagnostic( @@ -226,7 +239,6 @@ internal class DeckAttachmentUploadWorker( code = "HTTP:${response.status}", ) } - picker.release(started.request.file) }.onFailure { failure -> // Once the request body starts, a transport exception cannot prove whether the server // created the attachment. Never replay it automatically and risk a duplicate. @@ -243,9 +255,12 @@ internal class DeckAttachmentUploadWorker( jobId = jobId, failure = failure, ) - picker.release(started.request.file) } - return Result.success() + return resultAfterDurableUploadCapabilityRelease( + releaseCapability = { picker.release(started.request.file) }, + releasedResult = Result.success(), + retainedResult = Result.retry(), + ) } private fun recordUploadDiagnostic( @@ -279,16 +294,23 @@ internal class DeckAttachmentUploadWorker( internal fun failQueuedDurableUploadForUnavailableAccount( transitionToFailed: () -> Unit, - releaseSelection: () -> Unit, + releaseSelection: () -> Boolean, recordFailure: () -> Unit, failureResult: Result, + retryResult: Result, ): Result { transitionToFailed() - releaseSelection() + val released = releaseSelection() recordFailure() - return failureResult + return if (released) failureResult else retryResult } +internal fun resultAfterDurableUploadCapabilityRelease( + releaseCapability: () -> Boolean, + releasedResult: Result, + retainedResult: Result, +): Result = if (releaseCapability()) releasedResult else retainedResult + internal suspend fun processQueuedDurableUploadSource( requireCapability: () -> Unit, openSource: () -> Unit, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 77fb25e8d..84d0ca0b4 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -435,9 +435,13 @@ class AndroidDurableMultipartUploadPolicyTest { val result = failQueuedDurableUploadForUnavailableAccount( transitionToFailed = { events += "fail" }, - releaseSelection = { events += "release" }, + releaseSelection = { + events += "release" + true + }, recordFailure = { events += "diagnose" }, failureResult = "worker-failure", + retryResult = "worker-retry", ) assertEquals("worker-failure", result) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadTerminalCapabilityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadTerminalCapabilityTest.kt new file mode 100644 index 000000000..81dfbe0c2 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadTerminalCapabilityTest.kt @@ -0,0 +1,59 @@ +package dev.obiente.nextcloudnative + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AndroidDurableUploadTerminalCapabilityTest { + @Test + fun `terminal worker retries while capability cleanup remains uncommitted`() { + var releaseAttempts = 0 + + val result = resultAfterDurableUploadCapabilityRelease( + releaseCapability = { + releaseAttempts += 1 + false + }, + releasedResult = "finished", + retainedResult = "retry", + ) + + assertEquals("retry", result) + assertEquals(1, releaseAttempts) + } + + @Test + fun `terminal worker finishes after capability cleanup commits`() { + var releaseAttempts = 0 + + val result = resultAfterDurableUploadCapabilityRelease( + releaseCapability = { + releaseAttempts += 1 + true + }, + releasedResult = "finished", + retainedResult = "retry", + ) + + assertEquals("finished", result) + assertEquals(1, releaseAttempts) + } + + @Test + fun `removed account retries after terminal transition when release is retained`() { + val events = mutableListOf() + + val result = failQueuedDurableUploadForUnavailableAccount( + transitionToFailed = { events += "fail" }, + releaseSelection = { + events += "release" + false + }, + recordFailure = { events += "diagnose" }, + failureResult = "failed", + retryResult = "retry", + ) + + assertEquals("retry", result) + assertEquals(listOf("fail", "release", "diagnose"), events) + } +} From 4a1c61ce169af666f17f64731eb6f38183a20d1e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 10:33:10 +0200 Subject: [PATCH 21/53] fix(uploads): retain pending capability cleanup --- .../AndroidDurableMultipartUploads.kt | 34 ++++- .../AndroidDurableUploadWorker.kt | 29 +++- .../AndroidDurableUploadCleanupPruningTest.kt | 124 ++++++++++++++++++ 3 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 93418230b..8cd6fb89b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -199,6 +199,7 @@ internal data class AndroidDurableMultipartUploadJob( val request: NextcloudMultipartUploadRequest, val state: DurableUploadState, val message: String?, + val capabilityCleanupPending: Boolean = false, val updatedAtEpochMillis: Long = System.currentTimeMillis(), ) { init { @@ -214,6 +215,9 @@ internal data class AndroidDurableMultipartUploadJob( require(message == null || message.length <= MAX_DURABLE_UPLOAD_MESSAGE_CHARACTERS) { "The durable upload message is too long." } + require(!capabilityCleanupPending || state.isTerminal()) { + "Only a terminal durable upload can have pending capability cleanup." + } } fun status(): DurableUploadStatus = DurableUploadStatus( @@ -280,7 +284,7 @@ internal class AndroidDurableMultipartUploadStore( fun hasActiveSelection(selectionId: String): Boolean = synchronized(LOCK) { readAll().any { it.request.file.selectionId == selectionId && - !it.state.isTerminal() + it.mustRetain() } } @@ -295,6 +299,14 @@ internal class AndroidDurableMultipartUploadStore( removed } + fun completeCapabilityCleanup(id: String) = synchronized(LOCK) { + val current = readAll().toMutableList() + val index = current.indexOfFirst { job -> job.id == id } + if (index < 0 || !current[index].capabilityCleanupPending) return@synchronized + current[index] = current[index].copy(capabilityCleanupPending = false) + writeAll(pruneDurableUploadJobs(current)) + } + fun transition( id: String, expected: DurableUploadState, @@ -310,6 +322,7 @@ internal class AndroidDurableMultipartUploadStore( val updated = current[index].copy( state = target, message = message?.take(MAX_DURABLE_UPLOAD_MESSAGE_CHARACTERS), + capabilityCleanupPending = target.isTerminal(), updatedAtEpochMillis = System.currentTimeMillis(), ) current[index] = updated @@ -445,6 +458,12 @@ internal fun requireCanAddDurableUpload( require(current.none { it.id == job.id }) { "The attachment upload id is already in use." } + require( + current.count(AndroidDurableMultipartUploadJob::mustRetain) < + AndroidDurableMultipartUploadStore.MAX_STORED_UPLOADS, + ) { + "Background upload cleanup must finish before another upload can be queued." + } require(active.size < AndroidDurableMultipartUploadStore.MAX_ACTIVE_UPLOADS) { "Too many attachment uploads are already pending." } @@ -472,13 +491,16 @@ internal fun requireCanAddDurableUpload( internal fun pruneDurableUploadJobs( jobs: List, ): List { - val active = jobs.filterNot { it.state.isTerminal() } - val terminal = jobs.filter { it.state.isTerminal() } + val retained = jobs.filter(AndroidDurableMultipartUploadJob::mustRetain) + val terminal = jobs.filterNot(AndroidDurableMultipartUploadJob::mustRetain) .sortedByDescending(AndroidDurableMultipartUploadJob::updatedAtEpochMillis) - .take((AndroidDurableMultipartUploadStore.MAX_STORED_UPLOADS - active.size).coerceAtLeast(0)) - return (active + terminal).sortedBy(AndroidDurableMultipartUploadJob::updatedAtEpochMillis) + .take((AndroidDurableMultipartUploadStore.MAX_STORED_UPLOADS - retained.size).coerceAtLeast(0)) + return (retained + terminal).sortedBy(AndroidDurableMultipartUploadJob::updatedAtEpochMillis) } +private fun AndroidDurableMultipartUploadJob.mustRetain(): Boolean = + !state.isTerminal() || capabilityCleanupPending + private fun DurableUploadState.isTerminal(): Boolean = this == DurableUploadState.Completed || this == DurableUploadState.Failed || @@ -521,6 +543,7 @@ private fun AndroidDurableMultipartUploadJob.toJson(): JSONObject = JSONObject() .put("itemId", resource.itemId) .put("state", state.name) .put("message", message) + .put("capabilityCleanupPending", capabilityCleanupPending) .put("updatedAt", updatedAtEpochMillis) .put("method", request.method.name) .put("relativePath", request.relativePath) @@ -595,6 +618,7 @@ private fun JSONObject.toJob(): AndroidDurableMultipartUploadJob { request = request, state = DurableUploadState.valueOf(getString("state")), message = if (isNull("message")) null else getString("message"), + capabilityCleanupPending = optBoolean("capabilityCleanupPending", false), updatedAtEpochMillis = getLong("updatedAt"), ) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index 56eeb492e..410df37ac 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -52,6 +52,7 @@ internal class DeckAttachmentUploadWorker( ) return resultAfterDurableUploadCapabilityRelease( releaseCapability = { picker.release(initial.request.file) }, + completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, releasedResult = Result.success(), retainedResult = Result.retry(), ) @@ -59,6 +60,7 @@ internal class DeckAttachmentUploadWorker( if (initial.state != DurableUploadState.Queued) { return resultAfterDurableUploadCapabilityRelease( releaseCapability = { picker.release(initial.request.file) }, + completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, releasedResult = Result.success(), retainedResult = Result.retry(), ) @@ -130,6 +132,7 @@ internal class DeckAttachmentUploadWorker( ) }, releaseSelection = { picker.release(initial.request.file) }, + completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, recordFailure = { recordUploadDiagnostic( severity = SupportDiagnosticSeverity.Warning, @@ -161,6 +164,7 @@ internal class DeckAttachmentUploadWorker( ) resultAfterDurableUploadCapabilityRelease( releaseCapability = { picker.release(initial.request.file) }, + completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, releasedResult = Result.failure(), retainedResult = Result.retry(), ) @@ -258,6 +262,7 @@ internal class DeckAttachmentUploadWorker( } return resultAfterDurableUploadCapabilityRelease( releaseCapability = { picker.release(started.request.file) }, + completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, releasedResult = Result.success(), retainedResult = Result.retry(), ) @@ -295,21 +300,39 @@ internal class DeckAttachmentUploadWorker( internal fun failQueuedDurableUploadForUnavailableAccount( transitionToFailed: () -> Unit, releaseSelection: () -> Boolean, + completeCapabilityCleanup: () -> Unit = {}, recordFailure: () -> Unit, failureResult: Result, retryResult: Result, ): Result { transitionToFailed() - val released = releaseSelection() + val result = resultAfterDurableUploadCapabilityRelease( + releaseCapability = releaseSelection, + completeCapabilityCleanup = completeCapabilityCleanup, + releasedResult = failureResult, + retainedResult = retryResult, + ) recordFailure() - return if (released) failureResult else retryResult + return result } internal fun resultAfterDurableUploadCapabilityRelease( releaseCapability: () -> Boolean, + completeCapabilityCleanup: () -> Unit = {}, releasedResult: Result, retainedResult: Result, -): Result = if (releaseCapability()) releasedResult else retainedResult +): Result = try { + if (releaseCapability()) { + completeCapabilityCleanup() + releasedResult + } else { + retainedResult + } +} catch (cancelled: CancellationException) { + throw cancelled +} catch (_: Exception) { + retainedResult +} internal suspend fun processQueuedDurableUploadSource( requireCapability: () -> Unit, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt new file mode 100644 index 000000000..55a56f3c9 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt @@ -0,0 +1,124 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.DurableUploadScope +import dev.obiente.nextcloudnative.app.DurableUploadState +import dev.obiente.nextcloudnative.app.NextcloudApiMethod +import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest +import dev.obiente.nextcloudnative.app.localUploadFile +import kotlinx.coroutines.CancellationException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidDurableUploadCleanupPruningTest { + @Test + fun `pruning retains terminal rows until capability cleanup commits`() { + val pending = fixtureJob( + index = 1, + cleanupPending = true, + updatedAt = 0L, + ) + val history = (2..70).map { index -> fixtureJob(index = index, updatedAt = index.toLong()) } + + val pruned = pruneDurableUploadJobs(history + pending) + + assertEquals(AndroidDurableMultipartUploadStore.MAX_STORED_UPLOADS, pruned.size) + assertTrue(pending in pruned) + assertFalse(pruned.any { job -> job.id == fixtureId(2) }) + } + + @Test + fun `terminal transition persists cleanup until its commit`() { + val storage = MemoryStorage() + val store = AndroidDurableMultipartUploadStore(storage, PlaintextCipher) + val queued = fixtureJob(index = 1, state = DurableUploadState.Queued) + store.add(queued) + + store.transition(queued.id, DurableUploadState.Queued, DurableUploadState.Failed, "failed") + + assertTrue(AndroidDurableMultipartUploadStore(storage, PlaintextCipher).list().single().capabilityCleanupPending) + store.completeCapabilityCleanup(queued.id) + assertFalse(AndroidDurableMultipartUploadStore(storage, PlaintextCipher).list().single().capabilityCleanupPending) + } + + @Test + fun `pending cleanup consumes bounded queue capacity`() { + val pending = (1..AndroidDurableMultipartUploadStore.MAX_STORED_UPLOADS).map { index -> + fixtureJob(index = index, cleanupPending = true) + } + + assertFailsWith { + requireCanAddDurableUpload( + current = pending, + job = fixtureJob(index = 100, state = DurableUploadState.Queued), + ) + } + } + + @Test + fun `cleanup commit failure retries and preserves cancellation`() { + assertEquals( + "retry", + resultAfterDurableUploadCapabilityRelease( + releaseCapability = { true }, + completeCapabilityCleanup = { error("queue unavailable") }, + releasedResult = "finished", + retainedResult = "retry", + ), + ) + assertFailsWith { + resultAfterDurableUploadCapabilityRelease( + releaseCapability = { true }, + completeCapabilityCleanup = { throw CancellationException("worker stopped") }, + releasedResult = "finished", + retainedResult = "retry", + ) + } + } + + private fun fixtureJob( + index: Int, + state: DurableUploadState = DurableUploadState.Completed, + cleanupPending: Boolean = false, + updatedAt: Long = index.toLong(), + ): AndroidDurableMultipartUploadJob { + val cardId = index.toLong() + val scope = DurableUploadScope("deck-attachment", cardId.toString()) + val request = NextcloudMultipartUploadRequest( + method = NextcloudApiMethod.POST, + relativePath = "/index.php/apps/deck/api/v1.1/boards/7/stacks/11/cards/$cardId/attachments", + file = localUploadFile( + selectionId = "selection-${index.toString().padStart(16, '0')}", + displayName = "fixture-$index.txt", + mimeType = "text/plain", + sizeBytes = 16L, + ), + maximumFileBytes = 1024L, + ) + return AndroidDurableMultipartUploadJob( + id = fixtureId(index), + accountId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + scope = scope, + resource = resolveDurableUploadResource(scope, request), + request = request, + state = state, + message = null, + capabilityCleanupPending = cleanupPending, + updatedAtEpochMillis = updatedAt, + ) + } + + private fun fixtureId(index: Int) = "upload-${index.toString().padStart(16, '0')}" + + private class MemoryStorage(var value: String? = null) : AndroidDurableMultipartUploadEncryptedStorage { + override fun read(): String? = value + override fun write(value: String): Boolean = true.also { this.value = value } + } + + private object PlaintextCipher : AndroidDurableMultipartUploadCipher { + override fun encrypt(value: String): String = value + override fun decrypt(value: String): String = value + } +} From 2cdfd55efccd9742e81e6c6eee997380d248295a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 10:35:37 +0200 Subject: [PATCH 22/53] test(uploads): cover legacy cleanup marker --- .../AndroidDurableUploadCleanupPruningTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt index 55a56f3c9..1cbd9bf11 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt @@ -11,6 +11,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue +import org.json.JSONArray class AndroidDurableUploadCleanupPruningTest { @Test @@ -43,6 +44,22 @@ class AndroidDurableUploadCleanupPruningTest { assertFalse(AndroidDurableMultipartUploadStore(storage, PlaintextCipher).list().single().capabilityCleanupPending) } + @Test + fun `legacy terminal rows default to completed cleanup`() { + val storage = MemoryStorage() + val store = AndroidDurableMultipartUploadStore(storage, PlaintextCipher) + val queued = fixtureJob(index = 1, state = DurableUploadState.Queued) + store.add(queued) + store.transition(queued.id, DurableUploadState.Queued, DurableUploadState.Failed, "failed") + val legacy = JSONArray(checkNotNull(storage.value)) + legacy.getJSONObject(0).remove("capabilityCleanupPending") + storage.value = legacy.toString() + + val restored = AndroidDurableMultipartUploadStore(storage, PlaintextCipher).list().single() + + assertFalse(restored.capabilityCleanupPending) + } + @Test fun `pending cleanup consumes bounded queue capacity`() { val pending = (1..AndroidDurableMultipartUploadStore.MAX_STORED_UPLOADS).map { index -> From 0e2cb3e62c18aedcda9d063a1ffdad54c44e0b3c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 11:39:42 +0200 Subject: [PATCH 23/53] fix(uploads): recover pending capability cleanup --- .../AndroidDurableUploadScheduling.kt | 5 ++- .../AndroidDurableUploadCleanupPruningTest.kt | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index e42ad5707..c68737ce4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -170,7 +170,7 @@ internal suspend fun reconcileQueuedDurableUploads( schedule: suspend (AndroidDurableMultipartUploadJob) -> Unit, ): Boolean { var allScheduled = true - jobs.filter { job -> job.state == DurableUploadState.Queued }.forEach { job -> + jobs.filter(AndroidDurableMultipartUploadJob::requiresSchedulingRecovery).forEach { job -> try { if (!schedulerOwns(job)) schedule(job) } catch (cancelled: CancellationException) { @@ -182,6 +182,9 @@ internal suspend fun reconcileQueuedDurableUploads( return allScheduled } +private fun AndroidDurableMultipartUploadJob.requiresSchedulingRecovery(): Boolean = + state == DurableUploadState.Queued || capabilityCleanupPending + internal suspend fun retryQueuedDurableUploadScheduling( retryDelaysMillis: List = listOf(1_000L, 5_000L), reconcile: suspend () -> Boolean, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt index 1cbd9bf11..037c8c7c3 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt @@ -6,6 +6,7 @@ import dev.obiente.nextcloudnative.app.NextcloudApiMethod import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest import dev.obiente.nextcloudnative.app.localUploadFile import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -14,6 +15,47 @@ import kotlin.test.assertTrue import org.json.JSONArray class AndroidDurableUploadCleanupPruningTest { + @Test + fun `reconciliation schedules pending terminal cleanup but skips completed history`() = runBlocking { + val pending = fixtureJob(index = 1, cleanupPending = true) + val history = fixtureJob(index = 2) + val scheduled = mutableListOf() + + val allScheduled = reconcileQueuedDurableUploads( + jobs = listOf(pending, history), + schedule = { job -> scheduled += job }, + ) + + assertTrue(allScheduled) + assertEquals(listOf(pending), scheduled) + } + + @Test + fun `terminal cleanup scheduling failure is aggregated without blocking queued work`() = runBlocking { + val pending = fixtureJob(index = 1, cleanupPending = true) + val queued = fixtureJob(index = 2, state = DurableUploadState.Queued) + val attempts = mutableListOf() + + val allScheduled = reconcileQueuedDurableUploads(listOf(pending, queued)) { job -> + attempts += job.id + if (job == pending) error("synthetic cleanup scheduling failure") + } + + assertFalse(allScheduled) + assertEquals(listOf(pending.id, queued.id), attempts) + } + + @Test + fun `terminal cleanup reconciliation preserves cancellation`() = runBlocking { + val pending = fixtureJob(index = 1, cleanupPending = true) + + assertFailsWith { + reconcileQueuedDurableUploads(listOf(pending)) { + throw CancellationException("recovery stopped") + } + } + } + @Test fun `pruning retains terminal rows until capability cleanup commits`() { val pending = fixtureJob( From d7f9b4b6ef820db9f39e4354fa870d8f2e74f761 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 11:42:14 +0200 Subject: [PATCH 24/53] test(uploads): keep cleanup cancellation test void --- .../nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt index 037c8c7c3..3a5aabf5e 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt @@ -54,6 +54,7 @@ class AndroidDurableUploadCleanupPruningTest { throw CancellationException("recovery stopped") } } + Unit } @Test From 7c54894593d324b67339c7a2f9ea50dd986e1e09 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 12:29:34 +0200 Subject: [PATCH 25/53] fix(uploads): decouple terminal cleanup recovery --- .../AndroidDurableMultipartUploads.kt | 3 ++- .../AndroidDurableUploadScheduling.kt | 8 +++++--- .../NextcloudNativeApplication.kt | 16 ++++++++------- .../AndroidDurableUploadCleanupPruningTest.kt | 20 +++++++++++++++++++ 4 files changed, 36 insertions(+), 11 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 8cd6fb89b..fb0fecd79 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -111,9 +111,10 @@ internal class AndroidDurableMultipartUploads(context: Context) { } } - suspend fun reconcileQueuedUploads(): Boolean = + suspend fun reconcileQueuedUploads(allowQueuedScheduling: Boolean = true): Boolean = reconcileQueuedDurableUploads( jobs = store.list(), + allowQueuedScheduling = allowQueuedScheduling, schedulerOwns = { job -> workManager.getWorkInfosForUniqueWorkFlow(durableUploadWorkName(job.id)) .first() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index c68737ce4..effff5523 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -166,11 +166,12 @@ internal suspend fun constructAndReconcileQueuedDurableUploads( internal suspend fun reconcileQueuedDurableUploads( jobs: List, + allowQueuedScheduling: Boolean = true, schedulerOwns: suspend (AndroidDurableMultipartUploadJob) -> Boolean = { false }, schedule: suspend (AndroidDurableMultipartUploadJob) -> Unit, ): Boolean { var allScheduled = true - jobs.filter(AndroidDurableMultipartUploadJob::requiresSchedulingRecovery).forEach { job -> + jobs.filter { job -> job.requiresSchedulingRecovery(allowQueuedScheduling) }.forEach { job -> try { if (!schedulerOwns(job)) schedule(job) } catch (cancelled: CancellationException) { @@ -182,8 +183,9 @@ internal suspend fun reconcileQueuedDurableUploads( return allScheduled } -private fun AndroidDurableMultipartUploadJob.requiresSchedulingRecovery(): Boolean = - state == DurableUploadState.Queued || capabilityCleanupPending +private fun AndroidDurableMultipartUploadJob.requiresSchedulingRecovery( + allowQueuedScheduling: Boolean, +): Boolean = capabilityCleanupPending || (allowQueuedScheduling && state == DurableUploadState.Queued) internal suspend fun retryQueuedDurableUploadScheduling( retryDelaysMillis: List = listOf(1_000L, 5_000L), diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index ef812718f..1418e747b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt @@ -52,13 +52,15 @@ class NextcloudNativeApplication : Application() { ANDROID_ACCOUNT_PREFERENCES_NAME, Context.MODE_PRIVATE, ) - if (accountPreferences.durableUploadAccountResolutionAvailable()) { - val available = uploads ?: AndroidDurableMultipartUploads( - this@NextcloudNativeApplication, - ).also { uploads = it } - available::reconcileQueuedUploads - } else { - suspend { true } + val accountResolutionAvailable = + accountPreferences.durableUploadAccountResolutionAvailable() + val available = uploads ?: AndroidDurableMultipartUploads( + this@NextcloudNativeApplication, + ).also { uploads = it } + suspend { + available.reconcileQueuedUploads( + allowQueuedScheduling = accountResolutionAvailable, + ) } } }, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt index 3a5aabf5e..c03a052d5 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt @@ -45,6 +45,26 @@ class AndroidDurableUploadCleanupPruningTest { assertEquals(listOf(pending.id, queued.id), attempts) } + @Test + fun `unsupported account registry schedules terminal cleanup but not queued uploads`() = runBlocking { + val pending = fixtureJob(index = 1, cleanupPending = true) + val queued = fixtureJob(index = 2, state = DurableUploadState.Queued) + val scheduled = mutableListOf() + val accountResolutionAvailable = androidCredentialFreeRegistryAllowsAccountResolution( + """{"version":99,"accounts":[]}""", + ) + + val allScheduled = reconcileQueuedDurableUploads( + jobs = listOf(pending, queued), + allowQueuedScheduling = accountResolutionAvailable, + schedule = scheduled::add, + ) + + assertFalse(accountResolutionAvailable) + assertTrue(allScheduled) + assertEquals(listOf(pending), scheduled) + } + @Test fun `terminal cleanup reconciliation preserves cancellation`() = runBlocking { val pending = fixtureJob(index = 1, cleanupPending = true) From c08afb58b3919c88d76bcef5b8cf4efffc45b823 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 12:30:11 +0200 Subject: [PATCH 26/53] fix(uploads): validate persisted cleanup marker --- .../AndroidDurableMultipartUploads.kt | 11 ++++- .../AndroidDurableUploadCleanupPruningTest.kt | 43 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index fb0fecd79..8a02dc256 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -619,11 +619,20 @@ private fun JSONObject.toJob(): AndroidDurableMultipartUploadJob { request = request, state = DurableUploadState.valueOf(getString("state")), message = if (isNull("message")) null else getString("message"), - capabilityCleanupPending = optBoolean("capabilityCleanupPending", false), + capabilityCleanupPending = readCapabilityCleanupPending(), updatedAtEpochMillis = getLong("updatedAt"), ) } +private fun JSONObject.readCapabilityCleanupPending(): Boolean { + if (!has("capabilityCleanupPending")) return false + val persisted = get("capabilityCleanupPending") + check(persisted is Boolean) { + "The persisted capability cleanup marker is not a boolean." + } + return persisted +} + internal fun resolveDurableUploadResource( scope: DurableUploadScope, request: NextcloudMultipartUploadRequest, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt index c03a052d5..6865e023e 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt @@ -13,6 +13,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue import org.json.JSONArray +import org.json.JSONObject class AndroidDurableUploadCleanupPruningTest { @Test @@ -123,6 +124,48 @@ class AndroidDurableUploadCleanupPruningTest { assertFalse(restored.capabilityCleanupPending) } + @Test + fun `explicit cleanup marker booleans restore without coercion`() { + listOf(true, false).forEach { cleanupPending -> + val storage = MemoryStorage() + AndroidDurableMultipartUploadStore(storage, PlaintextCipher).add( + fixtureJob(index = 1, cleanupPending = cleanupPending), + ) + + val restored = AndroidDurableMultipartUploadStore(storage, PlaintextCipher).list().single() + + assertEquals(cleanupPending, restored.capabilityCleanupPending) + } + } + + @Test + fun `malformed cleanup markers leave the recovery queue unchanged`() { + val malformedValues = listOf( + "true", + "false", + 1, + JSONObject.NULL, + JSONObject().put("pending", true), + JSONArray().put(true), + ) + + malformedValues.forEach { malformedValue -> + val storage = MemoryStorage() + val store = AndroidDurableMultipartUploadStore(storage, PlaintextCipher) + store.add(fixtureJob(index = 1, cleanupPending = true)) + val malformedSnapshot = JSONArray(checkNotNull(storage.value)).also { array -> + array.getJSONObject(0).put("capabilityCleanupPending", malformedValue) + }.toString() + storage.value = malformedSnapshot + + assertFailsWith { store.list() } + assertFailsWith { + store.add(fixtureJob(index = 2, state = DurableUploadState.Queued)) + } + assertEquals(malformedSnapshot, storage.value) + } + } + @Test fun `pending cleanup consumes bounded queue capacity`() { val pending = (1..AndroidDurableMultipartUploadStore.MAX_STORED_UPLOADS).map { index -> From 5de864ef37553c04f3ae61ef94727d2e3469996c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 12:37:51 +0200 Subject: [PATCH 27/53] fix(uploads): run terminal cleanup offline --- .../AndroidDurableMultipartUploads.kt | 15 +++++- .../AndroidDurableUploadScheduling.kt | 7 ++- .../AndroidDurableUploadWorker.kt | 10 ++++ ...AndroidDurableMultipartUploadPolicyTest.kt | 13 +++-- .../AndroidDurableUploadCleanupPruningTest.kt | 52 ++++++++++++++----- ...roidDurableUploadTerminalCapabilityTest.kt | 3 ++ 6 files changed, 81 insertions(+), 19 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 8a02dc256..e01d3c387 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -120,6 +120,12 @@ internal class AndroidDurableMultipartUploads(context: Context) { .first() .any { work -> !work.state.isFinished } }, + cleanupCapability = { job -> + check(AndroidLocalUploadPicker(appContext).release(job.request.file)) { + "The durable upload capability cleanup remains pending." + } + store.completeCapabilityCleanup(job.id) + }, schedule = { job -> schedule(job).await() }, ) @@ -148,7 +154,7 @@ internal class AndroidDurableMultipartUploads(context: Context) { .setInputData(Data.Builder().putString(DeckAttachmentUploadWorker.KEY_JOB_ID, job.id).build()) .setConstraints( Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiredNetworkType(networkTypeForDurableUploadWork(job)) .build(), ) .build(), @@ -185,6 +191,13 @@ internal val DURABLE_UPLOAD_ACCOUNT_RECOVERY_WORK_POLICY = ExistingWorkPolicy.RE internal fun durableUploadWorkName(jobId: String) = "deck-attachment-$jobId" +internal fun networkTypeForDurableUploadWork(job: AndroidDurableMultipartUploadJob): NetworkType { + require(job.state == DurableUploadState.Queued && !job.capabilityCleanupPending) { + "Only a queued durable upload can use network-constrained upload work." + } + return NetworkType.CONNECTED +} + internal fun requestDurableUploadSchedulingRecoveryForQueuedStatuses( jobs: List, requestRecovery: () -> Unit = ::requestQueuedDurableUploadSchedulingRecovery, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index effff5523..ec4ec3631 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -168,12 +168,17 @@ internal suspend fun reconcileQueuedDurableUploads( jobs: List, allowQueuedScheduling: Boolean = true, schedulerOwns: suspend (AndroidDurableMultipartUploadJob) -> Boolean = { false }, + cleanupCapability: suspend (AndroidDurableMultipartUploadJob) -> Unit, schedule: suspend (AndroidDurableMultipartUploadJob) -> Unit, ): Boolean { var allScheduled = true jobs.filter { job -> job.requiresSchedulingRecovery(allowQueuedScheduling) }.forEach { job -> try { - if (!schedulerOwns(job)) schedule(job) + if (job.capabilityCleanupPending) { + cleanupCapability(job) + } else if (!schedulerOwns(job)) { + schedule(job) + } } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index 410df37ac..3a4c4c862 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -53,6 +53,7 @@ internal class DeckAttachmentUploadWorker( return resultAfterDurableUploadCapabilityRelease( releaseCapability = { picker.release(initial.request.file) }, completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, + onCleanupRetained = ::requestQueuedDurableUploadSchedulingRecovery, releasedResult = Result.success(), retainedResult = Result.retry(), ) @@ -61,6 +62,7 @@ internal class DeckAttachmentUploadWorker( return resultAfterDurableUploadCapabilityRelease( releaseCapability = { picker.release(initial.request.file) }, completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, + onCleanupRetained = ::requestQueuedDurableUploadSchedulingRecovery, releasedResult = Result.success(), retainedResult = Result.retry(), ) @@ -133,6 +135,7 @@ internal class DeckAttachmentUploadWorker( }, releaseSelection = { picker.release(initial.request.file) }, completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, + onCleanupRetained = ::requestQueuedDurableUploadSchedulingRecovery, recordFailure = { recordUploadDiagnostic( severity = SupportDiagnosticSeverity.Warning, @@ -165,6 +168,7 @@ internal class DeckAttachmentUploadWorker( resultAfterDurableUploadCapabilityRelease( releaseCapability = { picker.release(initial.request.file) }, completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, + onCleanupRetained = ::requestQueuedDurableUploadSchedulingRecovery, releasedResult = Result.failure(), retainedResult = Result.retry(), ) @@ -263,6 +267,7 @@ internal class DeckAttachmentUploadWorker( return resultAfterDurableUploadCapabilityRelease( releaseCapability = { picker.release(started.request.file) }, completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, + onCleanupRetained = ::requestQueuedDurableUploadSchedulingRecovery, releasedResult = Result.success(), retainedResult = Result.retry(), ) @@ -301,6 +306,7 @@ internal fun failQueuedDurableUploadForUnavailableAccount( transitionToFailed: () -> Unit, releaseSelection: () -> Boolean, completeCapabilityCleanup: () -> Unit = {}, + onCleanupRetained: () -> Unit = {}, recordFailure: () -> Unit, failureResult: Result, retryResult: Result, @@ -309,6 +315,7 @@ internal fun failQueuedDurableUploadForUnavailableAccount( val result = resultAfterDurableUploadCapabilityRelease( releaseCapability = releaseSelection, completeCapabilityCleanup = completeCapabilityCleanup, + onCleanupRetained = onCleanupRetained, releasedResult = failureResult, retainedResult = retryResult, ) @@ -319,6 +326,7 @@ internal fun failQueuedDurableUploadForUnavailableAccount( internal fun resultAfterDurableUploadCapabilityRelease( releaseCapability: () -> Boolean, completeCapabilityCleanup: () -> Unit = {}, + onCleanupRetained: () -> Unit = {}, releasedResult: Result, retainedResult: Result, ): Result = try { @@ -326,11 +334,13 @@ internal fun resultAfterDurableUploadCapabilityRelease( completeCapabilityCleanup() releasedResult } else { + runCatching(onCleanupRetained) retainedResult } } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { + runCatching(onCleanupRetained) retainedResult } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 84d0ca0b4..20e72c6be 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -573,6 +573,7 @@ class AndroidDurableMultipartUploadPolicyTest { val allScheduled = reconcileQueuedDurableUploads( jobs = listOf(owned, missing), schedulerOwns = { job -> job == owned }, + cleanupCapability = { error("Queued uploads must not enter local cleanup.") }, schedule = { job -> attempted += job.id }, ) @@ -957,10 +958,14 @@ class AndroidDurableMultipartUploadPolicyTest { ) val attempted = mutableListOf() - val allScheduled = reconcileQueuedDurableUploads(listOf(first, completed, second)) { job -> - attempted += job.id - if (job == first) throw IOException("Synthetic scheduler rejection") - } + val allScheduled = reconcileQueuedDurableUploads( + jobs = listOf(first, completed, second), + cleanupCapability = { error("Completed history must not enter cleanup.") }, + schedule = { job -> + attempted += job.id + if (job == first) throw IOException("Synthetic scheduler rejection") + }, + ) assertEquals(listOf(first.id, second.id), attempted) assertFalse(allScheduled) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt index 6865e023e..797d49879 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import androidx.work.NetworkType import dev.obiente.nextcloudnative.app.DurableUploadScope import dev.obiente.nextcloudnative.app.DurableUploadState import dev.obiente.nextcloudnative.app.NextcloudApiMethod @@ -17,18 +18,31 @@ import org.json.JSONObject class AndroidDurableUploadCleanupPruningTest { @Test - fun `reconciliation schedules pending terminal cleanup but skips completed history`() = runBlocking { + fun `reconciliation runs terminal cleanup without consulting upload work ownership`() = runBlocking { val pending = fixtureJob(index = 1, cleanupPending = true) val history = fixtureJob(index = 2) - val scheduled = mutableListOf() + val cleaned = mutableListOf() val allScheduled = reconcileQueuedDurableUploads( jobs = listOf(pending, history), - schedule = { job -> scheduled += job }, + schedulerOwns = { error("Local cleanup must not wait for upload work ownership.") }, + cleanupCapability = { job -> cleaned += job }, + schedule = { error("Terminal cleanup must not use network-constrained upload work.") }, ) assertTrue(allScheduled) - assertEquals(listOf(pending), scheduled) + assertEquals(listOf(pending), cleaned) + } + + @Test + fun `only queued uploads are eligible for connected upload work`() { + val queued = fixtureJob(index = 1, state = DurableUploadState.Queued) + val pending = fixtureJob(index = 2, cleanupPending = true) + + assertEquals(NetworkType.CONNECTED, networkTypeForDurableUploadWork(queued)) + assertFailsWith { + networkTypeForDurableUploadWork(pending) + } } @Test @@ -37,19 +51,24 @@ class AndroidDurableUploadCleanupPruningTest { val queued = fixtureJob(index = 2, state = DurableUploadState.Queued) val attempts = mutableListOf() - val allScheduled = reconcileQueuedDurableUploads(listOf(pending, queued)) { job -> - attempts += job.id - if (job == pending) error("synthetic cleanup scheduling failure") - } + val allScheduled = reconcileQueuedDurableUploads( + jobs = listOf(pending, queued), + cleanupCapability = { job -> + attempts += job.id + error("synthetic cleanup failure") + }, + schedule = { job -> attempts += job.id }, + ) assertFalse(allScheduled) assertEquals(listOf(pending.id, queued.id), attempts) } @Test - fun `unsupported account registry schedules terminal cleanup but not queued uploads`() = runBlocking { + fun `unsupported account registry runs terminal cleanup but not queued uploads`() = runBlocking { val pending = fixtureJob(index = 1, cleanupPending = true) val queued = fixtureJob(index = 2, state = DurableUploadState.Queued) + val cleaned = mutableListOf() val scheduled = mutableListOf() val accountResolutionAvailable = androidCredentialFreeRegistryAllowsAccountResolution( """{"version":99,"accounts":[]}""", @@ -58,12 +77,14 @@ class AndroidDurableUploadCleanupPruningTest { val allScheduled = reconcileQueuedDurableUploads( jobs = listOf(pending, queued), allowQueuedScheduling = accountResolutionAvailable, + cleanupCapability = cleaned::add, schedule = scheduled::add, ) assertFalse(accountResolutionAvailable) assertTrue(allScheduled) - assertEquals(listOf(pending), scheduled) + assertEquals(listOf(pending), cleaned) + assertTrue(scheduled.isEmpty()) } @Test @@ -71,9 +92,11 @@ class AndroidDurableUploadCleanupPruningTest { val pending = fixtureJob(index = 1, cleanupPending = true) assertFailsWith { - reconcileQueuedDurableUploads(listOf(pending)) { - throw CancellationException("recovery stopped") - } + reconcileQueuedDurableUploads( + jobs = listOf(pending), + cleanupCapability = { throw CancellationException("recovery stopped") }, + schedule = { error("Terminal cleanup must not schedule upload work.") }, + ) } Unit } @@ -182,15 +205,18 @@ class AndroidDurableUploadCleanupPruningTest { @Test fun `cleanup commit failure retries and preserves cancellation`() { + var recoveryRequests = 0 assertEquals( "retry", resultAfterDurableUploadCapabilityRelease( releaseCapability = { true }, completeCapabilityCleanup = { error("queue unavailable") }, + onCleanupRetained = { recoveryRequests += 1 }, releasedResult = "finished", retainedResult = "retry", ), ) + assertEquals(1, recoveryRequests) assertFailsWith { resultAfterDurableUploadCapabilityRelease( releaseCapability = { true }, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadTerminalCapabilityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadTerminalCapabilityTest.kt index 81dfbe0c2..5b38d77b2 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadTerminalCapabilityTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadTerminalCapabilityTest.kt @@ -7,18 +7,21 @@ class AndroidDurableUploadTerminalCapabilityTest { @Test fun `terminal worker retries while capability cleanup remains uncommitted`() { var releaseAttempts = 0 + var recoveryRequests = 0 val result = resultAfterDurableUploadCapabilityRelease( releaseCapability = { releaseAttempts += 1 false }, + onCleanupRetained = { recoveryRequests += 1 }, releasedResult = "finished", retainedResult = "retry", ) assertEquals("retry", result) assertEquals(1, releaseAttempts) + assertEquals(1, recoveryRequests) } @Test From f03c0fbb05f56055ecb8c61833de680a27100e5e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 12:45:45 +0200 Subject: [PATCH 28/53] fix(uploads): preserve cleanup with corrupt registry --- .../AndroidDurableUploadCleanupPruningTest.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt index 797d49879..b80838347 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt @@ -87,6 +87,29 @@ class AndroidDurableUploadCleanupPruningTest { assertTrue(scheduled.isEmpty()) } + @Test + fun `wrong typed account registry runs terminal cleanup but not queued uploads`() = runBlocking { + val pending = fixtureJob(index = 1, cleanupPending = true) + val queued = fixtureJob(index = 2, state = DurableUploadState.Queued) + val cleaned = mutableListOf() + val scheduled = mutableListOf() + val accountResolutionAvailable = durableUploadAccountResolutionAvailable { + throw ClassCastException("synthetic wrong-typed account registry") + } + + val allScheduled = reconcileQueuedDurableUploads( + jobs = listOf(pending, queued), + allowQueuedScheduling = accountResolutionAvailable, + cleanupCapability = cleaned::add, + schedule = scheduled::add, + ) + + assertFalse(accountResolutionAvailable) + assertTrue(allScheduled) + assertEquals(listOf(pending), cleaned) + assertTrue(scheduled.isEmpty()) + } + @Test fun `terminal cleanup reconciliation preserves cancellation`() = runBlocking { val pending = fixtureJob(index = 1, cleanupPending = true) From 06d3590e1d4faed6e3d75e387cc341b4c100b11c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 12:49:53 +0200 Subject: [PATCH 29/53] fix(uploads): retain unreadable capability metadata --- .../AndroidLocalUploadPicker.kt | 56 ++++++---- ...droidLocalUploadCapabilityLifecycleTest.kt | 103 ++++++++++++++++++ 2 files changed, 139 insertions(+), 20 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index 432b86785..439a7ff74 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -130,15 +130,14 @@ internal class AndroidLocalUploadPicker(context: Context) { } fun release(file: LocalUploadFile): Boolean { - val source = selections[file.selectionId] ?: load(file.selectionId) - return releaseDurableUploadCapability( - releasePermission = { - source?.let { - resolver.releasePersistableUriPermission( - it.uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION, - ) - } + return releaseStoredDurableUploadCapability( + cachedCapability = selections[file.selectionId], + loadCapability = { load(file.selectionId) }, + releasePermission = { source -> + resolver.releasePersistableUriPermission( + source.uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) }, removeMetadata = { preferences.edit() @@ -193,17 +192,15 @@ internal class AndroidLocalUploadPicker(context: Context) { private fun load(selectionId: String): SelectedSource? { val encrypted = preferences.getString(preferenceKey(selectionId), null) ?: return null - return runCatching { - val payload = JSONObject(cipher.decrypt(encrypted)) - val file = localUploadFile( - selectionId = payload.getString("selectionId"), - displayName = payload.getString("displayName"), - mimeType = if (payload.isNull("mimeType")) null else payload.getString("mimeType"), - sizeBytes = if (payload.isNull("sizeBytes")) null else payload.getLong("sizeBytes"), - ) - require(file.selectionId == selectionId) { "The persisted upload capability changed." } - SelectedSource(Uri.parse(payload.getString("uri")), file) - }.getOrNull() + val payload = JSONObject(cipher.decrypt(encrypted)) + val file = localUploadFile( + selectionId = payload.getString("selectionId"), + displayName = payload.getString("displayName"), + mimeType = if (payload.isNull("mimeType")) null else payload.getString("mimeType"), + sizeBytes = if (payload.isNull("sizeBytes")) null else payload.getLong("sizeBytes"), + ) + require(file.selectionId == selectionId) { "The persisted upload capability changed." } + return SelectedSource(Uri.parse(payload.getString("uri")), file) } private fun preferenceKey(selectionId: String): String = "$PREFERENCE_PREFIX$selectionId" @@ -260,6 +257,25 @@ internal fun releaseDurableUploadCapability( return runCatching(removeMetadata).getOrDefault(false) } +internal fun releaseStoredDurableUploadCapability( + cachedCapability: Capability?, + loadCapability: () -> Capability?, + releasePermission: (Capability) -> Unit, + removeMetadata: () -> Boolean, +): Boolean { + val capability = cachedCapability ?: try { + loadCapability() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + return false + } + return releaseDurableUploadCapability( + releasePermission = { capability?.let(releasePermission) }, + removeMetadata = removeMetadata, + ) +} + private data class AndroidUploadMetadata( val displayName: String, val sizeBytes: Long?, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt index 47d08df13..8567d7b38 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt @@ -1,5 +1,7 @@ package dev.obiente.nextcloudnative +import java.security.GeneralSecurityException +import kotlinx.coroutines.CancellationException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -69,4 +71,105 @@ class AndroidLocalUploadCapabilityLifecycleTest { assertFalse(released) assertTrue(permissionReleased) } + + @Test + fun `uncached unreadable encrypted metadata is retained without claiming capability release`() { + var encryptedMetadata: String? = "unreadable-encrypted-capability" + var permissionReleased = false + var cleanupPending = true + + val result = resultAfterDurableUploadCapabilityRelease( + releaseCapability = { + releaseStoredDurableUploadCapability( + cachedCapability = null, + loadCapability = { throw GeneralSecurityException("synthetic decryption failure") }, + releasePermission = { permissionReleased = true }, + removeMetadata = { true.also { encryptedMetadata = null } }, + ) + }, + completeCapabilityCleanup = { cleanupPending = false }, + releasedResult = "finished", + retainedResult = "retry", + ) + + assertEquals("retry", result) + assertTrue(cleanupPending) + assertFalse(permissionReleased) + assertEquals("unreadable-encrypted-capability", encryptedMetadata) + } + + @Test + fun `restored capability release revokes permission before deleting metadata`() { + val events = mutableListOf() + + val released = releaseStoredDurableUploadCapability( + cachedCapability = null, + loadCapability = { + events += "restore" + "content://synthetic/upload" + }, + releasePermission = { events += "permission:$it" }, + removeMetadata = { + events += "metadata" + true + }, + ) + + assertTrue(released) + assertEquals( + listOf("restore", "permission:content://synthetic/upload", "metadata"), + events, + ) + } + + @Test + fun `missing capability metadata is an idempotent cleanup success`() { + var metadataRemovals = 0 + + val released = releaseStoredDurableUploadCapability( + cachedCapability = null, + loadCapability = { null }, + releasePermission = { error("Missing metadata has no URI grant to release.") }, + removeMetadata = { + metadataRemovals += 1 + true + }, + ) + + assertTrue(released) + assertEquals(1, metadataRemovals) + } + + @Test + fun `cached capability releases without reading redundant stored metadata`() { + val events = mutableListOf() + + val released = releaseStoredDurableUploadCapability( + cachedCapability = "content://cached/upload", + loadCapability = { error("Cached cleanup must not read stored metadata.") }, + releasePermission = { events += "permission:$it" }, + removeMetadata = { + events += "metadata" + true + }, + ) + + assertTrue(released) + assertEquals( + listOf("permission:content://cached/upload", "metadata"), + events, + ) + } + + @Test + fun `capability restore preserves cancellation`() { + assertFailsWith { + releaseStoredDurableUploadCapability( + cachedCapability = null, + loadCapability = { throw CancellationException("cleanup stopped") }, + releasePermission = {}, + removeMetadata = { true }, + ) + } + } } From e2cd3e0dab629eaa3b29feec6a91ffec3faecd0e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 12:59:16 +0200 Subject: [PATCH 30/53] fix(uploads): clean cancelled picker grants --- .../AndroidDurableMultipartUploads.kt | 13 +++--- .../AndroidNextcloudServices.kt | 2 +- ...oidDurableUploadEnqueueCancellationTest.kt | 42 +++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index e01d3c387..af09dcf23 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -28,8 +28,12 @@ import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject -internal class AndroidDurableMultipartUploads(context: Context) { +internal class AndroidDurableMultipartUploads( + context: Context, + localUploadPicker: AndroidLocalUploadPicker? = null, +) { private val appContext = context.applicationContext + private val picker = localUploadPicker ?: AndroidLocalUploadPicker(appContext) private val store = AndroidDurableMultipartUploadStore(appContext) private val workManager = WorkManager.getInstance(appContext) @@ -39,7 +43,6 @@ internal class AndroidDurableMultipartUploads(context: Context) { request: NextcloudMultipartUploadRequest, ): DurableUploadEnqueueResult { val accountId = NextcloudDocumentIds.accountKey(session) - val picker = AndroidLocalUploadPicker(appContext) return try { val safeRequest = request.requireSafe() picker.requirePersisted(safeRequest.file) @@ -71,7 +74,7 @@ internal class AndroidDurableMultipartUploads(context: Context) { fun releaseIfUnowned(file: LocalUploadFile): Boolean = releaseUnownedDurableUploadSelection( selectionId = file.selectionId, hasActiveSelection = store::hasActiveSelection, - releaseSelection = { AndroidLocalUploadPicker(appContext).release(file) }, + releaseSelection = { picker.release(file) }, ) suspend fun runEnqueueWithCancellationCleanup( @@ -121,7 +124,7 @@ internal class AndroidDurableMultipartUploads(context: Context) { .any { work -> !work.state.isFinished } }, cleanupCapability = { job -> - check(AndroidLocalUploadPicker(appContext).release(job.request.file)) { + check(picker.release(job.request.file)) { "The durable upload capability cleanup remains pending." } store.completeCapabilityCleanup(job.id) @@ -138,7 +141,7 @@ internal class AndroidDurableMultipartUploads(context: Context) { ) { return false } - if (!AndroidLocalUploadPicker(appContext).release(job.request.file)) return false + if (!picker.release(job.request.file)) return false store.remove(uploadId) return true } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index b726ca66d..261e9b25a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -451,7 +451,7 @@ internal class AndroidNextcloudServices( requestPermissions = requestPlatformPermissions, ) private val projectContent = AndroidProjectContentClient(appContext, activity) - private val durableMultipartUploads = AndroidDurableMultipartUploads(appContext) + private val durableMultipartUploads = AndroidDurableMultipartUploads(appContext, localUploadPicker) private val deckCardDrafts = AndroidDeckCardDraftStore(appContext) private val supportDiagnostics = AndroidSupportDiagnostics.get(appContext) private val supportBundleExporter = AndroidSupportBundleExporter( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadEnqueueCancellationTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadEnqueueCancellationTest.kt index 1c99e704a..076bffb43 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadEnqueueCancellationTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadEnqueueCancellationTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import java.security.GeneralSecurityException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking import kotlin.test.Test @@ -61,6 +62,47 @@ class AndroidDurableUploadEnqueueCancellationTest { assertEquals(0, releases) } + @Test + fun `cancellation releases cached capability when encrypted storage is unreadable`() = runBlocking { + val expected = CancellationException("screen closed") + var cleanupResult: Boolean? = null + var encryptedMetadata: String? = "unreadable-encrypted-capability" + var loadAttempts = 0 + val events = mutableListOf() + + val actual = assertFailsWith { + runDurableUploadEnqueueWithCancellationCleanup( + enqueue = { throw expected }, + releaseUnownedSelection = { + cleanupResult = releaseUnownedDurableUploadSelection( + selectionId = "selection-123456", + hasActiveSelection = { false }, + releaseSelection = { + releaseStoredDurableUploadCapability( + cachedCapability = "content://cached/upload", + loadCapability = { + loadAttempts += 1 + throw GeneralSecurityException("synthetic decryption failure") + }, + releasePermission = { events += "permission:$it" }, + removeMetadata = { + events += "metadata" + true.also { encryptedMetadata = null } + }, + ) + }, + ) + }, + ) + } + + assertTrue(actual === expected) + assertTrue(cleanupResult == true) + assertEquals(0, loadAttempts) + assertEquals(listOf("permission:content://cached/upload", "metadata"), events) + assertEquals(null, encryptedMetadata) + } + @Test fun `unreadable ownership state retains the selection`() { var releases = 0 From baa3271f0fa6e2e56a36df851009415d43b3dda8 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 13:17:23 +0200 Subject: [PATCH 31/53] fix(uploads): release undelivered picker selections --- .../AndroidLocalUploadPicker.kt | 18 +++- ...droidLocalUploadCapabilityLifecycleTest.kt | 96 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index 439a7ff74..83aef3f70 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -113,7 +113,11 @@ internal class AndroidLocalUploadPicker(context: Context) { "The selected file could not be opened.", ) } - selection.continuation.resume(result) + resumeLocalUploadSelectionResult( + continuation = selection.continuation, + result = result, + releaseSelected = { file -> release(file) }, + ) } fun open(file: LocalUploadFile): InputStream { @@ -227,6 +231,18 @@ internal class AndroidLocalUploadCapabilityUnavailableException( cause: Throwable? = null, ) : IllegalStateException(message, cause) +internal fun resumeLocalUploadSelectionResult( + continuation: CancellableContinuation, + result: LocalUploadSelectionResult, + releaseSelected: (LocalUploadFile) -> Unit, +) { + continuation.resume(result) { _, undeliveredResult, _ -> + if (undeliveredResult is LocalUploadSelectionResult.Selected) { + runCatching { releaseSelected(undeliveredResult.file) } + } + } +} + /** * Acquires a durable picker capability without exposing an interval where a successful selection * can be reported before its metadata reaches app-private storage. diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt index 8567d7b38..abf532d43 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt @@ -1,7 +1,17 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.LocalUploadFile +import dev.obiente.nextcloudnative.app.LocalUploadSelectionResult +import dev.obiente.nextcloudnative.app.localUploadFile import java.security.GeneralSecurityException import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.CoroutineContext import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -9,6 +19,80 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class AndroidLocalUploadCapabilityLifecycleTest { + @Test + fun `selected capability is released when cancellation wins result delivery`() { + val file = localUploadFile( + selectionId = "selection-1234567890", + displayName = "cancelled.txt", + mimeType = "text/plain", + sizeBytes = 12L, + ) + val dispatcher = PausedDispatcher() + var resumeSelection: ((LocalUploadSelectionResult) -> Unit)? = null + var delivered = false + var persistedCapability: LocalUploadFile? = file + var cachedCapability: LocalUploadFile? = file + val scopeJob = Job() + val selectionJob = CoroutineScope(scopeJob + dispatcher).launch(start = CoroutineStart.UNDISPATCHED) { + suspendCancellableCoroutine { continuation -> + resumeSelection = { result -> + resumeLocalUploadSelectionResult( + continuation = continuation, + result = result, + releaseSelected = { cancelledFile -> + assertEquals(cachedCapability, cancelledFile) + persistedCapability = null + cachedCapability = null + }, + ) + } + } + delivered = true + } + + checkNotNull(resumeSelection)(LocalUploadSelectionResult.Selected(file)) + selectionJob.cancel() + dispatcher.runAll() + + assertTrue(selectionJob.isCancelled) + assertFalse(delivered) + assertEquals(null, persistedCapability) + assertEquals(null, cachedCapability) + scopeJob.cancel() + } + + @Test + fun `non-selected results do not request capability cleanup when delivery is cancelled`() { + listOf( + LocalUploadSelectionResult.Cancelled, + LocalUploadSelectionResult.Rejected("synthetic rejection"), + ).forEach { result -> + val dispatcher = PausedDispatcher() + var resumeSelection: (() -> Unit)? = null + var releases = 0 + val scopeJob = Job() + val selectionJob = CoroutineScope(scopeJob + dispatcher).launch(start = CoroutineStart.UNDISPATCHED) { + suspendCancellableCoroutine { continuation -> + resumeSelection = { + resumeLocalUploadSelectionResult( + continuation = continuation, + result = result, + releaseSelected = { releases += 1 }, + ) + } + } + } + + checkNotNull(resumeSelection).invoke() + selectionJob.cancel() + dispatcher.runAll() + + assertTrue(selectionJob.isCancelled) + assertEquals(0, releases) + scopeJob.cancel() + } + } + @Test fun `permission is taken before metadata commit and retained after success`() { val events = mutableListOf() @@ -172,4 +256,16 @@ class AndroidLocalUploadCapabilityLifecycleTest { ) } } + + private class PausedDispatcher : CoroutineDispatcher() { + private val tasks = ArrayDeque() + + override fun dispatch(context: CoroutineContext, block: Runnable) { + tasks.addLast(block) + } + + fun runAll() { + while (tasks.isNotEmpty()) tasks.removeFirst().run() + } + } } From 4a4eba7c20346b5e003fe77a90cc3d75b92fa626 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 13:35:22 +0200 Subject: [PATCH 32/53] fix(uploads): recover orphaned picker grants --- .../AndroidDurableMultipartUploads.kt | 20 +- .../AndroidLocalUploadPicker.kt | 478 ++++++++++++++++-- ...droidLocalUploadCapabilityLifecycleTest.kt | 377 ++++++++++++++ 3 files changed, 821 insertions(+), 54 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index af09dcf23..6ddcda62b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -114,9 +114,21 @@ internal class AndroidDurableMultipartUploads( } } - suspend fun reconcileQueuedUploads(allowQueuedScheduling: Boolean = true): Boolean = - reconcileQueuedDurableUploads( - jobs = store.list(), + suspend fun reconcileQueuedUploads(allowQueuedScheduling: Boolean = true): Boolean { + val (jobs, capabilitiesRecovered) = synchronized(AndroidDurableMultipartUploadStore.LOCK) { + val snapshot = store.list() + val retainedSelectionIds = snapshot.asSequence() + .filter { job -> + job.state == DurableUploadState.Queued || + job.state == DurableUploadState.Uploading || + job.capabilityCleanupPending + } + .map { job -> job.request.file.selectionId } + .toSet() + snapshot to picker.reconcileCapabilities(retainedSelectionIds) + } + val uploadsRecovered = reconcileQueuedDurableUploads( + jobs = jobs, allowQueuedScheduling = allowQueuedScheduling, schedulerOwns = { job -> workManager.getWorkInfosForUniqueWorkFlow(durableUploadWorkName(job.id)) @@ -131,6 +143,8 @@ internal class AndroidDurableMultipartUploads( }, schedule = { job -> schedule(job).await() }, ) + return capabilitiesRecovered && uploadsRecovered + } fun dismiss(session: NextcloudSession, scope: DurableUploadScope, uploadId: String): Boolean { val job = store.find(uploadId) ?: return false diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index 83aef3f70..d5b3852cb 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -32,7 +32,7 @@ internal class AndroidLocalUploadPicker(context: Context) { private val resolver = context.applicationContext.contentResolver private val preferences = appContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) private val cipher = SessionCipher() - private val selections = ConcurrentHashMap() + private val selections = PROCESS_SELECTIONS private var launcher: ActivityResultLauncher>? = null private var pending: PendingSelection? = null @@ -52,6 +52,7 @@ internal class AndroidLocalUploadPicker(context: Context) { pending = selection continuation.invokeOnCancellation { if (pending === selection) pending = null + runCatching { selection.readyFile?.let(::release) } } activeLauncher.launch(accepted.toTypedArray()) } @@ -64,16 +65,16 @@ internal class AndroidLocalUploadPicker(context: Context) { selection.continuation.resume(LocalUploadSelectionResult.Cancelled) return } - val result = runCatching { + val result = runCatching selectionResult@{ val metadata = resolver.queryUploadMetadata(uri) val mimeType = resolver.getType(uri)?.trim()?.lowercase()?.takeIf(String::isNotBlank) if (!isAcceptedUploadMimeType(mimeType, selection.acceptedMimeTypes)) { - return@runCatching LocalUploadSelectionResult.Rejected( + return@selectionResult LocalUploadSelectionResult.Rejected( "The selected file type is not accepted.", ) } if (metadata.sizeBytes != null && metadata.sizeBytes > selection.maximumBytes) { - return@runCatching LocalUploadSelectionResult.Rejected( + return@selectionResult LocalUploadSelectionResult.Rejected( "The selected file is larger than the allowed upload limit.", ) } @@ -84,29 +85,68 @@ internal class AndroidLocalUploadPicker(context: Context) { mimeType = mimeType, sizeBytes = metadata.sizeBytes, ) - runCatching { - acquireDurableUploadCapability( - takePermission = { - resolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION, - ) - }, - persistMetadata = { persist(source = SelectedSource(uri, file)) }, - releasePermission = { - resolver.releasePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION, - ) - }, - ) - }.getOrElse { - return@runCatching LocalUploadSelectionResult.Rejected( + val source = SelectedSource(uri, file) + var cancelledAfterAcquire = false + val acquisitionFailure = runCatching { + synchronized(CAPABILITY_LOCK) { + val existing = loadCapabilitySnapshot() + check( + !durableUploadCapabilityPermissionOwnedByAnother( + capabilities = existing, + targetSelectionId = token, + targetPermission = uri, + permissionOf = SelectedSource::uri, + samePermission = { first, second -> first == second }, + ), + ) { + "The selected file already has an active picker capability." + } + val grantPreExisting = !exactReadPermissionIsAbsent(uri) + val acquiring = source.copy( + phase = CapabilityPhase.Acquiring, + processGeneration = PROCESS_GENERATION, + grantPreExisting = grantPreExisting, + ) + val ready = acquiring.copy(phase = CapabilityPhase.Ready) + val cleanupPending = acquiring.copy(phase = CapabilityPhase.CleanupPending) + acquireDurableUploadCapability( + persistAcquiring = { persist(acquiring) }, + takePermission = { + resolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + }, + persistMetadata = { persist(ready) }, + markCleanupPending = { + PENDING_CLEANUP_SELECTIONS += token + persist(cleanupPending) + }, + releasePermission = { + if (shouldReleaseDurableUploadPermission(grantPreExisting, false)) { + releasePermission(uri) + } + }, + isPermissionAbsent = { exactReadPermissionIsAbsent(uri) }, + removeCapability = { removeMetadata(token) }, + onRollbackRetained = ::requestQueuedDurableUploadSchedulingRecovery, + ) + cancelledAfterAcquire = !finalizeDurableUploadCapabilityDelivery( + publishReady = { + selections[token] = ready + selection.readyFile = file + }, + continuationIsActive = { selection.continuation.isActive }, + cleanupUndelivered = { release(file) }, + ) + } + }.exceptionOrNull() + if (acquisitionFailure != null) { + return@selectionResult LocalUploadSelectionResult.Rejected( "The selected file provider cannot keep access for a background upload.", ) } - val source = SelectedSource(uri, file) - selections[token] = source + if (cancelledAfterAcquire) return@selectionResult LocalUploadSelectionResult.Cancelled LocalUploadSelectionResult.Selected(file) }.getOrElse { LocalUploadSelectionResult.Rejected( @@ -133,24 +173,109 @@ internal class AndroidLocalUploadPicker(context: Context) { requiredSource(file, useCachedSource = false) } - fun release(file: LocalUploadFile): Boolean { - return releaseStoredDurableUploadCapability( - cachedCapability = selections[file.selectionId], - loadCapability = { load(file.selectionId) }, - releasePermission = { source -> - resolver.releasePersistableUriPermission( - source.uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION, - ) + fun release(file: LocalUploadFile): Boolean = synchronized(CAPABILITY_LOCK) { + val source = try { + selections[file.selectionId] ?: load(file.selectionId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + return@synchronized retainCapabilityCleanup(file.selectionId) + } + if (source == null) { + val removed = durableUploadCleanupStep { + removeMetadata(file.selectionId) + } + return@synchronized if (removed) true else retainCapabilityCleanup(file.selectionId) + } + val cleanupPending = source.copy(phase = CapabilityPhase.CleanupPending) + PENDING_CLEANUP_SELECTIONS += file.selectionId + selections[file.selectionId] = cleanupPending + if (!durableUploadCleanupStep { persist(cleanupPending) }) { + requestQueuedDurableUploadSchedulingRecovery() + return@synchronized false + } + val capabilities = try { + loadCapabilitySnapshot() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + return@synchronized retainCapabilityCleanup(file.selectionId) + } + val ownedElsewhere = capabilities.anyOtherCapabilityOwnsUri( + source.uri, + file.selectionId, + ) + releaseDurableUploadCapability( + releasePermission = { + if (shouldReleaseDurableUploadPermission(source.grantPreExisting, ownedElsewhere)) { + releasePermission(source.uri) + } }, - removeMetadata = { - preferences.edit() - .remove(preferenceKey(file.selectionId)) - .commit() + isPermissionAbsent = { + source.grantPreExisting || ownedElsewhere || exactReadPermissionIsAbsent(source.uri) }, + removeMetadata = { removeMetadata(file.selectionId) }, ).also { released -> - if (released) selections.remove(file.selectionId) + if (released) { + selections.remove(file.selectionId) + } else { + requestQueuedDurableUploadSchedulingRecovery() + } + } + } + + fun reconcileCapabilities(ownedSelectionIds: Set): Boolean = synchronized(CAPABILITY_LOCK) { + val capabilities = try { + loadCapabilitySnapshot().toMutableMap() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + return@synchronized false + } + var allRecovered = true + capabilities.values + .sortedBy { capability -> capability.file.selectionId } + .filter { capability -> + shouldRecoverDurableUploadCapability( + phase = capability.phase, + processGeneration = capability.processGeneration, + currentProcessGeneration = PROCESS_GENERATION, + ownedByDurableJob = capability.file.selectionId in ownedSelectionIds, + cleanupExplicitlyPending = capability.file.selectionId in PENDING_CLEANUP_SELECTIONS, + ) + } + .forEach { capability -> + val selectionId = capability.file.selectionId + val ownedElsewhere = capabilities.anyOtherCapabilityOwnsUri( + capability.uri, + selectionId, + ) + val released = releaseDurableUploadCapability( + releasePermission = { + if ( + shouldReleaseDurableUploadPermission( + capability.grantPreExisting, + ownedElsewhere, + ) + ) { + releasePermission(capability.uri) + } + }, + isPermissionAbsent = { + capability.grantPreExisting || + ownedElsewhere || + exactReadPermissionIsAbsent(capability.uri) + }, + removeMetadata = { removeMetadata(selectionId) }, + ) + if (released) { + capabilities.remove(selectionId) + selections.remove(selectionId) + } else { + allRecovered = false + } } + allRecovered } private fun persist(source: SelectedSource): Boolean { @@ -160,12 +285,60 @@ internal class AndroidLocalUploadPicker(context: Context) { .put("displayName", source.file.displayName) .put("mimeType", source.file.mimeType) .put("sizeBytes", source.file.sizeBytes) - .toString() + .put("phase", source.phase.persistedValue) + .put("grantPreExisting", source.grantPreExisting) + source.processGeneration?.let { generation -> payload.put("processGeneration", generation) } + val encrypted = cipher.encrypt(payload.toString()) return preferences.edit() - .putString(preferenceKey(source.file.selectionId), cipher.encrypt(payload)) + .putString(preferenceKey(source.file.selectionId), encrypted) .commit() } + private fun removeMetadata(selectionId: String): Boolean = preferences.edit() + .remove(preferenceKey(selectionId)) + .commit() + .also { removed -> if (removed) PENDING_CLEANUP_SELECTIONS.remove(selectionId) } + + private fun retainCapabilityCleanup(selectionId: String): Boolean { + PENDING_CLEANUP_SELECTIONS += selectionId + return retainDurableUploadCapabilityCleanup(::requestQueuedDurableUploadSchedulingRecovery) + } + + private fun releasePermission(uri: Uri) { + resolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + private fun exactReadPermissionIsAbsent(uri: Uri): Boolean = resolver.persistedUriPermissions.none { + it.uri == uri && it.isReadPermission + } + + private fun loadCapabilitySnapshot(): Map { + val storedSelectionIds = preferences.all.keys + .asSequence() + .filter { key -> key.startsWith(PREFERENCE_PREFIX) } + .map { key -> key.removePrefix(PREFERENCE_PREFIX) } + .toList() + require(storedSelectionIds.size <= MAX_TRACKED_CAPABILITIES) { + "Too many picker capabilities are tracked." + } + return mergeDurableUploadCapabilities( + cachedCapabilities = selections.toMap(), + storedSelectionIds = storedSelectionIds, + loadStoredCapability = ::load, + ) + } + + private fun Map.anyOtherCapabilityOwnsUri( + uri: Uri, + selectionId: String, + ): Boolean = durableUploadCapabilityPermissionOwnedByAnother( + capabilities = this, + targetSelectionId = selectionId, + targetPermission = uri, + permissionOf = SelectedSource::uri, + samePermission = { first, second -> first == second }, + ) + private fun persistedSource(file: LocalUploadFile): SelectedSource { return requiredSource(file, useCachedSource = true) } @@ -191,6 +364,11 @@ internal class AndroidLocalUploadPicker(context: Context) { "The persisted local file metadata changed.", ) } + if (!isDurableUploadCapabilityReady(source.phase)) { + throw AndroidLocalUploadCapabilityUnavailableException( + "The local file selection is pending capability cleanup.", + ) + } return source } @@ -204,25 +382,74 @@ internal class AndroidLocalUploadPicker(context: Context) { sizeBytes = if (payload.isNull("sizeBytes")) null else payload.getLong("sizeBytes"), ) require(file.selectionId == selectionId) { "The persisted upload capability changed." } - return SelectedSource(Uri.parse(payload.getString("uri")), file) + val phase = if (payload.has("phase")) { + CapabilityPhase.fromPersistedValue(payload.requireStrictString("phase")) + } else { + CapabilityPhase.Ready + } + val processGeneration = payload.optionalStrictString("processGeneration") + ?.also(::requireSafeProcessGeneration) + val grantPreExisting = payload.optionalStrictBoolean("grantPreExisting") ?: true + return SelectedSource( + uri = Uri.parse(payload.getString("uri")), + file = file, + phase = phase, + processGeneration = processGeneration, + grantPreExisting = grantPreExisting, + ) } private fun preferenceKey(selectionId: String): String = "$PREFERENCE_PREFIX$selectionId" - private data class PendingSelection( + private class PendingSelection( val continuation: CancellableContinuation, val acceptedMimeTypes: List, val maximumBytes: Long, - ) + ) { + @Volatile + var readyFile: LocalUploadFile? = null + } private data class SelectedSource( val uri: Uri, val file: LocalUploadFile, + val phase: CapabilityPhase = CapabilityPhase.Ready, + val processGeneration: String? = PROCESS_GENERATION, + val grantPreExisting: Boolean = false, ) private companion object { const val PREFERENCES = "nextcloud_native_upload_capabilities" const val PREFERENCE_PREFIX = "upload_" + const val MAX_TRACKED_CAPABILITIES = 64 + val PROCESS_GENERATION = UUID.randomUUID().toString() + val PROCESS_SELECTIONS = ConcurrentHashMap() + val PENDING_CLEANUP_SELECTIONS = ConcurrentHashMap.newKeySet() + val CAPABILITY_LOCK = Any() + } +} + +private fun requireSafeProcessGeneration(value: String) { + require(value.length in 16..96 && value.all { it.isLetterOrDigit() || it == '-' }) { + "The picker capability process generation is invalid." + } +} + +internal fun JSONObject.optionalStrictString(key: String): String? { + if (!has(key) || isNull(key)) return null + return requireStrictString(key) +} + +internal fun JSONObject.requireStrictString(key: String): String = get(key).let { value -> + require(value is String) { "The $key value changed type." } + value +} + +internal fun JSONObject.optionalStrictBoolean(key: String): Boolean? { + if (!has(key) || isNull(key)) return null + return get(key).let { value -> + require(value is Boolean) { "The $key value changed type." } + value } } @@ -251,26 +478,122 @@ internal fun acquireDurableUploadCapability( takePermission: () -> Unit, persistMetadata: () -> Boolean, releasePermission: () -> Unit, + persistAcquiring: () -> Boolean = { true }, + markCleanupPending: () -> Boolean = { true }, + isPermissionAbsent: () -> Boolean = { false }, + removeCapability: () -> Boolean = { true }, + onRollbackRetained: () -> Unit = {}, ) { - takePermission() + val acquiringPersisted = try { + persistAcquiring() + } catch (cancelled: CancellationException) { + durableUploadCleanupStep(removeCapability) + runCatching(onRollbackRetained) + throw cancelled + } catch (failure: Exception) { + durableUploadCleanupStep(removeCapability) + runCatching(onRollbackRetained) + throw failure + } + if (!acquiringPersisted) { + durableUploadCleanupStep(removeCapability) + runCatching(onRollbackRetained) + error("The picker capability rollback could not be saved.") + } + try { + takePermission() + } catch (cancelled: CancellationException) { + runCatching(onRollbackRetained) + throw cancelled + } catch (failure: Exception) { + runCatching(onRollbackRetained) + throw failure + } val persisted = runCatching { persistMetadata() } if (persisted.getOrNull() == true) return - runCatching(releasePermission) + if (!durableUploadCleanupStep(markCleanupPending)) runCatching(onRollbackRetained) + val released = try { + releaseDurableUploadPermission(releasePermission, isPermissionAbsent) + } catch (cancelled: CancellationException) { + runCatching(onRollbackRetained) + throw cancelled + } + if (released) { + if (!durableUploadCleanupStep(removeCapability)) runCatching(onRollbackRetained) + } else { + runCatching(onRollbackRetained) + } persisted.exceptionOrNull()?.let { throw it } error("The durable upload capability could not be saved.") } +internal enum class CapabilityPhase(val persistedValue: String) { + Acquiring("acquiring"), + Ready("ready"), + CleanupPending("cleanup-pending"); + + companion object { + fun fromPersistedValue(value: String): CapabilityPhase = entries.singleOrNull { + phase -> phase.persistedValue == value + } ?: error("The picker capability phase is invalid.") + } +} + +internal fun shouldRecoverDurableUploadCapability( + phase: CapabilityPhase, + processGeneration: String?, + currentProcessGeneration: String, + ownedByDurableJob: Boolean, + cleanupExplicitlyPending: Boolean, +): Boolean = !ownedByDurableJob && ( + cleanupExplicitlyPending || + phase != CapabilityPhase.Ready || + processGeneration != currentProcessGeneration +) + +internal fun isDurableUploadCapabilityReady(phase: CapabilityPhase): Boolean = + phase == CapabilityPhase.Ready + +internal fun finalizeDurableUploadCapabilityDelivery( + publishReady: () -> Unit, + continuationIsActive: () -> Boolean, + cleanupUndelivered: () -> Unit, +): Boolean { + publishReady() + if (continuationIsActive()) return true + runCatching(cleanupUndelivered) + return false +} + /** - * Revokes the URI grant before synchronously deleting capability metadata. A failed grant release - * is still followed by metadata deletion because Android also throws when the grant was already - * absent; in either case the app must not retain an indefinitely reusable picker capability. + * Revokes the URI grant before synchronously deleting capability metadata. Android may throw when + * the grant is already absent, so an exception is accepted only after absence is verified. */ internal fun releaseDurableUploadCapability( releasePermission: () -> Unit, removeMetadata: () -> Boolean, + isPermissionAbsent: () -> Boolean = { false }, ): Boolean { - runCatching(releasePermission) - return runCatching(removeMetadata).getOrDefault(false) + if (!releaseDurableUploadPermission(releasePermission, isPermissionAbsent)) return false + return durableUploadCleanupStep(removeMetadata) +} + +internal fun releaseDurableUploadPermission( + releasePermission: () -> Unit, + isPermissionAbsent: () -> Boolean, +): Boolean = try { + releasePermission() + true +} catch (cancelled: CancellationException) { + throw cancelled +} catch (_: Exception) { + try { + isPermissionAbsent() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + false + } } internal fun releaseStoredDurableUploadCapability( @@ -278,6 +601,8 @@ internal fun releaseStoredDurableUploadCapability( loadCapability: () -> Capability?, releasePermission: (Capability) -> Unit, removeMetadata: () -> Boolean, + otherCapabilityOwnsPermission: (Capability) -> Boolean = { false }, + isPermissionAbsent: (Capability) -> Boolean = { false }, ): Boolean { val capability = cachedCapability ?: try { loadCapability() @@ -287,11 +612,62 @@ internal fun releaseStoredDurableUploadCapability( return false } return releaseDurableUploadCapability( - releasePermission = { capability?.let(releasePermission) }, + releasePermission = { + capability?.let { stored -> + if (!otherCapabilityOwnsPermission(stored)) releasePermission(stored) + } + }, removeMetadata = removeMetadata, + isPermissionAbsent = { capability == null || isPermissionAbsent(capability) }, ) } +internal fun mergeDurableUploadCapabilities( + cachedCapabilities: Map, + storedSelectionIds: Iterable, + loadStoredCapability: (String) -> Capability?, +): Map = buildMap { + putAll(cachedCapabilities) + storedSelectionIds.forEach { selectionId -> + if (selectionId !in this) { + put( + selectionId, + checkNotNull(loadStoredCapability(selectionId)) { + "The picker capability disappeared during recovery." + }, + ) + } + } +} + +internal fun durableUploadCapabilityPermissionOwnedByAnother( + capabilities: Map, + targetSelectionId: String, + targetPermission: Permission, + permissionOf: (Capability) -> Permission, + samePermission: (Permission, Permission) -> Boolean, +): Boolean = capabilities.any { (selectionId, capability) -> + selectionId != targetSelectionId && samePermission(targetPermission, permissionOf(capability)) +} + +internal fun shouldReleaseDurableUploadPermission( + grantPreExisting: Boolean, + ownedByAnotherCapability: Boolean, +): Boolean = !grantPreExisting && !ownedByAnotherCapability + +internal fun retainDurableUploadCapabilityCleanup(onCleanupRetained: () -> Unit): Boolean { + runCatching(onCleanupRetained) + return false +} + +private fun durableUploadCleanupStep(action: () -> Boolean): Boolean = try { + action() +} catch (cancelled: CancellationException) { + throw cancelled +} catch (_: Exception) { + false +} + private data class AndroidUploadMetadata( val displayName: String, val sizeBytes: Long?, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt index abf532d43..309b1f423 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine +import org.json.JSONObject import kotlin.coroutines.CoroutineContext import kotlin.test.Test import kotlin.test.assertEquals @@ -156,6 +157,48 @@ class AndroidLocalUploadCapabilityLifecycleTest { assertTrue(permissionReleased) } + @Test + fun `release exception with exact read grant present retains metadata`() { + var metadataPresent = true + + val released = releaseDurableUploadCapability( + releasePermission = { error("provider failure") }, + isPermissionAbsent = { false }, + removeMetadata = { true.also { metadataPresent = false } }, + ) + + assertFalse(released) + assertTrue(metadataPresent) + } + + @Test + fun `release exception with exact read grant absent deletes metadata`() { + var metadataPresent = true + + val released = releaseDurableUploadCapability( + releasePermission = { error("grant already absent") }, + isPermissionAbsent = { true }, + removeMetadata = { true.also { metadataPresent = false } }, + ) + + assertTrue(released) + assertFalse(metadataPresent) + } + + @Test + fun `release verification failure retains metadata`() { + var metadataPresent = true + + val released = releaseDurableUploadCapability( + releasePermission = { error("provider failure") }, + isPermissionAbsent = { error("permission query failed") }, + removeMetadata = { true.also { metadataPresent = false } }, + ) + + assertFalse(released) + assertTrue(metadataPresent) + } + @Test fun `uncached unreadable encrypted metadata is retained without claiming capability release`() { var encryptedMetadata: String? = "unreadable-encrypted-capability" @@ -245,6 +288,340 @@ class AndroidLocalUploadCapabilityLifecycleTest { ) } + @Test + fun `shared uri cleanup deletes only current capability metadata`() { + val events = mutableListOf() + + val released = releaseStoredDurableUploadCapability( + cachedCapability = "content://shared/upload", + loadCapability = { error("cache is authoritative") }, + otherCapabilityOwnsPermission = { true }, + releasePermission = { events += "permission" }, + isPermissionAbsent = { error("shared grant must remain") }, + removeMetadata = { + events += "metadata" + true + }, + ) + + assertTrue(released) + assertEquals(listOf("metadata"), events) + } + + @Test + fun `unreadable shared uri ownership retains current capability`() { + var metadataPresent = true + + val released = releaseStoredDurableUploadCapability( + cachedCapability = "content://shared/upload", + loadCapability = { error("cache is authoritative") }, + otherCapabilityOwnsPermission = { error("another capability is unreadable") }, + releasePermission = { error("ownership must be known before release") }, + isPermissionAbsent = { false }, + removeMetadata = { true.also { metadataPresent = false } }, + ) + + assertFalse(released) + assertTrue(metadataPresent) + } + + @Test + fun `cached duplicate selection owns shared uri without reading redundant storage`() { + var storedLoads = 0 + + val capabilities = mergeDurableUploadCapabilities( + cachedCapabilities = mapOf("selection-cached" to "content://shared/upload"), + storedSelectionIds = listOf("selection-cached"), + loadStoredCapability = { + storedLoads += 1 + error("cached capability must be authoritative") + }, + ) + + assertEquals("content://shared/upload", capabilities["selection-cached"]) + assertTrue( + durableUploadCapabilityPermissionOwnedByAnother( + capabilities = capabilities, + targetSelectionId = "selection-target", + targetPermission = "content://shared/upload", + permissionOf = { capability -> capability }, + samePermission = String::equals, + ), + ) + assertEquals(0, storedLoads) + } + + @Test + fun `persisted duplicate selection owns shared uri`() { + val capabilities = mergeDurableUploadCapabilities( + cachedCapabilities = emptyMap(), + storedSelectionIds = listOf("selection-persisted"), + loadStoredCapability = { "content://shared/upload" }, + ) + + assertEquals("content://shared/upload", capabilities["selection-persisted"]) + assertTrue( + durableUploadCapabilityPermissionOwnedByAnother( + capabilities = capabilities, + targetSelectionId = "selection-target", + targetPermission = "content://shared/upload", + permissionOf = { capability -> capability }, + samePermission = String::equals, + ), + ) + } + + @Test + fun `unreadable persisted duplicate ownership fails closed`() { + assertFailsWith { + mergeDurableUploadCapabilities( + cachedCapabilities = emptyMap(), + storedSelectionIds = listOf("selection-unreadable"), + loadStoredCapability = { throw GeneralSecurityException("synthetic decryption failure") }, + ) + } + } + + @Test + fun `failed acquisition rollback retains capability record for recovery`() { + var acquiringTracked = false + var capabilityClears = 0 + var recoveryRequests = 0 + + assertFailsWith { + acquireDurableUploadCapability( + persistAcquiring = { true.also { acquiringTracked = true } }, + takePermission = {}, + persistMetadata = { false }, + releasePermission = { error("provider failure") }, + isPermissionAbsent = { false }, + removeCapability = { true.also { capabilityClears += 1 } }, + onRollbackRetained = { recoveryRequests += 1 }, + ) + } + + assertTrue(acquiringTracked) + assertEquals(0, capabilityClears) + assertEquals(1, recoveryRequests) + } + + @Test + fun `ambiguous permission acquisition retains capability record for recovery`() { + val expected = IllegalStateException("binder failure") + var capabilityClears = 0 + var recoveryRequests = 0 + + val actual = assertFailsWith { + acquireDurableUploadCapability( + persistAcquiring = { true }, + takePermission = { throw expected }, + persistMetadata = { error("metadata must not be written") }, + releasePermission = { error("ambiguous acquisition is reconciled later") }, + removeCapability = { true.also { capabilityClears += 1 } }, + onRollbackRetained = { recoveryRequests += 1 }, + ) + } + + assertTrue(actual === expected) + assertEquals(0, capabilityClears) + assertEquals(1, recoveryRequests) + } + + @Test + fun `failed ready persistence cleans possibly written capability after grant release`() { + val events = mutableListOf() + var persistedPhase: CapabilityPhase? = null + + assertFailsWith { + acquireDurableUploadCapability( + persistAcquiring = { + events += "acquiring" + true.also { persistedPhase = CapabilityPhase.Acquiring } + }, + takePermission = { events += "permission" }, + persistMetadata = { + events += "ready-false" + false.also { persistedPhase = CapabilityPhase.Ready } + }, + markCleanupPending = { + events += "cleanup-pending" + true.also { persistedPhase = CapabilityPhase.CleanupPending } + }, + releasePermission = { events += "release" }, + removeCapability = { + events += "metadata" + true.also { persistedPhase = null } + }, + ) + } + + assertEquals( + listOf("acquiring", "permission", "ready-false", "cleanup-pending", "release", "metadata"), + events, + ) + assertEquals(null, persistedPhase) + } + + @Test + fun `current ready record is retained unless cleanup was requested`() { + assertFalse( + shouldRecoverDurableUploadCapability( + phase = CapabilityPhase.Ready, + processGeneration = "current-generation", + currentProcessGeneration = "current-generation", + ownedByDurableJob = false, + cleanupExplicitlyPending = false, + ), + ) + assertTrue( + shouldRecoverDurableUploadCapability( + phase = CapabilityPhase.Ready, + processGeneration = "current-generation", + currentProcessGeneration = "current-generation", + ownedByDurableJob = false, + cleanupExplicitlyPending = true, + ), + ) + } + + @Test + fun `prior ready and cleanup phases recover unless durable job owns them`() { + assertTrue( + shouldRecoverDurableUploadCapability( + phase = CapabilityPhase.Ready, + processGeneration = "prior-generation", + currentProcessGeneration = "current-generation", + ownedByDurableJob = false, + cleanupExplicitlyPending = false, + ), + ) + assertTrue( + shouldRecoverDurableUploadCapability( + phase = CapabilityPhase.CleanupPending, + processGeneration = "current-generation", + currentProcessGeneration = "current-generation", + ownedByDurableJob = false, + cleanupExplicitlyPending = false, + ), + ) + assertFalse( + shouldRecoverDurableUploadCapability( + phase = CapabilityPhase.CleanupPending, + processGeneration = "prior-generation", + currentProcessGeneration = "current-generation", + ownedByDurableJob = true, + cleanupExplicitlyPending = false, + ), + ) + } + + @Test + fun `preexisting or shared grants are never revoked by capability cleanup`() { + assertFalse( + shouldReleaseDurableUploadPermission( + grantPreExisting = true, + ownedByAnotherCapability = false, + ), + ) + assertFalse( + shouldReleaseDurableUploadPermission( + grantPreExisting = false, + ownedByAnotherCapability = true, + ), + ) + assertTrue( + shouldReleaseDurableUploadPermission( + grantPreExisting = false, + ownedByAnotherCapability = false, + ), + ) + } + + @Test + fun `failed acquiring commit clears possible record before taking permission`() { + val events = mutableListOf() + var capabilityPresent = false + var recoveryRequests = 0 + + assertFailsWith { + acquireDurableUploadCapability( + persistAcquiring = { + events += "acquiring-false" + capabilityPresent = true + false + }, + takePermission = { events += "permission" }, + persistMetadata = { + events += "ready" + true + }, + releasePermission = { events += "release" }, + removeCapability = { + events += "metadata" + true.also { capabilityPresent = false } + }, + onRollbackRetained = { recoveryRequests += 1 }, + ) + } + + assertEquals(listOf("acquiring-false", "metadata"), events) + assertFalse(capabilityPresent) + assertEquals(1, recoveryRequests) + } + + @Test + fun `only ready capability phase may open or enqueue`() { + assertTrue(isDurableUploadCapabilityReady(CapabilityPhase.Ready)) + assertFalse(isDurableUploadCapabilityReady(CapabilityPhase.Acquiring)) + assertFalse(isDurableUploadCapabilityReady(CapabilityPhase.CleanupPending)) + } + + @Test + fun `cancelled capability delivery publishes then cleans without leaking cleanup failure`() { + val events = mutableListOf() + + val delivered = finalizeDurableUploadCapabilityDelivery( + publishReady = { events += "ready" }, + continuationIsActive = { false }, + cleanupUndelivered = { + events += "cleanup" + error("synthetic retained cleanup") + }, + ) + + assertFalse(delivered) + assertEquals(listOf("ready", "cleanup"), events) + } + + @Test + fun `retained cleanup requests recovery and reports false`() { + var recoveryRequests = 0 + + val released = retainDurableUploadCapabilityCleanup { recoveryRequests += 1 } + + assertFalse(released) + assertEquals(1, recoveryRequests) + } + + @Test + fun `legacy nullable generation stays absent across cleanup serialization`() { + val payload = JSONObject().put("phase", CapabilityPhase.CleanupPending.persistedValue) + + assertEquals(null, payload.optionalStrictString("processGeneration")) + assertFalse(payload.has("processGeneration")) + } + + @Test + fun `grant ownership flag requires a raw boolean and legacy defaults conservatively`() { + val legacy = JSONObject() + val malformed = JSONObject().put("grantPreExisting", "false") + + assertTrue(legacy.optionalStrictBoolean("grantPreExisting") ?: true) + assertFailsWith { + malformed.optionalStrictBoolean("grantPreExisting") + } + } + @Test fun `capability restore preserves cancellation`() { assertFailsWith { From 989b7043486e758f27d4db8cf63698210db614b5 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 14:20:59 +0200 Subject: [PATCH 33/53] fix(uploads): preserve immediate recovery intent --- .../AndroidDurableMultipartUploads.kt | 29 +++++-- .../AndroidDurableUploadScheduling.kt | 82 ++++++++++++++++--- .../AndroidLocalUploadPicker.kt | 65 +++++++++++++-- ...AndroidDurableMultipartUploadPolicyTest.kt | 22 ++--- ...oidDurableUploadEnqueueCancellationTest.kt | 8 +- ...roidDurableUploadSchedulingRecoveryTest.kt | 48 +++++++++++ ...droidLocalUploadCapabilityLifecycleTest.kt | 36 +++++++- 7 files changed, 245 insertions(+), 45 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 6ddcda62b..a257087f7 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -75,6 +75,7 @@ internal class AndroidDurableMultipartUploads( selectionId = file.selectionId, hasActiveSelection = store::hasActiveSelection, releaseSelection = { picker.release(file) }, + markOwnershipCheckPending = { picker.markOwnershipCheckPending(file) }, ) suspend fun runEnqueueWithCancellationCleanup( @@ -186,12 +187,30 @@ internal fun releaseUnownedDurableUploadSelection( selectionId: String, hasActiveSelection: (String) -> Boolean, releaseSelection: () -> Boolean, + markOwnershipCheckPending: () -> Boolean = { false }, ): Boolean = synchronized(AndroidDurableMultipartUploadStore.LOCK) { - val selectionIsDefinitelyInactive = runCatching { - !hasActiveSelection(selectionId) - }.getOrNull() == true - if (!selectionIsDefinitelyInactive) return@synchronized false - runCatching(releaseSelection).getOrDefault(false) + val active = try { + hasActiveSelection(selectionId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + try { + markOwnershipCheckPending() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // The capability remains in its previous fail-closed state. + } + return@synchronized false + } + if (active) return@synchronized false + try { + releaseSelection() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + false + } } internal suspend fun runDurableUploadEnqueueWithCancellationCleanup( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index ec4ec3631..65128ac2b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -4,7 +4,11 @@ import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult import dev.obiente.nextcloudnative.app.DurableUploadState import java.util.UUID import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.selects.select import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -42,6 +46,14 @@ internal data class AndroidDurableUploadSchedulingRecoveryBatch( val workIdsToAwait: List, ) +internal sealed interface AndroidDurableUploadSchedulingRecoveryStep { + data object Completed : AndroidDurableUploadSchedulingRecoveryStep + + data class Interrupted( + val batch: AndroidDurableUploadSchedulingRecoveryBatch, + ) : AndroidDurableUploadSchedulingRecoveryStep +} + internal class AndroidDurableUploadSchedulingRecoverySignal { private val monitor = Any() private val wakeups = Channel(Channel.CONFLATED) @@ -64,14 +76,32 @@ internal class AndroidDurableUploadSchedulingRecoverySignal { suspend fun await(): AndroidDurableUploadSchedulingRecoveryBatch { wakeups.receive() - return synchronized(monitor) { - AndroidDurableUploadSchedulingRecoveryBatch( - immediate = immediatePending, - workIdsToAwait = workIdsToAwait.toList(), - ).also { - immediatePending = false - workIdsToAwait.clear() + return takeBatch() + } + + suspend fun runUntilRequested( + action: suspend () -> Unit, + ): AndroidDurableUploadSchedulingRecoveryStep = coroutineScope { + val running = async(start = CoroutineStart.UNDISPATCHED) { action() } + try { + select { + running.onAwait { AndroidDurableUploadSchedulingRecoveryStep.Completed } + wakeups.onReceive { + AndroidDurableUploadSchedulingRecoveryStep.Interrupted(takeBatch()) + } } + } finally { + running.cancel() + } + } + + private fun takeBatch(): AndroidDurableUploadSchedulingRecoveryBatch = synchronized(monitor) { + AndroidDurableUploadSchedulingRecoveryBatch( + immediate = immediatePending, + workIdsToAwait = workIdsToAwait.toList(), + ).also { + immediatePending = false + workIdsToAwait.clear() } } } @@ -98,13 +128,41 @@ internal suspend fun monitorQueuedDurableUploadScheduling( ) { require(workerFailureFollowUpDelayMillis > 0L) recover() + var immediatePending = false + val workIdsToAwait = linkedSetOf() + + fun addRequests(batch: AndroidDurableUploadSchedulingRecoveryBatch) { + immediatePending = immediatePending || batch.immediate + workIdsToAwait += batch.workIdsToAwait + } + while (true) { - val requests = recoverySignal.await() - if (requests.workIdsToAwait.isNotEmpty()) { - requests.workIdsToAwait.forEach { workId -> awaitWorkStopsRunning(workId) } - wait(workerFailureFollowUpDelayMillis) + if (!immediatePending && workIdsToAwait.isEmpty()) addRequests(recoverySignal.await()) + if (immediatePending) { + immediatePending = false + recover() + continue + } + + val workId = workIdsToAwait.first() + when (val step = recoverySignal.runUntilRequested { awaitWorkStopsRunning(workId) }) { + AndroidDurableUploadSchedulingRecoveryStep.Completed -> Unit + is AndroidDurableUploadSchedulingRecoveryStep.Interrupted -> { + addRequests(step.batch) + continue + } + } + when ( + val step = recoverySignal.runUntilRequested { + wait(workerFailureFollowUpDelayMillis) + } + ) { + AndroidDurableUploadSchedulingRecoveryStep.Completed -> { + workIdsToAwait.remove(workId) + recover() + } + is AndroidDurableUploadSchedulingRecoveryStep.Interrupted -> addRequests(step.batch) } - if (requests.immediate || requests.workIdsToAwait.isNotEmpty()) recover() } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index d5b3852cb..11bb0f8ed 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -224,6 +224,27 @@ internal class AndroidLocalUploadPicker(context: Context) { } } + fun markOwnershipCheckPending(file: LocalUploadFile): Boolean = synchronized(CAPABILITY_LOCK) { + val source = try { + selections[file.selectionId] ?: load(file.selectionId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + return@synchronized retainCapabilityCleanup(file.selectionId) + } + if (source == null || source.file != file) { + return@synchronized retainCapabilityCleanup(file.selectionId) + } + if (source.phase != CapabilityPhase.Ready) { + return@synchronized retainCapabilityCleanup(file.selectionId) + } + val ownershipCheckPending = source.copy(phase = CapabilityPhase.OwnershipCheckPending) + selections[file.selectionId] = ownershipCheckPending + val persisted = durableUploadCleanupStep { persist(ownershipCheckPending) } + requestQueuedDurableUploadSchedulingRecovery() + persisted + } + fun reconcileCapabilities(ownedSelectionIds: Set): Boolean = synchronized(CAPABILITY_LOCK) { val capabilities = try { loadCapabilitySnapshot().toMutableMap() @@ -235,17 +256,34 @@ internal class AndroidLocalUploadPicker(context: Context) { var allRecovered = true capabilities.values .sortedBy { capability -> capability.file.selectionId } - .filter { capability -> - shouldRecoverDurableUploadCapability( + .forEach { capability -> + val selectionId = capability.file.selectionId + val ownedByDurableJob = selectionId in ownedSelectionIds + if ( + shouldRestoreDurableUploadCapabilityAfterOwnershipCheck( + phase = capability.phase, + ownedByDurableJob = ownedByDurableJob, + ) + ) { + val ready = capability.copy( + phase = CapabilityPhase.Ready, + processGeneration = PROCESS_GENERATION, + ) + if (durableUploadCleanupStep { persist(ready) }) { + capabilities[selectionId] = ready + selections[selectionId] = ready + } else { + allRecovered = false + } + return@forEach + } + if (!shouldRecoverDurableUploadCapability( phase = capability.phase, processGeneration = capability.processGeneration, currentProcessGeneration = PROCESS_GENERATION, - ownedByDurableJob = capability.file.selectionId in ownedSelectionIds, - cleanupExplicitlyPending = capability.file.selectionId in PENDING_CLEANUP_SELECTIONS, - ) - } - .forEach { capability -> - val selectionId = capability.file.selectionId + ownedByDurableJob = ownedByDurableJob, + cleanupExplicitlyPending = selectionId in PENDING_CLEANUP_SELECTIONS, + )) return@forEach val ownedElsewhere = capabilities.anyOtherCapabilityOwnsUri( capability.uri, selectionId, @@ -389,7 +427,7 @@ internal class AndroidLocalUploadPicker(context: Context) { } val processGeneration = payload.optionalStrictString("processGeneration") ?.also(::requireSafeProcessGeneration) - val grantPreExisting = payload.optionalStrictBoolean("grantPreExisting") ?: true + val grantPreExisting = persistedDurableUploadGrantPreExisting(payload) return SelectedSource( uri = Uri.parse(payload.getString("uri")), file = file, @@ -453,6 +491,9 @@ internal fun JSONObject.optionalStrictBoolean(key: String): Boolean? { } } +internal fun persistedDurableUploadGrantPreExisting(payload: JSONObject): Boolean = + payload.optionalStrictBoolean("grantPreExisting") ?: false + internal class AndroidLocalUploadCapabilityUnavailableException( message: String, cause: Throwable? = null, @@ -530,6 +571,7 @@ internal fun acquireDurableUploadCapability( internal enum class CapabilityPhase(val persistedValue: String) { Acquiring("acquiring"), Ready("ready"), + OwnershipCheckPending("ownership-check-pending"), CleanupPending("cleanup-pending"); companion object { @@ -551,6 +593,11 @@ internal fun shouldRecoverDurableUploadCapability( processGeneration != currentProcessGeneration ) +internal fun shouldRestoreDurableUploadCapabilityAfterOwnershipCheck( + phase: CapabilityPhase, + ownedByDurableJob: Boolean, +): Boolean = phase == CapabilityPhase.OwnershipCheckPending && ownedByDurableJob + internal fun isDurableUploadCapabilityReady(phase: CapabilityPhase): Boolean = phase == CapabilityPhase.Ready diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 20e72c6be..42c5dda32 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -602,12 +602,12 @@ class AndroidDurableMultipartUploadPolicyTest { } @Test - fun `repeated worker failure wakes wait for ownership and follow up delay`() = runBlocking { + fun `coalesced immediate recovery preempts worker ownership and follow up waits`() = runBlocking { val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() val workId = UUID.randomUUID() val expectedCancellation = CancellationException("recovery owner stopped") - var workManagerOwnsJob = true var recoveryRuns = 0 + var ownershipWaits = 0 var delayRuns = 0 recoverySignal.request() recoverySignal.requestAfterWorkStopsRunning(workId) @@ -616,24 +616,15 @@ class AndroidDurableMultipartUploadPolicyTest { monitorQueuedDurableUploadScheduling( recover = { recoveryRuns += 1 - when (recoveryRuns) { - 1 -> Unit - 2 -> { - workManagerOwnsJob = true - recoverySignal.requestAfterWorkStopsRunning(workId) - } - else -> error("worker recovery bypassed its follow-up delay") - } + if (recoveryRuns == 2) throw expectedCancellation }, awaitWorkStopsRunning = { requestedWorkId -> assertEquals(workId, requestedWorkId) - assertTrue(workManagerOwnsJob) - workManagerOwnsJob = false + ownershipWaits += 1 }, wait = { delayMillis -> assertEquals(ANDROID_DURABLE_UPLOAD_SCHEDULING_FOLLOW_UP_DELAY_MILLIS, delayMillis) - assertFalse(workManagerOwnsJob) - if (++delayRuns == 2) throw expectedCancellation + delayRuns += 1 }, recoverySignal = recoverySignal, ) @@ -641,7 +632,8 @@ class AndroidDurableMultipartUploadPolicyTest { assertTrue(actual === expectedCancellation) assertEquals(2, recoveryRuns) - assertEquals(2, delayRuns) + assertEquals(0, ownershipWaits) + assertEquals(0, delayRuns) } @Test diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadEnqueueCancellationTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadEnqueueCancellationTest.kt index 076bffb43..bc7050901 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadEnqueueCancellationTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadEnqueueCancellationTest.kt @@ -104,8 +104,9 @@ class AndroidDurableUploadEnqueueCancellationTest { } @Test - fun `unreadable ownership state retains the selection`() { + fun `unreadable ownership state persists cleanup intent without releasing`() { var releases = 0 + var ownershipChecksPending = 0 val released = releaseUnownedDurableUploadSelection( selectionId = "selection-123456", @@ -114,10 +115,15 @@ class AndroidDurableUploadEnqueueCancellationTest { releases += 1 true }, + markOwnershipCheckPending = { + ownershipChecksPending += 1 + true + }, ) assertFalse(released) assertEquals(0, releases) + assertEquals(1, ownershipChecksPending) } @Test diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt new file mode 100644 index 000000000..ad6cefe63 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt @@ -0,0 +1,48 @@ +package dev.obiente.nextcloudnative + +import java.util.UUID +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AndroidDurableUploadSchedulingRecoveryTest { + @Test + fun `immediate recovery interrupts an unrelated worker follow up delay`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val workId = UUID.randomUUID() + val delayEntered = CompletableDeferred() + val expected = CancellationException("monitor stopped after immediate recovery") + var recoveryRuns = 0 + + recoverySignal.requestAfterWorkStopsRunning(workId) + val monitoring = async { + assertFailsWith { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + if (recoveryRuns == 2) throw expected + }, + awaitWorkStopsRunning = { requestedWorkId -> + assertEquals(workId, requestedWorkId) + }, + wait = { + delayEntered.complete(Unit) + CompletableDeferred().await() + }, + recoverySignal = recoverySignal, + ) + } + } + + delayEntered.await() + recoverySignal.request() + + assertTrue(monitoring.await() === expected) + assertEquals(2, recoveryRuns) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt index 309b1f423..fad636ed8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt @@ -573,9 +573,35 @@ class AndroidLocalUploadCapabilityLifecycleTest { fun `only ready capability phase may open or enqueue`() { assertTrue(isDurableUploadCapabilityReady(CapabilityPhase.Ready)) assertFalse(isDurableUploadCapabilityReady(CapabilityPhase.Acquiring)) + assertFalse(isDurableUploadCapabilityReady(CapabilityPhase.OwnershipCheckPending)) assertFalse(isDurableUploadCapabilityReady(CapabilityPhase.CleanupPending)) } + @Test + fun `ownership check intent restores only after the durable job is found`() { + assertTrue( + shouldRestoreDurableUploadCapabilityAfterOwnershipCheck( + phase = CapabilityPhase.OwnershipCheckPending, + ownedByDurableJob = true, + ), + ) + assertFalse( + shouldRestoreDurableUploadCapabilityAfterOwnershipCheck( + phase = CapabilityPhase.OwnershipCheckPending, + ownedByDurableJob = false, + ), + ) + assertTrue( + shouldRecoverDurableUploadCapability( + phase = CapabilityPhase.OwnershipCheckPending, + processGeneration = "current-generation", + currentProcessGeneration = "current-generation", + ownedByDurableJob = false, + cleanupExplicitlyPending = false, + ), + ) + } + @Test fun `cancelled capability delivery publishes then cleans without leaking cleanup failure`() { val events = mutableListOf() @@ -612,13 +638,17 @@ class AndroidLocalUploadCapabilityLifecycleTest { } @Test - fun `grant ownership flag requires a raw boolean and legacy defaults conservatively`() { + fun `legacy upload grants remain app owned while new provenance stays explicit`() { val legacy = JSONObject() + val preExisting = JSONObject().put("grantPreExisting", true) + val appOwned = JSONObject().put("grantPreExisting", false) val malformed = JSONObject().put("grantPreExisting", "false") - assertTrue(legacy.optionalStrictBoolean("grantPreExisting") ?: true) + assertFalse(persistedDurableUploadGrantPreExisting(legacy)) + assertTrue(persistedDurableUploadGrantPreExisting(preExisting)) + assertFalse(persistedDurableUploadGrantPreExisting(appOwned)) assertFailsWith { - malformed.optionalStrictBoolean("grantPreExisting") + persistedDurableUploadGrantPreExisting(malformed) } } From ff8222a96df668d8acb0fc587c029eaf629e7951 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 15:29:16 +0200 Subject: [PATCH 34/53] fix(uploads): enforce picker capability limit --- .../nextcloudnative/AndroidLocalUploadPicker.kt | 17 +++++++++++++++++ ...AndroidLocalUploadCapabilityLifecycleTest.kt | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index 11bb0f8ed..06725c696 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -90,6 +90,14 @@ internal class AndroidLocalUploadPicker(context: Context) { val acquisitionFailure = runCatching { synchronized(CAPABILITY_LOCK) { val existing = loadCapabilitySnapshot() + check( + durableUploadCapabilityHasCapacity( + trackedCapabilityCount = existing.size, + maximumTrackedCapabilities = MAX_TRACKED_CAPABILITIES, + ), + ) { + "Too many picker capabilities are tracked." + } check( !durableUploadCapabilityPermissionOwnedByAnother( capabilities = existing, @@ -494,6 +502,15 @@ internal fun JSONObject.optionalStrictBoolean(key: String): Boolean? { internal fun persistedDurableUploadGrantPreExisting(payload: JSONObject): Boolean = payload.optionalStrictBoolean("grantPreExisting") ?: false +internal fun durableUploadCapabilityHasCapacity( + trackedCapabilityCount: Int, + maximumTrackedCapabilities: Int, +): Boolean { + require(trackedCapabilityCount >= 0) + require(maximumTrackedCapabilities > 0) + return trackedCapabilityCount < maximumTrackedCapabilities +} + internal class AndroidLocalUploadCapabilityUnavailableException( message: String, cause: Throwable? = null, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt index fad636ed8..d4e30df35 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt @@ -652,6 +652,12 @@ class AndroidLocalUploadCapabilityLifecycleTest { } } + @Test + fun `capability acquisition reserves space before reaching the persisted limit`() { + assertTrue(durableUploadCapabilityHasCapacity(trackedCapabilityCount = 63, maximumTrackedCapabilities = 64)) + assertFalse(durableUploadCapabilityHasCapacity(trackedCapabilityCount = 64, maximumTrackedCapabilities = 64)) + } + @Test fun `capability restore preserves cancellation`() { assertFailsWith { From eba85360f18934eb8f36581379f52b9182252c58 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 15:59:32 +0200 Subject: [PATCH 35/53] fix(uploads): consume scheduling wakeups atomically --- .../AndroidDurableUploadScheduling.kt | 14 +++++++-- ...roidDurableUploadSchedulingRecoveryTest.kt | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index 65128ac2b..fbb504acb 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -54,7 +54,9 @@ internal sealed interface AndroidDurableUploadSchedulingRecoveryStep { ) : AndroidDurableUploadSchedulingRecoveryStep } -internal class AndroidDurableUploadSchedulingRecoverySignal { +internal class AndroidDurableUploadSchedulingRecoverySignal( + private val beforeBatchClaim: suspend () -> Unit = {}, +) { private val monitor = Any() private val wakeups = Channel(Channel.CONFLATED) private var immediatePending = false @@ -63,19 +65,20 @@ internal class AndroidDurableUploadSchedulingRecoverySignal { fun request() { synchronized(monitor) { immediatePending = true + wakeups.trySend(Unit) } - wakeups.trySend(Unit) } fun requestAfterWorkStopsRunning(workId: UUID) { synchronized(monitor) { workIdsToAwait += workId + wakeups.trySend(Unit) } - wakeups.trySend(Unit) } suspend fun await(): AndroidDurableUploadSchedulingRecoveryBatch { wakeups.receive() + beforeBatchClaim() return takeBatch() } @@ -87,6 +90,7 @@ internal class AndroidDurableUploadSchedulingRecoverySignal { select { running.onAwait { AndroidDurableUploadSchedulingRecoveryStep.Completed } wakeups.onReceive { + beforeBatchClaim() AndroidDurableUploadSchedulingRecoveryStep.Interrupted(takeBatch()) } } @@ -96,6 +100,9 @@ internal class AndroidDurableUploadSchedulingRecoverySignal { } private fun takeBatch(): AndroidDurableUploadSchedulingRecoveryBatch = synchronized(monitor) { + while (wakeups.tryReceive().isSuccess) { + // Every request represented by a drained token is included in the pending state below. + } AndroidDurableUploadSchedulingRecoveryBatch( immediate = immediatePending, workIdsToAwait = workIdsToAwait.toList(), @@ -138,6 +145,7 @@ internal suspend fun monitorQueuedDurableUploadScheduling( while (true) { if (!immediatePending && workIdsToAwait.isEmpty()) addRequests(recoverySignal.await()) + if (!immediatePending && workIdsToAwait.isEmpty()) continue if (immediatePending) { immediatePending = false recover() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt index ad6cefe63..dadf4d0fe 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt @@ -5,12 +5,43 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue class AndroidDurableUploadSchedulingRecoveryTest { + @Test + fun `request crossing wakeup consumption is claimed without a stale token`() = runBlocking { + val wakeupConsumed = CompletableDeferred() + val releaseBatchClaim = CompletableDeferred() + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal { + wakeupConsumed.complete(Unit) + releaseBatchClaim.await() + } + val workId = UUID.randomUUID() + recoverySignal.requestAfterWorkStopsRunning(workId) + val firstBatch = async { recoverySignal.await() } + wakeupConsumed.await() + + recoverySignal.request() + releaseBatchClaim.complete(Unit) + + assertEquals( + AndroidDurableUploadSchedulingRecoveryBatch( + immediate = true, + workIdsToAwait = listOf(workId), + ), + firstBatch.await(), + ) + val nextBatch = async { recoverySignal.await() } + yield() + assertFalse(nextBatch.isCompleted) + nextBatch.cancel() + } + @Test fun `immediate recovery interrupts an unrelated worker follow up delay`() = runBlocking { val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() From 29b63fd3b5cc43544ff71ad1e78bf5adc0256e70 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 00:56:33 +0200 Subject: [PATCH 36/53] fix(uploads): defer capability metadata read failures --- .../AndroidDurableUploadWorker.kt | 2 ++ .../AndroidLocalUploadPicker.kt | 7 +++- ...AndroidDurableUploadSourcePreflightTest.kt | 32 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index 3a4c4c862..7255efe89 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -356,6 +356,8 @@ internal suspend fun processQueuedDurableUploadSource( openSource() } catch (cancelled: CancellationException) { throw cancelled + } catch (failure: AndroidLocalUploadCapabilityReadException) { + return onProviderUnavailable(failure) } catch (_: AndroidLocalUploadCapabilityUnavailableException) { return onCapabilityUnavailable() } catch (_: FileNotFoundException) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index 06725c696..86dd29b26 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -398,7 +398,7 @@ internal class AndroidLocalUploadPicker(context: Context) { } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { - throw AndroidLocalUploadCapabilityUnavailableException( + throw AndroidLocalUploadCapabilityReadException( "The local file selection metadata could not be read.", failure, ) @@ -516,6 +516,11 @@ internal class AndroidLocalUploadCapabilityUnavailableException( cause: Throwable? = null, ) : IllegalStateException(message, cause) +internal class AndroidLocalUploadCapabilityReadException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) + internal fun resumeLocalUploadSelectionResult( continuation: CancellableContinuation, result: LocalUploadSelectionResult, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt index b29dd4836..d6e767290 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt @@ -98,6 +98,38 @@ class AndroidDurableUploadSourcePreflightTest { assertEquals(0, starts) } + @Test + fun `transient capability metadata failure leaves queued capability retained`() = runBlocking { + var queued = true + var retained = true + var starts = 0 + + val result = processQueuedDurableUploadSource( + requireCapability = { + throw AndroidLocalUploadCapabilityReadException( + "credential store unavailable", + IOException("keystore restarting"), + ) + }, + openSource = { starts += 1 }, + onCapabilityUnavailable = { + queued = false + retained = false + "failed" + }, + onProviderUnavailable = { "retried" }, + onReady = { + starts += 1 + "started" + }, + ) + + assertEquals("retried", result) + assertTrue(queued) + assertTrue(retained) + assertEquals(0, starts) + } + @Test fun `later provider success starts exactly once`() = runBlocking { var providerAttempts = 0 From 96e90b068e80078bd3c9e625813f7e246ba0f2a1 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 01:16:29 +0200 Subject: [PATCH 37/53] fix(uploads): reject malformed capability metadata --- .../AndroidLocalUploadCapabilityRead.kt | 43 +++++++++++ .../AndroidLocalUploadPicker.kt | 77 +++++++++---------- ...AndroidDurableUploadSourcePreflightTest.kt | 23 ++++++ 3 files changed, 102 insertions(+), 41 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt new file mode 100644 index 000000000..3c2d6901a --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt @@ -0,0 +1,43 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CancellationException + +internal class AndroidLocalUploadCapabilityUnavailableException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) + +internal class AndroidLocalUploadCapabilityReadException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) + +internal class AndroidLocalUploadCapabilityMalformedException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) + +internal inline fun readAndroidLocalUploadCapabilityPreference(read: () -> String?): String? = try { + read() +} catch (failure: ClassCastException) { + throw AndroidLocalUploadCapabilityMalformedException( + "The local file selection metadata has an invalid stored type.", + failure, + ) +} + +internal inline fun readAndroidLocalUploadCapability(load: () -> Result): Result = try { + load() +} catch (cancelled: CancellationException) { + throw cancelled +} catch (failure: AndroidLocalUploadCapabilityMalformedException) { + throw AndroidLocalUploadCapabilityUnavailableException( + "The local file selection metadata is invalid.", + failure, + ) +} catch (failure: Exception) { + throw AndroidLocalUploadCapabilityReadException( + "The local file selection metadata could not be read.", + failure, + ) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index 86dd29b26..43b808b60 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -393,15 +393,8 @@ internal class AndroidLocalUploadPicker(context: Context) { file: LocalUploadFile, useCachedSource: Boolean, ): SelectedSource { - val source = try { + val source = readAndroidLocalUploadCapability { selections[file.selectionId].takeIf { useCachedSource } ?: load(file.selectionId) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (failure: Exception) { - throw AndroidLocalUploadCapabilityReadException( - "The local file selection metadata could not be read.", - failure, - ) } ?: throw AndroidLocalUploadCapabilityUnavailableException( "The local file selection was not durably saved.", ) @@ -419,30 +412,42 @@ internal class AndroidLocalUploadPicker(context: Context) { } private fun load(selectionId: String): SelectedSource? { - val encrypted = preferences.getString(preferenceKey(selectionId), null) ?: return null - val payload = JSONObject(cipher.decrypt(encrypted)) - val file = localUploadFile( - selectionId = payload.getString("selectionId"), - displayName = payload.getString("displayName"), - mimeType = if (payload.isNull("mimeType")) null else payload.getString("mimeType"), - sizeBytes = if (payload.isNull("sizeBytes")) null else payload.getLong("sizeBytes"), - ) - require(file.selectionId == selectionId) { "The persisted upload capability changed." } - val phase = if (payload.has("phase")) { - CapabilityPhase.fromPersistedValue(payload.requireStrictString("phase")) - } else { - CapabilityPhase.Ready + val encrypted = readAndroidLocalUploadCapabilityPreference { + preferences.getString(preferenceKey(selectionId), null) + } ?: return null + val decrypted = cipher.decrypt(encrypted) + return try { + val payload = JSONObject(decrypted) + val file = localUploadFile( + selectionId = payload.getString("selectionId"), + displayName = payload.getString("displayName"), + mimeType = if (payload.isNull("mimeType")) null else payload.getString("mimeType"), + sizeBytes = if (payload.isNull("sizeBytes")) null else payload.getLong("sizeBytes"), + ) + require(file.selectionId == selectionId) { "The persisted upload capability changed." } + val phase = if (payload.has("phase")) { + CapabilityPhase.fromPersistedValue(payload.requireStrictString("phase")) + } else { + CapabilityPhase.Ready + } + val processGeneration = payload.optionalStrictString("processGeneration") + ?.also(::requireSafeProcessGeneration) + val grantPreExisting = persistedDurableUploadGrantPreExisting(payload) + SelectedSource( + uri = Uri.parse(payload.getString("uri")), + file = file, + phase = phase, + processGeneration = processGeneration, + grantPreExisting = grantPreExisting, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + throw AndroidLocalUploadCapabilityMalformedException( + "The local file selection metadata is invalid.", + failure, + ) } - val processGeneration = payload.optionalStrictString("processGeneration") - ?.also(::requireSafeProcessGeneration) - val grantPreExisting = persistedDurableUploadGrantPreExisting(payload) - return SelectedSource( - uri = Uri.parse(payload.getString("uri")), - file = file, - phase = phase, - processGeneration = processGeneration, - grantPreExisting = grantPreExisting, - ) } private fun preferenceKey(selectionId: String): String = "$PREFERENCE_PREFIX$selectionId" @@ -511,16 +516,6 @@ internal fun durableUploadCapabilityHasCapacity( return trackedCapabilityCount < maximumTrackedCapabilities } -internal class AndroidLocalUploadCapabilityUnavailableException( - message: String, - cause: Throwable? = null, -) : IllegalStateException(message, cause) - -internal class AndroidLocalUploadCapabilityReadException( - message: String, - cause: Throwable? = null, -) : IllegalStateException(message, cause) - internal fun resumeLocalUploadSelectionResult( continuation: CancellableContinuation, result: LocalUploadSelectionResult, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt index d6e767290..212374139 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt @@ -130,6 +130,29 @@ class AndroidDurableUploadSourcePreflightTest { assertEquals(0, starts) } + @Test + fun `malformed capability metadata is terminal while storage failures stay retryable`() { + assertFailsWith { + readAndroidLocalUploadCapability { + throw AndroidLocalUploadCapabilityMalformedException("invalid JSON") + } + } + assertFailsWith { + readAndroidLocalUploadCapability { + readAndroidLocalUploadCapabilityPreference { + throw ClassCastException("not a string") + } + } + } + assertFailsWith { + readAndroidLocalUploadCapability { + readAndroidLocalUploadCapabilityPreference { + throw IOException("preferences unavailable") + } + } + } + } + @Test fun `later provider success starts exactly once`() = runBlocking { var providerAttempts = 0 From 9b60b710dcfa03336192bd580678e5fab8f361cd Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 02:17:40 +0200 Subject: [PATCH 38/53] fix(uploads): isolate malformed picker capabilities --- .../AndroidLocalUploadCapabilityRead.kt | 2 + .../AndroidLocalUploadCapabilityRecovery.kt | 183 ++++++++++++++++ .../AndroidLocalUploadPicker.kt | 205 ++++++++---------- ...droidLocalUploadCapabilityLifecycleTest.kt | 95 ++++++++ 4 files changed, 374 insertions(+), 111 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt index 3c2d6901a..e85c394be 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt @@ -15,6 +15,8 @@ internal class AndroidLocalUploadCapabilityReadException( internal class AndroidLocalUploadCapabilityMalformedException( message: String, cause: Throwable? = null, + val cleanupPermissionIdentity: String? = null, + val grantPreExisting: Boolean? = null, ) : IllegalStateException(message, cause) internal inline fun readAndroidLocalUploadCapabilityPreference(read: () -> String?): String? = try { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt new file mode 100644 index 000000000..bfd862d95 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt @@ -0,0 +1,183 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CancellationException + +/** + * Revokes the URI grant before synchronously deleting capability metadata. Android may throw when + * the grant is already absent, so an exception is accepted only after absence is verified. + */ +internal fun releaseDurableUploadCapability( + releasePermission: () -> Unit, + removeMetadata: () -> Boolean, + isPermissionAbsent: () -> Boolean = { false }, +): Boolean { + if (!releaseDurableUploadPermission(releasePermission, isPermissionAbsent)) return false + return durableUploadCleanupStep(removeMetadata) +} + +internal fun releaseDurableUploadPermission( + releasePermission: () -> Unit, + isPermissionAbsent: () -> Boolean, +): Boolean = try { + releasePermission() + true +} catch (cancelled: CancellationException) { + throw cancelled +} catch (_: Exception) { + try { + isPermissionAbsent() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + false + } +} + +internal fun releaseStoredDurableUploadCapability( + cachedCapability: Capability?, + loadCapability: () -> Capability?, + releasePermission: (Capability) -> Unit, + removeMetadata: () -> Boolean, + otherCapabilityOwnsPermission: (Capability) -> Boolean = { false }, + isPermissionAbsent: (Capability) -> Boolean = { false }, +): Boolean { + val capability = cachedCapability ?: try { + loadCapability() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + return false + } + return releaseDurableUploadCapability( + releasePermission = { + capability?.let { stored -> + if (!otherCapabilityOwnsPermission(stored)) releasePermission(stored) + } + }, + removeMetadata = removeMetadata, + isPermissionAbsent = { capability == null || isPermissionAbsent(capability) }, + ) +} + +internal fun mergeDurableUploadCapabilities( + cachedCapabilities: Map, + storedSelectionIds: Iterable, + loadStoredCapability: (String) -> Capability?, +): Map = buildMap { + putAll(cachedCapabilities) + storedSelectionIds.forEach { selectionId -> + if (selectionId !in this) { + put( + selectionId, + checkNotNull(loadStoredCapability(selectionId)) { + "The picker capability disappeared during recovery." + }, + ) + } + } +} + +internal data class MalformedDurableUploadCapability( + val selectionId: String, + val cleanupPermissionIdentity: String?, + val grantPreExisting: Boolean?, +) + +internal data class DurableUploadCapabilitySnapshot( + val capabilities: Map, + val malformedCapabilities: Map, +) { + val trackedCapabilityCount: Int + get() = (capabilities.keys + malformedCapabilities.keys).size +} + +internal fun loadDurableUploadCapabilitySnapshot( + cachedCapabilities: Map, + storedSelectionIds: Iterable, + loadStoredCapability: (String) -> Capability?, +): DurableUploadCapabilitySnapshot { + val capabilities = cachedCapabilities.toMutableMap() + val malformed = linkedMapOf() + storedSelectionIds.forEach { selectionId -> + if (selectionId in capabilities) return@forEach + val stored = try { + checkNotNull(loadStoredCapability(selectionId)) { + "The picker capability disappeared during recovery." + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: AndroidLocalUploadCapabilityMalformedException) { + malformed[selectionId] = MalformedDurableUploadCapability( + selectionId, + failure.cleanupPermissionIdentity, + failure.grantPreExisting, + ) + return@forEach + } + capabilities[selectionId] = stored + } + return DurableUploadCapabilitySnapshot(capabilities.toMap(), malformed.toMap()) +} + +internal fun recoverMalformedDurableUploadCapability( + capability: MalformedDurableUploadCapability, + permission: Permission?, + ownedByAnotherCapability: Boolean, + releasePermission: (Permission) -> Unit, + isPermissionAbsent: (Permission) -> Boolean, + removeMetadata: (String) -> Boolean, +): Boolean { + permission ?: return false + val grantPreExisting = capability.grantPreExisting + if (grantPreExisting == null) { + val permissionCanRemain = ownedByAnotherCapability || try { + isPermissionAbsent(permission) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + false + } + return permissionCanRemain && durableUploadCleanupStep { + removeMetadata(capability.selectionId) + } + } + return releaseDurableUploadCapability( + releasePermission = { + if (shouldReleaseDurableUploadPermission(grantPreExisting, ownedByAnotherCapability)) { + releasePermission(permission) + } + }, + isPermissionAbsent = { + grantPreExisting || ownedByAnotherCapability || isPermissionAbsent(permission) + }, + removeMetadata = { removeMetadata(capability.selectionId) }, + ) +} + +internal fun durableUploadCapabilityPermissionOwnedByAnother( + capabilities: Map, + targetSelectionId: String, + targetPermission: Permission, + permissionOf: (Capability) -> Permission, + samePermission: (Permission, Permission) -> Boolean, +): Boolean = capabilities.any { (selectionId, capability) -> + selectionId != targetSelectionId && samePermission(targetPermission, permissionOf(capability)) +} + +internal fun shouldReleaseDurableUploadPermission( + grantPreExisting: Boolean, + ownedByAnotherCapability: Boolean, +): Boolean = !grantPreExisting && !ownedByAnotherCapability + +internal fun retainDurableUploadCapabilityCleanup(onCleanupRetained: () -> Unit): Boolean { + runCatching(onCleanupRetained) + return false +} + +internal fun durableUploadCleanupStep(action: () -> Boolean): Boolean = try { + action() +} catch (cancelled: CancellationException) { + throw cancelled +} catch (_: Exception) { + false +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index 43b808b60..579a96837 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -89,10 +89,11 @@ internal class AndroidLocalUploadPicker(context: Context) { var cancelledAfterAcquire = false val acquisitionFailure = runCatching { synchronized(CAPABILITY_LOCK) { - val existing = loadCapabilitySnapshot() + val snapshot = loadCapabilitySnapshot() + val existing = snapshot.capabilities check( durableUploadCapabilityHasCapacity( - trackedCapabilityCount = existing.size, + trackedCapabilityCount = snapshot.trackedCapabilityCount, maximumTrackedCapabilities = MAX_TRACKED_CAPABILITIES, ), ) { @@ -186,6 +187,8 @@ internal class AndroidLocalUploadPicker(context: Context) { selections[file.selectionId] ?: load(file.selectionId) } catch (cancelled: CancellationException) { throw cancelled + } catch (malformed: AndroidLocalUploadCapabilityMalformedException) { + return@synchronized releaseMalformedCapability(file.selectionId, malformed) } catch (_: Exception) { return@synchronized retainCapabilityCleanup(file.selectionId) } @@ -203,7 +206,7 @@ internal class AndroidLocalUploadPicker(context: Context) { return@synchronized false } val capabilities = try { - loadCapabilitySnapshot() + loadCapabilitySnapshot().capabilities } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { @@ -254,14 +257,26 @@ internal class AndroidLocalUploadPicker(context: Context) { } fun reconcileCapabilities(ownedSelectionIds: Set): Boolean = synchronized(CAPABILITY_LOCK) { - val capabilities = try { - loadCapabilitySnapshot().toMutableMap() + val snapshot = try { + loadCapabilitySnapshot() } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { return@synchronized false } + val capabilities = snapshot.capabilities.toMutableMap() var allRecovered = true + snapshot.malformedCapabilities.values + .sortedBy(MalformedDurableUploadCapability::selectionId) + .forEach { malformed -> + val recovered = recoverMalformedCapability(malformed, capabilities) + if (recovered) { + selections.remove(malformed.selectionId) + PENDING_CLEANUP_SELECTIONS.remove(malformed.selectionId) + } else { + allRecovered = false + } + } capabilities.values .sortedBy { capability -> capability.file.selectionId } .forEach { capability -> @@ -358,7 +373,7 @@ internal class AndroidLocalUploadPicker(context: Context) { it.uri == uri && it.isReadPermission } - private fun loadCapabilitySnapshot(): Map { + private fun loadCapabilitySnapshot(): DurableUploadCapabilitySnapshot { val storedSelectionIds = preferences.all.keys .asSequence() .filter { key -> key.startsWith(PREFERENCE_PREFIX) } @@ -367,11 +382,63 @@ internal class AndroidLocalUploadPicker(context: Context) { require(storedSelectionIds.size <= MAX_TRACKED_CAPABILITIES) { "Too many picker capabilities are tracked." } - return mergeDurableUploadCapabilities( + val snapshot = loadDurableUploadCapabilitySnapshot( cachedCapabilities = selections.toMap(), storedSelectionIds = storedSelectionIds, loadStoredCapability = ::load, ) + if (snapshot.malformedCapabilities.isNotEmpty()) { + PENDING_CLEANUP_SELECTIONS += snapshot.malformedCapabilities.keys + requestQueuedDurableUploadSchedulingRecovery() + } + return snapshot + } + + private fun releaseMalformedCapability( + selectionId: String, + malformed: AndroidLocalUploadCapabilityMalformedException, + ): Boolean { + PENDING_CLEANUP_SELECTIONS += selectionId + val snapshot = try { + loadCapabilitySnapshot() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + return retainCapabilityCleanup(selectionId) + } + val isolated = snapshot.malformedCapabilities[selectionId] + ?: MalformedDurableUploadCapability( + selectionId, + malformed.cleanupPermissionIdentity, + malformed.grantPreExisting, + ) + val recovered = recoverMalformedCapability(isolated, snapshot.capabilities) + if (recovered) { + selections.remove(selectionId) + PENDING_CLEANUP_SELECTIONS.remove(selectionId) + } else { + requestQueuedDurableUploadSchedulingRecovery() + } + return recovered + } + + private fun recoverMalformedCapability( + malformed: MalformedDurableUploadCapability, + capabilities: Map, + ): Boolean { + val permission = malformed.cleanupPermissionIdentity?.let(Uri::parse) + val ownedElsewhere = permission != null && capabilities.anyOtherCapabilityOwnsUri( + permission, + malformed.selectionId, + ) + return recoverMalformedDurableUploadCapability( + capability = malformed, + permission = permission, + ownedByAnotherCapability = ownedElsewhere, + releasePermission = ::releasePermission, + isPermissionAbsent = ::exactReadPermissionIsAbsent, + removeMetadata = ::removeMetadata, + ) } private fun Map.anyOtherCapabilityOwnsUri( @@ -416,8 +483,25 @@ internal class AndroidLocalUploadPicker(context: Context) { preferences.getString(preferenceKey(selectionId), null) } ?: return null val decrypted = cipher.decrypt(encrypted) + val payload = try { + JSONObject(decrypted) + } catch (failure: Exception) { + throw AndroidLocalUploadCapabilityMalformedException( + "The local file selection metadata is invalid.", + failure, + ) + } + val cleanupPermissionIdentity = try { + payload.requireStrictString("uri") + } catch (_: Exception) { + null + } + val cleanupGrantPreExisting = try { + persistedDurableUploadGrantPreExisting(payload) + } catch (_: Exception) { + null + } return try { - val payload = JSONObject(decrypted) val file = localUploadFile( selectionId = payload.getString("selectionId"), displayName = payload.getString("displayName"), @@ -446,6 +530,8 @@ internal class AndroidLocalUploadPicker(context: Context) { throw AndroidLocalUploadCapabilityMalformedException( "The local file selection metadata is invalid.", failure, + cleanupPermissionIdentity, + cleanupGrantPreExisting, ) } } @@ -629,109 +715,6 @@ internal fun finalizeDurableUploadCapabilityDelivery( return false } -/** - * Revokes the URI grant before synchronously deleting capability metadata. Android may throw when - * the grant is already absent, so an exception is accepted only after absence is verified. - */ -internal fun releaseDurableUploadCapability( - releasePermission: () -> Unit, - removeMetadata: () -> Boolean, - isPermissionAbsent: () -> Boolean = { false }, -): Boolean { - if (!releaseDurableUploadPermission(releasePermission, isPermissionAbsent)) return false - return durableUploadCleanupStep(removeMetadata) -} - -internal fun releaseDurableUploadPermission( - releasePermission: () -> Unit, - isPermissionAbsent: () -> Boolean, -): Boolean = try { - releasePermission() - true -} catch (cancelled: CancellationException) { - throw cancelled -} catch (_: Exception) { - try { - isPermissionAbsent() - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - false - } -} - -internal fun releaseStoredDurableUploadCapability( - cachedCapability: Capability?, - loadCapability: () -> Capability?, - releasePermission: (Capability) -> Unit, - removeMetadata: () -> Boolean, - otherCapabilityOwnsPermission: (Capability) -> Boolean = { false }, - isPermissionAbsent: (Capability) -> Boolean = { false }, -): Boolean { - val capability = cachedCapability ?: try { - loadCapability() - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - return false - } - return releaseDurableUploadCapability( - releasePermission = { - capability?.let { stored -> - if (!otherCapabilityOwnsPermission(stored)) releasePermission(stored) - } - }, - removeMetadata = removeMetadata, - isPermissionAbsent = { capability == null || isPermissionAbsent(capability) }, - ) -} - -internal fun mergeDurableUploadCapabilities( - cachedCapabilities: Map, - storedSelectionIds: Iterable, - loadStoredCapability: (String) -> Capability?, -): Map = buildMap { - putAll(cachedCapabilities) - storedSelectionIds.forEach { selectionId -> - if (selectionId !in this) { - put( - selectionId, - checkNotNull(loadStoredCapability(selectionId)) { - "The picker capability disappeared during recovery." - }, - ) - } - } -} - -internal fun durableUploadCapabilityPermissionOwnedByAnother( - capabilities: Map, - targetSelectionId: String, - targetPermission: Permission, - permissionOf: (Capability) -> Permission, - samePermission: (Permission, Permission) -> Boolean, -): Boolean = capabilities.any { (selectionId, capability) -> - selectionId != targetSelectionId && samePermission(targetPermission, permissionOf(capability)) -} - -internal fun shouldReleaseDurableUploadPermission( - grantPreExisting: Boolean, - ownedByAnotherCapability: Boolean, -): Boolean = !grantPreExisting && !ownedByAnotherCapability - -internal fun retainDurableUploadCapabilityCleanup(onCleanupRetained: () -> Unit): Boolean { - runCatching(onCleanupRetained) - return false -} - -private fun durableUploadCleanupStep(action: () -> Boolean): Boolean = try { - action() -} catch (cancelled: CancellationException) { - throw cancelled -} catch (_: Exception) { - false -} - private data class AndroidUploadMetadata( val displayName: String, val sizeBytes: Long?, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt index d4e30df35..6f2a2330e 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt @@ -371,6 +371,101 @@ class AndroidLocalUploadCapabilityLifecycleTest { ) } + @Test + fun `malformed persisted peer does not hide valid capabilities or block a new selection`() { + val snapshot = loadDurableUploadCapabilitySnapshot( + cachedCapabilities = emptyMap(), + storedSelectionIds = listOf("selection-malformed", "selection-valid"), + loadStoredCapability = { selectionId -> + if (selectionId == "selection-malformed") { + throw AndroidLocalUploadCapabilityMalformedException( + message = "invalid phase", + cleanupPermissionIdentity = "content://synthetic/malformed", + grantPreExisting = false, + ) + } + "content://synthetic/valid" + }, + ) + + assertEquals(mapOf("selection-valid" to "content://synthetic/valid"), snapshot.capabilities) + assertEquals(setOf("selection-malformed"), snapshot.malformedCapabilities.keys) + assertEquals(2, snapshot.trackedCapabilityCount) + assertTrue( + durableUploadCapabilityHasCapacity( + trackedCapabilityCount = snapshot.trackedCapabilityCount, + maximumTrackedCapabilities = 64, + ), + ) + assertEquals( + "content://synthetic/new", + (snapshot.capabilities + ("selection-new" to "content://synthetic/new"))["selection-new"], + ) + } + + @Test + fun `snapshot isolation preserves transient capability read failures`() { + assertFailsWith { + loadDurableUploadCapabilitySnapshot( + cachedCapabilities = emptyMap(), + storedSelectionIds = listOf("selection-unreadable"), + loadStoredCapability = { throw GeneralSecurityException("synthetic decryption failure") }, + ) + } + } + + @Test + fun `startup recovery releases a malformed app owned capability before deleting its row`() { + val events = mutableListOf() + val capability = MalformedDurableUploadCapability( + selectionId = "selection-malformed", + cleanupPermissionIdentity = "content://synthetic/malformed", + grantPreExisting = false, + ) + + val recovered = recoverMalformedDurableUploadCapability( + capability = capability, + permission = checkNotNull(capability.cleanupPermissionIdentity), + ownedByAnotherCapability = false, + releasePermission = { events += "release:$it" }, + isPermissionAbsent = { false }, + removeMetadata = { + events += "remove:$it" + true + }, + ) + + assertTrue(recovered) + assertEquals( + listOf( + "release:content://synthetic/malformed", + "remove:selection-malformed", + ), + events, + ) + } + + @Test + fun `malformed capability with unknown grant ownership remains durable while permission exists`() { + var metadataPresent = true + + val recovered = recoverMalformedDurableUploadCapability( + capability = MalformedDurableUploadCapability( + selectionId = "selection-malformed", + cleanupPermissionIdentity = "content://synthetic/malformed", + grantPreExisting = null, + ), + permission = "content://synthetic/malformed", + ownedByAnotherCapability = false, + releasePermission = { error("unknown ownership must not release") }, + isPermissionAbsent = { false }, + removeMetadata = { true.also { metadataPresent = false } }, + ) + + assertFalse(recovered) + assertTrue(metadataPresent) + } + @Test fun `unreadable persisted duplicate ownership fails closed`() { assertFailsWith { From b20aceb97d3dfafa3590e4ae39b31a22a72dbe2a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 02:52:20 +0200 Subject: [PATCH 39/53] fix(uploads): protect owned malformed capabilities --- .../AndroidDurableMultipartUploads.kt | 18 +- .../AndroidLocalUploadCapabilityRecovery.kt | 233 ++++++++++- ...ndroidLocalUploadCapabilityRecoveryScan.kt | 54 +++ .../AndroidLocalUploadPicker.kt | 371 ++++++++++-------- .../AndroidDurableUploadCleanupPruningTest.kt | 35 ++ ...droidLocalUploadCapabilityLifecycleTest.kt | 4 +- ...calUploadCapabilityOverflowRecoveryTest.kt | 323 +++++++++++++++ 7 files changed, 855 insertions(+), 183 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecoveryScan.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index a257087f7..0028e5bb3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -118,14 +118,7 @@ internal class AndroidDurableMultipartUploads( suspend fun reconcileQueuedUploads(allowQueuedScheduling: Boolean = true): Boolean { val (jobs, capabilitiesRecovered) = synchronized(AndroidDurableMultipartUploadStore.LOCK) { val snapshot = store.list() - val retainedSelectionIds = snapshot.asSequence() - .filter { job -> - job.state == DurableUploadState.Queued || - job.state == DurableUploadState.Uploading || - job.capabilityCleanupPending - } - .map { job -> job.request.file.selectionId } - .toSet() + val retainedSelectionIds = durableUploadCapabilityRetainedSelectionIds(snapshot) snapshot to picker.reconcileCapabilities(retainedSelectionIds) } val uploadsRecovered = reconcileQueuedDurableUploads( @@ -279,6 +272,15 @@ internal data class AndroidDurableMultipartUploadJob( ) } +internal fun durableUploadCapabilityRetainedSelectionIds( + jobs: Iterable, +): Set = jobs.asSequence() + .filter { job -> + job.state == DurableUploadState.Queued || job.state == DurableUploadState.Uploading + } + .map { job -> job.request.file.selectionId } + .toSet() + internal data class AndroidDurableUploadResource( val feature: String, val boardId: String?, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt index bfd862d95..5cd28f7bc 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt @@ -86,19 +86,27 @@ internal data class MalformedDurableUploadCapability( internal data class DurableUploadCapabilitySnapshot( val capabilities: Map, val malformedCapabilities: Map, + private val storedCapabilityCount: Int? = null, + val scanComplete: Boolean = true, ) { val trackedCapabilityCount: Int - get() = (capabilities.keys + malformedCapabilities.keys).size + get() = storedCapabilityCount ?: (capabilities.keys + malformedCapabilities.keys).size } internal fun loadDurableUploadCapabilitySnapshot( cachedCapabilities: Map, storedSelectionIds: Iterable, + maximumRecoverableCapabilities: Int = Int.MAX_VALUE, loadStoredCapability: (String) -> Capability?, ): DurableUploadCapabilitySnapshot { + require(maximumRecoverableCapabilities > 0) + val storedIds = storedSelectionIds.toList() + require((cachedCapabilities.keys + storedIds).size <= maximumRecoverableCapabilities) { + "Too many picker capabilities are pending bounded recovery." + } val capabilities = cachedCapabilities.toMutableMap() val malformed = linkedMapOf() - storedSelectionIds.forEach { selectionId -> + storedIds.forEach { selectionId -> if (selectionId in capabilities) return@forEach val stored = try { checkNotNull(loadStoredCapability(selectionId)) { @@ -119,36 +127,131 @@ internal fun loadDurableUploadCapabilitySnapshot( return DurableUploadCapabilitySnapshot(capabilities.toMap(), malformed.toMap()) } +internal fun malformedDurableUploadCapabilitiesForRecovery( + capabilities: Map, + ownedSelectionIds: Set, +): List = capabilities.values + .filterNot { capability -> capability.selectionId in ownedSelectionIds } + .sortedWith( + compareBy { capability -> + when (capability.grantPreExisting) { + true -> 0 + null -> 1 + false -> 2 + } + }.thenBy(MalformedDurableUploadCapability::selectionId), + ) + +internal enum class DurableUploadPermissionPeerProtection { + None, + RetainedAppOwnedGrant, + Ambiguous, +} + +internal data class DurableUploadPermissionPeer( + val selectionId: String, + val permission: Permission?, + val grantPreExisting: Boolean?, +) + +internal fun durableUploadPermissionPeerProtection( + peers: Iterable>, + targetSelectionId: String, + targetPermission: Permission, + samePermission: (Permission, Permission) -> Boolean, +): DurableUploadPermissionPeerProtection { + var ambiguous = false + peers.forEach { peer -> + if (peer.selectionId == targetSelectionId) return@forEach + val permission = peer.permission + if (permission == null) { + ambiguous = true + } else if (samePermission(targetPermission, permission)) { + if (peer.grantPreExisting == false) { + return DurableUploadPermissionPeerProtection.RetainedAppOwnedGrant + } + ambiguous = true + } + } + return if (ambiguous) { + DurableUploadPermissionPeerProtection.Ambiguous + } else { + DurableUploadPermissionPeerProtection.None + } +} + +internal fun malformedDurableUploadPeerBlocksDirectCleanup( + malformedPeers: Iterable>, + targetSelectionId: String, + targetPermission: Permission, + samePermission: (Permission, Permission) -> Boolean, +): Boolean = durableUploadPermissionPeerProtection( + peers = malformedPeers, + targetSelectionId = targetSelectionId, + targetPermission = targetPermission, + samePermission = samePermission, +) != DurableUploadPermissionPeerProtection.None + +internal enum class DurableUploadPermissionCleanupPlan { + ReleaseThenRemove, + RemoveWithoutRelease, + Retain, +} + +internal fun durableUploadPermissionCleanupPlan( + grantPreExisting: Boolean?, + peerProtection: DurableUploadPermissionPeerProtection, + permissionAbsent: Boolean = false, +): DurableUploadPermissionCleanupPlan = when { + grantPreExisting == true -> DurableUploadPermissionCleanupPlan.RemoveWithoutRelease + peerProtection == DurableUploadPermissionPeerProtection.RetainedAppOwnedGrant -> + DurableUploadPermissionCleanupPlan.RemoveWithoutRelease + peerProtection == DurableUploadPermissionPeerProtection.Ambiguous -> + DurableUploadPermissionCleanupPlan.Retain + grantPreExisting == false -> DurableUploadPermissionCleanupPlan.ReleaseThenRemove + permissionAbsent -> DurableUploadPermissionCleanupPlan.RemoveWithoutRelease + else -> DurableUploadPermissionCleanupPlan.Retain +} + internal fun recoverMalformedDurableUploadCapability( capability: MalformedDurableUploadCapability, permission: Permission?, - ownedByAnotherCapability: Boolean, + peerProtection: DurableUploadPermissionPeerProtection, releasePermission: (Permission) -> Unit, isPermissionAbsent: (Permission) -> Boolean, removeMetadata: (String) -> Boolean, ): Boolean { permission ?: return false val grantPreExisting = capability.grantPreExisting - if (grantPreExisting == null) { - val permissionCanRemain = ownedByAnotherCapability || try { + val permissionAbsent = if ( + grantPreExisting == null && + peerProtection == DurableUploadPermissionPeerProtection.None + ) { + try { isPermissionAbsent(permission) } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { false } - return permissionCanRemain && durableUploadCleanupStep { - removeMetadata(capability.selectionId) - } + } else { + false } + val cleanupPlan = durableUploadPermissionCleanupPlan( + grantPreExisting = grantPreExisting, + peerProtection = peerProtection, + permissionAbsent = permissionAbsent, + ) + if (cleanupPlan == DurableUploadPermissionCleanupPlan.Retain) return false return releaseDurableUploadCapability( releasePermission = { - if (shouldReleaseDurableUploadPermission(grantPreExisting, ownedByAnotherCapability)) { + if (cleanupPlan == DurableUploadPermissionCleanupPlan.ReleaseThenRemove) { releasePermission(permission) } }, isPermissionAbsent = { - grantPreExisting || ownedByAnotherCapability || isPermissionAbsent(permission) + cleanupPlan == DurableUploadPermissionCleanupPlan.RemoveWithoutRelease || + isPermissionAbsent(permission) }, removeMetadata = { removeMetadata(capability.selectionId) }, ) @@ -181,3 +284,113 @@ internal fun durableUploadCleanupStep(action: () -> Boolean): Boolean = try { } catch (_: Exception) { false } + +/** + * Acquires a durable picker capability without exposing an interval where a successful selection + * can be reported before its metadata reaches app-private storage. + */ +internal fun acquireDurableUploadCapability( + takePermission: () -> Unit, + persistMetadata: () -> Boolean, + releasePermission: () -> Unit, + persistAcquiring: () -> Boolean = { true }, + markCleanupPending: () -> Boolean = { true }, + isPermissionAbsent: () -> Boolean = { false }, + removeCapability: () -> Boolean = { true }, + onRollbackRetained: () -> Unit = {}, +) { + val acquiringPersisted = try { + persistAcquiring() + } catch (cancelled: CancellationException) { + durableUploadCleanupStep(removeCapability) + runCatching(onRollbackRetained) + throw cancelled + } catch (failure: Exception) { + durableUploadCleanupStep(removeCapability) + runCatching(onRollbackRetained) + throw failure + } + if (!acquiringPersisted) { + durableUploadCleanupStep(removeCapability) + runCatching(onRollbackRetained) + error("The picker capability rollback could not be saved.") + } + try { + takePermission() + } catch (cancelled: CancellationException) { + runCatching(onRollbackRetained) + throw cancelled + } catch (failure: Exception) { + runCatching(onRollbackRetained) + throw failure + } + val persisted = runCatching { persistMetadata() } + if (persisted.getOrNull() == true) return + if (!durableUploadCleanupStep(markCleanupPending)) runCatching(onRollbackRetained) + val released = try { + releaseDurableUploadPermission(releasePermission, isPermissionAbsent) + } catch (cancelled: CancellationException) { + runCatching(onRollbackRetained) + throw cancelled + } + if (released) { + if (!durableUploadCleanupStep(removeCapability)) runCatching(onRollbackRetained) + } else { + runCatching(onRollbackRetained) + } + persisted.exceptionOrNull()?.let { throw it } + error("The durable upload capability could not be saved.") +} + +internal enum class CapabilityPhase(val persistedValue: String) { + Acquiring("acquiring"), + Ready("ready"), + OwnershipCheckPending("ownership-check-pending"), + CleanupPending("cleanup-pending"); + + companion object { + fun fromPersistedValue(value: String): CapabilityPhase = entries.singleOrNull { + phase -> phase.persistedValue == value + } ?: error("The picker capability phase is invalid.") + } +} + +internal fun shouldRecoverDurableUploadCapability( + phase: CapabilityPhase, + processGeneration: String?, + currentProcessGeneration: String, + ownedByDurableJob: Boolean, + cleanupExplicitlyPending: Boolean, +): Boolean = !ownedByDurableJob && ( + cleanupExplicitlyPending || + phase != CapabilityPhase.Ready || + processGeneration != currentProcessGeneration +) + +internal fun shouldRestoreDurableUploadCapabilityAfterOwnershipCheck( + phase: CapabilityPhase, + ownedByDurableJob: Boolean, +): Boolean = phase == CapabilityPhase.OwnershipCheckPending && ownedByDurableJob + +internal fun isDurableUploadCapabilityReady(phase: CapabilityPhase): Boolean = + phase == CapabilityPhase.Ready + +internal fun durableUploadCapabilityHasCapacity( + trackedCapabilityCount: Int, + maximumTrackedCapabilities: Int, +): Boolean { + require(trackedCapabilityCount >= 0) + require(maximumTrackedCapabilities > 0) + return trackedCapabilityCount < maximumTrackedCapabilities +} + +internal fun finalizeDurableUploadCapabilityDelivery( + publishReady: () -> Unit, + continuationIsActive: () -> Boolean, + cleanupUndelivered: () -> Unit, +): Boolean { + publishReady() + if (continuationIsActive()) return true + runCatching(cleanupUndelivered) + return false +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecoveryScan.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecoveryScan.kt new file mode 100644 index 000000000..f1b437634 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecoveryScan.kt @@ -0,0 +1,54 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CancellationException + +internal class DurableUploadCapabilityRecoveryScan { + private val capabilities = linkedMapOf() + private val malformed = linkedMapOf() + + fun loadPage( + cachedCapabilities: Map, + storedSelectionIds: Iterable, + maximumRows: Int, + loadStoredCapability: (String) -> Capability?, + ): DurableUploadCapabilitySnapshot { + require(maximumRows > 0) + val storedIds = storedSelectionIds.toSet() + capabilities.keys.retainAll(storedIds) + malformed.keys.retainAll(storedIds) + cachedCapabilities.forEach { (selectionId, capability) -> + if (selectionId in storedIds) { + capabilities[selectionId] = capability + malformed.remove(selectionId) + } + } + storedIds.asSequence() + .filterNot { selectionId -> selectionId in capabilities || selectionId in malformed } + .sorted() + .take(maximumRows) + .forEach { selectionId -> + val stored = try { + checkNotNull(loadStoredCapability(selectionId)) { + "The picker capability disappeared during recovery." + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: AndroidLocalUploadCapabilityMalformedException) { + malformed[selectionId] = MalformedDurableUploadCapability( + selectionId, + failure.cleanupPermissionIdentity, + failure.grantPreExisting, + ) + return@forEach + } + capabilities[selectionId] = stored + } + val scannedIds = capabilities.keys + malformed.keys + return DurableUploadCapabilitySnapshot( + capabilities = capabilities.toMap(), + malformedCapabilities = malformed.toMap(), + storedCapabilityCount = storedIds.size, + scanComplete = scannedIds.containsAll(storedIds), + ) + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index 579a96837..84d2ceca0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -205,25 +205,42 @@ internal class AndroidLocalUploadPicker(context: Context) { requestQueuedDurableUploadSchedulingRecovery() return@synchronized false } - val capabilities = try { - loadCapabilitySnapshot().capabilities + val snapshot = try { + loadCapabilitySnapshot() } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { return@synchronized retainCapabilityCleanup(file.selectionId) } - val ownedElsewhere = capabilities.anyOtherCapabilityOwnsUri( - source.uri, - file.selectionId, + val malformedPeerBlocksCleanup = malformedPeerBlocksDirectCleanup( + malformedCapabilities = snapshot.malformedCapabilities, + targetSelectionId = file.selectionId, + targetPermissionIdentity = source.uri.toString(), + ) + if (malformedPeerBlocksCleanup) { + return@synchronized retainCapabilityCleanup(file.selectionId) + } + val cleanupPlan = durableUploadPermissionCleanupPlan( + grantPreExisting = source.grantPreExisting, + peerProtection = permissionPeerProtection( + capabilities = snapshot.capabilities, + malformedCapabilities = emptyMap(), + targetSelectionId = file.selectionId, + targetPermissionIdentity = source.uri.toString(), + ), ) + if (cleanupPlan == DurableUploadPermissionCleanupPlan.Retain) { + return@synchronized retainCapabilityCleanup(file.selectionId) + } releaseDurableUploadCapability( releasePermission = { - if (shouldReleaseDurableUploadPermission(source.grantPreExisting, ownedElsewhere)) { + if (cleanupPlan == DurableUploadPermissionCleanupPlan.ReleaseThenRemove) { releasePermission(source.uri) } }, isPermissionAbsent = { - source.grantPreExisting || ownedElsewhere || exactReadPermissionIsAbsent(source.uri) + cleanupPlan == DurableUploadPermissionCleanupPlan.RemoveWithoutRelease || + exactReadPermissionIsAbsent(source.uri) }, removeMetadata = { removeMetadata(file.selectionId) }, ).also { released -> @@ -258,27 +275,56 @@ internal class AndroidLocalUploadPicker(context: Context) { fun reconcileCapabilities(ownedSelectionIds: Set): Boolean = synchronized(CAPABILITY_LOCK) { val snapshot = try { - loadCapabilitySnapshot() + loadCapabilityRecoverySnapshot() } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { return@synchronized false } + if (snapshot.malformedCapabilities.isNotEmpty()) { + PENDING_CLEANUP_SELECTIONS += snapshot.malformedCapabilities.keys + requestQueuedDurableUploadSchedulingRecovery() + } + if (!snapshot.scanComplete) { + requestQueuedDurableUploadSchedulingRecovery() + return@synchronized false + } val capabilities = snapshot.capabilities.toMutableMap() + val malformedCapabilities = snapshot.malformedCapabilities.toMutableMap() var allRecovered = true - snapshot.malformedCapabilities.values - .sortedBy(MalformedDurableUploadCapability::selectionId) - .forEach { malformed -> - val recovered = recoverMalformedCapability(malformed, capabilities) - if (recovered) { - selections.remove(malformed.selectionId) - PENDING_CLEANUP_SELECTIONS.remove(malformed.selectionId) - } else { - allRecovered = false - } + var remainingRecoveryActions = MAX_RECOVERY_ROWS_PER_PASS + val malformedRecovery = malformedDurableUploadCapabilitiesForRecovery( + malformedCapabilities, + ownedSelectionIds, + ) + malformedRecovery.forEach { malformed -> + if (!malformedRecoveryIsActionable(malformed, capabilities, malformedCapabilities)) { + allRecovered = false + return@forEach + } + if (remainingRecoveryActions == 0) { + allRecovered = false + return@forEach } + remainingRecoveryActions -= 1 + val recovered = recoverMalformedCapability( + malformed, + capabilities, + malformedCapabilities, + ) + if (recovered) { + malformedCapabilities.remove(malformed.selectionId) + selections.remove(malformed.selectionId) + PENDING_CLEANUP_SELECTIONS.remove(malformed.selectionId) + } else { + allRecovered = false + } + } capabilities.values - .sortedBy { capability -> capability.file.selectionId } + .sortedWith( + compareByDescending { capability -> capability.grantPreExisting } + .thenBy { capability -> capability.file.selectionId }, + ) .forEach { capability -> val selectionId = capability.file.selectionId val ownedByDurableJob = selectionId in ownedSelectionIds @@ -288,6 +334,11 @@ internal class AndroidLocalUploadPicker(context: Context) { ownedByDurableJob = ownedByDurableJob, ) ) { + if (remainingRecoveryActions == 0) { + allRecovered = false + return@forEach + } + remainingRecoveryActions -= 1 val ready = capability.copy( phase = CapabilityPhase.Ready, processGeneration = PROCESS_GENERATION, @@ -307,24 +358,32 @@ internal class AndroidLocalUploadPicker(context: Context) { ownedByDurableJob = ownedByDurableJob, cleanupExplicitlyPending = selectionId in PENDING_CLEANUP_SELECTIONS, )) return@forEach - val ownedElsewhere = capabilities.anyOtherCapabilityOwnsUri( - capability.uri, - selectionId, + val cleanupPlan = durableUploadPermissionCleanupPlan( + grantPreExisting = capability.grantPreExisting, + peerProtection = permissionPeerProtection( + capabilities = capabilities, + malformedCapabilities = malformedCapabilities, + targetSelectionId = selectionId, + targetPermissionIdentity = capability.uri.toString(), + ), ) + if (cleanupPlan == DurableUploadPermissionCleanupPlan.Retain) { + allRecovered = false + return@forEach + } + if (remainingRecoveryActions == 0) { + allRecovered = false + return@forEach + } + remainingRecoveryActions -= 1 val released = releaseDurableUploadCapability( releasePermission = { - if ( - shouldReleaseDurableUploadPermission( - capability.grantPreExisting, - ownedElsewhere, - ) - ) { + if (cleanupPlan == DurableUploadPermissionCleanupPlan.ReleaseThenRemove) { releasePermission(capability.uri) } }, isPermissionAbsent = { - capability.grantPreExisting || - ownedElsewhere || + cleanupPlan == DurableUploadPermissionCleanupPlan.RemoveWithoutRelease || exactReadPermissionIsAbsent(capability.uri) }, removeMetadata = { removeMetadata(selectionId) }, @@ -374,17 +433,11 @@ internal class AndroidLocalUploadPicker(context: Context) { } private fun loadCapabilitySnapshot(): DurableUploadCapabilitySnapshot { - val storedSelectionIds = preferences.all.keys - .asSequence() - .filter { key -> key.startsWith(PREFERENCE_PREFIX) } - .map { key -> key.removePrefix(PREFERENCE_PREFIX) } - .toList() - require(storedSelectionIds.size <= MAX_TRACKED_CAPABILITIES) { - "Too many picker capabilities are tracked." - } + val storedSelectionIds = storedCapabilitySelectionIds() val snapshot = loadDurableUploadCapabilitySnapshot( cachedCapabilities = selections.toMap(), storedSelectionIds = storedSelectionIds, + maximumRecoverableCapabilities = MAX_RECOVERABLE_CAPABILITIES, loadStoredCapability = ::load, ) if (snapshot.malformedCapabilities.isNotEmpty()) { @@ -394,6 +447,20 @@ internal class AndroidLocalUploadPicker(context: Context) { return snapshot } + private fun loadCapabilityRecoverySnapshot(): DurableUploadCapabilitySnapshot = + RECOVERY_SCAN.loadPage( + cachedCapabilities = selections.toMap(), + storedSelectionIds = storedCapabilitySelectionIds(), + maximumRows = MAX_RECOVERY_ROWS_PER_PASS, + loadStoredCapability = ::load, + ) + + private fun storedCapabilitySelectionIds(): List = preferences.all.keys + .asSequence() + .filter { key -> key.startsWith(PREFERENCE_PREFIX) } + .map { key -> key.removePrefix(PREFERENCE_PREFIX) } + .toList() + private fun releaseMalformedCapability( selectionId: String, malformed: AndroidLocalUploadCapabilityMalformedException, @@ -412,7 +479,22 @@ internal class AndroidLocalUploadPicker(context: Context) { malformed.cleanupPermissionIdentity, malformed.grantPreExisting, ) - val recovered = recoverMalformedCapability(isolated, snapshot.capabilities) + val permissionIdentity = isolated.cleanupPermissionIdentity + if ( + permissionIdentity != null && + malformedPeerBlocksDirectCleanup( + malformedCapabilities = snapshot.malformedCapabilities, + targetSelectionId = selectionId, + targetPermissionIdentity = permissionIdentity, + ) + ) { + return retainCapabilityCleanup(selectionId) + } + val recovered = recoverMalformedCapability( + isolated, + snapshot.capabilities, + emptyMap(), + ) if (recovered) { selections.remove(selectionId) PENDING_CLEANUP_SELECTIONS.remove(selectionId) @@ -425,31 +507,101 @@ internal class AndroidLocalUploadPicker(context: Context) { private fun recoverMalformedCapability( malformed: MalformedDurableUploadCapability, capabilities: Map, + malformedCapabilities: Map, ): Boolean { val permission = malformed.cleanupPermissionIdentity?.let(Uri::parse) - val ownedElsewhere = permission != null && capabilities.anyOtherCapabilityOwnsUri( - permission, - malformed.selectionId, - ) + val peerProtection = permission?.let { target -> + permissionPeerProtection( + capabilities = capabilities, + malformedCapabilities = malformedCapabilities, + targetSelectionId = malformed.selectionId, + targetPermissionIdentity = target.toString(), + ) + } ?: DurableUploadPermissionPeerProtection.Ambiguous return recoverMalformedDurableUploadCapability( capability = malformed, permission = permission, - ownedByAnotherCapability = ownedElsewhere, + peerProtection = peerProtection, releasePermission = ::releasePermission, isPermissionAbsent = ::exactReadPermissionIsAbsent, removeMetadata = ::removeMetadata, ) } - private fun Map.anyOtherCapabilityOwnsUri( - uri: Uri, - selectionId: String, - ): Boolean = durableUploadCapabilityPermissionOwnedByAnother( - capabilities = this, - targetSelectionId = selectionId, - targetPermission = uri, - permissionOf = SelectedSource::uri, - samePermission = { first, second -> first == second }, + private fun malformedRecoveryIsActionable( + malformed: MalformedDurableUploadCapability, + capabilities: Map, + malformedCapabilities: Map, + ): Boolean { + val permission = malformed.cleanupPermissionIdentity?.let(Uri::parse) ?: return false + val peerProtection = permissionPeerProtection( + capabilities = capabilities, + malformedCapabilities = malformedCapabilities, + targetSelectionId = malformed.selectionId, + targetPermissionIdentity = permission.toString(), + ) + val permissionAbsent = if ( + malformed.grantPreExisting == null && + peerProtection == DurableUploadPermissionPeerProtection.None + ) { + try { + exactReadPermissionIsAbsent(permission) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + false + } + } else { + false + } + return durableUploadPermissionCleanupPlan( + malformed.grantPreExisting, + peerProtection, + permissionAbsent, + ) != DurableUploadPermissionCleanupPlan.Retain + } + + private fun permissionPeerProtection( + capabilities: Map, + malformedCapabilities: Map, + targetSelectionId: String, + targetPermissionIdentity: String, + ): DurableUploadPermissionPeerProtection = durableUploadPermissionPeerProtection( + peers = ( + capabilities.asSequence().map { (selectionId, capability) -> + DurableUploadPermissionPeer( + selectionId, + capability.uri.toString(), + capability.grantPreExisting, + ) + } + malformedCapabilities.asSequence().map { (_, capability) -> + DurableUploadPermissionPeer( + capability.selectionId, + capability.cleanupPermissionIdentity, + capability.grantPreExisting, + ) + } + ).asIterable(), + targetSelectionId = targetSelectionId, + targetPermission = targetPermissionIdentity, + samePermission = String::equals, + ) + + private fun malformedPeerBlocksDirectCleanup( + malformedCapabilities: Map, + targetSelectionId: String, + targetPermissionIdentity: String, + ): Boolean = malformedDurableUploadPeerBlocksDirectCleanup( + malformedPeers = malformedCapabilities.values.asSequence().map { capability -> + DurableUploadPermissionPeer( + capability.selectionId, + capability.cleanupPermissionIdentity, + capability.grantPreExisting, + ) + }.asIterable(), + targetSelectionId = targetSelectionId, + targetPermission = targetPermissionIdentity, + samePermission = String::equals, ) private fun persistedSource(file: LocalUploadFile): SelectedSource { @@ -559,10 +711,13 @@ internal class AndroidLocalUploadPicker(context: Context) { const val PREFERENCES = "nextcloud_native_upload_capabilities" const val PREFERENCE_PREFIX = "upload_" const val MAX_TRACKED_CAPABILITIES = 64 + const val MAX_RECOVERABLE_CAPABILITIES = 1_024 + const val MAX_RECOVERY_ROWS_PER_PASS = 1_024 val PROCESS_GENERATION = UUID.randomUUID().toString() val PROCESS_SELECTIONS = ConcurrentHashMap() val PENDING_CLEANUP_SELECTIONS = ConcurrentHashMap.newKeySet() val CAPABILITY_LOCK = Any() + val RECOVERY_SCAN = DurableUploadCapabilityRecoveryScan() } } @@ -593,15 +748,6 @@ internal fun JSONObject.optionalStrictBoolean(key: String): Boolean? { internal fun persistedDurableUploadGrantPreExisting(payload: JSONObject): Boolean = payload.optionalStrictBoolean("grantPreExisting") ?: false -internal fun durableUploadCapabilityHasCapacity( - trackedCapabilityCount: Int, - maximumTrackedCapabilities: Int, -): Boolean { - require(trackedCapabilityCount >= 0) - require(maximumTrackedCapabilities > 0) - return trackedCapabilityCount < maximumTrackedCapabilities -} - internal fun resumeLocalUploadSelectionResult( continuation: CancellableContinuation, result: LocalUploadSelectionResult, @@ -614,107 +760,6 @@ internal fun resumeLocalUploadSelectionResult( } } -/** - * Acquires a durable picker capability without exposing an interval where a successful selection - * can be reported before its metadata reaches app-private storage. - */ -internal fun acquireDurableUploadCapability( - takePermission: () -> Unit, - persistMetadata: () -> Boolean, - releasePermission: () -> Unit, - persistAcquiring: () -> Boolean = { true }, - markCleanupPending: () -> Boolean = { true }, - isPermissionAbsent: () -> Boolean = { false }, - removeCapability: () -> Boolean = { true }, - onRollbackRetained: () -> Unit = {}, -) { - val acquiringPersisted = try { - persistAcquiring() - } catch (cancelled: CancellationException) { - durableUploadCleanupStep(removeCapability) - runCatching(onRollbackRetained) - throw cancelled - } catch (failure: Exception) { - durableUploadCleanupStep(removeCapability) - runCatching(onRollbackRetained) - throw failure - } - if (!acquiringPersisted) { - durableUploadCleanupStep(removeCapability) - runCatching(onRollbackRetained) - error("The picker capability rollback could not be saved.") - } - try { - takePermission() - } catch (cancelled: CancellationException) { - runCatching(onRollbackRetained) - throw cancelled - } catch (failure: Exception) { - runCatching(onRollbackRetained) - throw failure - } - val persisted = runCatching { persistMetadata() } - if (persisted.getOrNull() == true) return - if (!durableUploadCleanupStep(markCleanupPending)) runCatching(onRollbackRetained) - val released = try { - releaseDurableUploadPermission(releasePermission, isPermissionAbsent) - } catch (cancelled: CancellationException) { - runCatching(onRollbackRetained) - throw cancelled - } - if (released) { - if (!durableUploadCleanupStep(removeCapability)) runCatching(onRollbackRetained) - } else { - runCatching(onRollbackRetained) - } - persisted.exceptionOrNull()?.let { throw it } - error("The durable upload capability could not be saved.") -} - -internal enum class CapabilityPhase(val persistedValue: String) { - Acquiring("acquiring"), - Ready("ready"), - OwnershipCheckPending("ownership-check-pending"), - CleanupPending("cleanup-pending"); - - companion object { - fun fromPersistedValue(value: String): CapabilityPhase = entries.singleOrNull { - phase -> phase.persistedValue == value - } ?: error("The picker capability phase is invalid.") - } -} - -internal fun shouldRecoverDurableUploadCapability( - phase: CapabilityPhase, - processGeneration: String?, - currentProcessGeneration: String, - ownedByDurableJob: Boolean, - cleanupExplicitlyPending: Boolean, -): Boolean = !ownedByDurableJob && ( - cleanupExplicitlyPending || - phase != CapabilityPhase.Ready || - processGeneration != currentProcessGeneration -) - -internal fun shouldRestoreDurableUploadCapabilityAfterOwnershipCheck( - phase: CapabilityPhase, - ownedByDurableJob: Boolean, -): Boolean = phase == CapabilityPhase.OwnershipCheckPending && ownedByDurableJob - -internal fun isDurableUploadCapabilityReady(phase: CapabilityPhase): Boolean = - phase == CapabilityPhase.Ready - -internal fun finalizeDurableUploadCapabilityDelivery( - publishReady: () -> Unit, - continuationIsActive: () -> Boolean, - cleanupUndelivered: () -> Unit, -): Boolean { - publishReady() - if (continuationIsActive()) return true - runCatching(cleanupUndelivered) - return false -} - private data class AndroidUploadMetadata( val displayName: String, val sizeBytes: Long?, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt index b80838347..c8523bd14 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt @@ -17,6 +17,41 @@ import org.json.JSONArray import org.json.JSONObject class AndroidDurableUploadCleanupPruningTest { + @Test + fun `oversized terminal cleanup jobs remain eligible for paged capability recovery`() { + val jobs = (1..1_025).map { index -> + fixtureJob( + index = index, + state = DurableUploadState.Failed, + cleanupPending = true, + ) + } + val retained = durableUploadCapabilityRetainedSelectionIds(jobs) + val storedIds = jobs.map { job -> job.request.file.selectionId } + val scan = DurableUploadCapabilityRecoveryScan() + + val first = scan.loadPage(emptyMap(), storedIds, maximumRows = 1_024) { + CapabilityPhase.CleanupPending + } + val second = scan.loadPage(emptyMap(), storedIds, maximumRows = 1_024) { + CapabilityPhase.CleanupPending + } + val recoverable = second.capabilities.count { (selectionId, phase) -> + shouldRecoverDurableUploadCapability( + phase = phase, + processGeneration = "prior-generation", + currentProcessGeneration = "current-generation", + ownedByDurableJob = selectionId in retained, + cleanupExplicitlyPending = true, + ) + } + + assertTrue(retained.isEmpty()) + assertFalse(first.scanComplete) + assertTrue(second.scanComplete) + assertEquals(1_025, recoverable) + } + @Test fun `reconciliation runs terminal cleanup without consulting upload work ownership`() = runBlocking { val pending = fixtureJob(index = 1, cleanupPending = true) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt index 6f2a2330e..556bfc209 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt @@ -426,7 +426,7 @@ class AndroidLocalUploadCapabilityLifecycleTest { val recovered = recoverMalformedDurableUploadCapability( capability = capability, permission = checkNotNull(capability.cleanupPermissionIdentity), - ownedByAnotherCapability = false, + peerProtection = DurableUploadPermissionPeerProtection.None, releasePermission = { events += "release:$it" }, isPermissionAbsent = { false }, removeMetadata = { @@ -456,7 +456,7 @@ class AndroidLocalUploadCapabilityLifecycleTest { grantPreExisting = null, ), permission = "content://synthetic/malformed", - ownedByAnotherCapability = false, + peerProtection = DurableUploadPermissionPeerProtection.None, releasePermission = { error("unknown ownership must not release") }, isPermissionAbsent = { false }, removeMetadata = { true.also { metadataPresent = false } }, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt new file mode 100644 index 000000000..9dc61a41a --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt @@ -0,0 +1,323 @@ +package dev.obiente.nextcloudnative + +import java.security.GeneralSecurityException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidLocalUploadCapabilityOverflowRecoveryTest { + @Test + fun `over admission limit capability state remains recoverable`() { + val storedIds = (1..65).map { index -> "selection-$index" } + + val snapshot = loadDurableUploadCapabilitySnapshot( + cachedCapabilities = emptyMap(), + storedSelectionIds = storedIds, + maximumRecoverableCapabilities = 1_024, + loadStoredCapability = { selectionId -> "content://synthetic/$selectionId" }, + ) + + assertEquals(65, snapshot.trackedCapabilityCount) + assertEquals(65, snapshot.capabilities.size) + assertFalse( + durableUploadCapabilityHasCapacity( + trackedCapabilityCount = snapshot.trackedCapabilityCount, + maximumTrackedCapabilities = 64, + ), + ) + } + + @Test + fun `malformed capability owned by a durable job is excluded from recovery`() { + val queued = malformed("selection-queued") + val abandoned = malformed("selection-abandoned") + + val recoverable = malformedDurableUploadCapabilitiesForRecovery( + capabilities = mapOf( + queued.selectionId to queued, + abandoned.selectionId to abandoned, + ), + ownedSelectionIds = setOf(queued.selectionId), + ) + + assertEquals(listOf(abandoned), recoverable) + } + + @Test + fun `oversized state is scanned in bounded repeatable pages`() { + var rowLoads = 0 + val storedIds = (1..1_025).map { index -> "selection-$index" } + val scan = DurableUploadCapabilityRecoveryScan() + + val first = scan.loadPage(emptyMap(), storedIds, maximumRows = 1_024) { + rowLoads += 1 + "content://synthetic/$it" + } + assertFalse(first.scanComplete) + assertEquals(1_025, first.trackedCapabilityCount) + assertEquals(1_024, rowLoads) + + val second = scan.loadPage(emptyMap(), storedIds, maximumRows = 1_024) { + rowLoads += 1 + "content://synthetic/$it" + } + assertTrue(second.scanComplete) + assertEquals(1_025, second.capabilities.size) + assertEquals(1_025, rowLoads) + } + + @Test + fun `paged recovery isolates malformed rows without swallowing transient failures`() { + val scan = DurableUploadCapabilityRecoveryScan() + val malformed = AndroidLocalUploadCapabilityMalformedException("invalid metadata") + + assertFailsWith { + scan.loadPage( + cachedCapabilities = emptyMap(), + storedSelectionIds = listOf("selection-malformed", "selection-transient"), + maximumRows = 2, + ) { selectionId -> + when (selectionId) { + "selection-malformed" -> throw malformed + else -> throw GeneralSecurityException("synthetic decryption failure") + } + } + } + + val recovered = scan.loadPage( + cachedCapabilities = emptyMap(), + storedSelectionIds = listOf("selection-malformed", "selection-transient"), + maximumRows = 2, + loadStoredCapability = { selectionId -> "content://synthetic/$selectionId" }, + ) + + assertTrue(recovered.scanComplete) + assertEquals(setOf("selection-malformed"), recovered.malformedCapabilities.keys) + assertEquals("content://synthetic/selection-transient", recovered.capabilities["selection-transient"]) + } + + @Test + fun `owned malformed app grant permits abandoned valid metadata cleanup without revocation`() { + val sharedUri = "content://synthetic/shared" + val owned = malformed("selection-owned", sharedUri) + val protection = protection( + targetSelectionId = "selection-valid", + targetPermission = sharedUri, + peers = arrayOf(owned.peer()), + ) + val cleanupPlan = durableUploadPermissionCleanupPlan( + grantPreExisting = false, + peerProtection = protection, + ) + val events = mutableListOf() + + val recovered = releaseDurableUploadCapability( + releasePermission = { + if (cleanupPlan == DurableUploadPermissionCleanupPlan.ReleaseThenRemove) { + events += "release" + } + }, + isPermissionAbsent = { + cleanupPlan == DurableUploadPermissionCleanupPlan.RemoveWithoutRelease + }, + removeMetadata = { true.also { events += "remove-valid" } }, + ) + + assertEquals(DurableUploadPermissionPeerProtection.RetainedAppOwnedGrant, protection) + assertTrue(recovered) + assertEquals(listOf("remove-valid"), events) + } + + @Test + fun `owned malformed app grant permits abandoned malformed metadata cleanup without revocation`() { + val sharedUri = "content://synthetic/shared" + val owned = malformed("selection-owned", sharedUri) + val abandoned = malformed("selection-abandoned", sharedUri) + val protection = protection( + targetSelectionId = abandoned.selectionId, + targetPermission = sharedUri, + peers = arrayOf(owned.peer(), abandoned.peer()), + ) + val events = mutableListOf() + + val recovered = recoverMalformedDurableUploadCapability( + capability = abandoned, + permission = sharedUri, + peerProtection = protection, + releasePermission = { events += "release" }, + isPermissionAbsent = { false }, + removeMetadata = { true.also { events += "remove-malformed" } }, + ) + + assertEquals(DurableUploadPermissionPeerProtection.RetainedAppOwnedGrant, protection) + assertTrue(recovered) + assertEquals(listOf("remove-malformed"), events) + } + + @Test + fun `ambiguous malformed peers block direct valid and malformed cleanup`() { + val sharedUri = "content://synthetic/shared" + val appOwnedPeer = malformed("selection-app-owned", sharedUri).peer() + val exactPeer = malformed("selection-peer", sharedUri).copy(grantPreExisting = true).peer() + val unknownPeer = malformed("selection-unknown").copy(cleanupPermissionIdentity = null).peer() + + listOf(appOwnedPeer, exactPeer, unknownPeer).forEach { peer -> + assertTrue( + malformedDurableUploadPeerBlocksDirectCleanup( + malformedPeers = listOf(peer), + targetSelectionId = "selection-valid", + targetPermission = sharedUri, + samePermission = String::equals, + ), + ) + assertTrue( + malformedDurableUploadPeerBlocksDirectCleanup( + malformedPeers = listOf(peer), + targetSelectionId = "selection-malformed", + targetPermission = sharedUri, + samePermission = String::equals, + ), + ) + } + } + + @Test + fun `peer protection keeps ambiguous provenance distinct from a retained app grant`() { + val sharedUri = "content://synthetic/shared" + val exactAppGrant = peer("selection-false", sharedUri, grantPreExisting = false) + val exactPreExisting = peer("selection-true", sharedUri, grantPreExisting = true) + val exactUnknownGrant = peer("selection-null", sharedUri, grantPreExisting = null) + val unknownPermission = peer("selection-unknown", permission = null, grantPreExisting = false) + + assertEquals( + DurableUploadPermissionPeerProtection.RetainedAppOwnedGrant, + protection("selection-target", sharedUri, arrayOf(exactAppGrant)), + ) + listOf(exactPreExisting, exactUnknownGrant, unknownPermission).forEach { peer -> + val protection = protection("selection-target", sharedUri, arrayOf(peer)) + assertEquals( + DurableUploadPermissionPeerProtection.Ambiguous, + protection, + ) + assertEquals( + DurableUploadPermissionCleanupPlan.Retain, + durableUploadPermissionCleanupPlan(false, protection), + ) + } + } + + @Test + fun `valid false grant is retained while only peer claims preexisting ownership`() { + val protection = protection( + targetSelectionId = "selection-false", + targetPermission = "content://synthetic/shared", + peers = arrayOf( + peer("selection-false", "content://synthetic/shared", grantPreExisting = false), + peer("selection-true", "content://synthetic/shared", grantPreExisting = true), + ), + ) + + assertEquals(DurableUploadPermissionPeerProtection.Ambiguous, protection) + assertEquals( + DurableUploadPermissionCleanupPlan.Retain, + durableUploadPermissionCleanupPlan(false, protection), + ) + } + + @Test + fun `cleanup planning preserves unknown target provenance until absence is proven`() { + assertEquals( + DurableUploadPermissionCleanupPlan.RemoveWithoutRelease, + durableUploadPermissionCleanupPlan( + grantPreExisting = null, + peerProtection = DurableUploadPermissionPeerProtection.RetainedAppOwnedGrant, + ), + ) + assertEquals( + DurableUploadPermissionCleanupPlan.Retain, + durableUploadPermissionCleanupPlan( + grantPreExisting = null, + peerProtection = DurableUploadPermissionPeerProtection.Ambiguous, + ), + ) + assertEquals( + DurableUploadPermissionCleanupPlan.Retain, + durableUploadPermissionCleanupPlan( + grantPreExisting = null, + peerProtection = DurableUploadPermissionPeerProtection.None, + permissionAbsent = false, + ), + ) + assertEquals( + DurableUploadPermissionCleanupPlan.RemoveWithoutRelease, + durableUploadPermissionCleanupPlan( + grantPreExisting = null, + peerProtection = DurableUploadPermissionPeerProtection.None, + permissionAbsent = true, + ), + ) + assertEquals( + DurableUploadPermissionCleanupPlan.RemoveWithoutRelease, + durableUploadPermissionCleanupPlan( + grantPreExisting = true, + peerProtection = DurableUploadPermissionPeerProtection.Ambiguous, + ), + ) + } + + @Test + fun `mutable malformed ownership leaves the final duplicate to revoke the grant`() { + val sharedUri = "content://synthetic/shared" + val peers = linkedMapOf( + "selection-one" to peer("selection-one", sharedUri, grantPreExisting = false), + "selection-two" to peer("selection-two", sharedUri, grantPreExisting = false), + ) + + val firstPlan = durableUploadPermissionCleanupPlan( + grantPreExisting = false, + peerProtection = protection("selection-one", sharedUri, peers.values.toTypedArray()), + ) + peers.remove("selection-one") + val secondPlan = durableUploadPermissionCleanupPlan( + grantPreExisting = false, + peerProtection = protection("selection-two", sharedUri, peers.values.toTypedArray()), + ) + + assertEquals(DurableUploadPermissionCleanupPlan.RemoveWithoutRelease, firstPlan) + assertEquals(DurableUploadPermissionCleanupPlan.ReleaseThenRemove, secondPlan) + } + + private fun protection( + targetSelectionId: String, + targetPermission: String, + peers: Array>, + ): DurableUploadPermissionPeerProtection = durableUploadPermissionPeerProtection( + peers = peers.asIterable(), + targetSelectionId = targetSelectionId, + targetPermission = targetPermission, + samePermission = String::equals, + ) + + private fun peer( + selectionId: String, + permission: String?, + grantPreExisting: Boolean?, + ) = DurableUploadPermissionPeer(selectionId, permission, grantPreExisting) + + private fun MalformedDurableUploadCapability.peer() = DurableUploadPermissionPeer( + selectionId, + cleanupPermissionIdentity, + grantPreExisting, + ) + + private fun malformed( + selectionId: String, + permissionIdentity: String = "content://synthetic/$selectionId", + ) = MalformedDurableUploadCapability( + selectionId = selectionId, + cleanupPermissionIdentity = permissionIdentity, + grantPreExisting = false, + ) +} From 3af2a6273474cfa559c3da29b32f18c5bae0b8f4 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 04:24:01 +0200 Subject: [PATCH 40/53] test(uploads): split account resolution coverage --- ...AndroidDurableMultipartUploadPolicyTest.kt | 39 ----------------- ...droidDurableUploadAccountResolutionTest.kt | 43 +++++++++++++++++++ 2 files changed, 43 insertions(+), 39 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolutionTest.kt diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 42c5dda32..09b97f571 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -390,45 +390,6 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(DurableUploadState.OutcomeUnknown, durableUploadStateForHttpResponse(500)) } - @Test - fun `inactive retained account defers when its credential is temporarily unavailable`() { - val retainedSession = NextcloudSession( - serverUrl = "https://cloud.example.test/nextcloud", - loginName = "alice", - appPassword = "fixture-password", - ) - val accountId = NextcloudDocumentIds.accountKey(retainedSession) - - val resolution = resolveDurableUploadSession( - expectedAccountId = accountId, - registry = DurableUploadAccountRegistry.Available(listOf(retainedSession.accountRecord())), - loadSession = { null }, - ) - - assertEquals(DurableUploadAccountResolution.DeferAccountActivation, resolution) - } - - @Test - fun `active retained account retries when its credential is temporarily unavailable`() { - val retainedSession = NextcloudSession( - serverUrl = "https://cloud.example.test/nextcloud", - loginName = "alice", - appPassword = "fixture-password", - ) - val accountId = NextcloudDocumentIds.accountKey(retainedSession) - - val resolution = resolveDurableUploadSession( - expectedAccountId = accountId, - registry = DurableUploadAccountRegistry.Available( - accounts = listOf(retainedSession.accountRecord()), - activeAccountId = retainedSession.accountId, - ), - loadSession = { null }, - ) - - assertEquals(DurableUploadAccountResolution.CredentialUnavailable, resolution) - } - @Test fun `removed account terminally fails and releases its queued upload exactly once`() { val events = mutableListOf() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolutionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolutionTest.kt new file mode 100644 index 000000000..ec1106e6d --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountResolutionTest.kt @@ -0,0 +1,43 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord +import kotlin.test.Test +import kotlin.test.assertEquals + +class AndroidDurableUploadAccountResolutionTest { + @Test + fun `inactive retained account defers when its credential is temporarily unavailable`() { + val retainedSession = fixtureSession() + + val resolution = resolveDurableUploadSession( + expectedAccountId = NextcloudDocumentIds.accountKey(retainedSession), + registry = DurableUploadAccountRegistry.Available(listOf(retainedSession.accountRecord())), + loadSession = { null }, + ) + + assertEquals(DurableUploadAccountResolution.DeferAccountActivation, resolution) + } + + @Test + fun `active retained account retries when its credential is temporarily unavailable`() { + val retainedSession = fixtureSession() + + val resolution = resolveDurableUploadSession( + expectedAccountId = NextcloudDocumentIds.accountKey(retainedSession), + registry = DurableUploadAccountRegistry.Available( + accounts = listOf(retainedSession.accountRecord()), + activeAccountId = retainedSession.accountId, + ), + loadSession = { null }, + ) + + assertEquals(DurableUploadAccountResolution.CredentialUnavailable, resolution) + } + + private fun fixtureSession() = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "alice", + appPassword = "fixture-password", + ) +} From df6d4b284b86d8cc21ba108e3d3558c79f8b1362 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 07:16:24 +0200 Subject: [PATCH 41/53] fix(uploads): reject corrupt capability ciphertext --- .../SessionCipherInstrumentedTest.kt | 40 +++++++++++++++ .../AndroidLocalUploadCapabilityRead.kt | 11 ++++ .../AndroidLocalUploadPicker.kt | 2 +- .../obiente/nextcloudnative/SessionCipher.kt | 35 +++++++++++-- ...AndroidDurableUploadSourcePreflightTest.kt | 50 +++++++++++++++++++ ...droidLocalUploadCapabilityLifecycleTest.kt | 29 +++++++++++ 6 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/SessionCipherInstrumentedTest.kt diff --git a/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/SessionCipherInstrumentedTest.kt b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/SessionCipherInstrumentedTest.kt new file mode 100644 index 000000000..fd798bffc --- /dev/null +++ b/androidApp/src/androidTest/kotlin/dev/obiente/nextcloudnative/SessionCipherInstrumentedTest.kt @@ -0,0 +1,40 @@ +package dev.obiente.nextcloudnative + +import android.util.Base64 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import javax.crypto.AEADBadTagException +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SessionCipherInstrumentedTest { + @Test + fun invalidBase64EnvelopeIsDefinitivelyRejected() { + val failure = invalidCiphertextFailure("not-base64.invalid") + + assertTrue(failure.cause is IllegalArgumentException) + } + + @Test + fun authenticatedCiphertextCorruptionIsDefinitivelyRejected() { + val cipher = SessionCipher() + val encrypted = cipher.encrypt("private upload capability") + val parts = encrypted.split('.', limit = 2) + val payload = Base64.decode(parts[1], Base64.NO_WRAP).also { bytes -> + bytes[0] = (bytes[0].toInt() xor 1).toByte() + } + val corrupted = parts[0] + "." + Base64.encodeToString(payload, Base64.NO_WRAP) + + val failure = invalidCiphertextFailure(corrupted) + + assertTrue(failure.cause is AEADBadTagException) + } + + private fun invalidCiphertextFailure(value: String): InvalidSessionCiphertextException = try { + SessionCipher().decrypt(value) + error("Corrupt ciphertext was accepted.") + } catch (failure: InvalidSessionCiphertextException) { + failure + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt index e85c394be..83fa9e705 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt @@ -28,6 +28,17 @@ internal inline fun readAndroidLocalUploadCapabilityPreference(read: () -> Strin ) } +internal inline fun decryptAndroidLocalUploadCapability(decrypt: () -> String): String = try { + decrypt() +} catch (cancelled: CancellationException) { + throw cancelled +} catch (failure: InvalidSessionCiphertextException) { + throw AndroidLocalUploadCapabilityMalformedException( + "The encrypted local file selection metadata is invalid.", + failure, + ) +} + internal inline fun readAndroidLocalUploadCapability(load: () -> Result): Result = try { load() } catch (cancelled: CancellationException) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index 84d2ceca0..d7c136392 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -634,7 +634,7 @@ internal class AndroidLocalUploadPicker(context: Context) { val encrypted = readAndroidLocalUploadCapabilityPreference { preferences.getString(preferenceKey(selectionId), null) } ?: return null - val decrypted = cipher.decrypt(encrypted) + val decrypted = decryptAndroidLocalUploadCapability { cipher.decrypt(encrypted) } val payload = try { JSONObject(decrypted) } catch (failure: Exception) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/SessionCipher.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/SessionCipher.kt index a9e327750..88babecd3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/SessionCipher.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/SessionCipher.kt @@ -1,14 +1,23 @@ package dev.obiente.nextcloudnative import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyProperties import android.util.Base64 +import java.security.GeneralSecurityException +import java.security.InvalidAlgorithmParameterException import java.security.KeyStore +import javax.crypto.AEADBadTagException import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.spec.GCMParameterSpec +internal class InvalidSessionCiphertextException( + message: String, + cause: Throwable? = null, +) : GeneralSecurityException(message, cause) + class SessionCipher { private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } @@ -22,13 +31,30 @@ class SessionCipher { } fun decrypt(value: String): String { + val (iv, encrypted) = decodeEnvelope(value) + val cipher = Cipher.getInstance(TRANSFORMATION) + try { + cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv)) + return cipher.doFinal(encrypted).toString(Charsets.UTF_8) + } catch (failure: InvalidAlgorithmParameterException) { + throw InvalidSessionCiphertextException("Invalid encrypted session envelope.", failure) + } catch (failure: KeyPermanentlyInvalidatedException) { + throw InvalidSessionCiphertextException("Encrypted session key is no longer valid.", failure) + } catch (failure: AEADBadTagException) { + throw InvalidSessionCiphertextException("Encrypted session authentication failed.", failure) + } + } + + private fun decodeEnvelope(value: String): Pair = try { val parts = value.split(SEPARATOR, limit = 2) require(parts.size == 2) { "Invalid encrypted session." } val iv = Base64.decode(parts[0], Base64.NO_WRAP) val encrypted = Base64.decode(parts[1], Base64.NO_WRAP) - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv)) - return cipher.doFinal(encrypted).toString(Charsets.UTF_8) + require(iv.size == GCM_IV_BYTES) { "Invalid encrypted session IV." } + require(encrypted.size >= GCM_TAG_BYTES) { "Invalid encrypted session payload." } + iv to encrypted + } catch (failure: IllegalArgumentException) { + throw InvalidSessionCiphertextException("Invalid encrypted session envelope.", failure) } private fun getOrCreateKey(): SecretKey { @@ -52,5 +78,8 @@ class SessionCipher { const val KEY_ALIAS = "dev.obiente.nextcloudnative.session" const val TRANSFORMATION = "AES/GCM/NoPadding" const val SEPARATOR = "." + const val GCM_IV_BYTES = 12 + const val GCM_TAG_BITS = 128 + const val GCM_TAG_BYTES = GCM_TAG_BITS / Byte.SIZE_BITS } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt index 212374139..774ec9ba9 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt @@ -2,6 +2,8 @@ package dev.obiente.nextcloudnative import java.io.FileNotFoundException import java.io.IOException +import java.security.GeneralSecurityException +import javax.crypto.AEADBadTagException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking import kotlin.test.Test @@ -151,6 +153,54 @@ class AndroidDurableUploadSourcePreflightTest { } } } + assertFailsWith { + readAndroidLocalUploadCapability { + decryptAndroidLocalUploadCapability { + throw GeneralSecurityException("keystore temporarily unavailable") + } + } + } + } + + @Test + fun `invalid encrypted capability envelope and authentication terminally fail the job`() = runBlocking { + listOf( + InvalidSessionCiphertextException( + "invalid base64 envelope", + IllegalArgumentException("bad base64"), + ), + InvalidSessionCiphertextException( + "authentication failed", + AEADBadTagException("bad tag"), + ), + ).forEach { failure -> + var queued = true + var terminalDispositions = 0 + var transientRetries = 0 + val result = processQueuedDurableUploadSource( + requireCapability = { + readAndroidLocalUploadCapability { + decryptAndroidLocalUploadCapability { throw failure } + } + }, + openSource = { error("Corrupt capability metadata must not open the provider.") }, + onCapabilityUnavailable = { + queued = false + terminalDispositions += 1 + "failed" + }, + onProviderUnavailable = { + transientRetries += 1 + "retried" + }, + onReady = { error("Corrupt capability metadata must not start an upload.") }, + ) + + assertEquals("failed", result) + assertFalse(queued) + assertEquals(1, terminalDispositions) + assertEquals(0, transientRetries) + } } @Test diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt index 556bfc209..f18e864c7 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt @@ -414,6 +414,35 @@ class AndroidLocalUploadCapabilityLifecycleTest { } } + @Test + fun `corrupt ciphertext remains durable when grant ownership cannot be reconstructed`() { + var encryptedMetadataPresent = true + val snapshot = loadDurableUploadCapabilitySnapshot( + cachedCapabilities = emptyMap(), + storedSelectionIds = listOf("selection-corrupt"), + loadStoredCapability = { + decryptAndroidLocalUploadCapability { + throw InvalidSessionCiphertextException("authentication failed") + } + }, + ) + val corrupt = snapshot.malformedCapabilities.getValue("selection-corrupt") + + val recovered = recoverMalformedDurableUploadCapability( + capability = corrupt, + permission = null, + peerProtection = DurableUploadPermissionPeerProtection.Ambiguous, + releasePermission = { error("Unknown permission must not be released.") }, + isPermissionAbsent = { error("Unknown permission cannot be queried.") }, + removeMetadata = { true.also { encryptedMetadataPresent = false } }, + ) + + assertEquals(null, corrupt.cleanupPermissionIdentity) + assertEquals(null, corrupt.grantPreExisting) + assertFalse(recovered) + assertTrue(encryptedMetadataPresent) + } + @Test fun `startup recovery releases a malformed app owned capability before deleting its row`() { val events = mutableListOf() From d276894d1ca501f0dc3d5f9c980e617545b53c51 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 09:03:19 +0200 Subject: [PATCH 42/53] refactor(platform): preserve service size boundary --- .../dev/obiente/nextcloudnative/app/NextcloudPlatform.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 ed004743e..27e346602 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -1346,8 +1346,7 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa /** * Streams one picker-authorized file to a reviewed same-origin multipart endpoint. * - * Implementations use the supplied session's credentials, including for retained background - * work, reject redirects and arbitrary local paths, and enforce request and response limits. + * Use supplied-session credentials for retained work, reject redirects and arbitrary paths, and enforce limits. */ suspend fun executeNextcloudMultipartUpload( session: NextcloudSession, From 323ed127db655af75efae0ea29c4b1bda853de12 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 07:09:40 +0000 Subject: [PATCH 43/53] 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 8c95d43d7..81309996e 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -645,7 +645,7 @@ "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": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "5b278014d8a2f6f98733095126ce7480a18618be82ec4a4aa0f252fa4dc54e50", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "5370183fb78190a6893e4a36638b0570de4d2d6b33acab18aef52f2b83d83589", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "fead6cbf4f723ea46f99c9155b190967205d24f198687b7bfd5a213c474374b8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "c96941c7582218754243f780a0bb0954ba31484719d8045f1e609f5d8c04a7c5", From 97e529d71c187406ac3e40d100394dee67f60329 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 09:39:52 +0200 Subject: [PATCH 44/53] fix(uploads): bound capability recovery --- .../AndroidLocalUploadCapabilityRead.kt | 13 ++++++++ .../AndroidLocalUploadCapabilityRecovery.kt | 5 +++ ...ndroidLocalUploadCapabilityRecoveryScan.kt | 16 ++++++++- .../AndroidLocalUploadPicker.kt | 30 +++++++++-------- .../AndroidDurableUploadCleanupPruningTest.kt | 25 +++++--------- ...AndroidDurableUploadSourcePreflightTest.kt | 26 +++++++++++++++ ...calUploadCapabilityOverflowRecoveryTest.kt | 33 ++++++++++++------- 7 files changed, 104 insertions(+), 44 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt index 83fa9e705..851100947 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt @@ -19,6 +19,19 @@ internal class AndroidLocalUploadCapabilityMalformedException( val grantPreExisting: Boolean? = null, ) : IllegalStateException(message, cause) +internal fun requireDurableUploadCapabilityReady(phase: CapabilityPhase) { + if (phase == CapabilityPhase.OwnershipCheckPending) { + throw AndroidLocalUploadCapabilityReadException( + "The local file selection ownership check is still pending.", + ) + } + if (!isDurableUploadCapabilityReady(phase)) { + throw AndroidLocalUploadCapabilityUnavailableException( + "The local file selection is pending capability cleanup.", + ) + } +} + internal inline fun readAndroidLocalUploadCapabilityPreference(read: () -> String?): String? = try { read() } catch (failure: ClassCastException) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt index 5cd28f7bc..7770aec78 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt @@ -88,11 +88,16 @@ internal data class DurableUploadCapabilitySnapshot( val malformedCapabilities: Map, private val storedCapabilityCount: Int? = null, val scanComplete: Boolean = true, + val recoveryQuarantined: Boolean = false, ) { val trackedCapabilityCount: Int get() = storedCapabilityCount ?: (capabilities.keys + malformedCapabilities.keys).size } +internal fun malformedDurableUploadCapabilityCanBecomeActionable( + capability: MalformedDurableUploadCapability, +): Boolean = capability.cleanupPermissionIdentity != null + internal fun loadDurableUploadCapabilitySnapshot( cachedCapabilities: Map, storedSelectionIds: Iterable, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecoveryScan.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecoveryScan.kt index f1b437634..a27e65e9e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecoveryScan.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecoveryScan.kt @@ -13,7 +13,21 @@ internal class DurableUploadCapabilityRecoveryScan { loadStoredCapability: (String) -> Capability?, ): DurableUploadCapabilitySnapshot { require(maximumRows > 0) - val storedIds = storedSelectionIds.toSet() + val storedIds = linkedSetOf() + storedSelectionIds.forEach { selectionId -> + if (selectionId !in storedIds && storedIds.size == maximumRows) { + capabilities.clear() + malformed.clear() + return DurableUploadCapabilitySnapshot( + capabilities = emptyMap(), + malformedCapabilities = emptyMap(), + storedCapabilityCount = maximumRows + 1, + scanComplete = false, + recoveryQuarantined = true, + ) + } + storedIds += selectionId + } capabilities.keys.retainAll(storedIds) malformed.keys.retainAll(storedIds) cachedCapabilities.forEach { (selectionId, capability) -> diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index d7c136392..49be68faa 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -283,8 +283,11 @@ internal class AndroidLocalUploadPicker(context: Context) { } if (snapshot.malformedCapabilities.isNotEmpty()) { PENDING_CLEANUP_SELECTIONS += snapshot.malformedCapabilities.keys - requestQueuedDurableUploadSchedulingRecovery() + if (snapshot.malformedCapabilities.values.any(::malformedDurableUploadCapabilityCanBecomeActionable)) { + requestQueuedDurableUploadSchedulingRecovery() + } } + if (snapshot.recoveryQuarantined) return@synchronized true if (!snapshot.scanComplete) { requestQueuedDurableUploadSchedulingRecovery() return@synchronized false @@ -299,7 +302,7 @@ internal class AndroidLocalUploadPicker(context: Context) { ) malformedRecovery.forEach { malformed -> if (!malformedRecoveryIsActionable(malformed, capabilities, malformedCapabilities)) { - allRecovered = false + if (malformedDurableUploadCapabilityCanBecomeActionable(malformed)) allRecovered = false return@forEach } if (remainingRecoveryActions == 0) { @@ -442,7 +445,9 @@ internal class AndroidLocalUploadPicker(context: Context) { ) if (snapshot.malformedCapabilities.isNotEmpty()) { PENDING_CLEANUP_SELECTIONS += snapshot.malformedCapabilities.keys - requestQueuedDurableUploadSchedulingRecovery() + if (snapshot.malformedCapabilities.values.any(::malformedDurableUploadCapabilityCanBecomeActionable)) { + requestQueuedDurableUploadSchedulingRecovery() + } } return snapshot } @@ -450,16 +455,17 @@ internal class AndroidLocalUploadPicker(context: Context) { private fun loadCapabilityRecoverySnapshot(): DurableUploadCapabilitySnapshot = RECOVERY_SCAN.loadPage( cachedCapabilities = selections.toMap(), - storedSelectionIds = storedCapabilitySelectionIds(), + storedSelectionIds = storedCapabilitySelectionIds(MAX_RECOVERY_ROWS_PER_PASS), maximumRows = MAX_RECOVERY_ROWS_PER_PASS, loadStoredCapability = ::load, ) - private fun storedCapabilitySelectionIds(): List = preferences.all.keys - .asSequence() - .filter { key -> key.startsWith(PREFERENCE_PREFIX) } - .map { key -> key.removePrefix(PREFERENCE_PREFIX) } - .toList() + private fun storedCapabilitySelectionIds(maximumRows: Int? = null): List { + val selectionIds = preferences.all.keys.asSequence() + .filter { key -> key.startsWith(PREFERENCE_PREFIX) } + .map { key -> key.removePrefix(PREFERENCE_PREFIX) } + return maximumRows?.let { limit -> selectionIds.take(limit + 1).toList() } ?: selectionIds.toList() + } private fun releaseMalformedCapability( selectionId: String, @@ -622,11 +628,7 @@ internal class AndroidLocalUploadPicker(context: Context) { "The persisted local file metadata changed.", ) } - if (!isDurableUploadCapabilityReady(source.phase)) { - throw AndroidLocalUploadCapabilityUnavailableException( - "The local file selection is pending capability cleanup.", - ) - } + requireDurableUploadCapabilityReady(source.phase) return source } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt index c8523bd14..7d525e5d8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt @@ -18,7 +18,7 @@ import org.json.JSONObject class AndroidDurableUploadCleanupPruningTest { @Test - fun `oversized terminal cleanup jobs remain eligible for paged capability recovery`() { + fun `oversized terminal cleanup state is quarantined before capability loading`() { val jobs = (1..1_025).map { index -> fixtureJob( index = index, @@ -30,26 +30,17 @@ class AndroidDurableUploadCleanupPruningTest { val storedIds = jobs.map { job -> job.request.file.selectionId } val scan = DurableUploadCapabilityRecoveryScan() - val first = scan.loadPage(emptyMap(), storedIds, maximumRows = 1_024) { + var loaded = 0 + val snapshot = scan.loadPage(emptyMap(), storedIds, maximumRows = 1_024) { + loaded += 1 CapabilityPhase.CleanupPending } - val second = scan.loadPage(emptyMap(), storedIds, maximumRows = 1_024) { - CapabilityPhase.CleanupPending - } - val recoverable = second.capabilities.count { (selectionId, phase) -> - shouldRecoverDurableUploadCapability( - phase = phase, - processGeneration = "prior-generation", - currentProcessGeneration = "current-generation", - ownedByDurableJob = selectionId in retained, - cleanupExplicitlyPending = true, - ) - } assertTrue(retained.isEmpty()) - assertFalse(first.scanComplete) - assertTrue(second.scanComplete) - assertEquals(1_025, recoverable) + assertFalse(snapshot.scanComplete) + assertTrue(snapshot.recoveryQuarantined) + assertTrue(snapshot.capabilities.isEmpty()) + assertEquals(0, loaded) } @Test diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt index 774ec9ba9..879e3d8f7 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt @@ -132,6 +132,32 @@ class AndroidDurableUploadSourcePreflightTest { assertEquals(0, starts) } + @Test + fun `pending ownership check defers the worker without releasing its capability`() = runBlocking { + var terminalDispositions = 0 + var transientRetries = 0 + + val result = processQueuedDurableUploadSource( + requireCapability = { + requireDurableUploadCapabilityReady(CapabilityPhase.OwnershipCheckPending) + }, + openSource = { error("A pending capability must not open its provider.") }, + onCapabilityUnavailable = { + terminalDispositions += 1 + "failed" + }, + onProviderUnavailable = { + transientRetries += 1 + "retried" + }, + onReady = { error("A pending capability must not start an upload.") }, + ) + + assertEquals("retried", result) + assertEquals(0, terminalDispositions) + assertEquals(1, transientRetries) + } + @Test fun `malformed capability metadata is terminal while storage failures stay retryable`() { assertFailsWith { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt index 9dc61a41a..48ece449c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt @@ -46,26 +46,35 @@ class AndroidLocalUploadCapabilityOverflowRecoveryTest { } @Test - fun `oversized state is scanned in bounded repeatable pages`() { + fun `oversized state is quarantined without retaining or loading unbounded rows`() { var rowLoads = 0 val storedIds = (1..1_025).map { index -> "selection-$index" } val scan = DurableUploadCapabilityRecoveryScan() - val first = scan.loadPage(emptyMap(), storedIds, maximumRows = 1_024) { + val snapshot = scan.loadPage(emptyMap(), storedIds, maximumRows = 1_024) { rowLoads += 1 "content://synthetic/$it" } - assertFalse(first.scanComplete) - assertEquals(1_025, first.trackedCapabilityCount) - assertEquals(1_024, rowLoads) + assertFalse(snapshot.scanComplete) + assertTrue(snapshot.recoveryQuarantined) + assertEquals(1_025, snapshot.trackedCapabilityCount) + assertTrue(snapshot.capabilities.isEmpty()) + assertTrue(snapshot.malformedCapabilities.isEmpty()) + assertEquals(0, rowLoads) + } - val second = scan.loadPage(emptyMap(), storedIds, maximumRows = 1_024) { - rowLoads += 1 - "content://synthetic/$it" - } - assertTrue(second.scanComplete) - assertEquals(1_025, second.capabilities.size) - assertEquals(1_025, rowLoads) + @Test + fun `malformed ciphertext without a permission identity remains quarantined without polling`() { + assertFalse( + malformedDurableUploadCapabilityCanBecomeActionable( + malformed("selection-corrupt").copy(cleanupPermissionIdentity = null), + ), + ) + assertTrue( + malformedDurableUploadCapabilityCanBecomeActionable( + malformed("selection-recoverable", "content://synthetic/recoverable"), + ), + ) } @Test From 815a9749364be453295cbe942b0c6762f9b57c28 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 10:19:16 +0200 Subject: [PATCH 45/53] fix(uploads): coalesce terminal recovery --- .../AndroidDurableMultipartUploads.kt | 26 +++++++++++++++++-- .../AndroidDurableUploadScheduling.kt | 23 ++++++++-------- .../AndroidDurableUploadWorker.kt | 7 ++++- .../AndroidLocalUploadPicker.kt | 7 +++-- ...AndroidDurableMultipartUploadPolicyTest.kt | 18 +++++++------ .../AndroidDurableUploadCleanupPruningTest.kt | 22 ++++++++++++++++ ...roidDurableUploadSchedulingRecoveryTest.kt | 6 ++--- 7 files changed, 82 insertions(+), 27 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 0028e5bb3..58e03fa63 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -130,10 +130,15 @@ internal class AndroidDurableMultipartUploads( .any { work -> !work.state.isFinished } }, cleanupCapability = { job -> - check(picker.release(job.request.file)) { + check( + reconcileTerminalDurableUploadCapabilityCleanup( + release = { onQuarantined -> picker.release(job.request.file, onQuarantined) }, + complete = { store.completeCapabilityCleanup(job.id) }, + retire = { store.remove(job.id) }, + ), + ) { "The durable upload capability cleanup remains pending." } - store.completeCapabilityCleanup(job.id) }, schedule = { job -> schedule(job).await() }, ) @@ -234,6 +239,23 @@ internal fun requestDurableUploadSchedulingRecoveryForQueuedStatuses( if (jobs.any { job -> job.state == DurableUploadState.Queued }) requestRecovery() } +internal fun reconcileTerminalDurableUploadCapabilityCleanup( + release: (onQuarantined: () -> Unit) -> Boolean, + complete: () -> Unit, + retire: () -> Unit, +): Boolean { + var quarantined = false + if (release { quarantined = true }) { + complete() + return true + } + if (quarantined) { + retire() + return true + } + return false +} + internal data class AndroidDurableMultipartUploadJob( val id: String, val accountId: String, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index fbb504acb..26774e12c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -43,7 +43,7 @@ internal const val ANDROID_DURABLE_UPLOAD_SCHEDULING_FOLLOW_UP_DELAY_MILLIS = 60 internal data class AndroidDurableUploadSchedulingRecoveryBatch( val immediate: Boolean, - val workIdsToAwait: List, + val workIdsToAwait: Map, ) internal sealed interface AndroidDurableUploadSchedulingRecoveryStep { @@ -60,7 +60,7 @@ internal class AndroidDurableUploadSchedulingRecoverySignal( private val monitor = Any() private val wakeups = Channel(Channel.CONFLATED) private var immediatePending = false - private val workIdsToAwait = linkedSetOf() + private val workIdsToAwait = linkedMapOf() fun request() { synchronized(monitor) { @@ -69,9 +69,10 @@ internal class AndroidDurableUploadSchedulingRecoverySignal( } } - fun requestAfterWorkStopsRunning(workId: UUID) { + fun requestAfterWorkStopsRunning(jobId: String, workId: UUID) { + require(jobId.isNotBlank()) synchronized(monitor) { - workIdsToAwait += workId + workIdsToAwait[jobId] = workId wakeups.trySend(Unit) } } @@ -105,7 +106,7 @@ internal class AndroidDurableUploadSchedulingRecoverySignal( } AndroidDurableUploadSchedulingRecoveryBatch( immediate = immediatePending, - workIdsToAwait = workIdsToAwait.toList(), + workIdsToAwait = workIdsToAwait.toMap(), ).also { immediatePending = false workIdsToAwait.clear() @@ -120,8 +121,8 @@ internal fun requestQueuedDurableUploadSchedulingRecovery() { ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL.request() } -internal fun requestQueuedDurableUploadSchedulingRecoveryAfterWorkStopsRunning(workId: UUID) { - ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL.requestAfterWorkStopsRunning(workId) +internal fun requestQueuedDurableUploadSchedulingRecoveryAfterWorkStopsRunning(jobId: String, workId: UUID) { + ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL.requestAfterWorkStopsRunning(jobId, workId) } internal suspend fun monitorQueuedDurableUploadScheduling( @@ -136,11 +137,11 @@ internal suspend fun monitorQueuedDurableUploadScheduling( require(workerFailureFollowUpDelayMillis > 0L) recover() var immediatePending = false - val workIdsToAwait = linkedSetOf() + val workIdsToAwait = linkedMapOf() fun addRequests(batch: AndroidDurableUploadSchedulingRecoveryBatch) { immediatePending = immediatePending || batch.immediate - workIdsToAwait += batch.workIdsToAwait + workIdsToAwait.putAll(batch.workIdsToAwait) } while (true) { @@ -152,7 +153,7 @@ internal suspend fun monitorQueuedDurableUploadScheduling( continue } - val workId = workIdsToAwait.first() + val (jobId, workId) = workIdsToAwait.entries.first() when (val step = recoverySignal.runUntilRequested { awaitWorkStopsRunning(workId) }) { AndroidDurableUploadSchedulingRecoveryStep.Completed -> Unit is AndroidDurableUploadSchedulingRecoveryStep.Interrupted -> { @@ -166,7 +167,7 @@ internal suspend fun monitorQueuedDurableUploadScheduling( } ) { AndroidDurableUploadSchedulingRecoveryStep.Completed -> { - workIdsToAwait.remove(workId) + workIdsToAwait.remove(jobId, workId) recover() } is AndroidDurableUploadSchedulingRecoveryStep.Interrupted -> addRequests(step.batch) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index 7255efe89..26221c770 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -23,7 +23,12 @@ internal class DeckAttachmentUploadWorker( ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result = runDurableUploadWorkerWithRecoverySignal( requestRecovery = { - requestQueuedDurableUploadSchedulingRecoveryAfterWorkStopsRunning(id) + val jobId = inputData.getString(KEY_JOB_ID)?.takeIf(String::isNotBlank) + if (jobId == null) { + requestQueuedDurableUploadSchedulingRecovery() + } else { + requestQueuedDurableUploadSchedulingRecoveryAfterWorkStopsRunning(jobId, id) + } }, ) { withContext(Dispatchers.IO) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index 49be68faa..ba1bb7de1 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -182,13 +182,13 @@ internal class AndroidLocalUploadPicker(context: Context) { requiredSource(file, useCachedSource = false) } - fun release(file: LocalUploadFile): Boolean = synchronized(CAPABILITY_LOCK) { + fun release(file: LocalUploadFile, onQuarantined: () -> Unit = {}): Boolean = synchronized(CAPABILITY_LOCK) { val source = try { selections[file.selectionId] ?: load(file.selectionId) } catch (cancelled: CancellationException) { throw cancelled } catch (malformed: AndroidLocalUploadCapabilityMalformedException) { - return@synchronized releaseMalformedCapability(file.selectionId, malformed) + return@synchronized releaseMalformedCapability(file.selectionId, malformed, onQuarantined) } catch (_: Exception) { return@synchronized retainCapabilityCleanup(file.selectionId) } @@ -470,6 +470,7 @@ internal class AndroidLocalUploadPicker(context: Context) { private fun releaseMalformedCapability( selectionId: String, malformed: AndroidLocalUploadCapabilityMalformedException, + onQuarantined: () -> Unit, ): Boolean { PENDING_CLEANUP_SELECTIONS += selectionId val snapshot = try { @@ -504,6 +505,8 @@ internal class AndroidLocalUploadPicker(context: Context) { if (recovered) { selections.remove(selectionId) PENDING_CLEANUP_SELECTIONS.remove(selectionId) + } else if (!malformedDurableUploadCapabilityCanBecomeActionable(isolated)) { + onQuarantined() } else { requestQueuedDurableUploadSchedulingRecovery() } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 09b97f571..20fbe21aa 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -565,13 +565,14 @@ class AndroidDurableMultipartUploadPolicyTest { @Test fun `coalesced immediate recovery preempts worker ownership and follow up waits`() = runBlocking { val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val jobId = "job-1" val workId = UUID.randomUUID() val expectedCancellation = CancellationException("recovery owner stopped") var recoveryRuns = 0 var ownershipWaits = 0 var delayRuns = 0 recoverySignal.request() - recoverySignal.requestAfterWorkStopsRunning(workId) + recoverySignal.requestAfterWorkStopsRunning(jobId, workId) val actual = assertFailsWith { monitorQueuedDurableUploadScheduling( @@ -598,21 +599,22 @@ class AndroidDurableMultipartUploadPolicyTest { } @Test - fun `recovery signal conflates immediate requests and deduplicates worker ids`() = runBlocking { + fun `recovery signal conflates immediate requests and replacement workers per durable job`() = runBlocking { val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() - val firstWorkId = UUID.randomUUID() - val secondWorkId = UUID.randomUUID() + val replacedWorkId = UUID.randomUUID() + val replacementWorkId = UUID.randomUUID() + val otherWorkId = UUID.randomUUID() recoverySignal.request() recoverySignal.request() - recoverySignal.requestAfterWorkStopsRunning(firstWorkId) - recoverySignal.requestAfterWorkStopsRunning(firstWorkId) - recoverySignal.requestAfterWorkStopsRunning(secondWorkId) + recoverySignal.requestAfterWorkStopsRunning("job-1", replacedWorkId) + repeat(100) { recoverySignal.requestAfterWorkStopsRunning("job-1", replacementWorkId) } + recoverySignal.requestAfterWorkStopsRunning("job-2", otherWorkId) assertEquals( AndroidDurableUploadSchedulingRecoveryBatch( immediate = true, - workIdsToAwait = listOf(firstWorkId, secondWorkId), + workIdsToAwait = mapOf("job-1" to replacementWorkId, "job-2" to otherWorkId), ), recoverySignal.await(), ) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt index 7d525e5d8..3638d2d83 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt @@ -60,6 +60,28 @@ class AndroidDurableUploadCleanupPruningTest { assertEquals(listOf(pending), cleaned) } + @Test + fun `permanently malformed terminal cleanup retires only its durable row`() { + val storage = MemoryStorage() + val store = AndroidDurableMultipartUploadStore(storage, PlaintextCipher) + val quarantined = fixtureJob(index = 1, cleanupPending = true) + val retained = fixtureJob(index = 2, cleanupPending = true) + store.add(quarantined) + store.add(retained) + + val reconciled = reconcileTerminalDurableUploadCapabilityCleanup( + release = { onQuarantined -> + onQuarantined() + false + }, + complete = { error("Malformed capability metadata must remain quarantined.") }, + retire = { store.remove(quarantined.id) }, + ) + + assertTrue(reconciled) + assertEquals(listOf(retained), AndroidDurableMultipartUploadStore(storage, PlaintextCipher).list()) + } + @Test fun `only queued uploads are eligible for connected upload work`() { val queued = fixtureJob(index = 1, state = DurableUploadState.Queued) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt index dadf4d0fe..0c03e50a0 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt @@ -22,7 +22,7 @@ class AndroidDurableUploadSchedulingRecoveryTest { releaseBatchClaim.await() } val workId = UUID.randomUUID() - recoverySignal.requestAfterWorkStopsRunning(workId) + recoverySignal.requestAfterWorkStopsRunning("job-1", workId) val firstBatch = async { recoverySignal.await() } wakeupConsumed.await() @@ -32,7 +32,7 @@ class AndroidDurableUploadSchedulingRecoveryTest { assertEquals( AndroidDurableUploadSchedulingRecoveryBatch( immediate = true, - workIdsToAwait = listOf(workId), + workIdsToAwait = mapOf("job-1" to workId), ), firstBatch.await(), ) @@ -50,7 +50,7 @@ class AndroidDurableUploadSchedulingRecoveryTest { val expected = CancellationException("monitor stopped after immediate recovery") var recoveryRuns = 0 - recoverySignal.requestAfterWorkStopsRunning(workId) + recoverySignal.requestAfterWorkStopsRunning("job-1", workId) val monitoring = async { assertFailsWith { monitorQueuedDurableUploadScheduling( From 94b3f5c6415e42564fc57e1b6e6fdab9e119bb1e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 11:33:34 +0200 Subject: [PATCH 46/53] fix(uploads): quarantine unknowable capability cleanup --- .../AndroidDurableMultipartUploads.kt | 4 +- .../AndroidDurableUploadAccountCleanup.kt | 13 +++- .../AndroidLocalUploadCapabilityRead.kt | 22 ++++++ .../AndroidLocalUploadCapabilityRecovery.kt | 30 ++++++++ .../AndroidLocalUploadPicker.kt | 68 +++++++++---------- .../AndroidDurableUploadCleanupPruningTest.kt | 24 +++++-- ...calUploadCapabilityOverflowRecoveryTest.kt | 12 ++++ 7 files changed, 131 insertions(+), 42 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 58e03fa63..b419ca0c1 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -134,7 +134,6 @@ internal class AndroidDurableMultipartUploads( reconcileTerminalDurableUploadCapabilityCleanup( release = { onQuarantined -> picker.release(job.request.file, onQuarantined) }, complete = { store.completeCapabilityCleanup(job.id) }, - retire = { store.remove(job.id) }, ), ) { "The durable upload capability cleanup remains pending." @@ -242,7 +241,6 @@ internal fun requestDurableUploadSchedulingRecoveryForQueuedStatuses( internal fun reconcileTerminalDurableUploadCapabilityCleanup( release: (onQuarantined: () -> Unit) -> Boolean, complete: () -> Unit, - retire: () -> Unit, ): Boolean { var quarantined = false if (release { quarantined = true }) { @@ -250,7 +248,7 @@ internal fun reconcileTerminalDurableUploadCapabilityCleanup( return true } if (quarantined) { - retire() + complete() return true } return false diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt index 926b6c9ba..d12aa609c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt @@ -15,12 +15,23 @@ internal class AndroidDurableUploadAccountCleanup(context: Context) { cancelWork = { job -> WorkManager.getInstance(appContext).cancelUniqueWork(durableUploadWorkName(job.id)).await() }, - releaseCapability = { job -> picker.release(job.request.file) }, + releaseCapability = { job -> + releaseAndroidDurableUploadCapabilityForAccountRemoval { onQuarantined -> + picker.release(job.request.file, onQuarantined) + } + }, removeJob = store::remove, ) } } +internal fun releaseAndroidDurableUploadCapabilityForAccountRemoval( + release: (onQuarantined: () -> Unit) -> Boolean, +): Boolean { + var quarantined = false + return release { quarantined = true } || quarantined +} + internal suspend fun removeAndroidDurableUploadJobs( jobs: List, cancelWork: suspend (AndroidDurableMultipartUploadJob) -> Unit, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt index 851100947..79d2acba5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative import kotlinx.coroutines.CancellationException +import org.json.JSONObject internal class AndroidLocalUploadCapabilityUnavailableException( message: String, @@ -67,3 +68,24 @@ internal inline fun readAndroidLocalUploadCapability(load: () -> Result failure, ) } + +internal fun JSONObject.optionalStrictString(key: String): String? { + if (!has(key) || isNull(key)) return null + return requireStrictString(key) +} + +internal fun JSONObject.requireStrictString(key: String): String = get(key).let { value -> + require(value is String) { "The $key value changed type." } + value +} + +internal fun JSONObject.optionalStrictBoolean(key: String): Boolean? { + if (!has(key) || isNull(key)) return null + return get(key).let { value -> + require(value is Boolean) { "The $key value changed type." } + value + } +} + +internal fun persistedDurableUploadGrantPreExisting(payload: JSONObject): Boolean = + payload.optionalStrictBoolean("grantPreExisting") ?: false diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt index 7770aec78..c7a45bb75 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt @@ -197,6 +197,36 @@ internal fun malformedDurableUploadPeerBlocksDirectCleanup( samePermission = samePermission, ) != DurableUploadPermissionPeerProtection.None +internal enum class DurableUploadMalformedPeerCleanupDisposition { + Proceed, + Retry, + Quarantine, +} + +internal fun durableUploadMalformedPeerCleanupDisposition( + malformedPeers: Iterable>, + targetSelectionId: String, + targetPermission: Permission, + samePermission: (Permission, Permission) -> Boolean, +): DurableUploadMalformedPeerCleanupDisposition { + val peers = malformedPeers.toList() + if (peers.any { peer -> peer.selectionId != targetSelectionId && peer.permission == null }) { + return DurableUploadMalformedPeerCleanupDisposition.Quarantine + } + return if ( + malformedDurableUploadPeerBlocksDirectCleanup( + peers, + targetSelectionId, + targetPermission, + samePermission, + ) + ) { + DurableUploadMalformedPeerCleanupDisposition.Retry + } else { + DurableUploadMalformedPeerCleanupDisposition.Proceed + } +} + internal enum class DurableUploadPermissionCleanupPlan { ReleaseThenRemove, RemoveWithoutRelease, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index ba1bb7de1..2b7a2d948 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -212,13 +212,16 @@ internal class AndroidLocalUploadPicker(context: Context) { } catch (_: Exception) { return@synchronized retainCapabilityCleanup(file.selectionId) } - val malformedPeerBlocksCleanup = malformedPeerBlocksDirectCleanup( + when (malformedPeerCleanupDisposition( malformedCapabilities = snapshot.malformedCapabilities, targetSelectionId = file.selectionId, targetPermissionIdentity = source.uri.toString(), - ) - if (malformedPeerBlocksCleanup) { - return@synchronized retainCapabilityCleanup(file.selectionId) + )) { + DurableUploadMalformedPeerCleanupDisposition.Quarantine -> + return@synchronized quarantineCapabilityCleanup(file.selectionId, onQuarantined) + DurableUploadMalformedPeerCleanupDisposition.Retry -> + return@synchronized retainCapabilityCleanup(file.selectionId) + DurableUploadMalformedPeerCleanupDisposition.Proceed -> Unit } val cleanupPlan = durableUploadPermissionCleanupPlan( grantPreExisting = source.grantPreExisting, @@ -361,6 +364,16 @@ internal class AndroidLocalUploadPicker(context: Context) { ownedByDurableJob = ownedByDurableJob, cleanupExplicitlyPending = selectionId in PENDING_CLEANUP_SELECTIONS, )) return@forEach + if ( + malformedPeerCleanupDisposition( + malformedCapabilities, + selectionId, + capability.uri.toString(), + ) == DurableUploadMalformedPeerCleanupDisposition.Quarantine + ) { + PENDING_CLEANUP_SELECTIONS.remove(selectionId) + return@forEach + } val cleanupPlan = durableUploadPermissionCleanupPlan( grantPreExisting = capability.grantPreExisting, peerProtection = permissionPeerProtection( @@ -427,6 +440,12 @@ internal class AndroidLocalUploadPicker(context: Context) { return retainDurableUploadCapabilityCleanup(::requestQueuedDurableUploadSchedulingRecovery) } + private fun quarantineCapabilityCleanup(selectionId: String, onQuarantined: () -> Unit): Boolean { + PENDING_CLEANUP_SELECTIONS.remove(selectionId) + onQuarantined() + return false + } + private fun releasePermission(uri: Uri) { resolver.releasePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) } @@ -487,15 +506,17 @@ internal class AndroidLocalUploadPicker(context: Context) { malformed.grantPreExisting, ) val permissionIdentity = isolated.cleanupPermissionIdentity - if ( - permissionIdentity != null && - malformedPeerBlocksDirectCleanup( + if (permissionIdentity != null) { + when (malformedPeerCleanupDisposition( malformedCapabilities = snapshot.malformedCapabilities, targetSelectionId = selectionId, targetPermissionIdentity = permissionIdentity, - ) - ) { - return retainCapabilityCleanup(selectionId) + )) { + DurableUploadMalformedPeerCleanupDisposition.Quarantine -> + return quarantineCapabilityCleanup(selectionId, onQuarantined) + DurableUploadMalformedPeerCleanupDisposition.Retry -> return retainCapabilityCleanup(selectionId) + DurableUploadMalformedPeerCleanupDisposition.Proceed -> Unit + } } val recovered = recoverMalformedCapability( isolated, @@ -506,7 +527,7 @@ internal class AndroidLocalUploadPicker(context: Context) { selections.remove(selectionId) PENDING_CLEANUP_SELECTIONS.remove(selectionId) } else if (!malformedDurableUploadCapabilityCanBecomeActionable(isolated)) { - onQuarantined() + return quarantineCapabilityCleanup(selectionId, onQuarantined) } else { requestQueuedDurableUploadSchedulingRecovery() } @@ -596,11 +617,11 @@ internal class AndroidLocalUploadPicker(context: Context) { samePermission = String::equals, ) - private fun malformedPeerBlocksDirectCleanup( + private fun malformedPeerCleanupDisposition( malformedCapabilities: Map, targetSelectionId: String, targetPermissionIdentity: String, - ): Boolean = malformedDurableUploadPeerBlocksDirectCleanup( + ): DurableUploadMalformedPeerCleanupDisposition = durableUploadMalformedPeerCleanupDisposition( malformedPeers = malformedCapabilities.values.asSequence().map { capability -> DurableUploadPermissionPeer( capability.selectionId, @@ -732,27 +753,6 @@ private fun requireSafeProcessGeneration(value: String) { } } -internal fun JSONObject.optionalStrictString(key: String): String? { - if (!has(key) || isNull(key)) return null - return requireStrictString(key) -} - -internal fun JSONObject.requireStrictString(key: String): String = get(key).let { value -> - require(value is String) { "The $key value changed type." } - value -} - -internal fun JSONObject.optionalStrictBoolean(key: String): Boolean? { - if (!has(key) || isNull(key)) return null - return get(key).let { value -> - require(value is Boolean) { "The $key value changed type." } - value - } -} - -internal fun persistedDurableUploadGrantPreExisting(payload: JSONObject): Boolean = - payload.optionalStrictBoolean("grantPreExisting") ?: false - internal fun resumeLocalUploadSelectionResult( continuation: CancellableContinuation, result: LocalUploadSelectionResult, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt index 3638d2d83..a25e708f1 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadCleanupPruningTest.kt @@ -61,7 +61,7 @@ class AndroidDurableUploadCleanupPruningTest { } @Test - fun `permanently malformed terminal cleanup retires only its durable row`() { + fun `permanently malformed terminal cleanup preserves its terminal status`() { val storage = MemoryStorage() val store = AndroidDurableMultipartUploadStore(storage, PlaintextCipher) val quarantined = fixtureJob(index = 1, cleanupPending = true) @@ -74,12 +74,28 @@ class AndroidDurableUploadCleanupPruningTest { onQuarantined() false }, - complete = { error("Malformed capability metadata must remain quarantined.") }, - retire = { store.remove(quarantined.id) }, + complete = { store.completeCapabilityCleanup(quarantined.id) }, ) assertTrue(reconciled) - assertEquals(listOf(retained), AndroidDurableMultipartUploadStore(storage, PlaintextCipher).list()) + assertEquals( + listOf(quarantined.copy(capabilityCleanupPending = false), retained), + AndroidDurableMultipartUploadStore(storage, PlaintextCipher).list(), + ) + } + + @Test + fun `account cleanup accepts a quarantined unknowable capability`() { + var quarantined = false + + val ready = releaseAndroidDurableUploadCapabilityForAccountRemoval { onQuarantined -> + onQuarantined() + quarantined = true + false + } + + assertTrue(ready) + assertTrue(quarantined) } @Test diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt index 48ece449c..a294ad235 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt @@ -192,6 +192,18 @@ class AndroidLocalUploadCapabilityOverflowRecoveryTest { } } + @Test + fun `unknowable malformed peer quarantines blocked cleanup without polling`() { + val disposition = durableUploadMalformedPeerCleanupDisposition( + malformedPeers = listOf(peer("selection-unknown", permission = null, grantPreExisting = null)), + targetSelectionId = "selection-valid", + targetPermission = "content://synthetic/valid", + samePermission = String::equals, + ) + + assertEquals(DurableUploadMalformedPeerCleanupDisposition.Quarantine, disposition) + } + @Test fun `peer protection keeps ambiguous provenance distinct from a retained app grant`() { val sharedUri = "content://synthetic/shared" From f4216a3cb2e0c8566ebb230546dbd632bcc4a2b2 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 13:29:05 +0200 Subject: [PATCH 47/53] fix(uploads): terminate quarantined cleanup --- .../AndroidDurableMultipartUploads.kt | 30 +++++-- .../AndroidDurableUploadWorker.kt | 36 +++++--- .../AndroidLocalUploadCapabilityRecovery.kt | 60 ++++++++++++- .../AndroidLocalUploadPicker.kt | 80 ++++++++--------- ...AndroidDurableMultipartUploadPolicyTest.kt | 2 +- ...roidDurableUploadTerminalCapabilityTest.kt | 47 +++++++++- ...calUploadCapabilityOverflowRecoveryTest.kt | 89 +++++++++++++++++++ 7 files changed, 277 insertions(+), 67 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index b419ca0c1..9c5aaf973 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -153,9 +153,10 @@ internal class AndroidDurableMultipartUploads( ) { return false } - if (!picker.release(job.request.file)) return false - store.remove(uploadId) - return true + return dismissTerminalDurableUploadStatus( + release = { onQuarantined -> picker.release(job.request.file, onQuarantined) }, + removeStatus = { store.remove(uploadId) }, + ) } private fun schedule( @@ -242,18 +243,29 @@ internal fun reconcileTerminalDurableUploadCapabilityCleanup( release: (onQuarantined: () -> Unit) -> Boolean, complete: () -> Unit, ): Boolean { - var quarantined = false - if (release { quarantined = true }) { - complete() - return true - } - if (quarantined) { + if (releaseOrQuarantineDurableUploadCapability(release)) { complete() return true } return false } +internal fun releaseOrQuarantineDurableUploadCapability( + release: (onQuarantined: () -> Unit) -> Boolean, +): Boolean { + var quarantined = false + return release { quarantined = true } || quarantined +} + +internal fun dismissTerminalDurableUploadStatus( + release: (onQuarantined: () -> Unit) -> Boolean, + removeStatus: () -> Unit, +): Boolean { + if (!releaseOrQuarantineDurableUploadCapability(release)) return false + removeStatus() + return true +} + internal data class AndroidDurableMultipartUploadJob( val id: String, val accountId: String, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index 26221c770..223f85af7 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -55,8 +55,8 @@ internal class DeckAttachmentUploadWorker( accountId = initial.accountId, jobId = jobId, ) - return resultAfterDurableUploadCapabilityRelease( - releaseCapability = { picker.release(initial.request.file) }, + return resultAfterDurableUploadCapabilityReleaseOrQuarantine( + releaseCapability = { onQuarantined -> picker.release(initial.request.file, onQuarantined) }, completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, onCleanupRetained = ::requestQueuedDurableUploadSchedulingRecovery, releasedResult = Result.success(), @@ -64,8 +64,8 @@ internal class DeckAttachmentUploadWorker( ) } if (initial.state != DurableUploadState.Queued) { - return resultAfterDurableUploadCapabilityRelease( - releaseCapability = { picker.release(initial.request.file) }, + return resultAfterDurableUploadCapabilityReleaseOrQuarantine( + releaseCapability = { onQuarantined -> picker.release(initial.request.file, onQuarantined) }, completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, onCleanupRetained = ::requestQueuedDurableUploadSchedulingRecovery, releasedResult = Result.success(), @@ -138,7 +138,7 @@ internal class DeckAttachmentUploadWorker( message = "The account used for this upload is no longer available.", ) }, - releaseSelection = { picker.release(initial.request.file) }, + releaseSelection = { onQuarantined -> picker.release(initial.request.file, onQuarantined) }, completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, onCleanupRetained = ::requestQueuedDurableUploadSchedulingRecovery, recordFailure = { @@ -170,8 +170,8 @@ internal class DeckAttachmentUploadWorker( accountId = initial.accountId, jobId = jobId, ) - resultAfterDurableUploadCapabilityRelease( - releaseCapability = { picker.release(initial.request.file) }, + resultAfterDurableUploadCapabilityReleaseOrQuarantine( + releaseCapability = { onQuarantined -> picker.release(initial.request.file, onQuarantined) }, completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, onCleanupRetained = ::requestQueuedDurableUploadSchedulingRecovery, releasedResult = Result.failure(), @@ -269,8 +269,8 @@ internal class DeckAttachmentUploadWorker( failure = failure, ) } - return resultAfterDurableUploadCapabilityRelease( - releaseCapability = { picker.release(started.request.file) }, + return resultAfterDurableUploadCapabilityReleaseOrQuarantine( + releaseCapability = { onQuarantined -> picker.release(started.request.file, onQuarantined) }, completeCapabilityCleanup = { store.completeCapabilityCleanup(jobId) }, onCleanupRetained = ::requestQueuedDurableUploadSchedulingRecovery, releasedResult = Result.success(), @@ -309,7 +309,7 @@ internal class DeckAttachmentUploadWorker( internal fun failQueuedDurableUploadForUnavailableAccount( transitionToFailed: () -> Unit, - releaseSelection: () -> Boolean, + releaseSelection: (onQuarantined: () -> Unit) -> Boolean, completeCapabilityCleanup: () -> Unit = {}, onCleanupRetained: () -> Unit = {}, recordFailure: () -> Unit, @@ -317,7 +317,7 @@ internal fun failQueuedDurableUploadForUnavailableAccount( retryResult: Result, ): Result { transitionToFailed() - val result = resultAfterDurableUploadCapabilityRelease( + val result = resultAfterDurableUploadCapabilityReleaseOrQuarantine( releaseCapability = releaseSelection, completeCapabilityCleanup = completeCapabilityCleanup, onCleanupRetained = onCleanupRetained, @@ -349,6 +349,20 @@ internal fun resultAfterDurableUploadCapabilityRelease( retainedResult } +internal fun resultAfterDurableUploadCapabilityReleaseOrQuarantine( + releaseCapability: (onQuarantined: () -> Unit) -> Boolean, + completeCapabilityCleanup: () -> Unit = {}, + onCleanupRetained: () -> Unit = {}, + releasedResult: Result, + retainedResult: Result, +): Result = resultAfterDurableUploadCapabilityRelease( + releaseCapability = { releaseOrQuarantineDurableUploadCapability(releaseCapability) }, + completeCapabilityCleanup = completeCapabilityCleanup, + onCleanupRetained = onCleanupRetained, + releasedResult = releasedResult, + retainedResult = retainedResult, +) + internal suspend fun processQueuedDurableUploadSource( requireCapability: () -> Unit, openSource: () -> Unit, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt index c7a45bb75..f0c593c78 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt @@ -94,10 +94,59 @@ internal data class DurableUploadCapabilitySnapshot( get() = storedCapabilityCount ?: (capabilities.keys + malformedCapabilities.keys).size } +internal class DurableUploadCapabilityOverflowException : IllegalStateException( + "Too many picker capabilities are pending bounded recovery.", +) + internal fun malformedDurableUploadCapabilityCanBecomeActionable( capability: MalformedDurableUploadCapability, ): Boolean = capability.cleanupPermissionIdentity != null +internal enum class DurableUploadMalformedRecoveryDisposition { + Recover, + Retry, + Quarantine, +} + +internal enum class DurableUploadMalformedReleaseResult { + Released, + Retry, + Quarantine, +} + +internal fun releaseMalformedDurableUploadCapability( + disposition: DurableUploadMalformedRecoveryDisposition, + recover: () -> Boolean, +): DurableUploadMalformedReleaseResult = when (disposition) { + DurableUploadMalformedRecoveryDisposition.Recover -> if (recover()) { + DurableUploadMalformedReleaseResult.Released + } else { + DurableUploadMalformedReleaseResult.Retry + } + DurableUploadMalformedRecoveryDisposition.Retry -> DurableUploadMalformedReleaseResult.Retry + DurableUploadMalformedRecoveryDisposition.Quarantine -> DurableUploadMalformedReleaseResult.Quarantine +} + +internal fun durableUploadMalformedRecoveryDisposition( + grantPreExisting: Boolean?, + peerProtection: DurableUploadPermissionPeerProtection, + permissionAbsent: Boolean?, +): DurableUploadMalformedRecoveryDisposition { + val cleanupPlan = durableUploadPermissionCleanupPlan( + grantPreExisting = grantPreExisting, + peerProtection = peerProtection, + permissionAbsent = permissionAbsent == true, + ) + return when { + cleanupPlan != DurableUploadPermissionCleanupPlan.Retain -> + DurableUploadMalformedRecoveryDisposition.Recover + permissionAbsent == null -> DurableUploadMalformedRecoveryDisposition.Retry + grantPreExisting == null && peerProtection == DurableUploadPermissionPeerProtection.None -> + DurableUploadMalformedRecoveryDisposition.Quarantine + else -> DurableUploadMalformedRecoveryDisposition.Quarantine + } +} + internal fun loadDurableUploadCapabilitySnapshot( cachedCapabilities: Map, storedSelectionIds: Iterable, @@ -105,9 +154,14 @@ internal fun loadDurableUploadCapabilitySnapshot( loadStoredCapability: (String) -> Capability?, ): DurableUploadCapabilitySnapshot { require(maximumRecoverableCapabilities > 0) - val storedIds = storedSelectionIds.toList() - require((cachedCapabilities.keys + storedIds).size <= maximumRecoverableCapabilities) { - "Too many picker capabilities are pending bounded recovery." + val trackedIds = cachedCapabilities.keys.toMutableSet() + if (trackedIds.size > maximumRecoverableCapabilities) throw DurableUploadCapabilityOverflowException() + val storedIds = linkedSetOf() + storedSelectionIds.forEach { selectionId -> + storedIds += selectionId + if (trackedIds.add(selectionId) && trackedIds.size > maximumRecoverableCapabilities) { + throw DurableUploadCapabilityOverflowException() + } } val capabilities = cachedCapabilities.toMutableMap() val malformed = linkedMapOf() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index 2b7a2d948..d22081494 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -209,6 +209,8 @@ internal class AndroidLocalUploadPicker(context: Context) { loadCapabilitySnapshot() } catch (cancelled: CancellationException) { throw cancelled + } catch (_: DurableUploadCapabilityOverflowException) { + return@synchronized quarantineCapabilityCleanup(file.selectionId, onQuarantined) } catch (_: Exception) { return@synchronized retainCapabilityCleanup(file.selectionId) } @@ -286,9 +288,6 @@ internal class AndroidLocalUploadPicker(context: Context) { } if (snapshot.malformedCapabilities.isNotEmpty()) { PENDING_CLEANUP_SELECTIONS += snapshot.malformedCapabilities.keys - if (snapshot.malformedCapabilities.values.any(::malformedDurableUploadCapabilityCanBecomeActionable)) { - requestQueuedDurableUploadSchedulingRecovery() - } } if (snapshot.recoveryQuarantined) return@synchronized true if (!snapshot.scanComplete) { @@ -304,8 +303,10 @@ internal class AndroidLocalUploadPicker(context: Context) { ownedSelectionIds, ) malformedRecovery.forEach { malformed -> - if (!malformedRecoveryIsActionable(malformed, capabilities, malformedCapabilities)) { - if (malformedDurableUploadCapabilityCanBecomeActionable(malformed)) allRecovered = false + val disposition = malformedRecoveryDisposition(malformed, capabilities, malformedCapabilities) + if (disposition != DurableUploadMalformedRecoveryDisposition.Recover) { + if (disposition == DurableUploadMalformedRecoveryDisposition.Retry) allRecovered = false + else PENDING_CLEANUP_SELECTIONS.remove(malformed.selectionId) return@forEach } if (remainingRecoveryActions == 0) { @@ -455,7 +456,7 @@ internal class AndroidLocalUploadPicker(context: Context) { } private fun loadCapabilitySnapshot(): DurableUploadCapabilitySnapshot { - val storedSelectionIds = storedCapabilitySelectionIds() + val storedSelectionIds = storedCapabilitySelectionIds(MAX_RECOVERABLE_CAPABILITIES) val snapshot = loadDurableUploadCapabilitySnapshot( cachedCapabilities = selections.toMap(), storedSelectionIds = storedSelectionIds, @@ -496,6 +497,8 @@ internal class AndroidLocalUploadPicker(context: Context) { loadCapabilitySnapshot() } catch (cancelled: CancellationException) { throw cancelled + } catch (_: DurableUploadCapabilityOverflowException) { + return quarantineCapabilityCleanup(selectionId, onQuarantined) } catch (_: Exception) { return retainCapabilityCleanup(selectionId) } @@ -505,33 +508,28 @@ internal class AndroidLocalUploadPicker(context: Context) { malformed.cleanupPermissionIdentity, malformed.grantPreExisting, ) - val permissionIdentity = isolated.cleanupPermissionIdentity - if (permissionIdentity != null) { - when (malformedPeerCleanupDisposition( - malformedCapabilities = snapshot.malformedCapabilities, - targetSelectionId = selectionId, - targetPermissionIdentity = permissionIdentity, - )) { - DurableUploadMalformedPeerCleanupDisposition.Quarantine -> - return quarantineCapabilityCleanup(selectionId, onQuarantined) - DurableUploadMalformedPeerCleanupDisposition.Retry -> return retainCapabilityCleanup(selectionId) - DurableUploadMalformedPeerCleanupDisposition.Proceed -> Unit + return when (releaseMalformedDurableUploadCapability( + disposition = malformedRecoveryDisposition( + isolated, + snapshot.capabilities, + snapshot.malformedCapabilities, + ), + recover = { + recoverMalformedCapability( + isolated, + snapshot.capabilities, + snapshot.malformedCapabilities, + ) + }, + )) { + DurableUploadMalformedReleaseResult.Released -> true.also { + selections.remove(selectionId) + PENDING_CLEANUP_SELECTIONS.remove(selectionId) } + DurableUploadMalformedReleaseResult.Retry -> retainCapabilityCleanup(selectionId) + DurableUploadMalformedReleaseResult.Quarantine -> + quarantineCapabilityCleanup(selectionId, onQuarantined) } - val recovered = recoverMalformedCapability( - isolated, - snapshot.capabilities, - emptyMap(), - ) - if (recovered) { - selections.remove(selectionId) - PENDING_CLEANUP_SELECTIONS.remove(selectionId) - } else if (!malformedDurableUploadCapabilityCanBecomeActionable(isolated)) { - return quarantineCapabilityCleanup(selectionId, onQuarantined) - } else { - requestQueuedDurableUploadSchedulingRecovery() - } - return recovered } private fun recoverMalformedCapability( @@ -558,19 +556,23 @@ internal class AndroidLocalUploadPicker(context: Context) { ) } - private fun malformedRecoveryIsActionable( + private fun malformedRecoveryDisposition( malformed: MalformedDurableUploadCapability, capabilities: Map, malformedCapabilities: Map, - ): Boolean { - val permission = malformed.cleanupPermissionIdentity?.let(Uri::parse) ?: return false + ): DurableUploadMalformedRecoveryDisposition { + val permission = malformed.cleanupPermissionIdentity?.let(Uri::parse) + ?: return DurableUploadMalformedRecoveryDisposition.Quarantine + if (malformedPeerCleanupDisposition(malformedCapabilities, malformed.selectionId, permission.toString()) == + DurableUploadMalformedPeerCleanupDisposition.Quarantine + ) return DurableUploadMalformedRecoveryDisposition.Quarantine val peerProtection = permissionPeerProtection( capabilities = capabilities, malformedCapabilities = malformedCapabilities, targetSelectionId = malformed.selectionId, targetPermissionIdentity = permission.toString(), ) - val permissionAbsent = if ( + val permissionAbsent: Boolean? = if ( malformed.grantPreExisting == null && peerProtection == DurableUploadPermissionPeerProtection.None ) { @@ -579,16 +581,12 @@ internal class AndroidLocalUploadPicker(context: Context) { } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { - false + null } } else { false } - return durableUploadPermissionCleanupPlan( - malformed.grantPreExisting, - peerProtection, - permissionAbsent, - ) != DurableUploadPermissionCleanupPlan.Retain + return durableUploadMalformedRecoveryDisposition(malformed.grantPreExisting, peerProtection, permissionAbsent) } private fun permissionPeerProtection( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 20fbe21aa..23decc2ec 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -396,7 +396,7 @@ class AndroidDurableMultipartUploadPolicyTest { val result = failQueuedDurableUploadForUnavailableAccount( transitionToFailed = { events += "fail" }, - releaseSelection = { + releaseSelection = { _ -> events += "release" true }, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadTerminalCapabilityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadTerminalCapabilityTest.kt index 5b38d77b2..bb7ef5b98 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadTerminalCapabilityTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadTerminalCapabilityTest.kt @@ -2,6 +2,8 @@ package dev.obiente.nextcloudnative import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class AndroidDurableUploadTerminalCapabilityTest { @Test @@ -9,7 +11,7 @@ class AndroidDurableUploadTerminalCapabilityTest { var releaseAttempts = 0 var recoveryRequests = 0 - val result = resultAfterDurableUploadCapabilityRelease( + val result = resultAfterDurableUploadCapabilityReleaseOrQuarantine( releaseCapability = { releaseAttempts += 1 false @@ -41,13 +43,54 @@ class AndroidDurableUploadTerminalCapabilityTest { assertEquals(1, releaseAttempts) } + @Test + fun `terminal worker finishes and commits cleanup after capability quarantine`() { + val events = mutableListOf() + + val result = resultAfterDurableUploadCapabilityReleaseOrQuarantine( + releaseCapability = { onQuarantined -> + events += "quarantine" + onQuarantined() + false + }, + completeCapabilityCleanup = { events += "complete" }, + onCleanupRetained = { events += "retry" }, + releasedResult = "finished", + retainedResult = "retry", + ) + + assertEquals("finished", result) + assertEquals(listOf("quarantine", "complete"), events) + } + + @Test + fun `terminal status dismissal accepts quarantine but not transient cleanup failure`() { + val events = mutableListOf() + assertTrue( + dismissTerminalDurableUploadStatus( + release = { onQuarantined -> + onQuarantined() + false + }, + removeStatus = { events += "remove" }, + ), + ) + assertFalse( + dismissTerminalDurableUploadStatus( + release = { false }, + removeStatus = { events += "unexpected" }, + ), + ) + assertEquals(listOf("remove"), events) + } + @Test fun `removed account retries after terminal transition when release is retained`() { val events = mutableListOf() val result = failQueuedDurableUploadForUnavailableAccount( transitionToFailed = { events += "fail" }, - releaseSelection = { + releaseSelection = { _ -> events += "release" false }, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt index a294ad235..cb1cf8a8a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt @@ -63,6 +63,30 @@ class AndroidLocalUploadCapabilityOverflowRecoveryTest { assertEquals(0, rowLoads) } + @Test + fun `direct snapshot overflow is distinct from a transient row failure`() { + var rowLoads = 0 + + assertFailsWith { + loadDurableUploadCapabilitySnapshot( + cachedCapabilities = emptyMap(), + storedSelectionIds = listOf("selection-one", "selection-two"), + maximumRecoverableCapabilities = 1, + loadStoredCapability = { rowLoads += 1 }, + ) + } + assertEquals(0, rowLoads) + assertFailsWith { + loadDurableUploadCapabilitySnapshot( + cachedCapabilities = mapOf("selection-cached" to "content://synthetic/cached"), + storedSelectionIds = listOf("selection-cached", "selection-stored"), + maximumRecoverableCapabilities = 1, + loadStoredCapability = { rowLoads += 1 }, + ) + } + assertEquals(0, rowLoads) + } + @Test fun `malformed ciphertext without a permission identity remains quarantined without polling`() { assertFalse( @@ -77,6 +101,40 @@ class AndroidLocalUploadCapabilityOverflowRecoveryTest { ) } + @Test + fun `unknown grant provenance is quarantined while a transient permission read retries`() { + assertEquals( + DurableUploadMalformedRecoveryDisposition.Quarantine, + durableUploadMalformedRecoveryDisposition( + grantPreExisting = null, + peerProtection = DurableUploadPermissionPeerProtection.None, + permissionAbsent = false, + ), + ) + assertEquals( + DurableUploadMalformedRecoveryDisposition.Retry, + durableUploadMalformedRecoveryDisposition( + grantPreExisting = null, + peerProtection = DurableUploadPermissionPeerProtection.None, + permissionAbsent = null, + ), + ) + assertEquals( + DurableUploadMalformedRecoveryDisposition.Recover, + durableUploadMalformedRecoveryDisposition( + grantPreExisting = null, + peerProtection = DurableUploadPermissionPeerProtection.None, + permissionAbsent = true, + ), + ) + assertEquals( + DurableUploadMalformedReleaseResult.Quarantine, + releaseMalformedDurableUploadCapability(DurableUploadMalformedRecoveryDisposition.Quarantine) { + error("quarantined unknown provenance must not attempt recovery") + }, + ) + } + @Test fun `paged recovery isolates malformed rows without swallowing transient failures`() { val scan = DurableUploadCapabilityRecoveryScan() @@ -227,6 +285,37 @@ class AndroidLocalUploadCapabilityOverflowRecoveryTest { durableUploadPermissionCleanupPlan(false, protection), ) } + assertEquals( + DurableUploadMalformedRecoveryDisposition.Quarantine, + durableUploadMalformedRecoveryDisposition( + grantPreExisting = null, + peerProtection = protection("selection-target", sharedUri, arrayOf(exactUnknownGrant)), + permissionAbsent = false, + ), + ) + } + + @Test + fun `stable malformed peer ambiguity quarantines direct release without recovery`() { + val sharedUri = "content://synthetic/shared" + val disposition = durableUploadMalformedRecoveryDisposition( + grantPreExisting = null, + peerProtection = protection( + "selection-target", + sharedUri, + arrayOf(peer("selection-peer", sharedUri, grantPreExisting = null)), + ), + permissionAbsent = false, + ) + var recoveryAttempted = false + + val result = releaseMalformedDurableUploadCapability(disposition) { + recoveryAttempted = true + true + } + + assertEquals(DurableUploadMalformedReleaseResult.Quarantine, result) + assertFalse(recoveryAttempted) } @Test From d55375887fc30167545863d537e700d1fc157064 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 06:58:33 +0200 Subject: [PATCH 48/53] fix(uploads): bound storage and preserve recovery backoff --- .../AndroidDurableMultipartUploads.kt | 10 +- .../AndroidDurableUploadScheduling.kt | 86 ++++-- .../AndroidDurableUploadWorker.kt | 2 + ...AndroidLocalUploadCapabilityPreferences.kt | 43 +++ .../AndroidLocalUploadCapabilityRead.kt | 2 + .../AndroidLocalUploadCapabilityRecovery.kt | 53 ++++ .../AndroidLocalUploadPicker.kt | 31 +- ...roidDurableUploadSchedulingRecoveryTest.kt | 288 ++++++++++++++++++ ...AndroidDurableUploadSourcePreflightTest.kt | 23 ++ ...droidLocalUploadCapabilityLifecycleTest.kt | 18 ++ ...calUploadCapabilityOverflowRecoveryTest.kt | 42 +++ 11 files changed, 565 insertions(+), 33 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityPreferences.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 9c5aaf973..d34d79ab8 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -115,7 +115,11 @@ internal class AndroidDurableMultipartUploads( } } - suspend fun reconcileQueuedUploads(allowQueuedScheduling: Boolean = true): Boolean { + suspend fun reconcileQueuedUploads( + allowQueuedScheduling: Boolean = true, + schedulingRecoverySignal: AndroidDurableUploadSchedulingRecoverySignal = + ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL, + ): Boolean { val (jobs, capabilitiesRecovered) = synchronized(AndroidDurableMultipartUploadStore.LOCK) { val snapshot = store.list() val retainedSelectionIds = durableUploadCapabilityRetainedSelectionIds(snapshot) @@ -139,7 +143,9 @@ internal class AndroidDurableMultipartUploads( "The durable upload capability cleanup remains pending." } }, - schedule = { job -> schedule(job).await() }, + schedule = { job -> + schedulingRecoverySignal.scheduleUnlessBackedOff(job.id) { schedule(job) }?.await() + }, ) return capabilitiesRecovered && uploadsRecovered } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index 26774e12c..6198f20c6 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -61,6 +61,7 @@ internal class AndroidDurableUploadSchedulingRecoverySignal( private val wakeups = Channel(Channel.CONFLATED) private var immediatePending = false private val workIdsToAwait = linkedMapOf() + private val backedOffWorkIds = mutableMapOf() fun request() { synchronized(monitor) { @@ -73,16 +74,32 @@ internal class AndroidDurableUploadSchedulingRecoverySignal( require(jobId.isNotBlank()) synchronized(monitor) { workIdsToAwait[jobId] = workId + backedOffWorkIds[jobId] = workId wakeups.trySend(Unit) } } + fun scheduleUnlessBackedOff(jobId: String, schedule: () -> Result): Result? { + require(jobId.isNotBlank()) + return synchronized(monitor) { + if (jobId in backedOffWorkIds) null else schedule() + } + } + + fun retireBackoff(jobId: String, workId: UUID): Boolean = synchronized(monitor) { + if (jobId in workIdsToAwait) false else backedOffWorkIds.remove(jobId, workId) + } + suspend fun await(): AndroidDurableUploadSchedulingRecoveryBatch { wakeups.receive() beforeBatchClaim() return takeBatch() } + fun tryTakePending(): AndroidDurableUploadSchedulingRecoveryBatch? = synchronized(monitor) { + if (!immediatePending && workIdsToAwait.isEmpty()) null else takeBatchLocked() + } + suspend fun runUntilRequested( action: suspend () -> Unit, ): AndroidDurableUploadSchedulingRecoveryStep = coroutineScope { @@ -101,10 +118,14 @@ internal class AndroidDurableUploadSchedulingRecoverySignal( } private fun takeBatch(): AndroidDurableUploadSchedulingRecoveryBatch = synchronized(monitor) { + takeBatchLocked() + } + + private fun takeBatchLocked(): AndroidDurableUploadSchedulingRecoveryBatch { while (wakeups.tryReceive().isSuccess) { // Every request represented by a drained token is included in the pending state below. } - AndroidDurableUploadSchedulingRecoveryBatch( + return AndroidDurableUploadSchedulingRecoveryBatch( immediate = immediatePending, workIdsToAwait = workIdsToAwait.toMap(), ).also { @@ -114,7 +135,7 @@ internal class AndroidDurableUploadSchedulingRecoverySignal( } } -private val ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL = +internal val ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL = AndroidDurableUploadSchedulingRecoverySignal() internal fun requestQueuedDurableUploadSchedulingRecovery() { @@ -131,6 +152,8 @@ internal suspend fun monitorQueuedDurableUploadScheduling( wait: suspend (Long) -> Unit, workerFailureFollowUpDelayMillis: Long = ANDROID_DURABLE_UPLOAD_SCHEDULING_FOLLOW_UP_DELAY_MILLIS, + monotonicTimeMillis: () -> Long = { System.nanoTime() / 1_000_000L }, + afterEmptyPendingBatchClaim: () -> Unit = {}, recoverySignal: AndroidDurableUploadSchedulingRecoverySignal = ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL, ) { @@ -138,10 +161,18 @@ internal suspend fun monitorQueuedDurableUploadScheduling( recover() var immediatePending = false val workIdsToAwait = linkedMapOf() + val followUpDeadlinesMillis = mutableMapOf() + val stoppedWorkIds = mutableMapOf() fun addRequests(batch: AndroidDurableUploadSchedulingRecoveryBatch) { immediatePending = immediatePending || batch.immediate - workIdsToAwait.putAll(batch.workIdsToAwait) + batch.workIdsToAwait.forEach { (jobId, workId) -> + if (workIdsToAwait.put(jobId, workId) != workId) { + followUpDeadlinesMillis[jobId] = + monotonicTimeMillis() + workerFailureFollowUpDelayMillis + stoppedWorkIds.remove(jobId) + } + } } while (true) { @@ -154,24 +185,45 @@ internal suspend fun monitorQueuedDurableUploadScheduling( } val (jobId, workId) = workIdsToAwait.entries.first() - when (val step = recoverySignal.runUntilRequested { awaitWorkStopsRunning(workId) }) { - AndroidDurableUploadSchedulingRecoveryStep.Completed -> Unit - is AndroidDurableUploadSchedulingRecoveryStep.Interrupted -> { - addRequests(step.batch) - continue + if (stoppedWorkIds[jobId] != workId) { + when (val step = recoverySignal.runUntilRequested { awaitWorkStopsRunning(workId) }) { + AndroidDurableUploadSchedulingRecoveryStep.Completed -> { + if (workIdsToAwait[jobId] != workId) continue + stoppedWorkIds[jobId] = workId + } + is AndroidDurableUploadSchedulingRecoveryStep.Interrupted -> { + addRequests(step.batch) + continue + } } } - when ( - val step = recoverySignal.runUntilRequested { - wait(workerFailureFollowUpDelayMillis) - } - ) { - AndroidDurableUploadSchedulingRecoveryStep.Completed -> { - workIdsToAwait.remove(jobId, workId) - recover() + + val remainingDelayMillis = + (followUpDeadlinesMillis.getValue(jobId) - monotonicTimeMillis()).coerceAtLeast(0L) + if (remainingDelayMillis > 0L) { + when ( + val step = recoverySignal.runUntilRequested { + wait(remainingDelayMillis) + } + ) { + AndroidDurableUploadSchedulingRecoveryStep.Completed -> Unit + is AndroidDurableUploadSchedulingRecoveryStep.Interrupted -> { + addRequests(step.batch) + continue + } } - is AndroidDurableUploadSchedulingRecoveryStep.Interrupted -> addRequests(step.batch) } + val pendingBatch = recoverySignal.tryTakePending() + if (pendingBatch != null) { + addRequests(pendingBatch) + continue + } + afterEmptyPendingBatchClaim() + followUpDeadlinesMillis.remove(jobId) + stoppedWorkIds.remove(jobId) + workIdsToAwait.remove(jobId, workId) + recoverySignal.retireBackoff(jobId, workId) + recover() } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index 223f85af7..dd36560a5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -375,6 +375,8 @@ internal suspend fun processQueuedDurableUploadSource( openSource() } catch (cancelled: CancellationException) { throw cancelled + } catch (_: DurableUploadCapabilityOverflowException) { + return onCapabilityUnavailable() } catch (failure: AndroidLocalUploadCapabilityReadException) { return onProviderUnavailable(failure) } catch (_: AndroidLocalUploadCapabilityUnavailableException) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityPreferences.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityPreferences.kt new file mode 100644 index 000000000..e8e894875 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityPreferences.kt @@ -0,0 +1,43 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import java.io.File + +internal class AndroidLocalUploadCapabilityPreferences( + context: Context, + private val preferenceName: String, + private val preferencePrefix: String, + private val maximumFileBytes: Long, +) { + private val preferenceFile = File(context.dataDir, "shared_prefs/$preferenceName.xml") + private val preferenceBackupFile = File("${preferenceFile.path}.bak") + private val preferences by lazy { + requireBoundedStorage() + context.getSharedPreferences(preferenceName, Context.MODE_PRIVATE) + } + + fun requireBoundedStorage() { + if ( + durableUploadCapabilityPreferenceStorageIsOversized( + primaryFileBytes = preferenceFile.length(), + backupFileBytes = preferenceBackupFile.length(), + maximumFileBytes = maximumFileBytes, + ) + ) throw DurableUploadCapabilityOverflowException() + } + + fun selectionIds(maximumRows: Int?): List = boundedDurableUploadCapabilitySelectionIds( + primaryFileBytes = preferenceFile.length(), + backupFileBytes = preferenceBackupFile.length(), + maximumFileBytes = maximumFileBytes, + maximumRows = maximumRows, + preferencePrefix = preferencePrefix, + preferenceKeys = { preferences.all.keys }, + ) + + fun getString(key: String): String? = preferences.getString(key, null) + + fun putString(key: String, value: String): Boolean = preferences.edit().putString(key, value).commit() + + fun remove(key: String): Boolean = preferences.edit().remove(key).commit() +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt index 79d2acba5..c9ec73ae0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRead.kt @@ -57,6 +57,8 @@ internal inline fun readAndroidLocalUploadCapability(load: () -> Result load() } catch (cancelled: CancellationException) { throw cancelled +} catch (overflow: DurableUploadCapabilityOverflowException) { + throw overflow } catch (failure: AndroidLocalUploadCapabilityMalformedException) { throw AndroidLocalUploadCapabilityUnavailableException( "The local file selection metadata is invalid.", diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt index f0c593c78..0d9cf5945 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt @@ -356,6 +356,59 @@ internal fun durableUploadCapabilityPermissionOwnedByAn selectionId != targetSelectionId && samePermission(targetPermission, permissionOf(capability)) } +internal fun malformedDurableUploadCapabilityPermissionOwnedByAnother( + capabilities: Map, + targetSelectionId: String, + targetPermission: Permission, + permissionOf: (MalformedDurableUploadCapability) -> Permission?, + samePermission: (Permission, Permission) -> Boolean, +): Boolean = capabilities.any { (selectionId, capability) -> + val permission = permissionOf(capability) + selectionId != targetSelectionId && permission != null && samePermission(targetPermission, permission) +} + +internal fun DurableUploadCapabilitySnapshot<*>.malformedCapabilityOwnsPermission( + targetSelectionId: String, + targetPermission: String, +): Boolean = malformedDurableUploadCapabilityPermissionOwnedByAnother( + capabilities = malformedCapabilities, + targetSelectionId = targetSelectionId, + targetPermission = targetPermission, + permissionOf = MalformedDurableUploadCapability::cleanupPermissionIdentity, + samePermission = String::equals, +) + +internal fun durableUploadCapabilityPreferenceStorageIsOversized( + primaryFileBytes: Long, + backupFileBytes: Long, + maximumFileBytes: Long, +): Boolean { + require(primaryFileBytes >= 0L && backupFileBytes >= 0L && maximumFileBytes > 0L) + return primaryFileBytes > maximumFileBytes || backupFileBytes > maximumFileBytes +} + +internal fun boundedDurableUploadCapabilitySelectionIds( + primaryFileBytes: Long, + backupFileBytes: Long, + maximumFileBytes: Long, + maximumRows: Int?, + preferencePrefix: String, + preferenceKeys: () -> Set, +): List { + require(maximumRows == null || maximumRows >= 0) + if ( + durableUploadCapabilityPreferenceStorageIsOversized( + primaryFileBytes, + backupFileBytes, + maximumFileBytes, + ) + ) throw DurableUploadCapabilityOverflowException() + val selectionIds = preferenceKeys().asSequence() + .filter { key -> key.startsWith(preferencePrefix) } + .map { key -> key.removePrefix(preferencePrefix) } + return maximumRows?.let { limit -> selectionIds.take(limit + 1).toList() } ?: selectionIds.toList() +} + internal fun shouldReleaseDurableUploadPermission( grantPreExisting: Boolean, ownedByAnotherCapability: Boolean, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index d22081494..d94764ccd 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -30,7 +30,9 @@ import kotlin.coroutines.resume internal class AndroidLocalUploadPicker(context: Context) { private val appContext = context.applicationContext private val resolver = context.applicationContext.contentResolver - private val preferences = appContext.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE) + private val preferences = AndroidLocalUploadCapabilityPreferences( + appContext, PREFERENCES, PREFERENCE_PREFIX, MAX_CAPABILITY_PREFERENCE_FILE_BYTES, + ) private val cipher = SessionCipher() private val selections = PROCESS_SELECTIONS private var launcher: ActivityResultLauncher>? = null @@ -110,6 +112,9 @@ internal class AndroidLocalUploadPicker(context: Context) { ) { "The selected file already has an active picker capability." } + check(!snapshot.malformedCapabilityOwnsPermission(token, uri.toString())) { + "The selected file already has a quarantined picker capability." + } val grantPreExisting = !exactReadPermissionIsAbsent(uri) val acquiring = source.copy( phase = CapabilityPhase.Acquiring, @@ -184,9 +189,12 @@ internal class AndroidLocalUploadPicker(context: Context) { fun release(file: LocalUploadFile, onQuarantined: () -> Unit = {}): Boolean = synchronized(CAPABILITY_LOCK) { val source = try { + preferences.requireBoundedStorage() selections[file.selectionId] ?: load(file.selectionId) } catch (cancelled: CancellationException) { throw cancelled + } catch (_: DurableUploadCapabilityOverflowException) { + return@synchronized quarantineCapabilityCleanup(file.selectionId, onQuarantined) } catch (malformed: AndroidLocalUploadCapabilityMalformedException) { return@synchronized releaseMalformedCapability(file.selectionId, malformed, onQuarantined) } catch (_: Exception) { @@ -283,6 +291,8 @@ internal class AndroidLocalUploadPicker(context: Context) { loadCapabilityRecoverySnapshot() } catch (cancelled: CancellationException) { throw cancelled + } catch (_: DurableUploadCapabilityOverflowException) { + return@synchronized true } catch (_: Exception) { return@synchronized false } @@ -426,14 +436,10 @@ internal class AndroidLocalUploadPicker(context: Context) { .put("grantPreExisting", source.grantPreExisting) source.processGeneration?.let { generation -> payload.put("processGeneration", generation) } val encrypted = cipher.encrypt(payload.toString()) - return preferences.edit() - .putString(preferenceKey(source.file.selectionId), encrypted) - .commit() + return preferences.putString(preferenceKey(source.file.selectionId), encrypted) } - private fun removeMetadata(selectionId: String): Boolean = preferences.edit() - .remove(preferenceKey(selectionId)) - .commit() + private fun removeMetadata(selectionId: String): Boolean = preferences.remove(preferenceKey(selectionId)) .also { removed -> if (removed) PENDING_CLEANUP_SELECTIONS.remove(selectionId) } private fun retainCapabilityCleanup(selectionId: String): Boolean { @@ -480,12 +486,8 @@ internal class AndroidLocalUploadPicker(context: Context) { loadStoredCapability = ::load, ) - private fun storedCapabilitySelectionIds(maximumRows: Int? = null): List { - val selectionIds = preferences.all.keys.asSequence() - .filter { key -> key.startsWith(PREFERENCE_PREFIX) } - .map { key -> key.removePrefix(PREFERENCE_PREFIX) } - return maximumRows?.let { limit -> selectionIds.take(limit + 1).toList() } ?: selectionIds.toList() - } + private fun storedCapabilitySelectionIds(maximumRows: Int? = null): List = + preferences.selectionIds(maximumRows) private fun releaseMalformedCapability( selectionId: String, @@ -656,7 +658,7 @@ internal class AndroidLocalUploadPicker(context: Context) { private fun load(selectionId: String): SelectedSource? { val encrypted = readAndroidLocalUploadCapabilityPreference { - preferences.getString(preferenceKey(selectionId), null) + preferences.getString(preferenceKey(selectionId)) } ?: return null val decrypted = decryptAndroidLocalUploadCapability { cipher.decrypt(encrypted) } val payload = try { @@ -737,6 +739,7 @@ internal class AndroidLocalUploadPicker(context: Context) { const val MAX_TRACKED_CAPABILITIES = 64 const val MAX_RECOVERABLE_CAPABILITIES = 1_024 const val MAX_RECOVERY_ROWS_PER_PASS = 1_024 + const val MAX_CAPABILITY_PREFERENCE_FILE_BYTES = 8L * 1024L * 1024L val PROCESS_GENERATION = UUID.randomUUID().toString() val PROCESS_SELECTIONS = ConcurrentHashMap() val PENDING_CLEANUP_SELECTIONS = ConcurrentHashMap.newKeySet() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt index 0c03e50a0..f279638af 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt @@ -1,5 +1,10 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.DurableUploadScope +import dev.obiente.nextcloudnative.app.DurableUploadState +import dev.obiente.nextcloudnative.app.NextcloudApiMethod +import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest +import dev.obiente.nextcloudnative.app.localUploadFile import java.util.UUID import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred @@ -49,6 +54,7 @@ class AndroidDurableUploadSchedulingRecoveryTest { val delayEntered = CompletableDeferred() val expected = CancellationException("monitor stopped after immediate recovery") var recoveryRuns = 0 + val scheduledJobIds = mutableListOf() recoverySignal.requestAfterWorkStopsRunning("job-1", workId) val monitoring = async { @@ -56,6 +62,12 @@ class AndroidDurableUploadSchedulingRecoveryTest { monitorQueuedDurableUploadScheduling( recover = { recoveryRuns += 1 + recoverySignal.scheduleUnlessBackedOff("job-1") { + scheduledJobIds += "job-1" + } + recoverySignal.scheduleUnlessBackedOff("job-2") { + scheduledJobIds += "job-2" + } if (recoveryRuns == 2) throw expected }, awaitWorkStopsRunning = { requestedWorkId -> @@ -75,5 +87,281 @@ class AndroidDurableUploadSchedulingRecoveryTest { assertTrue(monitoring.await() === expected) assertEquals(2, recoveryRuns) + assertEquals(listOf("job-2", "job-2"), scheduledJobIds) + } + + @Test + fun `immediate recovery preserves the failed job deadline`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val workId = UUID.randomUUID() + val firstDelayEntered = CompletableDeferred() + val expected = CancellationException("monitor stopped after failed job recovery") + val waits = mutableListOf() + val scheduledJobIds = mutableListOf() + var recoveryRuns = 0 + var nowMillis = 1_000L + + recoverySignal.requestAfterWorkStopsRunning("job-1", workId) + val monitoring = async { + assertFailsWith { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + recoverySignal.scheduleUnlessBackedOff("job-1") { + scheduledJobIds += "job-1" + } + if (recoveryRuns == 3) throw expected + }, + awaitWorkStopsRunning = { requestedWorkId -> + assertEquals(workId, requestedWorkId) + }, + wait = { delayMillis -> + waits += delayMillis + if (waits.size == 1) { + firstDelayEntered.complete(Unit) + CompletableDeferred().await() + } + }, + workerFailureFollowUpDelayMillis = 60_000L, + monotonicTimeMillis = { nowMillis }, + recoverySignal = recoverySignal, + ) + } + } + + firstDelayEntered.await() + nowMillis += 25_000L + recoverySignal.request() + + assertTrue(monitoring.await() === expected) + assertEquals(listOf(60_000L, 35_000L), waits) + assertEquals(listOf("job-1"), scheduledJobIds) + } + + @Test + fun `replacement work resets and coalesces the failed job deadline`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val replacedWorkId = UUID.randomUUID() + val supersededWorkId = UUID.randomUUID() + val replacementWorkId = UUID.randomUUID() + val expected = CancellationException("monitor stopped after replacement recovery") + val awaitedWorkIds = mutableListOf() + val waits = mutableListOf() + val scheduledJobIds = mutableListOf() + var recoveryRuns = 0 + + recoverySignal.requestAfterWorkStopsRunning("job-1", replacedWorkId) + val actual = assertFailsWith { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + recoverySignal.scheduleUnlessBackedOff("job-1") { + scheduledJobIds += "job-1" + } + if (recoveryRuns == 2) throw expected + }, + awaitWorkStopsRunning = { workId -> awaitedWorkIds += workId }, + wait = { delayMillis -> + waits += delayMillis + if (waits.size == 1) { + recoverySignal.requestAfterWorkStopsRunning("job-1", supersededWorkId) + repeat(100) { + recoverySignal.requestAfterWorkStopsRunning("job-1", replacementWorkId) + } + } + }, + workerFailureFollowUpDelayMillis = 60_000L, + monotonicTimeMillis = { 1_000L }, + recoverySignal = recoverySignal, + ) + } + + assertTrue(actual === expected) + assertEquals(listOf(replacedWorkId, replacementWorkId), awaitedWorkIds) + assertEquals(listOf(60_000L, 60_000L), waits) + assertEquals(listOf("job-1"), scheduledJobIds) + } + + @Test + fun `immediate reconciliation skips backed off upload and schedules unrelated work`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val backedOff = fixtureQueuedJob(index = 1) + val unrelated = fixtureQueuedJob(index = 2) + val attempted = mutableListOf() + recoverySignal.requestAfterWorkStopsRunning(backedOff.id, UUID.randomUUID()) + + val allScheduled = reconcileQueuedDurableUploads( + jobs = listOf(backedOff, unrelated), + cleanupCapability = { error("Queued uploads must not enter local cleanup.") }, + schedule = { job -> + recoverySignal.scheduleUnlessBackedOff(job.id) { attempted += job.id } + }, + ) + + assertTrue(allScheduled) + assertEquals(listOf(unrelated.id), attempted) + } + + @Test + fun `backoff exclusion does not delay terminal capability cleanup`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val terminalCleanup = fixtureQueuedJob(index = 1).copy( + state = DurableUploadState.Failed, + capabilityCleanupPending = true, + ) + var cleaned = false + recoverySignal.requestAfterWorkStopsRunning(terminalCleanup.id, UUID.randomUUID()) + + val allScheduled = reconcileQueuedDurableUploads( + jobs = listOf(terminalCleanup), + cleanupCapability = { cleaned = true }, + schedule = { job -> + recoverySignal.scheduleUnlessBackedOff(job.id) { + error("Terminal cleanup must not schedule upload work.") + } + }, + ) + + assertTrue(allScheduled) + assertTrue(cleaned) + } + + @Test + fun `replacement request in the post drain gap remains excluded`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val replacedWorkId = UUID.randomUUID() + val replacementWorkId = UUID.randomUUID() + val expected = CancellationException("monitor stopped after replacement recovery") + val awaitedWorkIds = mutableListOf() + val scheduledJobIds = mutableListOf() + var recoveryRuns = 0 + var gapRuns = 0 + + recoverySignal.requestAfterWorkStopsRunning("job-1", replacedWorkId) + val actual = assertFailsWith { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + recoverySignal.scheduleUnlessBackedOff("job-1") { + scheduledJobIds += "job-1" + } + if (recoveryRuns == 3) throw expected + }, + awaitWorkStopsRunning = { workId -> awaitedWorkIds += workId }, + wait = {}, + workerFailureFollowUpDelayMillis = 60_000L, + monotonicTimeMillis = { 1_000L }, + afterEmptyPendingBatchClaim = { + if (gapRuns++ == 0) { + recoverySignal.requestAfterWorkStopsRunning("job-1", replacementWorkId) + } + }, + recoverySignal = recoverySignal, + ) + } + + assertTrue(actual === expected) + assertEquals(listOf(replacedWorkId, replacementWorkId), awaitedWorkIds) + assertEquals(listOf("job-1"), scheduledJobIds) + } + + @Test + fun `same work request in the post drain gap starts a fresh backoff`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val workId = UUID.randomUUID() + val expected = CancellationException("monitor stopped after repeated work recovery") + val awaitedWorkIds = mutableListOf() + val waits = mutableListOf() + val scheduledJobIds = mutableListOf() + var recoveryRuns = 0 + var gapRuns = 0 + + recoverySignal.requestAfterWorkStopsRunning("job-1", workId) + val actual = assertFailsWith { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + recoverySignal.scheduleUnlessBackedOff("job-1") { + scheduledJobIds += "job-1" + } + if (recoveryRuns == 3) throw expected + }, + awaitWorkStopsRunning = { requestedWorkId -> awaitedWorkIds += requestedWorkId }, + wait = { delayMillis -> waits += delayMillis }, + workerFailureFollowUpDelayMillis = 60_000L, + monotonicTimeMillis = { 1_000L }, + afterEmptyPendingBatchClaim = { + if (gapRuns++ == 0) { + recoverySignal.requestAfterWorkStopsRunning("job-1", workId) + } + }, + recoverySignal = recoverySignal, + ) + } + + assertTrue(actual === expected) + assertEquals(listOf(workId, workId), awaitedWorkIds) + assertEquals(listOf(60_000L, 60_000L), waits) + assertEquals(listOf("job-1"), scheduledJobIds) + } + + @Test + fun `coalesced failed jobs age through one follow up interval`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val firstWorkId = UUID.randomUUID() + val secondWorkId = UUID.randomUUID() + val expected = CancellationException("monitor stopped after both jobs recovered") + val awaitedWorkIds = mutableListOf() + val waits = mutableListOf() + var recoveryRuns = 0 + var nowMillis = 1_000L + + recoverySignal.requestAfterWorkStopsRunning("job-1", firstWorkId) + recoverySignal.requestAfterWorkStopsRunning("job-2", secondWorkId) + val actual = assertFailsWith { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + if (recoveryRuns == 3) throw expected + }, + awaitWorkStopsRunning = { workId -> awaitedWorkIds += workId }, + wait = { delayMillis -> + waits += delayMillis + nowMillis += delayMillis + }, + workerFailureFollowUpDelayMillis = 60_000L, + monotonicTimeMillis = { nowMillis }, + recoverySignal = recoverySignal, + ) + } + + assertTrue(actual === expected) + assertEquals(listOf(firstWorkId, secondWorkId), awaitedWorkIds) + assertEquals(listOf(60_000L), waits) + } + + private fun fixtureQueuedJob(index: Int): AndroidDurableMultipartUploadJob { + val scope = DurableUploadScope("deck-attachment", index.toString()) + val request = NextcloudMultipartUploadRequest( + method = NextcloudApiMethod.POST, + relativePath = "/index.php/apps/deck/api/v1.1/boards/7/stacks/11/cards/$index/attachments", + file = localUploadFile( + selectionId = "selection-${index.toString().padStart(16, '0')}", + displayName = "fixture-$index.txt", + mimeType = "text/plain", + sizeBytes = 16L, + ), + maximumFileBytes = 1_024L, + ) + return AndroidDurableMultipartUploadJob( + id = "upload-${index.toString().padStart(16, '0')}", + accountId = index.toString(16).padStart(32, '0'), + scope = scope, + resource = resolveDurableUploadResource(scope, request), + request = request, + state = DurableUploadState.Queued, + message = null, + updatedAtEpochMillis = index.toLong(), + ) } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt index 879e3d8f7..83e46126f 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSourcePreflightTest.kt @@ -13,6 +13,29 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class AndroidDurableUploadSourcePreflightTest { + @Test + fun `oversized capability storage terminally fails without opening the provider`() = runBlocking { + var providerOpened = false + var transientRetries = 0 + + val result = processQueuedDurableUploadSource( + requireCapability = { + readAndroidLocalUploadCapability { throw DurableUploadCapabilityOverflowException() } + }, + openSource = { providerOpened = true }, + onCapabilityUnavailable = { "failed" }, + onProviderUnavailable = { + transientRetries += 1 + "retried" + }, + onReady = { "started" }, + ) + + assertEquals("failed", result) + assertFalse(providerOpened) + assertEquals(0, transientRetries) + } + @Test fun `missing or mismatched private metadata terminally fails and releases`() = runBlocking { listOf("missing", "mismatched").forEach { reason -> diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt index f18e864c7..cee9137e7 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityLifecycleTest.kt @@ -401,6 +401,24 @@ class AndroidLocalUploadCapabilityLifecycleTest { "content://synthetic/new", (snapshot.capabilities + ("selection-new" to "content://synthetic/new"))["selection-new"], ) + assertFalse( + malformedDurableUploadCapabilityPermissionOwnedByAnother( + capabilities = snapshot.malformedCapabilities, + targetSelectionId = "selection-new", + targetPermission = "content://synthetic/new", + permissionOf = MalformedDurableUploadCapability::cleanupPermissionIdentity, + samePermission = String::equals, + ), + ) + assertTrue( + malformedDurableUploadCapabilityPermissionOwnedByAnother( + capabilities = snapshot.malformedCapabilities, + targetSelectionId = "selection-new", + targetPermission = "content://synthetic/malformed", + permissionOf = MalformedDurableUploadCapability::cleanupPermissionIdentity, + samePermission = String::equals, + ), + ) } @Test diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt index cb1cf8a8a..08fcdc5db 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt @@ -8,6 +8,48 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class AndroidLocalUploadCapabilityOverflowRecoveryTest { + @Test + fun `preference storage is bounded before capability values are enumerated`() { + val maximumBytes = 8L * 1024L * 1024L + var valuesEnumerated = false + + assertFalse( + durableUploadCapabilityPreferenceStorageIsOversized( + primaryFileBytes = maximumBytes, + backupFileBytes = 0L, + maximumFileBytes = maximumBytes, + ), + ) + assertTrue( + durableUploadCapabilityPreferenceStorageIsOversized( + primaryFileBytes = maximumBytes + 1L, + backupFileBytes = 0L, + maximumFileBytes = maximumBytes, + ), + ) + assertTrue( + durableUploadCapabilityPreferenceStorageIsOversized( + primaryFileBytes = 0L, + backupFileBytes = maximumBytes + 1L, + maximumFileBytes = maximumBytes, + ), + ) + assertFailsWith { + boundedDurableUploadCapabilitySelectionIds( + primaryFileBytes = maximumBytes + 1L, + backupFileBytes = 0L, + maximumFileBytes = maximumBytes, + maximumRows = 1_024, + preferencePrefix = "upload_", + preferenceKeys = { + valuesEnumerated = true + setOf("upload_selection") + }, + ) + } + assertFalse(valuesEnumerated) + } + @Test fun `over admission limit capability state remains recoverable`() { val storedIds = (1..65).map { index -> "selection-$index" } From 50db150461553e227b19907d7b0f2ea7ecec74e1 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 07:27:08 +0200 Subject: [PATCH 49/53] fix(uploads): finish stable malformed-peer cleanup --- .../AndroidLocalUploadCapabilityRecovery.kt | 29 ++------ .../AndroidLocalUploadPicker.kt | 6 +- ...calUploadCapabilityOverflowRecoveryTest.kt | 74 ++++++++++++++++++- .../durable-multipart-scheduling-recovery.md | 2 +- 4 files changed, 80 insertions(+), 31 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt index 0d9cf5945..74bb39ba4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityRecovery.kt @@ -239,18 +239,6 @@ internal fun durableUploadPermissionPeerProtection( } } -internal fun malformedDurableUploadPeerBlocksDirectCleanup( - malformedPeers: Iterable>, - targetSelectionId: String, - targetPermission: Permission, - samePermission: (Permission, Permission) -> Boolean, -): Boolean = durableUploadPermissionPeerProtection( - peers = malformedPeers, - targetSelectionId = targetSelectionId, - targetPermission = targetPermission, - samePermission = samePermission, -) != DurableUploadPermissionPeerProtection.None - internal enum class DurableUploadMalformedPeerCleanupDisposition { Proceed, Retry, @@ -262,22 +250,17 @@ internal fun durableUploadMalformedPeerCleanupDisposition( targetSelectionId: String, targetPermission: Permission, samePermission: (Permission, Permission) -> Boolean, + targetGrantPreExisting: Boolean = false, ): DurableUploadMalformedPeerCleanupDisposition { + if (targetGrantPreExisting) return DurableUploadMalformedPeerCleanupDisposition.Proceed val peers = malformedPeers.toList() if (peers.any { peer -> peer.selectionId != targetSelectionId && peer.permission == null }) { return DurableUploadMalformedPeerCleanupDisposition.Quarantine } - return if ( - malformedDurableUploadPeerBlocksDirectCleanup( - peers, - targetSelectionId, - targetPermission, - samePermission, - ) - ) { - DurableUploadMalformedPeerCleanupDisposition.Retry - } else { - DurableUploadMalformedPeerCleanupDisposition.Proceed + return when (durableUploadPermissionPeerProtection(peers, targetSelectionId, targetPermission, samePermission)) { + DurableUploadPermissionPeerProtection.Ambiguous -> DurableUploadMalformedPeerCleanupDisposition.Quarantine + DurableUploadPermissionPeerProtection.RetainedAppOwnedGrant -> DurableUploadMalformedPeerCleanupDisposition.Retry + DurableUploadPermissionPeerProtection.None -> DurableUploadMalformedPeerCleanupDisposition.Proceed } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt index d94764ccd..9d5436bc2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadPicker.kt @@ -223,9 +223,7 @@ internal class AndroidLocalUploadPicker(context: Context) { return@synchronized retainCapabilityCleanup(file.selectionId) } when (malformedPeerCleanupDisposition( - malformedCapabilities = snapshot.malformedCapabilities, - targetSelectionId = file.selectionId, - targetPermissionIdentity = source.uri.toString(), + snapshot.malformedCapabilities, file.selectionId, source.uri.toString(), source.grantPreExisting, )) { DurableUploadMalformedPeerCleanupDisposition.Quarantine -> return@synchronized quarantineCapabilityCleanup(file.selectionId, onQuarantined) @@ -621,6 +619,7 @@ internal class AndroidLocalUploadPicker(context: Context) { malformedCapabilities: Map, targetSelectionId: String, targetPermissionIdentity: String, + targetGrantPreExisting: Boolean = false, ): DurableUploadMalformedPeerCleanupDisposition = durableUploadMalformedPeerCleanupDisposition( malformedPeers = malformedCapabilities.values.asSequence().map { capability -> DurableUploadPermissionPeer( @@ -632,6 +631,7 @@ internal class AndroidLocalUploadPicker(context: Context) { targetSelectionId = targetSelectionId, targetPermission = targetPermissionIdentity, samePermission = String::equals, + targetGrantPreExisting = targetGrantPreExisting, ) private fun persistedSource(file: LocalUploadFile): SelectedSource { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt index 08fcdc5db..11bb63946 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidLocalUploadCapabilityOverflowRecoveryTest.kt @@ -274,20 +274,20 @@ class AndroidLocalUploadCapabilityOverflowRecoveryTest { listOf(appOwnedPeer, exactPeer, unknownPeer).forEach { peer -> assertTrue( - malformedDurableUploadPeerBlocksDirectCleanup( + durableUploadMalformedPeerCleanupDisposition( malformedPeers = listOf(peer), targetSelectionId = "selection-valid", targetPermission = sharedUri, samePermission = String::equals, - ), + ) != DurableUploadMalformedPeerCleanupDisposition.Proceed, ) assertTrue( - malformedDurableUploadPeerBlocksDirectCleanup( + durableUploadMalformedPeerCleanupDisposition( malformedPeers = listOf(peer), targetSelectionId = "selection-malformed", targetPermission = sharedUri, samePermission = String::equals, - ), + ) != DurableUploadMalformedPeerCleanupDisposition.Proceed, ) } } @@ -304,6 +304,72 @@ class AndroidLocalUploadCapabilityOverflowRecoveryTest { assertEquals(DurableUploadMalformedPeerCleanupDisposition.Quarantine, disposition) } + @Test + fun `same permission malformed peer with stable provenance ambiguity quarantines valid cleanup`() { + val sharedUri = "content://synthetic/shared" + + listOf(null, true).forEach { grantPreExisting -> + val disposition = durableUploadMalformedPeerCleanupDisposition( + malformedPeers = listOf(peer("selection-malformed", sharedUri, grantPreExisting)), + targetSelectionId = "selection-valid", + targetPermission = sharedUri, + samePermission = String::equals, + ) + + assertEquals(DurableUploadMalformedPeerCleanupDisposition.Quarantine, disposition) + } + } + + @Test + fun `preexisting valid target removes only its metadata despite any malformed peer provenance`() { + val sharedUri = "content://synthetic/shared" + + listOf(null, false, true).forEach { peerGrantPreExisting -> + val peers = listOf(peer("selection-malformed", sharedUri, peerGrantPreExisting)) + val disposition = durableUploadMalformedPeerCleanupDisposition( + malformedPeers = peers, + targetSelectionId = "selection-valid-preexisting", + targetPermission = sharedUri, + samePermission = String::equals, + targetGrantPreExisting = true, + ) + val cleanupPlan = durableUploadPermissionCleanupPlan( + grantPreExisting = true, + peerProtection = protection("selection-valid-preexisting", sharedUri, peers.toTypedArray()), + ) + val events = mutableListOf() + + assertEquals(DurableUploadMalformedPeerCleanupDisposition.Proceed, disposition) + assertTrue(releaseDurableUploadCapability( + releasePermission = { + if (cleanupPlan == DurableUploadPermissionCleanupPlan.ReleaseThenRemove) events += "release" + }, + isPermissionAbsent = { cleanupPlan == DurableUploadPermissionCleanupPlan.RemoveWithoutRelease }, + removeMetadata = { events += "remove-target"; true }, + )) + assertEquals(listOf("remove-target"), events) + } + } + + @Test + fun `recoverable malformed owner still retries while unrelated malformed grants permit cleanup`() { + val sharedUri = "content://synthetic/shared" + val appOwnedPeer = peer("selection-malformed", sharedUri, grantPreExisting = false) + + assertEquals( + DurableUploadMalformedPeerCleanupDisposition.Retry, + durableUploadMalformedPeerCleanupDisposition( + listOf(appOwnedPeer), "selection-valid", sharedUri, String::equals, + ), + ) + assertEquals( + DurableUploadMalformedPeerCleanupDisposition.Proceed, + durableUploadMalformedPeerCleanupDisposition( + listOf(appOwnedPeer), "selection-valid", "content://synthetic/unrelated", String::equals, + ), + ) + } + @Test fun `peer protection keeps ambiguous provenance distinct from a retained app grant`() { val sharedUri = "content://synthetic/shared" diff --git a/changes/unreleased/durable-multipart-scheduling-recovery.md b/changes/unreleased/durable-multipart-scheduling-recovery.md index 9a041a598..eca161d76 100644 --- a/changes/unreleased/durable-multipart-scheduling-recovery.md +++ b/changes/unreleased/durable-multipart-scheduling-recovery.md @@ -4,4 +4,4 @@ pull: 439 platforms: android user-facing: yes -Restore every durably queued attachment upload at Android startup when background scheduling is interrupted. +Restore queued attachment uploads after interrupted Android scheduling. Quarantine malformed grant ownership without repeated cleanup polling, allowing terminal uploads to finish and be dismissed while preserving retained grants. From 25302373745057ce7059c8e4850ebf17a821e0b7 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 08:59:44 +0200 Subject: [PATCH 50/53] fix(android): bound upload scheduling recovery passes --- .../AndroidDurableUploadScheduling.kt | 49 +++++++-- .../NextcloudNativeApplication.kt | 58 +++++++---- ...AndroidDurableMultipartUploadPolicyTest.kt | 2 + ...roidDurableUploadSchedulingRecoveryTest.kt | 99 +++++++++++++++++++ .../durable-multipart-scheduling-recovery.md | 2 +- 5 files changed, 183 insertions(+), 27 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index 6198f20c6..cea88edfd 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -147,7 +147,7 @@ internal fun requestQueuedDurableUploadSchedulingRecoveryAfterWorkStopsRunning(j } internal suspend fun monitorQueuedDurableUploadScheduling( - recover: suspend () -> Unit, + recover: suspend () -> Boolean, awaitWorkStopsRunning: suspend (UUID) -> Unit = {}, wait: suspend (Long) -> Unit, workerFailureFollowUpDelayMillis: Long = @@ -158,11 +158,11 @@ internal suspend fun monitorQueuedDurableUploadScheduling( ANDROID_DURABLE_UPLOAD_SCHEDULING_RECOVERY_SIGNAL, ) { require(workerFailureFollowUpDelayMillis > 0L) - recover() var immediatePending = false val workIdsToAwait = linkedMapOf() val followUpDeadlinesMillis = mutableMapOf() val stoppedWorkIds = mutableMapOf() + var recoveryRetryDeadlineMillis: Long? = null fun addRequests(batch: AndroidDurableUploadSchedulingRecoveryBatch) { immediatePending = immediatePending || batch.immediate @@ -175,12 +175,36 @@ internal suspend fun monitorQueuedDurableUploadScheduling( } } + suspend fun recoverOnce() { + recoveryRetryDeadlineMillis = if (recover()) { + null + } else { + monotonicTimeMillis() + workerFailureFollowUpDelayMillis + } + } + + recoverOnce() + while (true) { - if (!immediatePending && workIdsToAwait.isEmpty()) addRequests(recoverySignal.await()) + if (!immediatePending && workIdsToAwait.isEmpty()) { + val retryDeadline = recoveryRetryDeadlineMillis + if (retryDeadline == null) { + addRequests(recoverySignal.await()) + } else { + val retryDelay = (retryDeadline - monotonicTimeMillis()).coerceAtLeast(0L) + val step = recoverySignal.runUntilRequested { if (retryDelay > 0L) wait(retryDelay) } + if (step is AndroidDurableUploadSchedulingRecoveryStep.Interrupted) { + addRequests(step.batch) + continue + } + recoverOnce() + continue + } + } if (!immediatePending && workIdsToAwait.isEmpty()) continue if (immediatePending) { immediatePending = false - recover() + recoverOnce() continue } @@ -201,12 +225,23 @@ internal suspend fun monitorQueuedDurableUploadScheduling( val remainingDelayMillis = (followUpDeadlinesMillis.getValue(jobId) - monotonicTimeMillis()).coerceAtLeast(0L) if (remainingDelayMillis > 0L) { + val recoveryRetryDelayMillis = recoveryRetryDeadlineMillis + ?.let { deadline -> (deadline - monotonicTimeMillis()).coerceAtLeast(0L) } + if (recoveryRetryDelayMillis == 0L) { + recoverOnce() + continue + } + val recoveryRetryFirst = + recoveryRetryDelayMillis != null && recoveryRetryDelayMillis < remainingDelayMillis when ( val step = recoverySignal.runUntilRequested { - wait(remainingDelayMillis) + wait(if (recoveryRetryFirst) requireNotNull(recoveryRetryDelayMillis) else remainingDelayMillis) } ) { - AndroidDurableUploadSchedulingRecoveryStep.Completed -> Unit + AndroidDurableUploadSchedulingRecoveryStep.Completed -> if (recoveryRetryFirst) { + recoverOnce() + continue + } is AndroidDurableUploadSchedulingRecoveryStep.Interrupted -> { addRequests(step.batch) continue @@ -223,7 +258,7 @@ internal suspend fun monitorQueuedDurableUploadScheduling( stoppedWorkIds.remove(jobId) workIdsToAwait.remove(jobId, workId) recoverySignal.retireBackoff(jobId, workId) - recover() + recoverOnce() } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index 1418e747b..d2091db13 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt @@ -43,30 +43,50 @@ class NextcloudNativeApplication : Application() { runAndroidDurableUploadStartupRecovery( recover = { var uploads: AndroidDurableMultipartUploads? = null + var recoveryFailureReported = false monitorQueuedDurableUploadScheduling( recover = { - keepRetryingQueuedDurableUploadScheduling( - reconcile = { - constructAndReconcileQueuedDurableUploads { - val accountPreferences = getSharedPreferences( - ANDROID_ACCOUNT_PREFERENCES_NAME, - Context.MODE_PRIVATE, - ) - val accountResolutionAvailable = - accountPreferences.durableUploadAccountResolutionAvailable() - val available = uploads ?: AndroidDurableMultipartUploads( - this@NextcloudNativeApplication, - ).also { uploads = it } - suspend { - available.reconcileQueuedUploads( - allowQueuedScheduling = accountResolutionAvailable, + val recovered = try { + retryQueuedDurableUploadScheduling( + reconcile = { + constructAndReconcileQueuedDurableUploads { + val accountPreferences = getSharedPreferences( + ANDROID_ACCOUNT_PREFERENCES_NAME, + Context.MODE_PRIVATE, ) + val accountResolutionAvailable = + accountPreferences.durableUploadAccountResolutionAvailable() + val available = uploads ?: AndroidDurableMultipartUploads( + this@NextcloudNativeApplication, + ).also { uploads = it } + suspend { + available.reconcileQueuedUploads( + allowQueuedScheduling = accountResolutionAvailable, + ) + } } + }, + wait = { delayMillis -> delay(delayMillis) }, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: AndroidDurableMultipartUploadRecoveryException) { + if (failure.disposition == DurableUploadQueueRecoveryDisposition.Quarantine) { + if (!recoveryFailureReported) { + runCatching(recordRecoveryFailure) + recoveryFailureReported = true } - }, - wait = { delayMillis -> delay(delayMillis) }, - recordRecoveryFailure = recordRecoveryFailure, - ) + return@monitorQueuedDurableUploadScheduling true + } + false + } + if (recovered) { + recoveryFailureReported = false + } else if (!recoveryFailureReported) { + runCatching(recordRecoveryFailure) + recoveryFailureReported = true + } + recovered }, awaitWorkStopsRunning = { workId -> awaitDurableUploadWorkToStopRunning( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 23decc2ec..9da8fc46c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -553,6 +553,7 @@ class AndroidDurableMultipartUploadPolicyTest { recover = { recoveryRuns += 1 if (recoveryRuns == 2) throw CancellationException("Lifecycle stopped") + true }, wait = { error("an immediate wake must not wait") }, recoverySignal = recoverySignal, @@ -579,6 +580,7 @@ class AndroidDurableMultipartUploadPolicyTest { recover = { recoveryRuns += 1 if (recoveryRuns == 2) throw expectedCancellation + true }, awaitWorkStopsRunning = { requestedWorkId -> assertEquals(workId, requestedWorkId) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt index f279638af..5a2626e44 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt @@ -9,6 +9,7 @@ import java.util.UUID import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield import kotlin.test.Test @@ -18,6 +19,38 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class AndroidDurableUploadSchedulingRecoveryTest { + @Test + fun `failed idle reconciliation retries without a new signal and success stops polling`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val recovered = CompletableDeferred() + val waits = mutableListOf() + var recoveryRuns = 0 + var nowMillis = 1_000L + val monitor = async { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + if (recoveryRuns == 2) recovered.complete(Unit) + recoveryRuns == 2 + }, + wait = { delayMillis -> + waits += delayMillis + nowMillis += delayMillis + }, + monotonicTimeMillis = { nowMillis }, + recoverySignal = recoverySignal, + ) + } + try { + recovered.await() + yield() + assertEquals(2, recoveryRuns) + assertEquals(listOf(60_000L), waits) + } finally { + monitor.cancelAndJoin() + } + } + @Test fun `request crossing wakeup consumption is claimed without a stale token`() = runBlocking { val wakeupConsumed = CompletableDeferred() @@ -69,6 +102,7 @@ class AndroidDurableUploadSchedulingRecoveryTest { scheduledJobIds += "job-2" } if (recoveryRuns == 2) throw expected + true }, awaitWorkStopsRunning = { requestedWorkId -> assertEquals(workId, requestedWorkId) @@ -90,6 +124,66 @@ class AndroidDurableUploadSchedulingRecoveryTest { assertEquals(listOf("job-2", "job-2"), scheduledJobIds) } + @Test + fun `persistent cleanup failure yields through immediate recovery to worker backoff expiry`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val workId = UUID.randomUUID() + val initialRetryWaitEntered = CompletableDeferred() + val backoffWaitEntered = CompletableDeferred() + val expected = CancellationException("monitor stopped after backed off upload recovered") + val scheduledJobIds = mutableListOf() + val waits = mutableListOf() + var recoveryRuns = 0 + var secondJobQueued = false + var nowMillis = 1_000L + + val monitoring = async { + assertFailsWith { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + if (secondJobQueued) { + recoverySignal.scheduleUnlessBackedOff("job-2") { + scheduledJobIds += "job-2" + } + } + if (scheduledJobIds.isNotEmpty()) throw expected + false + }, + awaitWorkStopsRunning = { requestedWorkId -> assertEquals(workId, requestedWorkId) }, + wait = { delayMillis -> + waits += delayMillis + when (waits.size) { + 1 -> { + initialRetryWaitEntered.complete(Unit) + CompletableDeferred().await() + } + 2 -> { + backoffWaitEntered.complete(Unit) + CompletableDeferred().await() + } + else -> nowMillis += delayMillis + } + }, + workerFailureFollowUpDelayMillis = 60_000L, + monotonicTimeMillis = { nowMillis }, + recoverySignal = recoverySignal, + ) + } + } + + initialRetryWaitEntered.await() + secondJobQueued = true + recoverySignal.requestAfterWorkStopsRunning("job-2", workId) + backoffWaitEntered.await() + recoverySignal.request() + + assertTrue(monitoring.await() === expected) + assertEquals(3, recoveryRuns) + assertEquals(listOf(60_000L, 60_000L, 60_000L), waits) + assertEquals(listOf("job-2"), scheduledJobIds) + } + @Test fun `immediate recovery preserves the failed job deadline`() = runBlocking { val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() @@ -111,6 +205,7 @@ class AndroidDurableUploadSchedulingRecoveryTest { scheduledJobIds += "job-1" } if (recoveryRuns == 3) throw expected + true }, awaitWorkStopsRunning = { requestedWorkId -> assertEquals(workId, requestedWorkId) @@ -159,6 +254,7 @@ class AndroidDurableUploadSchedulingRecoveryTest { scheduledJobIds += "job-1" } if (recoveryRuns == 2) throw expected + true }, awaitWorkStopsRunning = { workId -> awaitedWorkIds += workId }, wait = { delayMillis -> @@ -246,6 +342,7 @@ class AndroidDurableUploadSchedulingRecoveryTest { scheduledJobIds += "job-1" } if (recoveryRuns == 3) throw expected + true }, awaitWorkStopsRunning = { workId -> awaitedWorkIds += workId }, wait = {}, @@ -285,6 +382,7 @@ class AndroidDurableUploadSchedulingRecoveryTest { scheduledJobIds += "job-1" } if (recoveryRuns == 3) throw expected + true }, awaitWorkStopsRunning = { requestedWorkId -> awaitedWorkIds += requestedWorkId }, wait = { delayMillis -> waits += delayMillis }, @@ -323,6 +421,7 @@ class AndroidDurableUploadSchedulingRecoveryTest { recover = { recoveryRuns += 1 if (recoveryRuns == 3) throw expected + true }, awaitWorkStopsRunning = { workId -> awaitedWorkIds += workId }, wait = { delayMillis -> diff --git a/changes/unreleased/durable-multipart-scheduling-recovery.md b/changes/unreleased/durable-multipart-scheduling-recovery.md index eca161d76..c406be0fb 100644 --- a/changes/unreleased/durable-multipart-scheduling-recovery.md +++ b/changes/unreleased/durable-multipart-scheduling-recovery.md @@ -4,4 +4,4 @@ pull: 439 platforms: android user-facing: yes -Restore queued attachment uploads after interrupted Android scheduling. Quarantine malformed grant ownership without repeated cleanup polling, allowing terminal uploads to finish and be dismissed while preserving retained grants. +Restore queued attachment uploads after interrupted Android scheduling. Cleanup retries yield to failed-worker deadlines, and malformed grant ownership is quarantined without blocking other uploads or deleting retained grants. From 1662b7aaaf4d3da14eacb226f8dce5c29258f2b0 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 09:00:37 +0200 Subject: [PATCH 51/53] test(android): group scheduling monitor regressions by owner --- ...AndroidDurableMultipartUploadPolicyTest.kt | 58 ------------------- ...roidDurableUploadSchedulingRecoveryTest.kt | 58 +++++++++++++++++++ 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 9da8fc46c..340c17422 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -542,64 +542,6 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(listOf(missing.id), attempted) } - @Test - fun `a recovery request wakes the idle scheduling monitor without polling`() = runBlocking { - val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() - var recoveryRuns = 0 - recoverySignal.request() - - assertFailsWith { - monitorQueuedDurableUploadScheduling( - recover = { - recoveryRuns += 1 - if (recoveryRuns == 2) throw CancellationException("Lifecycle stopped") - true - }, - wait = { error("an immediate wake must not wait") }, - recoverySignal = recoverySignal, - ) - } - - assertEquals(2, recoveryRuns) - } - - @Test - fun `coalesced immediate recovery preempts worker ownership and follow up waits`() = runBlocking { - val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() - val jobId = "job-1" - val workId = UUID.randomUUID() - val expectedCancellation = CancellationException("recovery owner stopped") - var recoveryRuns = 0 - var ownershipWaits = 0 - var delayRuns = 0 - recoverySignal.request() - recoverySignal.requestAfterWorkStopsRunning(jobId, workId) - - val actual = assertFailsWith { - monitorQueuedDurableUploadScheduling( - recover = { - recoveryRuns += 1 - if (recoveryRuns == 2) throw expectedCancellation - true - }, - awaitWorkStopsRunning = { requestedWorkId -> - assertEquals(workId, requestedWorkId) - ownershipWaits += 1 - }, - wait = { delayMillis -> - assertEquals(ANDROID_DURABLE_UPLOAD_SCHEDULING_FOLLOW_UP_DELAY_MILLIS, delayMillis) - delayRuns += 1 - }, - recoverySignal = recoverySignal, - ) - } - - assertTrue(actual === expectedCancellation) - assertEquals(2, recoveryRuns) - assertEquals(0, ownershipWaits) - assertEquals(0, delayRuns) - } - @Test fun `recovery signal conflates immediate requests and replacement workers per durable job`() = runBlocking { val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt index 5a2626e44..7a50803bb 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt @@ -19,6 +19,64 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class AndroidDurableUploadSchedulingRecoveryTest { + @Test + fun `a recovery request wakes the idle scheduling monitor without polling`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + var recoveryRuns = 0 + recoverySignal.request() + + assertFailsWith { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + if (recoveryRuns == 2) throw CancellationException("Lifecycle stopped") + true + }, + wait = { error("an immediate wake must not wait") }, + recoverySignal = recoverySignal, + ) + } + + assertEquals(2, recoveryRuns) + } + + @Test + fun `coalesced immediate recovery preempts worker ownership and follow up waits`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val jobId = "job-1" + val workId = UUID.randomUUID() + val expectedCancellation = CancellationException("recovery owner stopped") + var recoveryRuns = 0 + var ownershipWaits = 0 + var delayRuns = 0 + recoverySignal.request() + recoverySignal.requestAfterWorkStopsRunning(jobId, workId) + + val actual = assertFailsWith { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + if (recoveryRuns == 2) throw expectedCancellation + true + }, + awaitWorkStopsRunning = { requestedWorkId -> + assertEquals(workId, requestedWorkId) + ownershipWaits += 1 + }, + wait = { delayMillis -> + assertEquals(ANDROID_DURABLE_UPLOAD_SCHEDULING_FOLLOW_UP_DELAY_MILLIS, delayMillis) + delayRuns += 1 + }, + recoverySignal = recoverySignal, + ) + } + + assertTrue(actual === expectedCancellation) + assertEquals(2, recoveryRuns) + assertEquals(0, ownershipWaits) + assertEquals(0, delayRuns) + } + @Test fun `failed idle reconciliation retries without a new signal and success stops polling`() = runBlocking { val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() From 9d2c9f062f99a78746543614085a7f051b700cdb Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 09:03:45 +0200 Subject: [PATCH 52/53] fix(android): coalesce cleanup recovery self wakeups --- .../AndroidDurableUploadScheduling.kt | 8 +- ...roidDurableUploadSchedulingRecoveryTest.kt | 75 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt index cea88edfd..3974984ac 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadScheduling.kt @@ -176,13 +176,19 @@ internal suspend fun monitorQueuedDurableUploadScheduling( } suspend fun recoverOnce() { - recoveryRetryDeadlineMillis = if (recover()) { + val recovered = recover() + // Cleanup can request recovery itself. Coalesce signals raised during this pass into + // a timed retry instead of letting the same failure bypass every worker deadline. + val pending = recoverySignal.tryTakePending() + if (pending != null) addRequests(pending.copy(immediate = false)) + recoveryRetryDeadlineMillis = if (recovered && pending?.immediate != true) { null } else { monotonicTimeMillis() + workerFailureFollowUpDelayMillis } } + recoverySignal.tryTakePending()?.let(::addRequests) recoverOnce() while (true) { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt index 7a50803bb..981a568e3 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadSchedulingRecoveryTest.kt @@ -109,6 +109,81 @@ class AndroidDurableUploadSchedulingRecoveryTest { } } + @Test + fun `self signaling cleanup cannot prevent a stopped worker backoff from expiring`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val workId = UUID.randomUUID() + val expected = CancellationException("stopped after the queued upload was scheduled") + val scheduled = mutableListOf() + val waits = mutableListOf() + var recoveryRuns = 0 + var ownershipWaits = 0 + var nowMillis = 1_000L + recoverySignal.requestAfterWorkStopsRunning("job-1", workId) + + val actual = assertFailsWith { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + assertTrue(recoveryRuns <= 3, "Cleanup signals must not spin ahead of the worker deadline") + recoverySignal.request() + recoverySignal.scheduleUnlessBackedOff("job-1") { scheduled += "job-1" } + if (scheduled.isNotEmpty()) throw expected + false + }, + awaitWorkStopsRunning = { requestedWorkId -> + assertEquals(workId, requestedWorkId) + ownershipWaits += 1 + }, + wait = { delayMillis -> + waits += delayMillis + nowMillis += delayMillis + }, + monotonicTimeMillis = { nowMillis }, + recoverySignal = recoverySignal, + ) + } + + assertTrue(actual === expected) + assertEquals(2, recoveryRuns) + assertEquals(1, ownershipWaits) + assertEquals(listOf(60_000L), waits) + assertEquals(listOf("job-1"), scheduled) + } + + @Test + fun `idle self signaling cleanup waits for its retry deadline instead of spinning`() = runBlocking { + val recoverySignal = AndroidDurableUploadSchedulingRecoverySignal() + val expected = CancellationException("stopped after the bounded cleanup retry") + val waits = mutableListOf() + var recoveryRuns = 0 + var nowMillis = 1_000L + + val actual = assertFailsWith { + monitorQueuedDurableUploadScheduling( + recover = { + recoveryRuns += 1 + recoverySignal.request() + if (recoveryRuns == 2) { + assertEquals(listOf(60_000L), waits) + throw expected + } + false + }, + wait = { delayMillis -> + waits += delayMillis + nowMillis += delayMillis + }, + monotonicTimeMillis = { nowMillis }, + recoverySignal = recoverySignal, + ) + } + + assertTrue(actual === expected) + assertEquals(2, recoveryRuns) + assertEquals(listOf(60_000L), waits) + } + @Test fun `request crossing wakeup consumption is claimed without a stale token`() = runBlocking { val wakeupConsumed = CompletableDeferred() From ce1c82ae557c3b1f15a16fef68d680dc97841ee1 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:11:40 +0000 Subject: [PATCH 53/53] 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 81309996e..c0e48e17a 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -488,7 +488,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DocumentPreview.kt": "a9a8743dd7a381504282cc6ddc68034425024ccc1ae51667da9734bbfc7b1a79", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DurableMutationRecoveryDialog.kt": "e720eadb477a347762cd1894285788ac9f6820972431fe0a1953d01955667bfe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt": "2b7ef2d18b4a23615686ced0b7c9c621c58dc5edd0202104d0ca55b1ebf61d81", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "ddf80ca67d954f6e063c9e88c75794fcb81cbd6d42887c45d1a04b1cefe4f2fd", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "cff6ba11283705120375452d6d539c20581f4dd0115dd3d07049eb965242b019",