diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 1e0e10810..5a6f51747 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) } - ?: AndroidAccountRetentionSnapshot.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/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index d83a5d1d6..8bf4f5687 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -290,6 +290,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 c4da0f73d..6c658c393 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 @@ -9,6 +11,27 @@ 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 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 a168b0469..e4bead91c 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,218 +137,86 @@ 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) { - if (durableUploadAccountMismatchOutcome(initial.accountId, accountServices.accountRetentionSnapshot()) == - 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 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) +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 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(), - ), - ) } + return allScheduled +} - internal companion object { - const val KEY_JOB_ID = "job_id" - } +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 enum class DurableUploadAccountMismatchOutcome { - DeferAccountRecovery, - AccountUnavailable, +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 fun durableUploadAccountMismatchOutcome( - expectedAccountId: String, - accountSnapshot: AndroidAccountRetentionSnapshot, -): DurableUploadAccountMismatchOutcome = when (accountSnapshot) { - is AndroidAccountRetentionSnapshot.Available -> { - if (androidAccountIdentityIsRetained(expectedAccountId, accountSnapshot.accounts)) { - DurableUploadAccountMismatchOutcome.DeferAccountRecovery - } else { - DurableUploadAccountMismatchOutcome.AccountUnavailable +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) return + if (!recoveryFailureReported) { + runCatching(recordRecoveryFailure) + recoveryFailureReported = true } + wait(followUpDelayMillis) } - AndroidAccountRetentionSnapshot.Unavailable -> DurableUploadAccountMismatchOutcome.DeferAccountRecovery +} + +internal sealed interface DurableUploadAccountResolution { + data class Available(val session: NextcloudSession) : DurableUploadAccountResolution + data object RegistryUnavailable : DurableUploadAccountResolution + data object CredentialUnavailable : DurableUploadAccountResolution + data object AccountUnavailable : DurableUploadAccountResolution +} + +internal sealed interface DurableUploadAccountRegistry { + data class Available(val accounts: List) : DurableUploadAccountRegistry + data object Unavailable : DurableUploadAccountRegistry } internal fun queuedDurableUploadsForAccount( @@ -362,6 +226,48 @@ internal fun queuedDurableUploadsForAccount( job.accountId == accountId && job.state == DurableUploadState.Queued } +internal fun resolveDurableUploadSession( + expectedAccountId: String, + registry: DurableUploadAccountRegistry, + loadSession: (NextcloudAccountId) -> 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 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, + readRegistry: () -> DurableUploadAccountRegistry, + recoverRegistry: () -> NextcloudSession?, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): DurableUploadAccountResolution { + val initial = readRegistry() + val recoveryRequired = when (initial) { + DurableUploadAccountRegistry.Unavailable -> true + is DurableUploadAccountRegistry.Available -> initial.accounts.none { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == expectedAccountId + } + } + if (!recoveryRequired) return resolveDurableUploadSession(expectedAccountId, initial, loadSession) + val recoveredSession = recoverRegistry() + if ( + recoveredSession != null && + NextcloudDocumentIds.accountKey(recoveredSession) == expectedAccountId + ) { + return DurableUploadAccountResolution.Available(recoveredSession) + } + return resolveDurableUploadSession(expectedAccountId, readRegistry(), loadSession) +} + internal data class AndroidDurableMultipartUploadJob( val id: String, val accountId: String, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt new file mode 100644 index 000000000..95a19f136 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadWorker.kt @@ -0,0 +1,251 @@ +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.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 8a999054c..18a3d0cbc 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -380,7 +380,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() @@ -432,6 +432,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) } @@ -2896,7 +2898,6 @@ internal class AndroidNextcloudServices( override fun releaseLocalUploadFile(file: LocalUploadFile) { localUploadPicker?.release(file) } - override suspend fun executeNextcloudMultipartUpload( session: NextcloudSession, request: NextcloudMultipartUploadRequest, @@ -2947,7 +2948,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 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..a6ac371fa 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 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 } + } + } + }, + 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 c3feb755c..d2600d314 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) @@ -315,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", @@ -323,65 +338,324 @@ class AndroidDurableMultipartUploadPolicyTest { ) val accountId = NextcloudDocumentIds.accountKey(retainedSession) - assertEquals( - DurableUploadAccountMismatchOutcome.DeferAccountRecovery, - durableUploadAccountMismatchOutcome( - accountId, - AndroidAccountRetentionSnapshot.Available(listOf(retainedSession.accountRecord())), - ), + val resolution = resolveDurableUploadSession( + expectedAccountId = accountId, + registry = DurableUploadAccountRegistry.Available(listOf(retainedSession.accountRecord())), + loadSession = { null }, ) + + assertEquals(DurableUploadAccountResolution.CredentialUnavailable, resolution) } @Test - fun `unreadable account registry defers queued upload recovery`() { - assertEquals( - DurableUploadAccountMismatchOutcome.DeferAccountRecovery, - durableUploadAccountMismatchOutcome(ACCOUNT_A, AndroidAccountRetentionSnapshot.Unavailable), + fun `removed account terminally fails and releases its queued upload exactly once`() { + val events = mutableListOf() + + 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 - fun `valid account registry without expected account makes upload unavailable`() { - val retainedSession = NextcloudSession( - serverUrl = "https://cloud.example.test/nextcloud", - loginName = "alice", - appPassword = "fixture-password", + fun `account activation resumes only its queued uploads`() { + val queuedForA = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val queuedForB = fixtureJob(index = 2, account = ACCOUNT_B, cardId = 43) + val completedForA = fixtureJob( + index = 3, + account = ACCOUNT_A, + cardId = 44, + state = DurableUploadState.Completed, ) - val accountId = NextcloudDocumentIds.accountKey(retainedSession) assertEquals( - DurableUploadAccountMismatchOutcome.AccountUnavailable, - durableUploadAccountMismatchOutcome( - accountId, - AndroidAccountRetentionSnapshot.Available(emptyList()), + listOf(queuedForA), + queuedDurableUploadsForAccount(listOf(queuedForA, queuedForB, completedForA), ACCOUNT_A), + ) + } + + @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( - DurableUploadAccountMismatchOutcome.AccountUnavailable, - durableUploadAccountMismatchOutcome( - accountId, - AndroidAccountRetentionSnapshot.Available( - listOf(retainedSession.copy(loginName = "another-account").accountRecord()), - ), - ), + listOf("registry", "recover", "registry", "load:${queuedSession.accountId.storageKey}"), + events, ) } @Test - fun `account activation resumes only its queued uploads`() { - val queuedForA = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) - val queuedForB = fixtureJob(index = 2, account = ACCOUNT_B, cardId = 43) - val completedForA = fixtureJob( + 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() - assertEquals( - listOf(queuedForA), - queuedDurableUploadsForAccount(listOf(queuedForA, queuedForB, completedForA), ACCOUNT_A), + 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 `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( + listOf(queuedSession.accountRecord(), otherSession.accountRecord()), + ), + loadSession = { otherSession }, + ) + + assertEquals(DurableUploadAccountResolution.CredentialUnavailable, resolved) } private fun fixtureJob( @@ -422,6 +696,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/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") + } + } + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 30c0b55a3..a955421e4 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -548,6 +548,19 @@ class AndroidPersistedSessionTest { assertEquals("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED", restored.diagnosticCode) } + @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() 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 d6abe80a9..4644db14b 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 744f4ccdc..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, @@ -1347,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 57e844197..01c1b1786 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -633,7 +633,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": "4fdb230c0fa832a73bb7f7bb916ac8fdec7f53ba22aa519bb5de1b7b4243a97d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "b512f78514b3ad04c6335ff68d9f67d7e418a82b1ef8d101414ef21c464e1112", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "fead6cbf4f723ea46f99c9155b190967205d24f198687b7bfd5a213c474374b8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "c96941c7582218754243f780a0bb0954ba31484719d8045f1e609f5d8c04a7c5",