Skip to content
Open

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package dev.obiente.nextcloudnative

import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

internal class AndroidAccountOperationGuard {
private val monitor = Any()
private val accountLeases = mutableMapOf<String, AccountLease>()

suspend fun <Result> withAccount(accountId: String, action: suspend () -> Result): Result {
val lease = synchronized(monitor) {
accountLeases.getOrPut(accountId) { AccountLease() }.also { it.references += 1 }
}
return try {
lease.mutex.withLock { action() }
} finally {
synchronized(monitor) {
lease.references -= 1
if (lease.references == 0) accountLeases.remove(accountId, lease)
}
}
}

suspend fun <Result> withAccountSession(
accountId: String,
resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?,
unavailable: suspend () -> Result,
action: suspend (dev.obiente.nextcloudnative.app.NextcloudSession) -> Result,
): Result = withAccount(accountId) {
val session = resolveSession()
if (androidAccountOperationSessionIsCurrent(accountId, session)) {
action(requireNotNull(session))
} else {
unavailable()
}
}

private class AccountLease(
val mutex: Mutex = Mutex(),
var references: Int = 0,
)
}

internal val ANDROID_ACCOUNT_OPERATION_GUARD = AndroidAccountOperationGuard()

internal fun androidAccountOperationSessionIsCurrent(
expectedAccountId: String,
currentSession: dev.obiente.nextcloudnative.app.NextcloudSession?,
): Boolean = currentSession != null && NextcloudDocumentIds.accountKey(currentSession) == expectedAccountId
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ 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
Expand Down Expand Up @@ -90,6 +91,18 @@ internal class AndroidDurableMultipartUploads(context: Context) {
.map(AndroidDurableMultipartUploadJob::status)
.toList()

suspend fun resumeQueuedForAccount(accountId: String) {
queuedDurableUploadsForAccount(store.list(), accountId).forEach { job ->
try {
schedule(job, ExistingWorkPolicy.APPEND_OR_REPLACE).await()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
// The queue stays authoritative; status refresh or a later activation can retry.
}
}
}

fun dismiss(session: NextcloudSession, scope: DurableUploadScope, uploadId: String): Boolean {
val job = store.find(uploadId) ?: return false
if (
Expand All @@ -104,10 +117,13 @@ internal class AndroidDurableMultipartUploads(context: Context) {
return true
}

private fun schedule(job: AndroidDurableMultipartUploadJob): Operation =
private fun schedule(
job: AndroidDurableMultipartUploadJob,
policy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP,
): Operation =
WorkManager.getInstance(appContext).enqueueUniqueWork(
"deck-attachment-${job.id}",
ExistingWorkPolicy.KEEP,
durableUploadWorkName(job.id),
policy,
OneTimeWorkRequestBuilder<DeckAttachmentUploadWorker>()
.setInputData(Data.Builder().putString(DeckAttachmentUploadWorker.KEY_JOB_ID, job.id).build())
.setConstraints(
Expand All @@ -123,6 +139,8 @@ internal class AndroidDurableMultipartUploads(context: Context) {
}
}

internal fun durableUploadWorkName(jobId: String) = "deck-attachment-$jobId"

internal class DeckAttachmentUploadWorker(
appContext: Context,
params: WorkerParameters,
Expand Down Expand Up @@ -151,13 +169,48 @@ internal class DeckAttachmentUploadWorker(
}
if (initial.state != DurableUploadState.Queued) return@withContext Result.success()

val session = AndroidNextcloudServices(applicationContext).loadSession()
return@withContext uploadQueuedJob(store, initial, picker, jobId)
}

private suspend fun uploadQueuedJob(
store: AndroidDurableMultipartUploadStore,
initial: AndroidDurableMultipartUploadJob,
picker: AndroidLocalUploadPicker,
jobId: String,
): Result = ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(initial.accountId) {
performQueuedUpload(store, initial, picker, jobId)
}

private suspend fun performQueuedUpload(
store: AndroidDurableMultipartUploadStore,
initial: AndroidDurableMultipartUploadJob,
picker: AndroidLocalUploadPicker,
jobId: String,
): Result {
val accountServices = AndroidNextcloudServices(applicationContext)
val session = accountServices.loadSession()
if (session == null || NextcloudDocumentIds.accountKey(session) != initial.accountId) {
val retainedSession = resolveStoredAndroidAccountSession(
accountIdentity = initial.accountId,
listAccounts = accountServices::listAccounts,
loadSession = { accountId -> accountServices.loadSession(accountId) },
)
if (durableUploadAccountMismatchOutcome(initial.accountId, retainedSession) ==
DurableUploadAccountMismatchOutcome.DeferRetainedAccount
) {
recordUploadDiagnostic(
severity = SupportDiagnosticSeverity.Warning,
outcome = "account-deferred",
accountId = initial.accountId,
jobId = jobId,
)
return Result.success()
}
store.transition(
jobId,
expected = DurableUploadState.Queued,
target = DurableUploadState.Failed,
message = "The account used for this upload is no longer active.",
message = "The account used for this upload is no longer available.",
)
picker.release(initial.request.file)
recordUploadDiagnostic(
Expand All @@ -166,7 +219,7 @@ internal class DeckAttachmentUploadWorker(
accountId = initial.accountId,
jobId = jobId,
)
return@withContext Result.failure()
return Result.failure()
}
val capabilityReady = runCatching {
picker.requirePersisted(initial.request.file)
Expand All @@ -186,14 +239,14 @@ internal class DeckAttachmentUploadWorker(
accountId = initial.accountId,
jobId = jobId,
)
return@withContext Result.failure()
return Result.failure()
}
val started = store.transition(
jobId,
expected = DurableUploadState.Queued,
target = DurableUploadState.Uploading,
message = null,
) ?: return@withContext Result.success()
) ?: return Result.success()
val services = AndroidNextcloudServices(applicationContext, localUploadPicker = picker)
val outcome = runCatching {
services.executeNextcloudMultipartUpload(session, started.request)
Expand Down Expand Up @@ -252,7 +305,7 @@ internal class DeckAttachmentUploadWorker(
)
picker.release(started.request.file)
}
Result.success()
return Result.success()
}

private fun recordUploadDiagnostic(
Expand Down Expand Up @@ -284,6 +337,28 @@ internal class DeckAttachmentUploadWorker(
}
}

internal enum class DurableUploadAccountMismatchOutcome {
DeferRetainedAccount,
AccountUnavailable,
}

internal fun durableUploadAccountMismatchOutcome(
expectedAccountId: String,
retainedSession: NextcloudSession?,
): DurableUploadAccountMismatchOutcome =
if (retainedSession != null && NextcloudDocumentIds.accountKey(retainedSession) == expectedAccountId) {
DurableUploadAccountMismatchOutcome.DeferRetainedAccount
} else {
DurableUploadAccountMismatchOutcome.AccountUnavailable
}

internal fun queuedDurableUploadsForAccount(
jobs: List<AndroidDurableMultipartUploadJob>,
accountId: String,
): List<AndroidDurableMultipartUploadJob> = jobs.filter { job ->
job.accountId == accountId && job.state == DurableUploadState.Queued
}

internal data class AndroidDurableMultipartUploadJob(
val id: String,
val accountId: String,
Expand Down Expand Up @@ -381,6 +456,13 @@ internal class AndroidDurableMultipartUploadStore(
writeAll(readAll().filterNot { it.id == id })
}

fun removeForAccount(accountId: String): List<AndroidDurableMultipartUploadJob> = synchronized(LOCK) {
val current = readAll()
val removed = current.filter { job -> job.accountId == accountId }
if (removed.isNotEmpty()) writeAll(current.filterNot { job -> job.accountId == accountId })
removed
}

fun transition(
id: String,
expected: DurableUploadState,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package dev.obiente.nextcloudnative

import android.content.Context
import androidx.work.WorkManager
import androidx.work.await

internal class AndroidDurableUploadAccountCleanup(context: Context) {
private val appContext = context.applicationContext
private val store = AndroidDurableMultipartUploadStore(appContext)

suspend fun removeForAccount(accountId: String) {
store.list().filter { job -> job.accountId == accountId }.forEach { job ->
WorkManager.getInstance(appContext).cancelUniqueWork(durableUploadWorkName(job.id)).await()
}
val removed = store.removeForAccount(accountId)
val picker = AndroidLocalUploadPicker(appContext)
removed.forEach { job -> picker.release(job.request.file) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retain upload rows when capability release fails

When removing an inactive account with queued durable uploads, AndroidLocalUploadPicker.release can return false if deleting its persisted capability metadata fails, but this result is ignored after removeForAccount has already deleted the queue rows. Account removal then reports success while the encrypted URI metadata, and potentially its persisted grant, has no remaining recovery record or cleanup path; retain the job until release succeeds or surface and durably track the partial cleanup.

AGENTS.md reference: AGENTS.md:L292-L293

Useful? React with 👍 / 👎.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -385,14 +385,26 @@ internal class AndroidFileOfflineRepository(context: Context) {
return update.state.folderAvailability(accountId, folder.path)
}

fun execute(
suspend fun execute(
expectedAccountId: String,
userId: String,
jobId: Long,
cancellation: DocumentRequestCancellation,
): AndroidOfflineExecutionOutcome = ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(expectedAccountId) {
executeWhileAccountRetained(expectedAccountId, userId, jobId, cancellation)
}

private fun executeWhileAccountRetained(
expectedAccountId: String,
userId: String,
jobId: Long,
cancellation: DocumentRequestCancellation,
): AndroidOfflineExecutionOutcome {
val session = AndroidNextcloudServices(appContext).loadSession()
if (session == null || NextcloudDocumentIds.accountKey(session) != expectedAccountId) {
val services = AndroidNextcloudServices(appContext)
val session = resolveStoredAndroidAccountSession(
expectedAccountId, services::listAccounts, services::loadSession,
)
Comment thread
veryCrunchy marked this conversation as resolved.
if (session == null) {
finish(
jobId,
FileOfflineJobResult.PermanentFailure("Sign in to this account to finish the offline download."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
Expand Down Expand Up @@ -53,17 +55,25 @@ internal class AndroidFileSyncSessionSchedulingGuard {
persist: () -> Unit,
cancelAll: () -> Unit,
publishAccount: (String) -> Unit = {},
restoreSchedules: (String) -> Unit = {},
) {
synchronized(monitor) {
val accountChanged = accountId != replacementAccountId
persist()
generation += 1
accountId = null
accountId = replacementAccountId
try {
persist()
accountId = replacementAccountId
publishAccount(replacementAccountId)
} finally {
if (accountChanged) cancelAll()
if (accountChanged) {
try {
cancelAll()
} finally {
restoreSchedules(replacementAccountId)
}
} else {
restoreSchedules(replacementAccountId)
}
}
}
}
Expand All @@ -74,10 +84,10 @@ internal class AndroidFileSyncSessionSchedulingGuard {
clearPublishedAccount: () -> Unit = {},
) {
synchronized(monitor) {
persist()
generation += 1
accountId = null
try {
persist()
clearPublishedAccount()
} finally {
cancelAll()
Expand Down Expand Up @@ -165,6 +175,28 @@ internal class AndroidFileSyncScheduler(context: Context) {
)
}

fun restorePersistedPairSchedules(accountId: String) {
val request = OneTimeWorkRequestBuilder<AndroidFileSyncScheduleRestorationWorker>()
.setInputData(
Data.Builder()
.putString(AndroidFileSyncScheduleRestorationWorker.KEY_ACCOUNT_ID, accountId)
.build(),
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.addTag(TAG)
.build()
workManager.enqueueUniqueWork(
"file-sync-restore-$accountId",
ExistingWorkPolicy.REPLACE,
request,
)
}

suspend fun cancel(pairId: String) {
workManager.cancelUniqueWork(workName(pairId)).await()
}
Expand Down
Loading