From 69ff64e8a08c190de3c627068882edac0af5d851 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 22:56:43 +0200 Subject: [PATCH 01/18] 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 f7c37c6acd8dcdc9309383ca715874a2f2edb8ed Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 22:58:14 +0200 Subject: [PATCH 02/18] 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 16bbb38ae54b34a0fc3c19979f2963cc0190bfcb Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 04:32:25 +0200 Subject: [PATCH 03/18] 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 744f4ccdc..9deb64744 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -1347,8 +1347,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 f0469725338aca82aaf1b74236114de46ccc3592 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 08:02:15 +0200 Subject: [PATCH 04/18] 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 487d38c01..abad6eb08 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|12432 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 9deb64744..5b41cee46 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -1347,10 +1347,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 e17de2421930a0ff94ba2054658611952fe611c8 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 13:59:01 +0200 Subject: [PATCH 05/18] 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 95c2f1a6c..7a472660e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -2889,7 +2889,6 @@ internal class AndroidNextcloudServices( override fun releaseLocalUploadFile(file: LocalUploadFile) { localUploadPicker?.release(file) } - override suspend fun executeNextcloudMultipartUpload( session: NextcloudSession, request: NextcloudMultipartUploadRequest, @@ -2940,7 +2939,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 9092b2690db3092aeaba1ddec96d092e3e6e5cdb Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 15:53:47 +0200 Subject: [PATCH 06/18] 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 c1bb40764536c9c20d5c5a587a5b1cb5244d3dca Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 17:38:07 +0200 Subject: [PATCH 07/18] 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 c41cb5f8d..35a76f125 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -300,6 +300,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 7a472660e..1a7af2c87 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() @@ -433,6 +433,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 263602739..1fe5fb3ad 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 e6f9d9106735e0aadaebc3be5222a37152d911ea Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 01:14:48 +0200 Subject: [PATCH 08/18] 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 339cf665e81d26852ac94b552299bb7ef37d2e67 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 02:06:02 +0200 Subject: [PATCH 09/18] 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 43efbfb37..ae5a9c17e 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? = 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 1a7af2c87..f385d247a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -438,6 +438,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 4ce92880944865f3c4a4e8896c9edd49022de6a5 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 02:40:02 +0200 Subject: [PATCH 10/18] 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 ae5a9c17e..1bda05f59 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? = diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index c4da0f73d..d5d80d5d6 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 @@ -9,6 +10,14 @@ internal fun removeActiveAndroidAccountCredentialState( state: AndroidAccountCredentialState, ): AndroidAccountCredentialState = state.registry.activeAccountId?.let(state::remove) ?: state +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 3ba0545fc5af1c572db549cccab1ae61d04f4941 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/18] chore(website): refresh marketing captures From be6b6767bdfae3f8d631d69eee5f5c4dc938db7f Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 05:14:00 +0200 Subject: [PATCH 12/18] 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 f385d247a..bd8ea911e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -433,7 +433,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 85d2b25a0c2db91e45b71c76bdc164b052f90cf2 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 09:49:49 +0200 Subject: [PATCH 13/18] 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 d5d80d5d6..2929c2ba3 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 @@ -18,6 +19,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 bd8ea911e..633cceba2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -433,12 +433,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 9fb4ab320847049439ef45f279739fc46d931d22 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 10:01:12 +0200 Subject: [PATCH 14/18] 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 e7ebb778a51951b3d80e80d7f52b05cd2c279e78 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 10:18:09 +0200 Subject: [PATCH 15/18] 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 2929c2ba3..6c658c393 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -20,7 +20,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 7922ac264274c3c07d858ea6e391c068750a2f64 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 15:31:25 +0200 Subject: [PATCH 16/18] 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 5b41cee46..541366ef4 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 32a34ef44122752c9e5b4f2bfcf8d1f7192dc155 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 04:17:18 +0200 Subject: [PATCH 17/18] 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 6c658c393..20c684233 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -12,11 +12,21 @@ internal fun removeActiveAndroidAccountCredentialState( ): AndroidAccountCredentialState = state.registry.activeAccountId?.let(state::remove) ?: state 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 19f32f571ccbc6c5ea1ebc37ba9f709649a10baa 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/18] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 391f48c7f..2fa749d9f 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -202,6 +202,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaCollections.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaTransferCenterHost.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeNotesHierarchy.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudChunkUploadPolicy.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileListing.kt", @@ -216,6 +219,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudStatusMessages.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesApi.kt", @@ -285,6 +289,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualFolderRetention.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualStorageHydrationPolling.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualStoragePresentation.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceUpdateBanners.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/AdaptiveShell.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShell.kt", @@ -434,7 +439,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateChannels.kt": "f8aef5ec39978ef0d80ff6cbab00a9a4af34d73c2f3a759eb483a5cadfb8170a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateSettings.kt": "f41c20a9e0a91d917c746998ffa1e0061af1f7086eb55aedf495eb2604fbc71f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspaceNavigationMemory.kt": "2a48ec4fda7ca47657253891e880a4169b16dbc83c197808532a92169961d569", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt": "e2e9e8f823353180f0aaa7d4a5c4fc824591f0878845cfc6d19a0210645342fc", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt": "91842afe8be45598eca9f23da7012401c53c6571737d1a9b38e82332ce84123c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePresentation.kt": "e5655f80ca80cdb88ac49a54554e7d4cf684293efb2af4e291f0ef4b34529594", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppsWorkspace.kt": "87b5ee4b871c85d55a8f43f3cb07ee619672def16fade42f819d67c913ac5295", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarDateTimeFields.kt": "dbd41b992d9ebf24f76e6e66b03c8de23cf76267eca831eee318e5a582231d81", @@ -452,9 +457,9 @@ "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": "4a4408bdaa088f1ac4e24ce55c5269906da84262602b574398c4c61ff71393a2", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt": "427dd6352a5958a5fd31b9b8ed8cd0f8d1eac1b25800ea33b8bad171125e8f5e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusPresentation.kt": "96eb3aa478be8932e695e2dfe2067cc7b8dccf5ffa6cc1370f2db27b8119e0ff", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt": "318a9181974c1f94d5689d00fa417cc57fa5669f0de8d87e1ad0d5a8f89344af", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt": "cf84b77e3d239161c1c84eae7ae949caeb5206c3f09346e61ead6a3ec99533c1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardWidgetsAcquisition.kt": "7b646f0b992dddf9e16abbb497fc3832e284401d65fcc556a771aef00fc88a95", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckAsyncSafety.kt": "f6d939c1d1cf41b2421aad6906e9de7fb64800b146906312210472eebd22a93e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckCardDraftPersistence.kt": "f8aa5c05244022efd2b2c9cf356275f1a96812ebd5d44ec6a501d39c90413a0b", @@ -482,7 +487,7 @@ "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": "0cd94f93c0b5fcaa21d33a71e43e651eb56c75771a7f0c86a3a3be90f65c256f", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "21a9dbe66b1ad067c10df887bd97034b8d13b6e9dfe14cfced89b02f53575ff4", "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/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", @@ -535,11 +540,11 @@ "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": "7e2f815391373593077ee1a863f0039e778087a23e158d65cd66a38470a9f221", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "9905f2412761723e7dfe715e6260c3490c80c3fc16426d9f663742ec0eed5f85", "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": "096a154e9d6169da4cc82c0f87d2c572a1a5e97e396cae9eb739d05ce05b0d82", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "257c413e859fb1fbf1a26b5c372a36288c81779cf2e6dc6620ff4d7456e38cee", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "94d4b63c903d181f8e91bd2876fdee8bd7eefe011ba416acaa0a30c64b1473d4", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "1d206c76800e92662b8980a41cd7792684e4c8b8ef94d0119109d5420da343c8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt": "88f84a03a3c2d95b130601d5aac62fad4b4e1ed559c7851d7d47a3b44d1d2bc9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavMutation.kt": "f32d31bb3564ef2e4a565f840c0db227e2632f6e15251a29151f233c0b25f718", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavResourceStatus.kt": "816453d49fb983cf4eca6c93335d102570f037a6e064188f89f9b734ec1ebea0", @@ -549,12 +554,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksDav.kt": "83fe24e21779ca5a50ab2ae839692bb7d96de63b6c997ee941ca925ac6fe4546", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksLoading.kt": "7ad2ff8b58245db2b678fc79969c70327dccbe07e8b98a5b83fcfb8ec0b45fd1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksRecovery.kt": "475acdbe17f41fa2f1aecbeee73f1b5ba124e22c2fbc75368b8fbf0c575ec43b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt": "ca9e58778d5c68eeb6ce7f105b4e74226b3820135de721994819173e1558cf02", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt": "2a8a18f9a1a933505f775763f8aa1b7dbd2d615eda15c6b2d82ea32385252ca1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksState.kt": "ccd6e1f9b2cf2931f5431380bc2749b5f68cbc5781e7de3d0f34621005caca6e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceActions.kt": "211cc20d9e7f60cc9337591acfad3e63653bff3c97aa6a693b6f647b976f0dfb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceHeader.kt": "0cacd1a4887bc8c830a1667e445bc11339a4c3b888feebaee8625ddc708965ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayout.kt": "f878809aef689f4f47311225e487efe8fb411be1180264b1e69be74bdf1ab5b6", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt": "a6f1e2919f4b68f2301105f8b7919ca1b62b1810bcc4f550d307ea9f127bb55c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt": "3c89436210b7c93d5970cf65b97ad5893adf6b12d485ab160831c3946770bfc2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ImageDecodeBounds.kt": "6a218194682e175396d57b4d4a3fc8aea2c0b39a11212daa61a4d60bc8ed42a5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingFileShare.kt": "2ffac5d12aea372662848799769a9a4f2c887fa31bc2def8647a5773b89d2e35", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingShareUploadScreen.kt": "7730c0b937b79a85cd5b1d9b302783f561a314b7ca022d51bf836a51cefa583d", @@ -613,6 +618,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaCollections.kt": "0d57ec1eaa6802513aafb88153f23e603a64fd7d1028e586227986ed155c6b14", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaTransferCenterHost.kt": "85b0eb13a4376d0f3d2c101063e6bfffe05a61a97d7109a5079e68ff11d05a64", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeNotesHierarchy.kt": "b753f1e3517a34f57d90f6a4c4067b8bfcc8085af080e99c33b3f4d29dffcbf4", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt": "0886c2513430f6940fd4eefe6f85091215122ef7129d9c218ab6a00823a59434", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt": "99315e08ee2d9abbdcee0527abd61e614201a2ac81e39c180c34f2ff23480afe", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt": "569376bf76a4df6a5ca76efeb9bca5308d771f737272eea679fde32f7f5278bd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudChunkUploadPolicy.kt": "2635374193979991fa6b0e4d244a248b27d9ff4fcca148b47412530cb3031d87", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt": "570371d4b41907de1c2abd202a2c767dbe7d734d4c098266380b530c41aba75c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileListing.kt": "1f3de4f31d44d3ead139686d3fbcfbd27ccb520a9dab06ccc1d3e582ee25fb71", @@ -620,14 +628,15 @@ "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": "0434daff47c75ae69f0b8a4c0a4e0f32cc5ba4343c0bc16835f5f9acf696653b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "14d43a632afa7c5bf970d90d1387285624182be00569b57c0955670de017cc6c", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "9223dd455c6a1c35a10769616fceb9dfb2d92ef4d43b0a52c2c29e40f1a196cf", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "d4dfd3c6a6793ab9c702564f2564c9323ef2909fc381e32f5fb9c24f702fb85b", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "6e39b83171c589ceee55f2f6aa635ea23126ee8f24135bce5bdfe6055774a9de", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "ac2206703b224364c1a3ff4097026c9c81d856042c20d31e5c28358016c3062d", "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": "0d86a2eb73cdd0ea3e0990936f456a5590e052fcab02e4f290e4df5facfff920", + "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/NextcloudSessionLoading.kt": "0abcfe2da22b8e49f6ee292d8b340cc36dafd2cf0658cbb8ca847481a351fe19", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "c96941c7582218754243f780a0bb0954ba31484719d8045f1e609f5d8c04a7c5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudStatusMessages.kt": "03d7d79e38bdd9ac2e6a90e7172d2e9b7ea361df4d3e3d1a75d64584adc590ec", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesApi.kt": "e3c685b02592ea791b0c9a478a098fc5bf428893aa4764110c793a78040f341e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesFolderOperations.kt": "db6a04cfcd3b25b17c996cdc6dba21847d14f5970e06e6c24a3e2c85139dbe1d", @@ -659,7 +668,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PlatformEmbeddedWebApp.kt": "a069dce940071b07df4cb3773d0c29a5b8c1be1785206d2ff9a7c17af33d911a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PlatformImage.kt": "5c1ebb3dc168c0a53d6db05c54b7329a571dec808aef3de7fba1a52bd93b2d3d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PlatformVideoPlayback.kt": "8f103ac182fdca3c78f1ffe7a3b15f06f05abb3be173747255fe4a9c84726758", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt": "19d2205dc43f8a245f5fb9cfc3e65cf55430f13fb091eb9d6812d4d05946180f", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt": "a30e7fc55f40b13883d2ceb56de0ca72b67e8fb1489d2d261eb66b0a05daef48", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ProjectNewsAndUpdates.kt": "2ed4cdbd06b23ad2240f5bf245f24ad582d450b716604f6682d4fd785b92f809", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PublicContentDigest.kt": "175cbd645bb72bbf59085e5f749835397f29d6a39a289015fb5b7071d5a0688d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RawPhotoPreview.kt": "73c49576766e266ac5b29e072db6b2e987c2a11107460f7a8019fb8ef4f923ff", @@ -667,7 +676,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RecognizedFaceRemovalPicker.kt": "790361162a60502f2db49218bbb524cdeb9a43403281e9a8ae25ef1354275558", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RecognizedFaceSelection.kt": "a41835093c50db3b4c8414525dafe7542aa71aef295b480f352c7d5afc7abc24", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPicker.kt": "e78c0dccb27918465f2d9d72e555e07c7996a06721f3afef80911b7392b6f746", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt": "6c1cb2aef1762b459e23691e8babb2f07c2bac9dfb334d09919b61cecb89d089", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt": "d01f7fe50933f3d3a7303a04788e8c3c839e461bf6cf551592c92ce7a8d35201", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RetainedContentNotice.kt": "77bcf477e60d7e3022abea07ada109c92e57b4a3c2dd57e61de7bdf53d242b4f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ServerCertificateReviewDialog.kt": "f7c4d2489596788486893ed746eb762060b5080f64dae5659eca8b48feabf0ea", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SettingsSectionContent.kt": "3ce0895751bc569843ffabfcdae6ca11c68b4c700a954cb5e1370391cd87e472", @@ -696,6 +705,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualFolderRetention.kt": "598fa756e16d25552d783db55c144008d87289521821fc962544fdb29db496f0", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualStorageHydrationPolling.kt": "2c2ce4e17038ab8f0262d95f1671e0daec8d4567b848e1fc17c1a7e7e21d93e6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualStoragePresentation.kt": "bbd155bcf900a8d5470c974a1e3a5733de1fefe6bd320d449d566f6ce7b9b627", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt": "d6bbaa8758978f97941617fb525bbbd49622c01361d85a5a0015ab6f3b5077fd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceUpdateBanners.kt": "0404109434d93ebd1bf801d2d7a95abc9f69b7a471ec55cc7197e6702997a978", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/AdaptiveShell.kt": "f8005f52e2d83106bb78a4230539c3dd5bd80a1d6db24a850c8b69e03df4687f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShell.kt": "bf3018bb72e652dd86c39f6a7a855032b7b002ec3059b66a8300c7044b5df91e",