diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index ba538af3c..3b4b0ad16 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -57,10 +57,10 @@ internal class AndroidAccountCredentialController( }, ) - fun accountRetentionSnapshot(): AndroidAccountRetentionSnapshot = readRegistryForCredentialLoad() - ?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts, registry.activeAccountId) } - ?: AndroidAccountRetentionSnapshot.Unavailable + fun accountRetentionSnapshot(): AndroidAccountRetentionSnapshot = + readRegistryForCredentialLoad().asAccountRetentionSnapshot() + fun durableUploadAccountRegistry(): DurableUploadAccountRegistry = readCredentialFreeRegistry().asDurableRegistry() fun activeAccountId(): NextcloudAccountId? = readCredentialFreeRegistry()?.activeAccountId fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index 48c923432..a55a86e4b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -359,6 +359,7 @@ internal fun resolveStoredAndroidAccountSession( internal const val ANDROID_ACCOUNT_SESSION_KEY = "encrypted_session" internal const val ANDROID_ACCOUNT_REGISTRY_KEY = "account_registry_v1" +internal const val ANDROID_ACCOUNT_PREFERENCES_NAME = "nextcloud_native" internal const val ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX = "account_credential_v1:" internal const val ANDROID_QUARANTINED_SESSION_KEY = "encrypted_session_quarantine" internal const val ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY = "pending_account_removal_cleanup_v2" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index c5713fd0e..78256c27a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -1,5 +1,7 @@ package dev.obiente.nextcloudnative +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry import dev.obiente.nextcloudnative.app.NextcloudSession import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable @@ -27,6 +29,37 @@ internal suspend fun replaceAndroidActiveStateWithAccountLeases( } } +internal fun NextcloudAccountRegistry?.asDurableRegistry(): DurableUploadAccountRegistry = + this?.let { registry -> + DurableUploadAccountRegistry.Available( + accounts = registry.accounts, + activeAccountId = registry.activeAccountId, + ) + } + ?: DurableUploadAccountRegistry.Unavailable + +internal fun NextcloudAccountRegistry?.asAccountRetentionSnapshot(): AndroidAccountRetentionSnapshot = + this?.let { registry -> + AndroidAccountRetentionSnapshot.Available( + accounts = registry.accounts, + activeAccountId = registry.activeAccountId, + ) + } + ?: AndroidAccountRetentionSnapshot.Unavailable + +internal fun SharedPreferences.durableUploadAccountResolutionAvailable(): Boolean = + 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, recovered: AndroidAccountCredentialState, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 98e5d3d7c..18308c0cc 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 @@ -103,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 ( @@ -141,209 +137,95 @@ 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() +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 } - 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) { - when (durableUploadAccountMismatchOutcome(initial.accountId, accountServices.accountRetentionSnapshot())) { - 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 + } + return allScheduled +} + +internal suspend fun constructAndReconcileQueuedDurableUploads( + createReconciler: () -> suspend () -> Boolean, +): Boolean { + val reconcile = try { + createReconciler() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + throw AndroidDurableMultipartUploadRecoveryException(failure) + } + return reconcile() +} + +internal suspend fun retryQueuedDurableUploadScheduling( + retryDelaysMillis: List = listOf(1_000L, 5_000L), + reconcile: suspend () -> Boolean, + wait: suspend (Long) -> Unit, +): Boolean { + if (reconcile()) return true + retryDelaysMillis.forEach { delayMillis -> + require(delayMillis >= 0L) + wait(delayMillis) + if (reconcile()) return true + } + return false +} + +internal suspend fun keepRetryingQueuedDurableUploadScheduling( + retryDelaysMillis: List = listOf(1_000L, 5_000L), + followUpDelayMillis: Long = 60_000L, + reconcile: suspend () -> Boolean, + wait: suspend (Long) -> Unit, + recordRecoveryFailure: () -> Unit = {}, +) { + require(followUpDelayMillis > 0L) + var recoveryFailureReported = false + while (true) { + val recovered = try { + retryQueuedDurableUploadScheduling(retryDelaysMillis, reconcile, wait) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: AndroidDurableMultipartUploadRecoveryException) { + if (failure.disposition == DurableUploadQueueRecoveryDisposition.Quarantine) { + if (!recoveryFailureReported) runCatching(recordRecoveryFailure) + return } - 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() + false } - val started = store.transition( - jobId, - expected = DurableUploadState.Queued, - target = DurableUploadState.Uploading, - message = null, - ) ?: return Result.success() - val services = AndroidNextcloudServices( - applicationContext, - localUploadPicker = picker, - accountMutationLeaseHeld = true, - ) - 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) + if (recovered) return + if (!recoveryFailureReported) { + runCatching(recordRecoveryFailure) + recoveryFailureReported = true } - return Result.success() + wait(followUpDelayMillis) } +} - 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 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 companion object { - const val KEY_JOB_ID = "job_id" - } +internal sealed interface DurableUploadAccountRegistry { + data class Available( + val accounts: List, + val activeAccountId: NextcloudAccountId? = null, + ) : DurableUploadAccountRegistry + + data object Unavailable : DurableUploadAccountRegistry } internal fun queuedDurableUploadsForAccount( @@ -353,6 +235,52 @@ internal fun queuedDurableUploadsForAccount( job.accountId == accountId && job.state == DurableUploadState.Queued } +internal fun resolveDurableUploadSession( + expectedAccountId: String, + registry: DurableUploadAccountRegistry, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): DurableUploadAccountResolution { + val availableRegistry = when (registry) { + is DurableUploadAccountRegistry.Available -> registry + DurableUploadAccountRegistry.Unavailable -> return DurableUploadAccountResolution.RegistryUnavailable + } + val account = availableRegistry.accounts.singleOrNull { record -> + NextcloudDocumentIds.accountKey(record.serverUrl, record.loginName) == expectedAccountId + } ?: return DurableUploadAccountResolution.AccountUnavailable + val session = loadSession(account.id) + ?.takeIf { loaded -> NextcloudDocumentIds.accountKey(loaded) == expectedAccountId } + ?: return if (account.id == availableRegistry.activeAccountId) { + DurableUploadAccountResolution.CredentialUnavailable + } else { + DurableUploadAccountResolution.DeferAccountActivation + } + return DurableUploadAccountResolution.Available(session) +} + +internal fun resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId: String, + readRegistry: () -> DurableUploadAccountRegistry, + recoverRegistry: () -> NextcloudSession?, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): DurableUploadAccountResolution { + val initial = readRegistry() + val recoveryRequired = when (initial) { + DurableUploadAccountRegistry.Unavailable -> true + is DurableUploadAccountRegistry.Available -> initial.accounts.none { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == expectedAccountId + } + } + if (!recoveryRequired) return resolveDurableUploadSession(expectedAccountId, initial, loadSession) + val recoveredSession = recoverRegistry() + if ( + recoveredSession != null && + NextcloudDocumentIds.accountKey(recoveredSession) == expectedAccountId + ) { + return DurableUploadAccountResolution.Available(recoveredSession) + } + return resolveDurableUploadSession(expectedAccountId, readRegistry(), loadSession) +} + internal data class AndroidDurableMultipartUploadJob( val id: String, val accountId: String, @@ -482,11 +410,30 @@ internal class AndroidDurableMultipartUploadStore( private fun readAll(): List { val encrypted = try { storage.read() + } catch (cancelled: CancellationException) { + throw cancelled } catch (failure: Exception) { - throw AndroidDurableMultipartUploadRecoveryException(failure) + throw AndroidDurableMultipartUploadRecoveryException( + failure, + if (failure is ClassCastException) { + DurableUploadQueueRecoveryDisposition.Quarantine + } else { + DurableUploadQueueRecoveryDisposition.Retry + }, + ) } ?: return emptyList() + val decrypted = try { + cipher.decrypt(encrypted) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + throw AndroidDurableMultipartUploadRecoveryException( + failure, + DurableUploadQueueRecoveryDisposition.Quarantine, + ) + } return try { - val array = JSONArray(cipher.decrypt(encrypted)) + val array = JSONArray(decrypted) check(array.length() <= MAX_STORED_UPLOADS) { "The durable upload queue contains too many rows." } @@ -499,8 +446,13 @@ internal class AndroidDurableMultipartUploadStore( "The durable upload queue contains duplicate rows." } jobs + } catch (cancelled: CancellationException) { + throw cancelled } catch (failure: Exception) { - throw AndroidDurableMultipartUploadRecoveryException(failure) + throw AndroidDurableMultipartUploadRecoveryException( + failure, + DurableUploadQueueRecoveryDisposition.Quarantine, + ) } } @@ -541,8 +493,14 @@ internal interface AndroidDurableMultipartUploadCipher { fun decrypt(value: String): String } +internal enum class DurableUploadQueueRecoveryDisposition { + Retry, + Quarantine, +} + internal class AndroidDurableMultipartUploadRecoveryException( cause: Exception, + val disposition: DurableUploadQueueRecoveryDisposition = DurableUploadQueueRecoveryDisposition.Retry, ) : IllegalStateException( "The saved background upload queue is unavailable. Its recovery data was left unchanged.", cause, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt new file mode 100644 index 000000000..825a9cc0d --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -0,0 +1,260 @@ +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) + if (!services.isDurableUploadAccountResolutionAvailable()) return Result.retry() + val accountResolution = resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId = initial.accountId, + readRegistry = services::durableUploadAccountRegistry, + recoverRegistry = { services.loadSession() }, + loadSession = services::loadSession, + ) + 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.DeferAccountActivation -> { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-deferred", + accountId = initial.accountId, + jobId = jobId, + ) + return Result.success() + } + 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) + 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 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 { + kotlin.Result.success(request()) +} catch (cancelled: CancellationException) { + throw cancelled +} catch (failure: Exception) { + kotlin.Result.failure(failure) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 2d1344f27..1121b7bff 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -381,7 +381,7 @@ internal class AndroidNextcloudServices( ) : NextcloudPlatformServices { private val appContext = context.applicationContext private val activity = context as? Activity - private val preferences = appContext.getSharedPreferences("nextcloud_native", Context.MODE_PRIVATE) + private val preferences = appContext.getSharedPreferences(ANDROID_ACCOUNT_PREFERENCES_NAME, Context.MODE_PRIVATE) private val httpClient = OkHttpClient.Builder() .useAndroidNextcloudCertificateTrust(appContext) .trackJvmNetworkFailures() @@ -437,6 +437,8 @@ internal class AndroidNextcloudServices( } private val nativeMediaPreviewDecodeMutex = Mutex() private val mediaTimelineCarryoverStore = MediaTimelineDavCarryoverStore() + internal fun isDurableUploadAccountResolutionAvailable() = preferences.durableUploadAccountResolutionAvailable() + internal fun durableUploadAccountRegistry() = accountCredentials.durableUploadAccountRegistry() private val memoriesTimeline = MemoriesPreferredTimelineReadService { session, request -> executeNextcloudApi(session, request) } @@ -2881,7 +2883,6 @@ internal class AndroidNextcloudServices( override fun releaseLocalUploadFile(file: LocalUploadFile) { localUploadPicker?.release(file) } - override suspend fun executeNextcloudMultipartUpload( session: NextcloudSession, request: NextcloudMultipartUploadRequest, @@ -2932,7 +2933,6 @@ internal class AndroidNextcloudServices( } } } - override suspend fun enqueueDurableMultipartUpload( session: NextcloudSession, scope: DurableUploadScope, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index c2de11a79..0187080ef 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -225,6 +225,11 @@ internal fun restoreAndroidCredentialFreeRegistry( } } +internal fun androidCredentialFreeRegistryAllowsAccountResolution(encoded: String?): Boolean { + val restored = encoded?.let(::restoreAndroidCredentialFreeRegistry) ?: return true + return restored.registry != null || restored.credentialRecoveryRequired +} + internal fun recoverAndroidCredentialFreeRegistryForCredentialLoad( restored: RestoredAndroidCredentialFreeRegistry?, recover: () -> NextcloudAccountRegistry?, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidTestSafety.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidTestSafety.kt index 08f5a244d..15b6a18b0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidTestSafety.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidTestSafety.kt @@ -4,7 +4,7 @@ import android.content.Context import java.net.URI import java.util.Locale -internal const val TEST_PREFERENCES_NAME = "nextcloud_native" +internal const val TEST_PREFERENCES_NAME = ANDROID_ACCOUNT_PREFERENCES_NAME internal const val KEY_TEST_READ_ONLY = "emulator_test_read_only" internal const val KEY_TEST_WRITE_SCOPE_SERVER = "emulator_test_write_scope_server" internal const val KEY_TEST_WRITE_SCOPE_PATH = "emulator_test_write_scope_path" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index d5ca61bcc..37310ca46 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 = { + constructAndReconcileQueuedDurableUploads { + val accountPreferences = getSharedPreferences( + ANDROID_ACCOUNT_PREFERENCES_NAME, + Context.MODE_PRIVATE, + ) + if (accountPreferences.durableUploadAccountResolutionAvailable()) { + val available = uploads ?: AndroidDurableMultipartUploads( + this@NextcloudNativeApplication, + ).also { uploads = it } + available::reconcileQueuedUploads + } else { + suspend { true } + } + } + }, + wait = { delayMillis -> delay(delayMillis) }, + recordRecoveryFailure = recordRecoveryFailure, + ) + }, + 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/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 b51979285..2f07b1280 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 @@ -18,6 +19,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) @@ -63,17 +78,26 @@ class AndroidDurableMultipartUploadPolicyTest { @Test fun `encrypted queue read and decryption failures preserve recoverable jobs`() { - listOf("read", "decrypt").forEach { failureMode -> + listOf("read", "read-type", "decrypt").forEach { failureMode -> val storage = FakeDurableUploadEncryptedStorage() val cipher = FakeDurableUploadCipher() val recoverable = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) AndroidDurableMultipartUploadStore(storage, cipher).add(recoverable) val encryptedBeforeFailure = storage.value if (failureMode == "read") storage.readFailure = IOException("synthetic read failure") + if (failureMode == "read-type") storage.readFailure = ClassCastException("synthetic stored type") if (failureMode == "decrypt") cipher.decryptFailure = IOException("synthetic decrypt failure") val restarted = AndroidDurableMultipartUploadStore(storage, cipher) - assertFailsWith { restarted.list() } + val failure = assertFailsWith { restarted.list() } + assertEquals( + if (failureMode == "read") { + DurableUploadQueueRecoveryDisposition.Retry + } else { + DurableUploadQueueRecoveryDisposition.Quarantine + }, + failure.disposition, + ) assertFailsWith { restarted.add(fixtureJob(index = 2, account = ACCOUNT_A, cardId = 43)) } @@ -315,7 +339,7 @@ class AndroidDurableMultipartUploadPolicyTest { } @Test - fun `retained background account is deferred without reading its credential`() { + fun `inactive retained account defers when its credential is temporarily unavailable`() { val retainedSession = NextcloudSession( serverUrl = "https://cloud.example.test/nextcloud", loginName = "alice", @@ -323,25 +347,17 @@ 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.DeferAccountActivation, resolution) } @Test - fun `active account with unreadable credential keeps its upload scheduled`() { + fun `active retained account retries when its credential is temporarily unavailable`() { val retainedSession = NextcloudSession( serverUrl = "https://cloud.example.test/nextcloud", loginName = "alice", @@ -349,43 +365,31 @@ class AndroidDurableMultipartUploadPolicyTest { ) val accountId = NextcloudDocumentIds.accountKey(retainedSession) - assertEquals( - DurableUploadAccountMismatchOutcome.RetryAccountRecovery, - durableUploadAccountMismatchOutcome( - accountId, - AndroidAccountRetentionSnapshot.Available( - accounts = listOf(retainedSession.accountRecord()), - activeAccountId = retainedSession.accountId, - ), + val resolution = resolveDurableUploadSession( + expectedAccountId = accountId, + registry = DurableUploadAccountRegistry.Available( + accounts = listOf(retainedSession.accountRecord()), + activeAccountId = retainedSession.accountId, ), + loadSession = { null }, ) + + assertEquals(DurableUploadAccountResolution.CredentialUnavailable, resolution) } @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 accountId = NextcloudDocumentIds.accountKey(retainedSession) + fun `removed account terminally fails and releases its queued upload exactly once`() { + val events = mutableListOf() - assertEquals( - DurableUploadAccountMismatchOutcome.AccountUnavailable, - durableUploadAccountMismatchOutcome( - accountId, - AndroidAccountRetentionSnapshot.Available(emptyList()), - ), - ) - assertEquals( - DurableUploadAccountMismatchOutcome.AccountUnavailable, - durableUploadAccountMismatchOutcome( - accountId, - AndroidAccountRetentionSnapshot.Available( - listOf(retainedSession.copy(loginName = "another-account").accountRecord()), - ), - ), + val result = failQueuedDurableUploadForUnavailableAccount( + transitionToFailed = { events += "fail" }, + releaseSelection = { events += "release" }, + recordFailure = { events += "diagnose" }, + failureResult = "worker-failure", ) + + assertEquals("worker-failure", result) + assertEquals(listOf("fail", "release", "diagnose"), events) } @Test @@ -405,6 +409,307 @@ 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), + registry = DurableUploadAccountRegistry.Available( + listOf(activeSession.accountRecord(), queuedSession.accountRecord()), + ), + loadSession = { accountId -> + loadedAccountIds += accountId.storageKey + when (accountId) { + queuedSession.accountId -> queuedSession + activeSession.accountId -> activeSession + else -> null + } + }, + ) + + 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 registry: DurableUploadAccountRegistry = DurableUploadAccountRegistry.Unavailable + val events = mutableListOf() + + val resolved = resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), + readRegistry = { + events += "registry" + registry + }, + recoverRegistry = { + events += "recover" + registry = DurableUploadAccountRegistry.Available(listOf(queuedSession.accountRecord())) + null + }, + loadSession = { + events += "load:${it.storageKey}" + queuedSession + }, + ) + + assertEquals(DurableUploadAccountResolution.Available(queuedSession), resolved) + assertEquals( + 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") + var accountReads = 0 + + val resolved = resolveDurableUploadSessionWithRegistryRecovery( + expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), + readRegistry = { + accountReads += 1 + DurableUploadAccountRegistry.Unavailable + }, + recoverRegistry = { queuedSession }, + loadSession = { error("the uncommitted registry must not hide the recovered session") }, + ) + + assertEquals(DurableUploadAccountResolution.Available(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), + readRegistry = { + DurableUploadAccountRegistry.Available(listOf(queuedSession.accountRecord())) + }, + recoverRegistry = { + registryRecoveryAttempted = true + null + }, + loadSession = { queuedSession }, + ) + + assertEquals(DurableUploadAccountResolution.Available(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 `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 `permanently unreadable queue is quarantined without background polling`() = runBlocking { + var attempts = 0 + var diagnostics = 0 + + keepRetryingQueuedDurableUploadScheduling( + reconcile = { + attempts += 1 + throw AndroidDurableMultipartUploadRecoveryException( + IOException("synthetic invalid ciphertext"), + DurableUploadQueueRecoveryDisposition.Quarantine, + ) + }, + wait = { error("a quarantined queue must not schedule another poll") }, + recordRecoveryFailure = { diagnostics += 1 }, + ) + + assertEquals(1, attempts) + assertEquals(1, diagnostics) + } + + @Test + fun `startup recovery contains uploader construction failures`() = runBlocking { + val failure = assertFailsWith { + constructAndReconcileQueuedDurableUploads { + 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 + 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") + val otherSession = fixtureSession("bob") + var credentialRead = false + + val missing = resolveDurableUploadSession( + expectedAccountId = NextcloudDocumentIds.accountKey(queuedSession), + registry = DurableUploadAccountRegistry.Available(listOf(otherSession.accountRecord())), + loadSession = { + credentialRead = true + otherSession + }, + ) + + assertEquals(DurableUploadAccountResolution.AccountUnavailable, 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), + registry = DurableUploadAccountRegistry.Available( + accounts = listOf(queuedSession.accountRecord(), otherSession.accountRecord()), + activeAccountId = queuedSession.accountId, + ), + loadSession = { otherSession }, + ) + + assertEquals(DurableUploadAccountResolution.CredentialUnavailable, resolved) + } + private fun fixtureJob( index: Int, account: String, @@ -443,6 +748,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" @@ -452,7 +763,7 @@ class AndroidDurableMultipartUploadPolicyTest { private class FakeDurableUploadEncryptedStorage( var value: String? = null, ) : AndroidDurableMultipartUploadEncryptedStorage { - var readFailure: IOException? = null + var readFailure: Exception? = null var failWrites: Boolean = false override fun read(): String? { 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..01d4587e7 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadRegistryAvailabilityTest.kt @@ -0,0 +1,46 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +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") + } + } + } + + @Test + fun `non-string registry stops startup recovery without another poll`() = runBlocking { + var attempts = 0 + + keepRetryingQueuedDurableUploadScheduling( + reconcile = { + attempts += 1 + if (durableUploadAccountResolutionAvailable { throw ClassCastException("wrong type") }) { + error("The uploader must not be constructed for an unreadable registry") + } + true + }, + wait = { error("Permanent registry corruption must not be polled") }, + ) + + assertEquals(1, attempts) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index fc14f0f07..0b0ad511b 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -563,6 +563,17 @@ class AndroidPersistedSessionTest { assertTrue(androidIndependentCredentialStateCanBeExplicitlyReset(null)) } + @Test + fun futureCredentialFreeRegistryDefersDurableUploadAccountResolution() { + val futureRegistry = """{"version":99,"accounts":[]}""" + val healthyAccount = firstSession().accountRecord() + val healthyRegistry = encodeNextcloudAccountRegistry(NextcloudAccountRegistry.Empty.upsertAndSelect(healthyAccount)) + assertFalse(androidCredentialFreeRegistryAllowsAccountResolution(futureRegistry)) + assertTrue(androidCredentialFreeRegistryAllowsAccountResolution(healthyRegistry)) + assertTrue(androidCredentialFreeRegistryAllowsAccountResolution("{not-json")) + assertTrue(androidCredentialFreeRegistryAllowsAccountResolution(null)) + } + @Test fun credentialSlotReadDecryptsOnlyTheRequestedAccount() { val first = firstSession() 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 7d0074d62..ed786333c 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -28,7 +28,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12348 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1724 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1717 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoEditing.kt|847 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoFolderBrowsing.kt|895 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoTimelinePaging.kt|860 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index be3b4d09a..ed004743e 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, @@ -635,7 +634,6 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa kind: DurableMutationRecoveryKind, expectedEncoded: String, ): Boolean = false - /** Loads an account-scoped verified app contract without any cached user records. */ suspend fun loadCachedDynamicAppDiscovery( session: NextcloudSession, @@ -1348,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 8100381e8..7ec9bf57b 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -488,7 +488,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DocumentPreview.kt": "a9a8743dd7a381504282cc6ddc68034425024ccc1ae51667da9734bbfc7b1a79", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DurableMutationRecoveryDialog.kt": "e720eadb477a347762cd1894285788ac9f6820972431fe0a1953d01955667bfe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt": "2b7ef2d18b4a23615686ced0b7c9c621c58dc5edd0202104d0ca55b1ebf61d81", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "ddf80ca67d954f6e063c9e88c75794fcb81cbd6d42887c45d1a04b1cefe4f2fd", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "cff6ba11283705120375452d6d539c20581f4dd0115dd3d07049eb965242b019", @@ -645,7 +645,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "3685bbab002ab692fba9c0ad4309ff5da3e81be2a3576ede3185788b540e7f95", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "5b278014d8a2f6f98733095126ce7480a18618be82ec4a4aa0f252fa4dc54e50", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "fead6cbf4f723ea46f99c9155b190967205d24f198687b7bfd5a213c474374b8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "c96941c7582218754243f780a0bb0954ba31484719d8045f1e609f5d8c04a7c5",