diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index fef7d124b..7f9526893 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 @@ -18,21 +16,14 @@ 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 -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 @@ -141,202 +132,6 @@ 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) - } - - private suspend fun performQueuedUpload( - store: AndroidDurableMultipartUploadStore, - initial: AndroidDurableMultipartUploadJob, - picker: AndroidLocalUploadPicker, - jobId: String, - ): Result { - val accountServices = AndroidNextcloudServices(applicationContext) - val session = accountServices.loadSession() - if (session == null || NextcloudDocumentIds.accountKey(session) != initial.accountId) { - val retainedSession = resolveStoredAndroidAccountSession( - accountIdentity = initial.accountId, - listAccounts = accountServices::listAccounts, - loadSession = { accountId -> accountServices.loadSession(accountId) }, - ) - if (durableUploadAccountMismatchOutcome(initial.accountId, retainedSession) == - DurableUploadAccountMismatchOutcome.DeferRetainedAccount - ) { - 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 services = AndroidNextcloudServices(applicationContext, localUploadPicker = picker) - val outcome = runCatching { - services.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 enum class DurableUploadAccountMismatchOutcome { DeferRetainedAccount, AccountUnavailable, @@ -359,6 +154,36 @@ 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 fun resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId: String, + listAccounts: () -> List, + recoverRegistry: () -> Unit, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): NextcloudSession? { + val accounts = listAccounts() + val accountAvailable = accounts.any { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == expectedAccountId + } + if (!accountAvailable) { + recoverRegistry() + 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 new file mode 100644 index 000000000..3ddf297bb --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -0,0 +1,215 @@ +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 session = resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId = initial.accountId, + listAccounts = services::listAccounts, + recoverRegistry = { services.loadSession() }, + 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", + 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) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 4573df03d..6f046064f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -406,6 +406,7 @@ internal class AndroidNextcloudServices( private val localUploadPicker: AndroidLocalUploadPicker? = null, private val requestPlatformPermissions: ((Array) -> Boolean)? = null, private val onThemePreferenceChanged: (ThemePreference) -> Unit = {}, + private val accountMutationLeaseHeld: Boolean = false, ) : NextcloudPlatformServices { private val appContext = context.applicationContext private val activity = context as? Activity @@ -2891,7 +2892,6 @@ internal class AndroidNextcloudServices( override fun releaseLocalUploadFile(file: LocalUploadFile) { localUploadPicker?.release(file) } - override suspend fun executeNextcloudMultipartUpload( session: NextcloudSession, request: NextcloudMultipartUploadRequest, @@ -2927,6 +2927,7 @@ internal class AndroidNextcloudServices( streamingBody = requestBody, maxResponseBytes = safeRequest.maximumResponseBytes, client = noRedirectHttpClient, + accountMutationSerialized = accountMutationLeaseHeld, ) NextcloudApiResponse( response.status, @@ -2941,7 +2942,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 7efb683ab..8e7f3510e 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -2,20 +2,38 @@ 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 +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 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 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) @@ -355,6 +373,107 @@ 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 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()) + }, + loadSession = { + events += "load:${it.storageKey}" + queuedSession + }, + ) + + assertEquals(queuedSession, resolved) + assertEquals( + listOf("list", "recover", "list", "load:${queuedSession.accountId.storageKey}"), + events, + ) + } + + @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 }, + loadSession = { queuedSession }, + ) + + assertEquals(queuedSession, resolved) + assertFalse(registryRecoveryAttempted) + } + + @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, @@ -393,6 +512,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..7fb711c14 --- /dev/null +++ b/changes/unreleased/172-account-background-uploads.md @@ -0,0 +1,7 @@ +category: fix +issue: 172 +pull: 438 +platforms: android +user-facing: yes + +Queued Deck attachment uploads now keep using the account that created them after another account is selected. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index eb9135155..83b8a226e 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|12435 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 c034cb47a..b9e31ef17 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -1346,8 +1346,8 @@ 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 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, diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 3f4950339..6a95c04a4 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -623,7 +623,7 @@ "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": "e91ef3e37306c8bbb78e1190142096ede1c2ef17951221a2a4a2de96bf8a2081", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "b52a9c1d04fc6cb30828509c7b4cdb1f1484cf566dd24da4d42b46d70b30ecb7", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "fead6cbf4f723ea46f99c9155b190967205d24f198687b7bfd5a213c474374b8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "c96941c7582218754243f780a0bb0954ba31484719d8045f1e609f5d8c04a7c5",