From 3bdd6464ec0ed18607f764f3f9eda440d8690357 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 22:56:43 +0200 Subject: [PATCH 01/23] fix(android): preserve account-owned background uploads --- .../AndroidDurableMultipartUploads.kt | 34 ++++++++-- ...AndroidDurableMultipartUploadPolicyTest.kt | 63 +++++++++++++++++++ .../172-account-background-uploads.md | 7 +++ 3 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 changes/unreleased/172-account-background-uploads.md diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 98e5d3d7c..ea81a557e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -18,6 +18,8 @@ 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 @@ -187,10 +189,17 @@ internal class DeckAttachmentUploadWorker( picker: AndroidLocalUploadPicker, jobId: String, ): Result { - val accountServices = AndroidNextcloudServices(applicationContext) - val session = accountServices.loadSession() - if (session == null || NextcloudDocumentIds.accountKey(session) != initial.accountId) { - when (durableUploadAccountMismatchOutcome(initial.accountId, accountServices.accountRetentionSnapshot())) { + val services = AndroidNextcloudServices(applicationContext) + val accountSnapshot = services.accountRetentionSnapshot() + val session = (accountSnapshot as? AndroidAccountRetentionSnapshot.Available)?.let { available -> + resolveDurableUploadSession( + expectedAccountId = initial.accountId, + accounts = available.accounts, + loadSession = services::loadSession, + ) + } + if (session == null) { + when (durableUploadAccountMismatchOutcome(initial.accountId, accountSnapshot)) { DurableUploadAccountMismatchOutcome.RetryAccountRecovery -> { recordUploadDiagnostic( severity = SupportDiagnosticSeverity.Warning, @@ -252,13 +261,13 @@ internal class DeckAttachmentUploadWorker( target = DurableUploadState.Uploading, message = null, ) ?: return Result.success() - val services = AndroidNextcloudServices( + val uploadServices = AndroidNextcloudServices( applicationContext, localUploadPicker = picker, accountMutationLeaseHeld = true, ) val outcome = runCatching { - services.executeNextcloudMultipartUpload(session, started.request) + uploadServices.executeNextcloudMultipartUpload(session, started.request) } outcome.onSuccess { response -> val state = durableUploadStateForHttpResponse(response.status) @@ -353,6 +362,19 @@ internal fun queuedDurableUploadsForAccount( job.accountId == accountId && job.state == DurableUploadState.Queued } +internal fun resolveDurableUploadSession( + expectedAccountId: String, + accounts: List, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): NextcloudSession? { + val account = accounts.singleOrNull { record -> + NextcloudDocumentIds.accountKey(record.serverUrl, record.loginName) == expectedAccountId + } ?: return null + return loadSession(account.id)?.takeIf { session -> + NextcloudDocumentIds.accountKey(session) == expectedAccountId + } +} + internal data class AndroidDurableMultipartUploadJob( val id: String, val accountId: String, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index b51979285..4d7bc4b53 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -14,6 +14,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue import org.json.JSONArray @@ -405,6 +406,62 @@ class AndroidDurableMultipartUploadPolicyTest { ) } + @Test + fun `background upload resolves the queued account instead of the active account`() { + val queuedSession = fixtureSession("alice") + val activeSession = fixtureSession("bob") + val loadedAccountIds = mutableListOf() + + val resolved = resolveDurableUploadSession( + expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), + accounts = listOf(activeSession.accountRecord(), queuedSession.accountRecord()), + loadSession = { accountId -> + loadedAccountIds += accountId.storageKey + when (accountId) { + queuedSession.accountId -> queuedSession + activeSession.accountId -> activeSession + else -> null + } + }, + ) + + assertEquals(queuedSession, resolved) + assertEquals(listOf(queuedSession.accountId.storageKey), loadedAccountIds) + } + + @Test + fun `background upload never substitutes another account on the same server path`() { + val queuedSession = fixtureSession("alice") + val otherSession = fixtureSession("bob") + var credentialRead = false + + val missing = resolveDurableUploadSession( + expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), + accounts = listOf(otherSession.accountRecord()), + loadSession = { + credentialRead = true + otherSession + }, + ) + + assertNull(missing) + assertFalse(credentialRead) + } + + @Test + fun `background upload rejects a credential that does not match its registry owner`() { + val queuedSession = fixtureSession("alice") + val otherSession = fixtureSession("bob") + + val resolved = resolveDurableUploadSession( + expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), + accounts = listOf(queuedSession.accountRecord(), otherSession.accountRecord()), + loadSession = { otherSession }, + ) + + assertNull(resolved) + } + private fun fixtureJob( index: Int, account: String, @@ -443,6 +500,12 @@ class AndroidDurableMultipartUploadPolicyTest { private fun selectionId(index: Int): String = "selection-${index.toString().padStart(16, '0')}" + private fun fixtureSession(loginName: String): NextcloudSession = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = loginName, + appPassword = "fixture-password", + ) + private companion object { const val ACCOUNT_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" const val ACCOUNT_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" diff --git a/changes/unreleased/172-account-background-uploads.md b/changes/unreleased/172-account-background-uploads.md new file mode 100644 index 000000000..e7969ec3c --- /dev/null +++ b/changes/unreleased/172-account-background-uploads.md @@ -0,0 +1,7 @@ +category: fix +issue: 172 +pull: none +platforms: android +user-facing: yes + +Queued Deck attachment uploads now keep using the account that created them after another account is selected. From a770194fc741c193635aeea713f022bd1f966d9f Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 22:58:14 +0200 Subject: [PATCH 02/23] chore(changelog): link account background upload fix --- changes/unreleased/172-account-background-uploads.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/unreleased/172-account-background-uploads.md b/changes/unreleased/172-account-background-uploads.md index e7969ec3c..7fb711c14 100644 --- a/changes/unreleased/172-account-background-uploads.md +++ b/changes/unreleased/172-account-background-uploads.md @@ -1,6 +1,6 @@ category: fix issue: 172 -pull: none +pull: 438 platforms: android user-facing: yes From 1843de96a801e2643f7b1c78c3581aa289cd2f82 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 04:32:25 +0200 Subject: [PATCH 03/23] docs(platform): bind uploads to supplied sessions --- .../dev/obiente/nextcloudnative/app/NextcloudPlatform.kt | 6 ++++-- 1 file changed, 4 insertions(+), 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 be3b4d09a..a4e7bf3a4 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -1348,8 +1348,10 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa /** * Streams one picker-authorized file to a reviewed same-origin multipart endpoint. * - * Implementations attach the active account credentials, reject redirects, enforce both - * request and response limits, and never accept an arbitrary local path from shared code. + * Implementations attach credentials belonging to the supplied session and its account, + * reject redirects, enforce both request and response limits, and never accept an arbitrary + * local path from shared code. The supplied session may own retained background work without + * being the account currently selected in the UI. */ suspend fun executeNextcloudMultipartUpload( session: NextcloudSession, From 2f7800b33835b6aa436b53d628d81219461cbb08 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 08:02:15 +0200 Subject: [PATCH 04/23] refactor(platform): keep upload contract compact --- tools/kotlin-file-size-baseline.txt | 2 +- .../dev/obiente/nextcloudnative/app/NextcloudPlatform.kt | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 7d0074d62..ed786333c 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -28,7 +28,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12348 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1724 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1717 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoEditing.kt|847 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoFolderBrowsing.kt|895 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoTimelinePaging.kt|860 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 a4e7bf3a4..bc01aad80 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -1348,10 +1348,8 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa /** * Streams one picker-authorized file to a reviewed same-origin multipart endpoint. * - * Implementations attach credentials belonging to the supplied session and its account, - * reject redirects, enforce both request and response limits, and never accept an arbitrary - * local path from shared code. The supplied session may own retained background work without - * being the account currently selected in the UI. + * Implementations use the supplied session's credentials, including for retained background + * work, reject redirects and arbitrary local paths, and enforce request and response limits. */ suspend fun executeNextcloudMultipartUpload( session: NextcloudSession, From 3de794e8687e950180cafd70fca85e5aba5e477d Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 13:59:01 +0200 Subject: [PATCH 05/23] fix(android): honor durable upload account lease --- .../AndroidDurableMultipartUploads.kt | 2 +- .../AndroidDurableUploadExecution.kt | 13 +++++++++++++ .../nextcloudnative/AndroidNextcloudServices.kt | 2 -- .../AndroidDurableMultipartUploadPolicyTest.kt | 15 +++++++++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadExecution.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index ea81a557e..3545cd6eb 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -266,7 +266,7 @@ internal class DeckAttachmentUploadWorker( localUploadPicker = picker, accountMutationLeaseHeld = true, ) - val outcome = runCatching { + val outcome = captureDurableUploadRequestOutcome { uploadServices.executeNextcloudMultipartUpload(session, started.request) } outcome.onSuccess { response -> diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadExecution.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadExecution.kt new file mode 100644 index 000000000..04683ffb3 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadExecution.kt @@ -0,0 +1,13 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CancellationException + +internal suspend fun captureDurableUploadRequestOutcome( + request: suspend () -> Result, +): kotlin.Result = try { + kotlin.Result.success(request()) +} catch (cancelled: CancellationException) { + throw cancelled +} catch (failure: Exception) { + kotlin.Result.failure(failure) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 2d1344f27..04898ad8f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -2881,7 +2881,6 @@ internal class AndroidNextcloudServices( override fun releaseLocalUploadFile(file: LocalUploadFile) { localUploadPicker?.release(file) } - override suspend fun executeNextcloudMultipartUpload( session: NextcloudSession, request: NextcloudMultipartUploadRequest, @@ -2932,7 +2931,6 @@ internal class AndroidNextcloudServices( } } } - override suspend fun enqueueDurableMultipartUpload( session: NextcloudSession, scope: DurableUploadScope, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 4d7bc4b53..8deee4513 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -9,6 +9,7 @@ import dev.obiente.nextcloudnative.app.accountRecord import dev.obiente.nextcloudnative.app.afterProcessRecovery import dev.obiente.nextcloudnative.app.localUploadFile import java.io.IOException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -19,6 +20,20 @@ import kotlin.test.assertTrue import org.json.JSONArray class AndroidDurableMultipartUploadPolicyTest { + @Test + fun `worker cancellation does not become a terminal upload outcome`() = runBlocking { + assertFailsWith { + captureDurableUploadRequestOutcome { + throw CancellationException("worker stopped") + } + } + assertTrue( + captureDurableUploadRequestOutcome { + throw IOException("transport failed") + }.isFailure, + ) + } + @Test fun `account cleanup removes a row only after its source capability is released`() = runBlocking { val first = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) From f63bda77c6f5b2026305c5a316657e1a60da5fbc Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 15:53:47 +0200 Subject: [PATCH 06/23] refactor(android): own durable upload execution --- .../AndroidDurableMultipartUploads.kt | 232 ++---------------- .../AndroidDurableUploadExecution.kt | 13 - .../AndroidDurableUploadWorker.kt | 229 +++++++++++++++++ 3 files changed, 243 insertions(+), 231 deletions(-) delete mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadExecution.kt create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 3545cd6eb..fdbc4d262 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -1,7 +1,6 @@ package dev.obiente.nextcloudnative import android.content.Context -import androidx.work.CoroutineWorker import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingWorkPolicy @@ -9,7 +8,6 @@ import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.Operation import androidx.work.WorkManager -import androidx.work.WorkerParameters import androidx.work.await import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult import dev.obiente.nextcloudnative.app.DurableUploadScope @@ -23,18 +21,9 @@ import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import dev.obiente.nextcloudnative.app.NextcloudApiMethod import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest import dev.obiente.nextcloudnative.app.NextcloudSession -import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent -import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft -import dev.obiente.nextcloudnative.app.SupportDiagnosticFieldDraft -import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity -import dev.obiente.nextcloudnative.app.SupportDiagnosticValuePrivacy -import dev.obiente.nextcloudnative.app.afterProcessRecovery import dev.obiente.nextcloudnative.app.localUploadFile -import dev.obiente.nextcloudnative.app.toSupportDiagnosticExceptionDraft import java.util.UUID import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject @@ -143,216 +132,23 @@ internal class AndroidDurableMultipartUploads(context: Context) { internal fun durableUploadWorkName(jobId: String) = "deck-attachment-$jobId" -internal class DeckAttachmentUploadWorker( - appContext: Context, - params: WorkerParameters, -) : CoroutineWorker(appContext, params) { - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - val jobId = inputData.getString(KEY_JOB_ID)?.takeIf(String::isNotBlank) - ?: return@withContext Result.failure() - val store = AndroidDurableMultipartUploadStore(applicationContext) - val initial = store.find(jobId) ?: return@withContext Result.success() - val picker = AndroidLocalUploadPicker(applicationContext) - if (initial.state.afterProcessRecovery() != initial.state) { - store.transition( - jobId, - expected = DurableUploadState.Uploading, - 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@withContext Result.success() - } - if (initial.state != DurableUploadState.Queued) return@withContext Result.success() - - return@withContext uploadQueuedJob(store, initial, picker, jobId) - } - - private suspend fun uploadQueuedJob( - store: AndroidDurableMultipartUploadStore, - initial: AndroidDurableMultipartUploadJob, - picker: AndroidLocalUploadPicker, - jobId: String, - ): Result = ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(initial.accountId) { - performQueuedUpload(store, initial, picker, jobId) - } +internal enum class DurableUploadAccountMismatchOutcome { + DeferAccountRecovery, + AccountUnavailable, +} - private suspend fun performQueuedUpload( - store: AndroidDurableMultipartUploadStore, - initial: AndroidDurableMultipartUploadJob, - picker: AndroidLocalUploadPicker, - jobId: String, - ): Result { - val services = AndroidNextcloudServices(applicationContext) - val accountSnapshot = services.accountRetentionSnapshot() - val session = (accountSnapshot as? AndroidAccountRetentionSnapshot.Available)?.let { available -> - resolveDurableUploadSession( - expectedAccountId = initial.accountId, - accounts = available.accounts, - loadSession = services::loadSession, - ) - } - if (session == null) { - when (durableUploadAccountMismatchOutcome(initial.accountId, accountSnapshot)) { - DurableUploadAccountMismatchOutcome.RetryAccountRecovery -> { - recordUploadDiagnostic( - severity = SupportDiagnosticSeverity.Warning, - outcome = "account-retry", - accountId = initial.accountId, - jobId = jobId, - ) - return Result.retry() - } - DurableUploadAccountMismatchOutcome.DeferAccountActivation -> { - recordUploadDiagnostic( - severity = SupportDiagnosticSeverity.Warning, - outcome = "account-deferred", - accountId = initial.accountId, - jobId = jobId, - ) - return Result.success() - } - DurableUploadAccountMismatchOutcome.AccountUnavailable -> Unit - } - store.transition( - jobId, - expected = DurableUploadState.Queued, - target = DurableUploadState.Failed, - message = "The account used for this upload is no longer available.", - ) - picker.release(initial.request.file) - recordUploadDiagnostic( - severity = SupportDiagnosticSeverity.Warning, - outcome = "account-unavailable", - accountId = initial.accountId, - jobId = jobId, - ) - return Result.failure() - } - 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() - } - val started = store.transition( - jobId, - expected = DurableUploadState.Queued, - target = DurableUploadState.Uploading, - message = null, - ) ?: return Result.success() - val uploadServices = AndroidNextcloudServices( - applicationContext, - localUploadPicker = picker, - accountMutationLeaseHeld = true, - ) - val outcome = captureDurableUploadRequestOutcome { - uploadServices.executeNextcloudMultipartUpload(session, started.request) - } - outcome.onSuccess { response -> - val state = durableUploadStateForHttpResponse(response.status) - val message = when (state) { - DurableUploadState.Completed -> null - DurableUploadState.Failed -> - "The server rejected this upload (HTTP ${response.status})." - DurableUploadState.OutcomeUnknown -> - "The server returned HTTP ${response.status}, but the upload result is unknown. " + - "Check the card before uploading again." - DurableUploadState.Queued, - DurableUploadState.Uploading, - -> error("The upload response state is invalid.") - } - store.transition( - jobId, - expected = DurableUploadState.Uploading, - target = state, - message = message, - ) - if (state != DurableUploadState.Completed) { - recordUploadDiagnostic( - severity = SupportDiagnosticSeverity.Warning, - outcome = when (state) { - DurableUploadState.Failed -> "rejected" - DurableUploadState.OutcomeUnknown -> "outcome-unknown" - DurableUploadState.Completed, - DurableUploadState.Queued, - DurableUploadState.Uploading, - -> error("Only failed upload states are diagnosed here.") - }, - accountId = initial.accountId, - jobId = jobId, - 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. - store.transition( - jobId, - expected = DurableUploadState.Uploading, - target = DurableUploadState.OutcomeUnknown, - message = "The upload result is unknown. Check the card before uploading again.", - ) - recordUploadDiagnostic( - severity = SupportDiagnosticSeverity.Error, - outcome = "outcome-unknown", - accountId = initial.accountId, - jobId = jobId, - failure = failure, - ) - picker.release(started.request.file) +internal fun durableUploadAccountMismatchOutcome( + expectedAccountId: String, + accountSnapshot: AndroidAccountRetentionSnapshot, +): DurableUploadAccountMismatchOutcome = when (accountSnapshot) { + is AndroidAccountRetentionSnapshot.Available -> { + if (androidAccountIdentityIsRetained(expectedAccountId, accountSnapshot.accounts)) { + DurableUploadAccountMismatchOutcome.DeferAccountRecovery + } else { + DurableUploadAccountMismatchOutcome.AccountUnavailable } - return Result.success() - } - - private fun recordUploadDiagnostic( - severity: SupportDiagnosticSeverity, - outcome: String, - accountId: String, - jobId: String, - code: String? = null, - failure: Throwable? = null, - ) { - AndroidSupportDiagnostics.get(applicationContext).recordForAccountIdentity( - accountId, - SupportDiagnosticEventDraft( - severity = severity, - component = SupportDiagnosticComponent.Media, - operation = "media.durable-upload", - outcome = outcome, - code = code, - fields = listOf( - SupportDiagnosticFieldDraft("job", jobId, SupportDiagnosticValuePrivacy.Identifier), - ), - exception = failure?.toSupportDiagnosticExceptionDraft(), - ), - ) - } - - internal companion object { - const val KEY_JOB_ID = "job_id" } + AndroidAccountRetentionSnapshot.Unavailable -> DurableUploadAccountMismatchOutcome.DeferAccountRecovery } internal fun queuedDurableUploadsForAccount( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadExecution.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadExecution.kt deleted file mode 100644 index 04683ffb3..000000000 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadExecution.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.obiente.nextcloudnative - -import kotlinx.coroutines.CancellationException - -internal suspend fun captureDurableUploadRequestOutcome( - request: suspend () -> Result, -): kotlin.Result = try { - kotlin.Result.success(request()) -} catch (cancelled: CancellationException) { - throw cancelled -} catch (failure: Exception) { - kotlin.Result.failure(failure) -} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt new file mode 100644 index 000000000..2fddb1b23 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -0,0 +1,229 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dev.obiente.nextcloudnative.app.DurableUploadState +import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent +import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft +import dev.obiente.nextcloudnative.app.SupportDiagnosticFieldDraft +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 kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class DeckAttachmentUploadWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val jobId = inputData.getString(KEY_JOB_ID)?.takeIf(String::isNotBlank) + ?: return@withContext Result.failure() + val store = AndroidDurableMultipartUploadStore(applicationContext) + val initial = store.find(jobId) ?: return@withContext Result.success() + val picker = AndroidLocalUploadPicker(applicationContext) + if (initial.state.afterProcessRecovery() != initial.state) { + store.transition( + jobId, + expected = DurableUploadState.Uploading, + 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@withContext Result.success() + } + if (initial.state != DurableUploadState.Queued) return@withContext Result.success() + + return@withContext uploadQueuedJob(store, initial, picker, jobId) + } + + private suspend fun uploadQueuedJob( + store: AndroidDurableMultipartUploadStore, + initial: AndroidDurableMultipartUploadJob, + picker: AndroidLocalUploadPicker, + jobId: String, + ): Result = ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(initial.accountId) { + performQueuedUpload(store, initial, picker, jobId) + } + + private suspend fun performQueuedUpload( + store: AndroidDurableMultipartUploadStore, + initial: AndroidDurableMultipartUploadJob, + picker: AndroidLocalUploadPicker, + jobId: String, + ): Result { + val services = AndroidNextcloudServices(applicationContext) + val accountSnapshot = services.accountRetentionSnapshot() + val session = (accountSnapshot as? AndroidAccountRetentionSnapshot.Available)?.let { available -> + resolveDurableUploadSession( + expectedAccountId = initial.accountId, + accounts = available.accounts, + loadSession = services::loadSession, + ) + } + if (session == null) { + if ( + durableUploadAccountMismatchOutcome(initial.accountId, accountSnapshot) == + DurableUploadAccountMismatchOutcome.DeferAccountRecovery + ) { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-deferred", + accountId = initial.accountId, + jobId = jobId, + ) + return Result.success() + } + store.transition( + jobId, + expected = DurableUploadState.Queued, + target = DurableUploadState.Failed, + message = "The account used for this upload is no longer available.", + ) + picker.release(initial.request.file) + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-unavailable", + accountId = initial.accountId, + jobId = jobId, + ) + return Result.failure() + } + 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() + } + val started = store.transition( + jobId, + expected = DurableUploadState.Queued, + target = DurableUploadState.Uploading, + message = null, + ) ?: return Result.success() + val uploadServices = AndroidNextcloudServices( + applicationContext, + localUploadPicker = picker, + accountMutationLeaseHeld = true, + ) + val outcome = captureDurableUploadRequestOutcome { + uploadServices.executeNextcloudMultipartUpload(session, started.request) + } + outcome.onSuccess { response -> + val state = durableUploadStateForHttpResponse(response.status) + val message = when (state) { + DurableUploadState.Completed -> null + DurableUploadState.Failed -> + "The server rejected this upload (HTTP ${response.status})." + DurableUploadState.OutcomeUnknown -> + "The server returned HTTP ${response.status}, but the upload result is unknown. " + + "Check the card before uploading again." + DurableUploadState.Queued, + DurableUploadState.Uploading, + -> error("The upload response state is invalid.") + } + store.transition( + jobId, + expected = DurableUploadState.Uploading, + target = state, + message = message, + ) + if (state != DurableUploadState.Completed) { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = when (state) { + DurableUploadState.Failed -> "rejected" + DurableUploadState.OutcomeUnknown -> "outcome-unknown" + DurableUploadState.Completed, + DurableUploadState.Queued, + DurableUploadState.Uploading, + -> error("Only failed upload states are diagnosed here.") + }, + accountId = initial.accountId, + jobId = jobId, + 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. + store.transition( + jobId, + expected = DurableUploadState.Uploading, + target = DurableUploadState.OutcomeUnknown, + message = "The upload result is unknown. Check the card before uploading again.", + ) + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Error, + outcome = "outcome-unknown", + accountId = initial.accountId, + jobId = jobId, + failure = failure, + ) + picker.release(started.request.file) + } + return Result.success() + } + + private fun recordUploadDiagnostic( + severity: SupportDiagnosticSeverity, + outcome: String, + accountId: String, + jobId: String, + code: String? = null, + failure: Throwable? = null, + ) { + AndroidSupportDiagnostics.get(applicationContext).recordForAccountIdentity( + accountId, + SupportDiagnosticEventDraft( + severity = severity, + component = SupportDiagnosticComponent.Media, + operation = "media.durable-upload", + outcome = outcome, + code = code, + fields = listOf( + SupportDiagnosticFieldDraft("job", jobId, SupportDiagnosticValuePrivacy.Identifier), + ), + exception = failure?.toSupportDiagnosticExceptionDraft(), + ), + ) + } + + internal companion object { + const val KEY_JOB_ID = "job_id" + } +} + +internal suspend fun captureDurableUploadRequestOutcome( + request: suspend () -> Result, +): kotlin.Result = try { + kotlin.Result.success(request()) +} catch (cancelled: CancellationException) { + throw cancelled +} catch (failure: Exception) { + kotlin.Result.failure(failure) +} From d400a485b1372ca757f1cce71a2ff9015ddd5d89 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 17:38:07 +0200 Subject: [PATCH 07/23] fix(uploads): recover worker account metadata --- .../AndroidAccountCredentialRecovery.kt | 1 + .../AndroidDurableMultipartUploads.kt | 99 ++++++++++ .../AndroidDurableUploadWorker.kt | 27 +-- .../AndroidNextcloudServices.kt | 7 +- .../AndroidPersistedSession.kt | 5 + .../nextcloudnative/AndroidTestSafety.kt | 2 +- .../NextcloudNativeApplication.kt | 62 ++++++ ...AndroidDurableMultipartUploadPolicyTest.kt | 183 ++++++++++++++++++ .../AndroidPersistedSessionTest.kt | 13 ++ 9 files changed, 377 insertions(+), 22 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index 48c923432..a55a86e4b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -359,6 +359,7 @@ internal fun resolveStoredAndroidAccountSession( internal const val ANDROID_ACCOUNT_SESSION_KEY = "encrypted_session" internal const val ANDROID_ACCOUNT_REGISTRY_KEY = "account_registry_v1" +internal const val ANDROID_ACCOUNT_PREFERENCES_NAME = "nextcloud_native" internal const val ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX = "account_credential_v1:" internal const val ANDROID_QUARANTINED_SESSION_KEY = "encrypted_session_quarantine" internal const val ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY = "pending_account_removal_cleanup_v2" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index fdbc4d262..11ffbd01a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -94,6 +94,11 @@ internal class AndroidDurableMultipartUploads(context: Context) { } } + suspend fun reconcileQueuedUploads(): Boolean = reconcileQueuedDurableUploads( + jobs = store.list(), + schedule = { job -> schedule(job).await() }, + ) + fun dismiss(session: NextcloudSession, scope: DurableUploadScope, uploadId: String): Boolean { val job = store.find(uploadId) ?: return false if ( @@ -132,6 +137,77 @@ 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 (_: AndroidDurableMultipartUploadRecoveryException) { + false + } + if (recovered) { + recoveryFailureReported = false + } else if (!recoveryFailureReported) { + runCatching(recordRecoveryFailure) + recoveryFailureReported = true + } + wait(followUpDelayMillis) + } +} + internal enum class DurableUploadAccountMismatchOutcome { DeferAccountRecovery, AccountUnavailable, @@ -171,6 +247,29 @@ internal fun resolveDurableUploadSession( } } +internal fun resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId: String, + listAccounts: () -> List, + recoverRegistry: () -> NextcloudSession?, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): NextcloudSession? { + val accounts = listAccounts() + val accountAvailable = accounts.any { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == expectedAccountId + } + if (!accountAvailable) { + val recoveredSession = recoverRegistry() + if ( + recoveredSession != null && + NextcloudDocumentIds.accountKey(recoveredSession) == expectedAccountId + ) { + return recoveredSession + } + return resolveDurableUploadSession(expectedAccountId, listAccounts(), loadSession) + } + return resolveDurableUploadSession(expectedAccountId, accounts, loadSession) +} + 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 2fddb1b23..b8ccf0099 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -62,27 +62,14 @@ internal class DeckAttachmentUploadWorker( jobId: String, ): Result { val services = AndroidNextcloudServices(applicationContext) - val accountSnapshot = services.accountRetentionSnapshot() - val session = (accountSnapshot as? AndroidAccountRetentionSnapshot.Available)?.let { available -> - resolveDurableUploadSession( - expectedAccountId = initial.accountId, - accounts = available.accounts, - loadSession = services::loadSession, - ) - } + if (!services.isDurableUploadAccountResolutionAvailable()) return Result.retry() + val session = resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId = initial.accountId, + listAccounts = services::listAccounts, + recoverRegistry = { services.loadSession() }, + loadSession = services::loadSession, + ) if (session == null) { - if ( - durableUploadAccountMismatchOutcome(initial.accountId, accountSnapshot) == - DurableUploadAccountMismatchOutcome.DeferAccountRecovery - ) { - recordUploadDiagnostic( - severity = SupportDiagnosticSeverity.Warning, - outcome = "account-deferred", - accountId = initial.accountId, - jobId = jobId, - ) - return Result.success() - } store.transition( jobId, expected = DurableUploadState.Queued, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 04898ad8f..1fef99f19 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -381,7 +381,7 @@ internal class AndroidNextcloudServices( ) : NextcloudPlatformServices { private val appContext = context.applicationContext private val activity = context as? Activity - private val preferences = appContext.getSharedPreferences("nextcloud_native", Context.MODE_PRIVATE) + private val preferences = appContext.getSharedPreferences(ANDROID_ACCOUNT_PREFERENCES_NAME, Context.MODE_PRIVATE) private val httpClient = OkHttpClient.Builder() .useAndroidNextcloudCertificateTrust(appContext) .trackJvmNetworkFailures() @@ -437,6 +437,11 @@ internal class AndroidNextcloudServices( } private val nativeMediaPreviewDecodeMutex = Mutex() private val mediaTimelineCarryoverStore = MediaTimelineDavCarryoverStore() + + internal fun isDurableUploadAccountResolutionAvailable(): Boolean = + androidCredentialFreeRegistryAllowsAccountResolution( + preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null), + ) private val memoriesTimeline = MemoriesPreferredTimelineReadService { session, request -> executeNextcloudApi(session, request) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index c2de11a79..0187080ef 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -225,6 +225,11 @@ internal fun restoreAndroidCredentialFreeRegistry( } } +internal fun androidCredentialFreeRegistryAllowsAccountResolution(encoded: String?): Boolean { + val restored = encoded?.let(::restoreAndroidCredentialFreeRegistry) ?: return true + return restored.registry != null || restored.credentialRecoveryRequired +} + internal fun recoverAndroidCredentialFreeRegistryForCredentialLoad( restored: RestoredAndroidCredentialFreeRegistry?, recover: () -> NextcloudAccountRegistry?, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidTestSafety.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidTestSafety.kt index 08f5a244d..15b6a18b0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidTestSafety.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidTestSafety.kt @@ -4,7 +4,7 @@ import android.content.Context import java.net.URI import java.util.Locale -internal const val TEST_PREFERENCES_NAME = "nextcloud_native" +internal const val TEST_PREFERENCES_NAME = ANDROID_ACCOUNT_PREFERENCES_NAME internal const val KEY_TEST_READ_ONLY = "emulator_test_read_only" internal const val KEY_TEST_WRITE_SCOPE_SERVER = "emulator_test_write_scope_server" internal const val KEY_TEST_WRITE_SCOPE_PATH = "emulator_test_write_scope_path" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index d5ca61bcc..c9c37d478 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt @@ -3,8 +3,18 @@ package dev.obiente.nextcloudnative import android.app.Application import android.content.Context import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent +import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft +import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch class NextcloudNativeApplication : Application() { + private val startupRecoveryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var accountCleanupListener: SharedPreferences.OnSharedPreferenceChangeListener? = null override fun attachBaseContext(base: Context) { @@ -15,5 +25,57 @@ class NextcloudNativeApplication : Application() { override fun onCreate() { super.onCreate() accountCleanupListener = installAndroidAccountRemovalCleanupRecovery(this) + startupRecoveryScope.launch { + val recordRecoveryFailure = { + AndroidSupportDiagnostics.get(this@NextcloudNativeApplication).record( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.Media, + operation = "media.durable-upload-startup", + outcome = "recovery-blocked", + code = "DURABLE_UPLOAD_QUEUE_RECOVERY_FAILED", + ), + ) + } + runAndroidDurableUploadStartupRecovery( + recover = { + var uploads: AndroidDurableMultipartUploads? = null + keepRetryingQueuedDurableUploadScheduling( + reconcile = { + val accountRegistry = getSharedPreferences( + ANDROID_ACCOUNT_PREFERENCES_NAME, + Context.MODE_PRIVATE, + ).getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) + if (androidCredentialFreeRegistryAllowsAccountResolution(accountRegistry)) { + constructAndReconcileQueuedDurableUploads { + val available = uploads ?: AndroidDurableMultipartUploads( + this@NextcloudNativeApplication, + ).also { uploads = it } + available::reconcileQueuedUploads + } + } else { + true + } + }, + wait = { delayMillis -> delay(delayMillis) }, + recordRecoveryFailure = recordRecoveryFailure, + ) + }, + recordRecoveryFailure = recordRecoveryFailure, + ) + } + } +} + +internal suspend fun runAndroidDurableUploadStartupRecovery( + recover: suspend () -> Unit, + recordRecoveryFailure: () -> Unit, +) { + try { + recover() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: AndroidDurableMultipartUploadRecoveryException) { + runCatching(recordRecoveryFailure) } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 8deee4513..6642bb772 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -2,6 +2,7 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.DurableUploadScope import dev.obiente.nextcloudnative.app.DurableUploadState +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import dev.obiente.nextcloudnative.app.NextcloudApiMethod import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest import dev.obiente.nextcloudnative.app.NextcloudSession @@ -444,6 +445,188 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(listOf(queuedSession.accountId.storageKey), loadedAccountIds) } + @Test + fun `background upload recovers missing account metadata before rejecting the account`() { + val queuedSession = fixtureSession("alice") + var accounts = emptyList() + val events = mutableListOf() + + val resolved = resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), + listAccounts = { + events += "list" + accounts + }, + recoverRegistry = { + events += "recover" + accounts = listOf(queuedSession.accountRecord()) + null + }, + loadSession = { + events += "load:${it.storageKey}" + queuedSession + }, + ) + + assertEquals(queuedSession, resolved) + assertEquals( + listOf("list", "recover", "list", "load:${queuedSession.accountId.storageKey}"), + events, + ) + } + + @Test + fun `background upload retains a matching recovered session when registry repair cannot persist`() { + val queuedSession = fixtureSession("alice") + var accountReads = 0 + + val resolved = resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), + listAccounts = { + accountReads += 1 + emptyList() + }, + recoverRegistry = { queuedSession }, + loadSession = { error("the uncommitted registry must not hide the recovered session") }, + ) + + assertEquals(queuedSession, resolved) + assertEquals(1, accountReads) + } + + @Test + fun `background upload skips registry recovery when account metadata is healthy`() { + val queuedSession = fixtureSession("alice") + var registryRecoveryAttempted = false + + val resolved = resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), + listAccounts = { listOf(queuedSession.accountRecord()) }, + recoverRegistry = { + registryRecoveryAttempted = true + null + }, + loadSession = { queuedSession }, + ) + + assertEquals(queuedSession, resolved) + assertFalse(registryRecoveryAttempted) + } + + @Test + fun `startup reconciliation schedules every queued upload across accounts`() = runBlocking { + val first = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val second = fixtureJob(index = 2, account = ACCOUNT_B, cardId = 43) + val completed = fixtureJob( + index = 3, + account = ACCOUNT_A, + cardId = 44, + state = DurableUploadState.Completed, + ) + val attempted = mutableListOf() + + val allScheduled = reconcileQueuedDurableUploads(listOf(first, completed, second)) { job -> + attempted += job.id + if (job == first) throw IOException("Synthetic scheduler rejection") + } + + assertEquals(listOf(first.id, second.id), attempted) + assertFalse(allScheduled) + } + + @Test + fun `startup scheduling retries an observed asynchronous failure`() = runBlocking { + var attempts = 0 + val waits = mutableListOf() + + val recovered = retryQueuedDurableUploadScheduling( + retryDelaysMillis = listOf(10L, 20L), + reconcile = { + attempts += 1 + attempts >= 2 + }, + wait = { delayMillis -> waits += delayMillis }, + ) + + assertTrue(recovered) + assertEquals(2, attempts) + assertEquals(listOf(10L), waits) + } + + @Test + fun `exhausted startup scheduling is reported before the next recovery cycle`() { + var attempts = 0 + var diagnostics = 0 + var recoveryCycles = 0 + val waits = mutableListOf() + + assertFailsWith { + runBlocking { + keepRetryingQueuedDurableUploadScheduling( + retryDelaysMillis = listOf(10L), + followUpDelayMillis = 20L, + reconcile = { + attempts += 1 + false + }, + wait = { delayMillis -> + waits += delayMillis + if (delayMillis == 20L && ++recoveryCycles == 2) { + throw CancellationException("stop after two cycles") + } + }, + recordRecoveryFailure = { diagnostics += 1 }, + ) + } + } + + assertEquals(4, attempts) + assertEquals(1, diagnostics) + assertEquals(listOf(10L, 20L, 10L, 20L), waits) + } + + @Test + fun `startup recovery contains uploader construction failures`() = runBlocking { + val failure = assertFailsWith { + constructAndReconcileQueuedDurableUploads { + throw IOException("synthetic keystore failure") + } + } + + assertTrue(failure.cause is IOException) + } + + @Test + fun `startup recovery contains an unreadable queue and records one bounded diagnostic`() = runBlocking { + val events = mutableListOf() + + runAndroidDurableUploadStartupRecovery( + recover = { + events += "recover" + throw AndroidDurableMultipartUploadRecoveryException(IOException("sensitive storage detail")) + }, + recordRecoveryFailure = { events += "diagnose" }, + ) + + assertEquals(listOf("recover", "diagnose"), events) + } + + @Test + fun `startup recovery preserves cancellation`() { + val events = mutableListOf() + + assertFailsWith { + runBlocking { + runAndroidDurableUploadStartupRecovery( + recover = { throw CancellationException("application stopped") }, + recordRecoveryFailure = { events += "diagnose" }, + ) + } + } + + assertTrue(events.isEmpty()) + } + @Test fun `background upload never substitutes another account on the same server path`() { val queuedSession = fixtureSession("alice") diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index fc14f0f07..37d3ec487 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -563,6 +563,19 @@ class AndroidPersistedSessionTest { assertTrue(androidIndependentCredentialStateCanBeExplicitlyReset(null)) } + @Test + fun futureCredentialFreeRegistryDefersDurableUploadAccountResolution() { + val futureRegistry = """{"version":99,"accounts":[]}""" + val healthyRegistry = encodeNextcloudAccountRegistry( + NextcloudAccountRegistry.Empty.upsertAndSelect(firstSession().accountRecord()), + ) + + assertFalse(androidCredentialFreeRegistryAllowsAccountResolution(futureRegistry)) + assertTrue(androidCredentialFreeRegistryAllowsAccountResolution(healthyRegistry)) + assertTrue(androidCredentialFreeRegistryAllowsAccountResolution("{not-json")) + assertTrue(androidCredentialFreeRegistryAllowsAccountResolution(null)) + } + @Test fun credentialSlotReadDecryptsOnlyTheRequestedAccount() { val first = firstSession() From 5cfe4d8358aab408bfd7d0ba3def47501cf9691e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 01:14:48 +0200 Subject: [PATCH 08/23] fix(uploads): defer ambiguous account recovery --- .../AndroidDurableMultipartUploads.kt | 5 ++--- .../nextcloudnative/AndroidDurableUploadWorker.kt | 11 ++--------- .../AndroidDurableMultipartUploadPolicyTest.kt | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 11ffbd01a..9f24e3939 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -198,9 +198,8 @@ internal suspend fun keepRetryingQueuedDurableUploadScheduling( } catch (_: AndroidDurableMultipartUploadRecoveryException) { false } - if (recovered) { - recoveryFailureReported = false - } else if (!recoveryFailureReported) { + if (recovered) return + if (!recoveryFailureReported) { runCatching(recordRecoveryFailure) recoveryFailureReported = true } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index b8ccf0099..f17663fda 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -70,20 +70,13 @@ internal class DeckAttachmentUploadWorker( loadSession = services::loadSession, ) if (session == null) { - store.transition( - jobId, - expected = DurableUploadState.Queued, - target = DurableUploadState.Failed, - message = "The account used for this upload is no longer available.", - ) - picker.release(initial.request.file) recordUploadDiagnostic( severity = SupportDiagnosticSeverity.Warning, - outcome = "account-unavailable", + outcome = "account-resolution-deferred", accountId = initial.accountId, jobId = jobId, ) - return Result.failure() + return Result.retry() } val capabilityReady = runCatching { picker.requirePersisted(initial.request.file) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 6642bb772..4a8616630 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -585,6 +585,21 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(listOf(10L, 20L, 10L, 20L), waits) } + @Test + fun `successful startup reconciliation stops background polling`() = runBlocking { + var attempts = 0 + + keepRetryingQueuedDurableUploadScheduling( + reconcile = { + attempts += 1 + true + }, + wait = { error("a successful reconciliation must not schedule another poll") }, + ) + + assertEquals(1, attempts) + } + @Test fun `startup recovery contains uploader construction failures`() = runBlocking { val failure = assertFailsWith { From 47a915eaeca3b55890c2c9e91a2a52d94de84a48 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 02:06:02 +0200 Subject: [PATCH 09/23] fix(uploads): retire removed account work --- .../AndroidAccountCredentialController.kt | 4 + .../AndroidDurableMultipartUploads.kt | 71 +++++----- .../AndroidDurableUploadWorker.kt | 62 +++++++-- .../AndroidNextcloudServices.kt | 2 + ...AndroidDurableMultipartUploadPolicyTest.kt | 129 +++++++----------- 5 files changed, 146 insertions(+), 122 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index ba538af3c..4cd9d9c1e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -61,6 +61,10 @@ internal class AndroidAccountCredentialController( ?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts, registry.activeAccountId) } ?: AndroidAccountRetentionSnapshot.Unavailable + fun durableUploadAccountRegistry(): DurableUploadAccountRegistry = readCredentialFreeRegistry() + ?.let { registry -> DurableUploadAccountRegistry.Available(registry.accounts) } + ?: DurableUploadAccountRegistry.Unavailable + fun activeAccountId(): NextcloudAccountId? = readCredentialFreeRegistry()?.activeAccountId fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 9f24e3939..e4bead91c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -207,23 +207,16 @@ internal suspend fun keepRetryingQueuedDurableUploadScheduling( } } -internal enum class DurableUploadAccountMismatchOutcome { - DeferAccountRecovery, - AccountUnavailable, +internal sealed interface DurableUploadAccountResolution { + data class Available(val session: NextcloudSession) : DurableUploadAccountResolution + data object RegistryUnavailable : DurableUploadAccountResolution + data object CredentialUnavailable : DurableUploadAccountResolution + data object AccountUnavailable : DurableUploadAccountResolution } -internal fun durableUploadAccountMismatchOutcome( - expectedAccountId: String, - accountSnapshot: AndroidAccountRetentionSnapshot, -): DurableUploadAccountMismatchOutcome = when (accountSnapshot) { - is AndroidAccountRetentionSnapshot.Available -> { - if (androidAccountIdentityIsRetained(expectedAccountId, accountSnapshot.accounts)) { - DurableUploadAccountMismatchOutcome.DeferAccountRecovery - } else { - DurableUploadAccountMismatchOutcome.AccountUnavailable - } - } - AndroidAccountRetentionSnapshot.Unavailable -> DurableUploadAccountMismatchOutcome.DeferAccountRecovery +internal sealed interface DurableUploadAccountRegistry { + data class Available(val accounts: List) : DurableUploadAccountRegistry + data object Unavailable : DurableUploadAccountRegistry } internal fun queuedDurableUploadsForAccount( @@ -235,38 +228,44 @@ internal fun queuedDurableUploadsForAccount( internal fun resolveDurableUploadSession( expectedAccountId: String, - accounts: List, + registry: DurableUploadAccountRegistry, loadSession: (NextcloudAccountId) -> NextcloudSession?, -): NextcloudSession? { +): DurableUploadAccountResolution { + val accounts = when (registry) { + is DurableUploadAccountRegistry.Available -> registry.accounts + DurableUploadAccountRegistry.Unavailable -> return DurableUploadAccountResolution.RegistryUnavailable + } val account = accounts.singleOrNull { record -> NextcloudDocumentIds.accountKey(record.serverUrl, record.loginName) == expectedAccountId - } ?: return null - return loadSession(account.id)?.takeIf { session -> - NextcloudDocumentIds.accountKey(session) == expectedAccountId - } + } ?: return DurableUploadAccountResolution.AccountUnavailable + val session = loadSession(account.id) + ?.takeIf { loaded -> NextcloudDocumentIds.accountKey(loaded) == expectedAccountId } + ?: return DurableUploadAccountResolution.CredentialUnavailable + return DurableUploadAccountResolution.Available(session) } internal fun resolveDurableUploadSessionWithRegistryRecovery( expectedAccountId: String, - listAccounts: () -> List, + readRegistry: () -> DurableUploadAccountRegistry, recoverRegistry: () -> NextcloudSession?, loadSession: (NextcloudAccountId) -> NextcloudSession?, -): NextcloudSession? { - val accounts = listAccounts() - val accountAvailable = accounts.any { account -> - NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == expectedAccountId - } - if (!accountAvailable) { - val recoveredSession = recoverRegistry() - if ( - recoveredSession != null && - NextcloudDocumentIds.accountKey(recoveredSession) == expectedAccountId - ) { - return recoveredSession +): 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 } - return resolveDurableUploadSession(expectedAccountId, listAccounts(), loadSession) } - return resolveDurableUploadSession(expectedAccountId, accounts, loadSession) + 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( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index f17663fda..95a19f136 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -63,20 +63,50 @@ internal class DeckAttachmentUploadWorker( ): Result { val services = AndroidNextcloudServices(applicationContext) if (!services.isDurableUploadAccountResolutionAvailable()) return Result.retry() - val session = resolveDurableUploadSessionWithRegistryRecovery( + val accountResolution = resolveDurableUploadSessionWithRegistryRecovery( expectedAccountId = initial.accountId, - listAccounts = services::listAccounts, + readRegistry = services::durableUploadAccountRegistry, recoverRegistry = { services.loadSession() }, loadSession = services::loadSession, ) - if (session == null) { - recordUploadDiagnostic( - severity = SupportDiagnosticSeverity.Warning, - outcome = "account-resolution-deferred", - accountId = initial.accountId, - jobId = jobId, - ) - return Result.retry() + val session = when (accountResolution) { + is DurableUploadAccountResolution.Available -> accountResolution.session + DurableUploadAccountResolution.RegistryUnavailable, + DurableUploadAccountResolution.CredentialUnavailable, + -> { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = when (accountResolution) { + DurableUploadAccountResolution.RegistryUnavailable -> "account-registry-unavailable" + else -> "account-resolution-deferred" + }, + accountId = initial.accountId, + jobId = jobId, + ) + return Result.retry() + } + DurableUploadAccountResolution.AccountUnavailable -> { + return failQueuedDurableUploadForUnavailableAccount( + transitionToFailed = { + store.transition( + jobId, + expected = DurableUploadState.Queued, + target = DurableUploadState.Failed, + message = "The account used for this upload is no longer available.", + ) + }, + releaseSelection = { picker.release(initial.request.file) }, + recordFailure = { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-unavailable", + accountId = initial.accountId, + jobId = jobId, + ) + }, + failureResult = Result.failure(), + ) + } } val capabilityReady = runCatching { picker.requirePersisted(initial.request.file) @@ -198,6 +228,18 @@ internal class DeckAttachmentUploadWorker( } } +internal fun failQueuedDurableUploadForUnavailableAccount( + transitionToFailed: () -> Unit, + releaseSelection: () -> Unit, + recordFailure: () -> Unit, + failureResult: Result, +): Result { + transitionToFailed() + releaseSelection() + recordFailure() + return failureResult +} + internal suspend fun captureDurableUploadRequestOutcome( request: suspend () -> Result, ): kotlin.Result = try { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 1fef99f19..7b0f1b50d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -442,6 +442,8 @@ internal class AndroidNextcloudServices( androidCredentialFreeRegistryAllowsAccountResolution( preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null), ) + internal fun durableUploadAccountRegistry(): DurableUploadAccountRegistry = + accountCredentials.durableUploadAccountRegistry() private val memoriesTimeline = MemoriesPreferredTimelineReadService { session, request -> executeNextcloudApi(session, request) } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 4a8616630..1b61421a0 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -2,7 +2,6 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.DurableUploadScope import dev.obiente.nextcloudnative.app.DurableUploadState -import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import dev.obiente.nextcloudnative.app.NextcloudApiMethod import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest import dev.obiente.nextcloudnative.app.NextcloudSession @@ -16,7 +15,6 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertTrue import org.json.JSONArray @@ -332,7 +330,7 @@ class AndroidDurableMultipartUploadPolicyTest { } @Test - fun `retained background account is deferred without reading its credential`() { + fun `retained background account defers when its credential is temporarily unavailable`() { val retainedSession = NextcloudSession( serverUrl = "https://cloud.example.test/nextcloud", loginName = "alice", @@ -340,69 +338,28 @@ class AndroidDurableMultipartUploadPolicyTest { ) val accountId = NextcloudDocumentIds.accountKey(retainedSession) - assertEquals( - DurableUploadAccountMismatchOutcome.DeferAccountActivation, - durableUploadAccountMismatchOutcome( - accountId, - AndroidAccountRetentionSnapshot.Available(listOf(retainedSession.accountRecord())), - ), + val resolution = resolveDurableUploadSession( + expectedAccountId = accountId, + registry = DurableUploadAccountRegistry.Available(listOf(retainedSession.accountRecord())), + loadSession = { null }, ) - } - @Test - fun `unreadable account registry defers queued upload recovery`() { - assertEquals( - DurableUploadAccountMismatchOutcome.RetryAccountRecovery, - durableUploadAccountMismatchOutcome(ACCOUNT_A, AndroidAccountRetentionSnapshot.Unavailable), - ) + assertEquals(DurableUploadAccountResolution.CredentialUnavailable, resolution) } @Test - fun `active account with unreadable credential keeps its upload scheduled`() { - val retainedSession = NextcloudSession( - serverUrl = "https://cloud.example.test/nextcloud", - loginName = "alice", - appPassword = "fixture-password", - ) - val accountId = NextcloudDocumentIds.accountKey(retainedSession) - - assertEquals( - DurableUploadAccountMismatchOutcome.RetryAccountRecovery, - durableUploadAccountMismatchOutcome( - accountId, - AndroidAccountRetentionSnapshot.Available( - accounts = listOf(retainedSession.accountRecord()), - activeAccountId = retainedSession.accountId, - ), - ), - ) - } + fun `removed account terminally fails and releases its queued upload exactly once`() { + val events = mutableListOf() - @Test - fun `valid account registry without expected account makes upload unavailable`() { - val retainedSession = NextcloudSession( - serverUrl = "https://cloud.example.test/nextcloud", - loginName = "alice", - appPassword = "fixture-password", + val result = failQueuedDurableUploadForUnavailableAccount( + transitionToFailed = { events += "fail" }, + releaseSelection = { events += "release" }, + recordFailure = { events += "diagnose" }, + failureResult = "worker-failure", ) - val accountId = NextcloudDocumentIds.accountKey(retainedSession) - assertEquals( - DurableUploadAccountMismatchOutcome.AccountUnavailable, - durableUploadAccountMismatchOutcome( - accountId, - AndroidAccountRetentionSnapshot.Available(emptyList()), - ), - ) - assertEquals( - DurableUploadAccountMismatchOutcome.AccountUnavailable, - durableUploadAccountMismatchOutcome( - accountId, - AndroidAccountRetentionSnapshot.Available( - listOf(retainedSession.copy(loginName = "another-account").accountRecord()), - ), - ), - ) + assertEquals("worker-failure", result) + assertEquals(listOf("fail", "release", "diagnose"), events) } @Test @@ -430,7 +387,9 @@ class AndroidDurableMultipartUploadPolicyTest { val resolved = resolveDurableUploadSession( expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), - accounts = listOf(activeSession.accountRecord(), queuedSession.accountRecord()), + registry = DurableUploadAccountRegistry.Available( + listOf(activeSession.accountRecord(), queuedSession.accountRecord()), + ), loadSession = { accountId -> loadedAccountIds += accountId.storageKey when (accountId) { @@ -441,25 +400,25 @@ class AndroidDurableMultipartUploadPolicyTest { }, ) - assertEquals(queuedSession, resolved) + assertEquals(DurableUploadAccountResolution.Available(queuedSession), resolved) assertEquals(listOf(queuedSession.accountId.storageKey), loadedAccountIds) } @Test fun `background upload recovers missing account metadata before rejecting the account`() { val queuedSession = fixtureSession("alice") - var accounts = emptyList() + var registry: DurableUploadAccountRegistry = DurableUploadAccountRegistry.Unavailable val events = mutableListOf() val resolved = resolveDurableUploadSessionWithRegistryRecovery( expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), - listAccounts = { - events += "list" - accounts + readRegistry = { + events += "registry" + registry }, recoverRegistry = { events += "recover" - accounts = listOf(queuedSession.accountRecord()) + registry = DurableUploadAccountRegistry.Available(listOf(queuedSession.accountRecord())) null }, loadSession = { @@ -468,13 +427,27 @@ class AndroidDurableMultipartUploadPolicyTest { }, ) - assertEquals(queuedSession, resolved) + assertEquals(DurableUploadAccountResolution.Available(queuedSession), resolved) assertEquals( - listOf("list", "recover", "list", "load:${queuedSession.accountId.storageKey}"), + listOf("registry", "recover", "registry", "load:${queuedSession.accountId.storageKey}"), events, ) } + @Test + fun `background upload defers when the credential-free registry remains unreadable`() { + val queuedSession = fixtureSession("alice") + + val resolved = resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), + readRegistry = { DurableUploadAccountRegistry.Unavailable }, + recoverRegistry = { null }, + loadSession = { error("an unreadable registry must not select a credential") }, + ) + + assertEquals(DurableUploadAccountResolution.RegistryUnavailable, resolved) + } + @Test fun `background upload retains a matching recovered session when registry repair cannot persist`() { val queuedSession = fixtureSession("alice") @@ -482,15 +455,15 @@ class AndroidDurableMultipartUploadPolicyTest { val resolved = resolveDurableUploadSessionWithRegistryRecovery( expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), - listAccounts = { + readRegistry = { accountReads += 1 - emptyList() + DurableUploadAccountRegistry.Unavailable }, recoverRegistry = { queuedSession }, loadSession = { error("the uncommitted registry must not hide the recovered session") }, ) - assertEquals(queuedSession, resolved) + assertEquals(DurableUploadAccountResolution.Available(queuedSession), resolved) assertEquals(1, accountReads) } @@ -501,7 +474,9 @@ class AndroidDurableMultipartUploadPolicyTest { val resolved = resolveDurableUploadSessionWithRegistryRecovery( expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), - listAccounts = { listOf(queuedSession.accountRecord()) }, + readRegistry = { + DurableUploadAccountRegistry.Available(listOf(queuedSession.accountRecord())) + }, recoverRegistry = { registryRecoveryAttempted = true null @@ -509,7 +484,7 @@ class AndroidDurableMultipartUploadPolicyTest { loadSession = { queuedSession }, ) - assertEquals(queuedSession, resolved) + assertEquals(DurableUploadAccountResolution.Available(queuedSession), resolved) assertFalse(registryRecoveryAttempted) } @@ -650,14 +625,14 @@ class AndroidDurableMultipartUploadPolicyTest { val missing = resolveDurableUploadSession( expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), - accounts = listOf(otherSession.accountRecord()), + registry = DurableUploadAccountRegistry.Available(listOf(otherSession.accountRecord())), loadSession = { credentialRead = true otherSession }, ) - assertNull(missing) + assertEquals(DurableUploadAccountResolution.AccountUnavailable, missing) assertFalse(credentialRead) } @@ -668,11 +643,13 @@ class AndroidDurableMultipartUploadPolicyTest { val resolved = resolveDurableUploadSession( expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), - accounts = listOf(queuedSession.accountRecord(), otherSession.accountRecord()), + registry = DurableUploadAccountRegistry.Available( + listOf(queuedSession.accountRecord(), otherSession.accountRecord()), + ), loadSession = { otherSession }, ) - assertNull(resolved) + assertEquals(DurableUploadAccountResolution.CredentialUnavailable, resolved) } private fun fixtureJob( From 41ade6c5a8c7c9ded8b1023697c3114cbd9b37b3 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 02:40:02 +0200 Subject: [PATCH 10/23] refactor(accounts): keep registry adapter bounded --- .../AndroidAccountCredentialController.kt | 10 +++------- .../AndroidAccountCredentialTransitions.kt | 9 +++++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 4cd9d9c1e..3b4b0ad16 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -57,14 +57,10 @@ internal class AndroidAccountCredentialController( }, ) - fun accountRetentionSnapshot(): AndroidAccountRetentionSnapshot = readRegistryForCredentialLoad() - ?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts, registry.activeAccountId) } - ?: AndroidAccountRetentionSnapshot.Unavailable - - fun durableUploadAccountRegistry(): DurableUploadAccountRegistry = readCredentialFreeRegistry() - ?.let { registry -> DurableUploadAccountRegistry.Available(registry.accounts) } - ?: DurableUploadAccountRegistry.Unavailable + fun accountRetentionSnapshot(): AndroidAccountRetentionSnapshot = + readRegistryForCredentialLoad().asAccountRetentionSnapshot() + fun durableUploadAccountRegistry(): DurableUploadAccountRegistry = readCredentialFreeRegistry().asDurableRegistry() fun activeAccountId(): NextcloudAccountId? = readCredentialFreeRegistry()?.activeAccountId fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index c5713fd0e..3cfbfd0a7 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry import dev.obiente.nextcloudnative.app.NextcloudSession import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable @@ -27,6 +28,14 @@ internal suspend fun replaceAndroidActiveStateWithAccountLeases( } } +internal fun NextcloudAccountRegistry?.asDurableRegistry(): DurableUploadAccountRegistry = + this?.let { registry -> DurableUploadAccountRegistry.Available(registry.accounts) } + ?: DurableUploadAccountRegistry.Unavailable + +internal fun NextcloudAccountRegistry?.asAccountRetentionSnapshot(): AndroidAccountRetentionSnapshot = + this?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts) } + ?: AndroidAccountRetentionSnapshot.Unavailable + internal suspend fun rollbackUnavailableAndroidAccountRemoval( active: Boolean = false, recovered: AndroidAccountCredentialState, From 7b9e6cc05e9a0c84ee13714068b0b129c551bd43 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:53:35 +0000 Subject: [PATCH 11/23] chore(website): refresh marketing captures From d5c6611e36353f4336c24c1fe028f6462edb62dc Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 05:14:00 +0200 Subject: [PATCH 12/23] refactor(accounts): keep Android services bounded --- .../dev/obiente/nextcloudnative/AndroidNextcloudServices.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 7b0f1b50d..1c6a6db12 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -437,7 +437,6 @@ internal class AndroidNextcloudServices( } private val nativeMediaPreviewDecodeMutex = Mutex() private val mediaTimelineCarryoverStore = MediaTimelineDavCarryoverStore() - internal fun isDurableUploadAccountResolutionAvailable(): Boolean = androidCredentialFreeRegistryAllowsAccountResolution( preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null), From c7a93dc6c2cffc8c5774320b325483737b22790c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 09:49:49 +0200 Subject: [PATCH 13/23] refactor(android): keep upload resolution bounded --- .../AndroidAccountCredentialTransitions.kt | 4 ++++ .../obiente/nextcloudnative/AndroidNextcloudServices.kt | 8 ++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index 3cfbfd0a7..c63de1349 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import android.content.SharedPreferences import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry import dev.obiente.nextcloudnative.app.NextcloudSession import kotlinx.coroutines.CancellationException @@ -36,6 +37,9 @@ internal fun NextcloudAccountRegistry?.asAccountRetentionSnapshot(): AndroidAcco this?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts) } ?: AndroidAccountRetentionSnapshot.Unavailable +internal fun SharedPreferences.durableUploadAccountResolutionAvailable(): Boolean = + androidCredentialFreeRegistryAllowsAccountResolution(getString(ANDROID_ACCOUNT_REGISTRY_KEY, null)) + internal suspend fun rollbackUnavailableAndroidAccountRemoval( active: Boolean = false, recovered: AndroidAccountCredentialState, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 1c6a6db12..1121b7bff 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -437,12 +437,8 @@ internal class AndroidNextcloudServices( } private val nativeMediaPreviewDecodeMutex = Mutex() private val mediaTimelineCarryoverStore = MediaTimelineDavCarryoverStore() - internal fun isDurableUploadAccountResolutionAvailable(): Boolean = - androidCredentialFreeRegistryAllowsAccountResolution( - preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null), - ) - internal fun durableUploadAccountRegistry(): DurableUploadAccountRegistry = - accountCredentials.durableUploadAccountRegistry() + internal fun isDurableUploadAccountResolutionAvailable() = preferences.durableUploadAccountResolutionAvailable() + internal fun durableUploadAccountRegistry() = accountCredentials.durableUploadAccountRegistry() private val memoriesTimeline = MemoriesPreferredTimelineReadService { session, request -> executeNextcloudApi(session, request) } From b04f8b66c200cbada74006a8482f174eb298f1f8 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 10:01:12 +0200 Subject: [PATCH 14/23] fix(uploads): contain corrupt registry preference --- .../NextcloudNativeApplication.kt | 16 ++++++++-------- .../AndroidDurableMultipartUploadPolicyTest.kt | 6 ++++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index c9c37d478..a6ac371fa 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt @@ -42,19 +42,19 @@ class NextcloudNativeApplication : Application() { var uploads: AndroidDurableMultipartUploads? = null keepRetryingQueuedDurableUploadScheduling( reconcile = { - val accountRegistry = getSharedPreferences( - ANDROID_ACCOUNT_PREFERENCES_NAME, - Context.MODE_PRIVATE, - ).getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) - if (androidCredentialFreeRegistryAllowsAccountResolution(accountRegistry)) { - constructAndReconcileQueuedDurableUploads { + constructAndReconcileQueuedDurableUploads { + val accountRegistry = getSharedPreferences( + ANDROID_ACCOUNT_PREFERENCES_NAME, + Context.MODE_PRIVATE, + ).getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) + if (androidCredentialFreeRegistryAllowsAccountResolution(accountRegistry)) { val available = uploads ?: AndroidDurableMultipartUploads( this@NextcloudNativeApplication, ).also { uploads = it } available::reconcileQueuedUploads + } else { + suspend { true } } - } else { - true } }, wait = { delayMillis -> delay(delayMillis) }, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 1b61421a0..d2600d314 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -582,8 +582,14 @@ class AndroidDurableMultipartUploadPolicyTest { throw IOException("synthetic keystore failure") } } + val malformedPreference = assertFailsWith { + constructAndReconcileQueuedDurableUploads { + throw ClassCastException("synthetic non-string account registry") + } + } assertTrue(failure.cause is IOException) + assertTrue(malformedPreference.cause is ClassCastException) } @Test From 93752cf18f2032a1cd13dc3f6d286a93b050856c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 10:18:09 +0200 Subject: [PATCH 15/23] fix(uploads): retry corrupt registry preference --- .../AndroidAccountCredentialTransitions.kt | 12 ++++++++- ...idDurableUploadRegistryAvailabilityTest.kt | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadRegistryAvailabilityTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index c63de1349..7d154c1ed 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -38,7 +38,17 @@ internal fun NextcloudAccountRegistry?.asAccountRetentionSnapshot(): AndroidAcco ?: AndroidAccountRetentionSnapshot.Unavailable internal fun SharedPreferences.durableUploadAccountResolutionAvailable(): Boolean = - androidCredentialFreeRegistryAllowsAccountResolution(getString(ANDROID_ACCOUNT_REGISTRY_KEY, null)) + durableUploadAccountResolutionAvailable { + getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) + } + +internal fun durableUploadAccountResolutionAvailable( + readRegistry: () -> String?, +): Boolean = try { + androidCredentialFreeRegistryAllowsAccountResolution(readRegistry()) +} catch (_: ClassCastException) { + false +} internal suspend fun rollbackUnavailableAndroidAccountRemoval( active: Boolean = false, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadRegistryAvailabilityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadRegistryAvailabilityTest.kt new file mode 100644 index 000000000..b05498b1b --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadRegistryAvailabilityTest.kt @@ -0,0 +1,26 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CancellationException +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +class AndroidDurableUploadRegistryAvailabilityTest { + @Test + fun `non-string registry preference defers durable upload account resolution`() { + assertFalse( + durableUploadAccountResolutionAvailable { + throw ClassCastException("synthetic non-string registry") + }, + ) + } + + @Test + fun `registry availability check preserves worker cancellation`() { + assertFailsWith { + durableUploadAccountResolutionAvailable { + throw CancellationException("worker stopped") + } + } + } +} From 54386f6801ac13781831231bc02e62852974fd76 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 15:31:25 +0200 Subject: [PATCH 16/23] refactor(platform): keep recovery contract bounded --- .../kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt | 1 - 1 file changed, 1 deletion(-) 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 bc01aad80..831c7c236 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -622,7 +622,6 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa accountScope: String, kind: DurableMutationRecoveryKind, ): String? = null - suspend fun saveDurableMutationRecovery( session: NextcloudSession, accountScope: String, From 7367e96d0962daa60ac5232d19695af8ad6dc3bb Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 04:17:18 +0200 Subject: [PATCH 17/23] fix(accounts): preserve background recovery state --- .../AndroidAccountCredentialTransitions.kt | 14 ++++++++-- .../AndroidDurableMultipartUploads.kt | 19 ++++++++++---- .../AndroidDurableUploadWorker.kt | 9 +++++++ .../AndroidAccountRecoveryPriorityTest.kt | 12 +++++++++ ...AndroidDurableMultipartUploadPolicyTest.kt | 26 +++++++++++++++++-- .../AndroidPersistedSessionTest.kt | 6 ++--- 6 files changed, 73 insertions(+), 13 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index 7d154c1ed..78256c27a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -30,11 +30,21 @@ internal suspend fun replaceAndroidActiveStateWithAccountLeases( } internal fun NextcloudAccountRegistry?.asDurableRegistry(): DurableUploadAccountRegistry = - this?.let { registry -> DurableUploadAccountRegistry.Available(registry.accounts) } + this?.let { registry -> + DurableUploadAccountRegistry.Available( + accounts = registry.accounts, + activeAccountId = registry.activeAccountId, + ) + } ?: DurableUploadAccountRegistry.Unavailable internal fun NextcloudAccountRegistry?.asAccountRetentionSnapshot(): AndroidAccountRetentionSnapshot = - this?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts) } + this?.let { registry -> + AndroidAccountRetentionSnapshot.Available( + accounts = registry.accounts, + activeAccountId = registry.activeAccountId, + ) + } ?: AndroidAccountRetentionSnapshot.Unavailable internal fun SharedPreferences.durableUploadAccountResolutionAvailable(): Boolean = diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index e4bead91c..676c151eb 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -211,11 +211,16 @@ 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) : DurableUploadAccountRegistry + data class Available( + val accounts: List, + val activeAccountId: NextcloudAccountId? = null, + ) : DurableUploadAccountRegistry + data object Unavailable : DurableUploadAccountRegistry } @@ -231,16 +236,20 @@ internal fun resolveDurableUploadSession( registry: DurableUploadAccountRegistry, loadSession: (NextcloudAccountId) -> NextcloudSession?, ): DurableUploadAccountResolution { - val accounts = when (registry) { - is DurableUploadAccountRegistry.Available -> registry.accounts + val availableRegistry = when (registry) { + is DurableUploadAccountRegistry.Available -> registry DurableUploadAccountRegistry.Unavailable -> return DurableUploadAccountResolution.RegistryUnavailable } - val account = accounts.singleOrNull { record -> + 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 DurableUploadAccountResolution.CredentialUnavailable + ?: return if (account.id == availableRegistry.activeAccountId) { + DurableUploadAccountResolution.CredentialUnavailable + } else { + DurableUploadAccountResolution.DeferAccountActivation + } return DurableUploadAccountResolution.Available(session) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt index 95a19f136..825a9cc0d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -85,6 +85,15 @@ internal class DeckAttachmentUploadWorker( ) return Result.retry() } + DurableUploadAccountResolution.DeferAccountActivation -> { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-deferred", + accountId = initial.accountId, + jobId = jobId, + ) + return Result.success() + } DurableUploadAccountResolution.AccountUnavailable -> { return failQueuedDurableUploadForUnavailableAccount( transitionToFailed = { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt index 9187d2588..49f6a8095 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt @@ -2,6 +2,7 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.FileSyncConfiguration import dev.obiente.nextcloudnative.app.FileSyncPair +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.accountRecord import kotlin.test.Test @@ -12,6 +13,17 @@ import kotlin.test.assertTrue import kotlinx.coroutines.runBlocking class AndroidAccountRecoveryPriorityTest { + @Test + fun accountRegistryAdapterPreservesTheActiveAccount() { + val expected = NextcloudSession("https://cloud.example.test/nextcloud", "alice", "secret") + val registry = NextcloudAccountRegistry.Empty.upsertAndSelect(expected.accountRecord()) + + assertEquals( + AndroidExpectedAccountState.Active, + registry.asAccountRetentionSnapshot().expectedAccountState(NextcloudDocumentIds.accountKey(expected)), + ) + } + @Test fun scheduleRestorationRetriesOnlyWhenTheExpectedAccountMayStillBeActive() { val expected = NextcloudSession("https://cloud.example.test/nextcloud", "alice", "secret") diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index d2600d314..cb45e46a9 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -330,7 +330,7 @@ class AndroidDurableMultipartUploadPolicyTest { } @Test - fun `retained background account defers when its credential is temporarily unavailable`() { + fun `inactive retained account defers when its credential is temporarily unavailable`() { val retainedSession = NextcloudSession( serverUrl = "https://cloud.example.test/nextcloud", loginName = "alice", @@ -344,6 +344,27 @@ class AndroidDurableMultipartUploadPolicyTest { 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) } @@ -650,7 +671,8 @@ class AndroidDurableMultipartUploadPolicyTest { val resolved = resolveDurableUploadSession( expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), registry = DurableUploadAccountRegistry.Available( - listOf(queuedSession.accountRecord(), otherSession.accountRecord()), + accounts = listOf(queuedSession.accountRecord(), otherSession.accountRecord()), + activeAccountId = queuedSession.accountId, ), loadSession = { otherSession }, ) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 37d3ec487..0b0ad511b 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -566,10 +566,8 @@ class AndroidPersistedSessionTest { @Test fun futureCredentialFreeRegistryDefersDurableUploadAccountResolution() { val futureRegistry = """{"version":99,"accounts":[]}""" - val healthyRegistry = encodeNextcloudAccountRegistry( - NextcloudAccountRegistry.Empty.upsertAndSelect(firstSession().accountRecord()), - ) - + val healthyAccount = firstSession().accountRecord() + val healthyRegistry = encodeNextcloudAccountRegistry(NextcloudAccountRegistry.Empty.upsertAndSelect(healthyAccount)) assertFalse(androidCredentialFreeRegistryAllowsAccountResolution(futureRegistry)) assertTrue(androidCredentialFreeRegistryAllowsAccountResolution(healthyRegistry)) assertTrue(androidCredentialFreeRegistryAllowsAccountResolution("{not-json")) From d815af948fd03dc2378edeebfdec8735ee5b4e94 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:54:42 +0000 Subject: [PATCH 18/23] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 8100381e8..294c26395 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -13,7 +13,6 @@ "tools/marketing-capture-inputs.txt", "ui/build.gradle.kts", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityFilters.kt", @@ -76,10 +75,8 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt", @@ -463,7 +460,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarScheduleViews.kt": "f37848200d712829405848db3606b5bec49c1421cc00de6144403f0f390ef5ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarWorkspaceNotice.kt": "b5c0cbbd46371eac5835758c836b41c7878d55f2e5da8f29679dde3aa210bf33", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarWorkspacePresentation.kt": "cd4638b118da879acfa711eed9bfcd643df4a847774925e8adae20bdd5c90b3b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "fbefd12e3574060cfc6d7f1abb27f7173e9f8eb2fa8de3c80f85e650757d6d4f", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "84a6e5ca035ac7796427c25f4b4ed1162832c4febbc44606dc06413cc2e33b58", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChoresWorkspaceContext.kt": "f22d2bfc408f52eb61f3587e9bcf79ba7cb4c7c6afd1717f04f35731f1906391", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardOcsResponse.kt": "25780cc210c8e2f56fb2e45ab23a29a9030f6350a9a1ea29412650106a0d111f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt": "1a0a6b50c2b1f8b528d637520cb95acab42d1a6f4edde4fd47ced7a47bd4ddad", @@ -497,11 +494,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt": "468f9bf41ea353dab6f816d333c3a6179d8d8e1f01f98f8eaf0890d5c0d381d6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt": "e0e7b54e02869eabe797c1af6c77a78b0f7eaf7cb609c3cce8d35a8a710e6511", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "2cb445b752faa33f051cfd618bfac3c9f39fc20abce3176f782fe032aef4a98c", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "88b66d6102b7325903521ae7ab85bf74a24a8234ccc6406d2692d76070b74e14", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "09debe6409b538c3944a63b1e2c5022dc3e0584d746ef288183bfe0983fb1f7b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt": "df5c2b3ef90a8a7d0ea02d6587b563ebde8e84d471073f718a7d901f2c0a65fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt": "fc7f8ac5ffb094da9d9a9f05bb2d072b13ff9da9281708f247b546258e0fc2e2", @@ -552,7 +547,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceToolbar.kt": "644e576f094b73a78dae541d15621edf86e75c0408553f6319d4fcefe55ed3d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GenericNativeFallback.kt": "768f8704a9999b45d06e39c61529bd9af370315ff1f33d7eb4f8b463aaaedbbf", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarComponents.kt": "84cca2c35c707d0c7915595d3fd7ebc463f40a31b59f44117210cb82b61716c2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "404d55e3cb691609cdbde00eaaddf230ec4e1626c342512b935e7c383630c8da", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "047c10233277632f1ff5e4dd7a77739c71629f05850c03070d20229adabd443d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactDetailDialog.kt": "ab6826a14ac9fbf62677bed2f6e93ef0fb1a57f1f074bb1a9e79fef212f715b3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "c0b42fa450b8748281c7208385f7257d286bc1f842588bc737f3c296dd4ec1d9", @@ -640,12 +635,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "5b29a90b69bb32aba118ef6c8b3f9d6eb26c03835823119b4a0f5bb1c1f4cb17", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "fb424bf8979ac292ef30daba64e905e6f5123066cda793aa0244907e11b465c9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "63a26e6b034604a525a358d8e422a3870d9fc3dc6a88c2cdf97a095259236bbf", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "29b0b80824eeb2d154f52a9eacc10bd4d7068b7eca3bd9427aec67903bf6ee0d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "d0ffb485a5b09ed8a02d470bb66acac8f1847a7d1d23df346e67cbf8bdeaf81f", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "6e39b83171c589ceee55f2f6aa635ea23126ee8f24135bce5bdfe6055774a9de", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "2d338b92d8bfe1895374af0a7d43a984e92a8f07fc22572c24a8746a50fcf05b", "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": "3685bbab002ab692fba9c0ad4309ff5da3e81be2a3576ede3185788b540e7f95", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "b512f78514b3ad04c6335ff68d9f67d7e418a82b1ef8d101414ef21c464e1112", "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 58ee098584b1ccf72b7d7b63c7daea330ff9d079 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:56 +0000 Subject: [PATCH 19/23] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 294c26395..7e932e657 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -13,6 +13,7 @@ "tools/marketing-capture-inputs.txt", "ui/build.gradle.kts", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityFilters.kt", @@ -75,8 +76,10 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt", @@ -460,7 +463,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarScheduleViews.kt": "f37848200d712829405848db3606b5bec49c1421cc00de6144403f0f390ef5ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarWorkspaceNotice.kt": "b5c0cbbd46371eac5835758c836b41c7878d55f2e5da8f29679dde3aa210bf33", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarWorkspacePresentation.kt": "cd4638b118da879acfa711eed9bfcd643df4a847774925e8adae20bdd5c90b3b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "84a6e5ca035ac7796427c25f4b4ed1162832c4febbc44606dc06413cc2e33b58", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "fbefd12e3574060cfc6d7f1abb27f7173e9f8eb2fa8de3c80f85e650757d6d4f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChoresWorkspaceContext.kt": "f22d2bfc408f52eb61f3587e9bcf79ba7cb4c7c6afd1717f04f35731f1906391", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardOcsResponse.kt": "25780cc210c8e2f56fb2e45ab23a29a9030f6350a9a1ea29412650106a0d111f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt": "1a0a6b50c2b1f8b528d637520cb95acab42d1a6f4edde4fd47ced7a47bd4ddad", @@ -494,9 +497,11 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt": "468f9bf41ea353dab6f816d333c3a6179d8d8e1f01f98f8eaf0890d5c0d381d6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt": "e0e7b54e02869eabe797c1af6c77a78b0f7eaf7cb609c3cce8d35a8a710e6511", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "88b66d6102b7325903521ae7ab85bf74a24a8234ccc6406d2692d76070b74e14", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "2cb445b752faa33f051cfd618bfac3c9f39fc20abce3176f782fe032aef4a98c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "09debe6409b538c3944a63b1e2c5022dc3e0584d746ef288183bfe0983fb1f7b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt": "df5c2b3ef90a8a7d0ea02d6587b563ebde8e84d471073f718a7d901f2c0a65fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt": "fc7f8ac5ffb094da9d9a9f05bb2d072b13ff9da9281708f247b546258e0fc2e2", @@ -547,7 +552,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceToolbar.kt": "644e576f094b73a78dae541d15621edf86e75c0408553f6319d4fcefe55ed3d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GenericNativeFallback.kt": "768f8704a9999b45d06e39c61529bd9af370315ff1f33d7eb4f8b463aaaedbbf", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarComponents.kt": "84cca2c35c707d0c7915595d3fd7ebc463f40a31b59f44117210cb82b61716c2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "047c10233277632f1ff5e4dd7a77739c71629f05850c03070d20229adabd443d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "404d55e3cb691609cdbde00eaaddf230ec4e1626c342512b935e7c383630c8da", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactDetailDialog.kt": "ab6826a14ac9fbf62677bed2f6e93ef0fb1a57f1f074bb1a9e79fef212f715b3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "c0b42fa450b8748281c7208385f7257d286bc1f842588bc737f3c296dd4ec1d9", @@ -635,12 +640,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "5b29a90b69bb32aba118ef6c8b3f9d6eb26c03835823119b4a0f5bb1c1f4cb17", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "fb424bf8979ac292ef30daba64e905e6f5123066cda793aa0244907e11b465c9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "d0ffb485a5b09ed8a02d470bb66acac8f1847a7d1d23df346e67cbf8bdeaf81f", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "6e39b83171c589ceee55f2f6aa635ea23126ee8f24135bce5bdfe6055774a9de", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "2d338b92d8bfe1895374af0a7d43a984e92a8f07fc22572c24a8746a50fcf05b", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "63a26e6b034604a525a358d8e422a3870d9fc3dc6a88c2cdf97a095259236bbf", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "29b0b80824eeb2d154f52a9eacc10bd4d7068b7eca3bd9427aec67903bf6ee0d", + "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": "b512f78514b3ad04c6335ff68d9f67d7e418a82b1ef8d101414ef21c464e1112", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "af6c9640b659810f935e725148220936dcbc271e41f5a04e2abf89ff08403eb6", "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 147e3c49fd43de8ffe5dee09f1c0b05dbd8681e8 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 09:25:00 +0200 Subject: [PATCH 20/23] fix(platform): honor the source size ceiling --- .../kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt | 1 - website/public/screenshots/capture-manifest.json | 2 +- 2 files 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 831c7c236..ed004743e 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -634,7 +634,6 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa kind: DurableMutationRecoveryKind, expectedEncoded: String, ): Boolean = false - /** Loads an account-scoped verified app contract without any cached user records. */ suspend fun loadCachedDynamicAppDiscovery( session: NextcloudSession, diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 7e932e657..8c95d43d7 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": "af6c9640b659810f935e725148220936dcbc271e41f5a04e2abf89ff08403eb6", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "5b278014d8a2f6f98733095126ce7480a18618be82ec4a4aa0f252fa4dc54e50", "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 bfcfd0fd98fecc1536f062ebabb81c8d72ae0cc6 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 10:12:39 +0200 Subject: [PATCH 21/23] fix(uploads): quarantine unreadable queues --- .../AndroidDurableMultipartUploads.kt | 42 +++++++++++++++++-- ...AndroidDurableMultipartUploadPolicyTest.kt | 36 ++++++++++++++-- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 676c151eb..18308c0cc 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -195,7 +195,11 @@ internal suspend fun keepRetryingQueuedDurableUploadScheduling( retryQueuedDurableUploadScheduling(retryDelaysMillis, reconcile, wait) } catch (cancelled: CancellationException) { throw cancelled - } catch (_: AndroidDurableMultipartUploadRecoveryException) { + } catch (failure: AndroidDurableMultipartUploadRecoveryException) { + if (failure.disposition == DurableUploadQueueRecoveryDisposition.Quarantine) { + if (!recoveryFailureReported) runCatching(recordRecoveryFailure) + return + } false } if (recovered) return @@ -406,11 +410,30 @@ internal class AndroidDurableMultipartUploadStore( private fun readAll(): List { val encrypted = try { storage.read() + } catch (cancelled: CancellationException) { + throw cancelled } catch (failure: Exception) { - throw AndroidDurableMultipartUploadRecoveryException(failure) + throw AndroidDurableMultipartUploadRecoveryException( + failure, + if (failure is ClassCastException) { + DurableUploadQueueRecoveryDisposition.Quarantine + } else { + DurableUploadQueueRecoveryDisposition.Retry + }, + ) } ?: return emptyList() + val decrypted = try { + cipher.decrypt(encrypted) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + throw AndroidDurableMultipartUploadRecoveryException( + failure, + DurableUploadQueueRecoveryDisposition.Quarantine, + ) + } return try { - val array = JSONArray(cipher.decrypt(encrypted)) + val array = JSONArray(decrypted) check(array.length() <= MAX_STORED_UPLOADS) { "The durable upload queue contains too many rows." } @@ -423,8 +446,13 @@ internal class AndroidDurableMultipartUploadStore( "The durable upload queue contains duplicate rows." } jobs + } catch (cancelled: CancellationException) { + throw cancelled } catch (failure: Exception) { - throw AndroidDurableMultipartUploadRecoveryException(failure) + throw AndroidDurableMultipartUploadRecoveryException( + failure, + DurableUploadQueueRecoveryDisposition.Quarantine, + ) } } @@ -465,8 +493,14 @@ internal interface AndroidDurableMultipartUploadCipher { fun decrypt(value: String): String } +internal enum class DurableUploadQueueRecoveryDisposition { + Retry, + Quarantine, +} + internal class AndroidDurableMultipartUploadRecoveryException( cause: Exception, + val disposition: DurableUploadQueueRecoveryDisposition = DurableUploadQueueRecoveryDisposition.Retry, ) : IllegalStateException( "The saved background upload queue is unavailable. Its recovery data was left unchanged.", cause, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index cb45e46a9..2f07b1280 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -78,17 +78,26 @@ class AndroidDurableMultipartUploadPolicyTest { @Test fun `encrypted queue read and decryption failures preserve recoverable jobs`() { - listOf("read", "decrypt").forEach { failureMode -> + listOf("read", "read-type", "decrypt").forEach { failureMode -> val storage = FakeDurableUploadEncryptedStorage() val cipher = FakeDurableUploadCipher() val recoverable = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) AndroidDurableMultipartUploadStore(storage, cipher).add(recoverable) val encryptedBeforeFailure = storage.value if (failureMode == "read") storage.readFailure = IOException("synthetic read failure") + if (failureMode == "read-type") storage.readFailure = ClassCastException("synthetic stored type") if (failureMode == "decrypt") cipher.decryptFailure = IOException("synthetic decrypt failure") val restarted = AndroidDurableMultipartUploadStore(storage, cipher) - assertFailsWith { restarted.list() } + val failure = assertFailsWith { restarted.list() } + assertEquals( + if (failureMode == "read") { + DurableUploadQueueRecoveryDisposition.Retry + } else { + DurableUploadQueueRecoveryDisposition.Quarantine + }, + failure.disposition, + ) assertFailsWith { restarted.add(fixtureJob(index = 2, account = ACCOUNT_A, cardId = 43)) } @@ -596,6 +605,27 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(1, attempts) } + @Test + fun `permanently unreadable queue is quarantined without background polling`() = runBlocking { + var attempts = 0 + var diagnostics = 0 + + keepRetryingQueuedDurableUploadScheduling( + reconcile = { + attempts += 1 + throw AndroidDurableMultipartUploadRecoveryException( + IOException("synthetic invalid ciphertext"), + DurableUploadQueueRecoveryDisposition.Quarantine, + ) + }, + wait = { error("a quarantined queue must not schedule another poll") }, + recordRecoveryFailure = { diagnostics += 1 }, + ) + + assertEquals(1, attempts) + assertEquals(1, diagnostics) + } + @Test fun `startup recovery contains uploader construction failures`() = runBlocking { val failure = assertFailsWith { @@ -733,7 +763,7 @@ class AndroidDurableMultipartUploadPolicyTest { private class FakeDurableUploadEncryptedStorage( var value: String? = null, ) : AndroidDurableMultipartUploadEncryptedStorage { - var readFailure: IOException? = null + var readFailure: Exception? = null var failWrites: Boolean = false override fun read(): String? { From 3f6a01a6af4c8e72e473db00f6386b92d647bda0 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 11:11:12 +0200 Subject: [PATCH 22/23] fix(uploads): stop retrying malformed registries --- .../NextcloudNativeApplication.kt | 6 +++--- ...idDurableUploadRegistryAvailabilityTest.kt | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index a6ac371fa..37310ca46 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt @@ -43,11 +43,11 @@ class NextcloudNativeApplication : Application() { keepRetryingQueuedDurableUploadScheduling( reconcile = { constructAndReconcileQueuedDurableUploads { - val accountRegistry = getSharedPreferences( + val accountPreferences = getSharedPreferences( ANDROID_ACCOUNT_PREFERENCES_NAME, Context.MODE_PRIVATE, - ).getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) - if (androidCredentialFreeRegistryAllowsAccountResolution(accountRegistry)) { + ) + if (accountPreferences.durableUploadAccountResolutionAvailable()) { val available = uploads ?: AndroidDurableMultipartUploads( this@NextcloudNativeApplication, ).also { uploads = it } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadRegistryAvailabilityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadRegistryAvailabilityTest.kt index b05498b1b..01d4587e7 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadRegistryAvailabilityTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadRegistryAvailabilityTest.kt @@ -1,7 +1,9 @@ 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 @@ -23,4 +25,22 @@ class AndroidDurableUploadRegistryAvailabilityTest { } } } + + @Test + fun `non-string registry stops startup recovery without another poll`() = runBlocking { + var attempts = 0 + + keepRetryingQueuedDurableUploadScheduling( + reconcile = { + attempts += 1 + if (durableUploadAccountResolutionAvailable { throw ClassCastException("wrong type") }) { + error("The uploader must not be constructed for an unreadable registry") + } + true + }, + wait = { error("Permanent registry corruption must not be polled") }, + ) + + assertEquals(1, attempts) + } } From 435dab3e2886d0d1439c8fbb0a1633552c4ce225 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:58:45 +0000 Subject: [PATCH 23/23] 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..7ec9bf57b 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",