Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e8c0717
fix(android): reject self-provider picker roots
veryCrunchy Sep 5, 2026
65b0aff
chore(changelog): record picker provider guard
veryCrunchy Sep 5, 2026
7bcfee5
fix(android): preflight self-provider sync roots
veryCrunchy Sep 5, 2026
af7d7bc
fix(android): recover legacy picker roots safely
veryCrunchy Sep 5, 2026
75da6a5
fix(android): prepare account removal before lease
veryCrunchy Sep 5, 2026
b5c8ccb
fix(android): bind provider recovery to removed account
veryCrunchy Sep 6, 2026
32925b6
refactor(android): split picker persistence helpers
veryCrunchy Sep 6, 2026
6d34a92
fix(android): retain unclassified download recovery
veryCrunchy Sep 6, 2026
8a45c14
fix(android): revalidate account removal under lease
veryCrunchy Sep 6, 2026
472976e
fix(android): harden legacy provider retirement
veryCrunchy Sep 6, 2026
ef91456
fix(android): recover moved provider directories
veryCrunchy Sep 6, 2026
8e3b47b
refactor(android): preserve sync engine boundary
veryCrunchy Sep 6, 2026
4fb89d4
fix(android): order provider recovery account fencing
veryCrunchy Sep 6, 2026
2fa9a3d
fix(android): scope legacy SAF retirement completion
veryCrunchy Sep 6, 2026
70a4c3e
fix(android): prepare malformed reset before lease
veryCrunchy Sep 6, 2026
bd11db7
fix(android): reuse recovery lease for provider range reads
veryCrunchy Sep 9, 2026
923cf9f
chore(android): tighten document provider size baseline
veryCrunchy Sep 9, 2026
7f93ecb
Merge branch 'fix/durable-upload-scheduling-recovery-stack' into fix/…
veryCrunchy Sep 9, 2026
606098f
Merge branch 'fix/durable-upload-scheduling-recovery-stack' into fix/…
veryCrunchy Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ internal class AndroidAccountCredentialController(
private val clearPreviewAccount: (String) -> Unit,
private val notifyDocumentRootsChanged: () -> Unit,
private val resumeQueuedUploads: suspend (String) -> Unit,
private val prepareAccountRemoval: suspend (NextcloudSession) -> Unit,
private val removeQueuedUploads: suspend (NextcloudSession) -> Unit,
private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String, String?, String?, String?) -> Unit,
private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String, String, String?, String?, String?) -> Unit,
private val activatePersistedAccount: suspend (NextcloudSession) -> Unit,
) {
private val appContext = context.applicationContext
private val accountRemovalLeases = AndroidAccountRemovalLeaseCoordinator(appContext)
private val handoffCleanup = AndroidExternalFileHandoffCleanup(appContext, preferences, ::commitPreferences)
private val accountRemovalCleanupJournal = AndroidAccountRemovalCleanupJournal(
preferences = preferences,
Expand Down Expand Up @@ -158,11 +158,10 @@ internal class AndroidAccountCredentialController(
val session = current.sessions[accountId]
?: return@withLock removeUnavailableAccount(accountId, current)
val pendingCleanup = pendingAndroidAccountRemovalCleanup(session)
withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session)) {
accountRemovalLeases.withLease(session) {
val active = current.registry.activeAccountId == accountId
removeAndroidAccountCredentialData(
active = active,
prepareAccountRemoval = { prepareAccountRemoval(session) },
removeQueuedUploads = { removeQueuedUploads(session) },
clearActiveAccount = { clearSession(current, pendingCleanup) },
rollbackActiveRemoval = {
Expand Down Expand Up @@ -193,11 +192,11 @@ internal class AndroidAccountCredentialController(
val unavailableSession = NextcloudSession(target.record.serverUrl, target.record.loginName, appPassword = "")
val accountIdentity = NextcloudDocumentIds.accountKey(unavailableSession)
val pendingCleanup = pendingAndroidAccountRemovalCleanup(unavailableSession)
withAndroidAccountRemovalLease(accountIdentity) {
accountRemovalLeases.withUnavailableLease(unavailableSession) {
removeUnavailableAndroidAccountCredentialData(
accountIdentity = accountIdentity,
active = target.wasActive,
prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) },
prepareAccountRemoval = {},
removeAccountOwnedWorkWithoutCredentials = { identity ->
retryQueuedUploadsCleanupWithoutCredentials(
pendingCleanup.accountStorageKey,
Expand Down Expand Up @@ -231,11 +230,9 @@ internal class AndroidAccountCredentialController(
check(current.activeSession == expectedSession) {
"The account changed before its remote session could be revoked."
}
val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession)
val pendingCleanup = pendingAndroidAccountRemovalCleanup(expectedSession)
revokeAndroidSessionWithAccountLease(
accountIdentity = accountIdentity,
preflight = { prepareAccountRemoval(expectedSession) },
accountRemovalLeases.revoke(
session = expectedSession,
revoke = revokeRemoteSession,
removeLocalAccount = {
removeAndroidAccountCredentialData(
Expand Down Expand Up @@ -265,12 +262,10 @@ internal class AndroidAccountCredentialController(
if (session == null) {
clearSession(read.state)
} else {
val accountIdentity = NextcloudDocumentIds.accountKey(session)
val pendingCleanup = pendingAndroidAccountRemovalCleanup(session)
withAndroidAccountRemovalLease(accountIdentity) {
accountRemovalLeases.withLease(session) {
removeAndroidAccountCredentialData(
active = true,
prepareAccountRemoval = { prepareAccountRemoval(session) },
removeQueuedUploads = { removeQueuedUploads(session) },
clearActiveAccount = { clearSession(read.state, pendingCleanup) },
rollbackActiveRemoval = {
Expand Down Expand Up @@ -330,21 +325,27 @@ internal class AndroidAccountCredentialController(
}
private suspend fun clearUnregisteredIndependentCredentialSlots(suspectEncrypted: String?) =
clearUnregisteredAndroidAccountCredentialSlots(
preferences, sessionCipher, accountRemovalCleanupJournal, suspectEncrypted,
prepareAccountRemoval, removeQueuedUploads, ::commitPreferences, ::recordAccountRemovalCleanupFailure,
::clearInvalidStore)
preferences = preferences,
sessionCipher = sessionCipher,
cleanupJournal = accountRemovalCleanupJournal,
suspectEncrypted = suspectEncrypted,
prepareAccountRemoval = { session -> prepareAndroidAccountRemoval(appContext, session) },
revalidateAccountRemoval = { session -> preflightAndroidAccountRemoval(appContext, session) },
removeAccountOwnedState = removeQueuedUploads,
commitPreferences = ::commitPreferences,
recordCleanupFailure = ::recordAccountRemovalCleanupFailure,
clearInvalidStore = ::clearInvalidStore,
)

private suspend fun clearRecoveredInvalidStore(
current: AndroidAccountCredentialState,
suspectEncrypted: String,
) {
val activeSession = current.activeSession
if (activeSession != null) {
val accountIdentity = NextcloudDocumentIds.accountKey(activeSession)
val pendingCleanup = pendingAndroidAccountRemovalCleanup(activeSession)
withAndroidAccountRemovalLease(accountIdentity) {
accountRemovalLeases.withLease(activeSession) {
removeRecoveredAndroidAccountCredentialData(
prepareAccountRemoval = { prepareAccountRemoval(activeSession) },
removeQueuedUploads = { removeQueuedUploads(activeSession) },
clearRecoveredAccount = {
persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted, pendingCleanup)
Expand Down Expand Up @@ -724,7 +725,6 @@ internal class AndroidAccountCredentialController(
throw failure
}
}

private fun encryptState(state: AndroidAccountCredentialState): String = try {
sessionCipher.encrypt(encodeAndroidAccountCredentialState(state))
} catch (failure: Exception) {
Expand All @@ -734,7 +734,6 @@ internal class AndroidAccountCredentialController(
)
throw failure
}

private fun encryptCredentialSlot(session: NextcloudSession): String = try {
sessionCipher.encrypt(encodeAndroidPersistedSession(session))
} catch (failure: Exception) {
Expand Down Expand Up @@ -796,5 +795,4 @@ internal class AndroidAccountCredentialController(
component = SupportDiagnosticComponent.Cache,
failure = failure,
)

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ internal suspend fun loadAndroidAccountFileListing(
val read: suspend () -> NextcloudFileListing = {
readAndroidAccountFileListing(cache, NextcloudDocumentIds.accountKey(session), path, request)
}
return if (accountLeaseHeld) read() else withRetainedAndroidAccountFileRead(session, resolveSession, guard, read)
return if (accountLeaseHeld) read() else withRetainedAndroidAccountFileRead(session, resolveSession, guard, read = read)
}

private suspend fun readAndroidAccountFileListing(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.obiente.nextcloudnative

import android.util.Base64
import dev.obiente.nextcloudnative.app.NextcloudFile
import dev.obiente.nextcloudnative.app.NextcloudFileRangeSession
import dev.obiente.nextcloudnative.app.NextcloudSession
import java.io.FileNotFoundException
Expand All @@ -14,9 +15,13 @@ internal suspend fun <Result> withRetainedAndroidAccountFileRead(
expectedSession: NextcloudSession,
resolveSession: suspend () -> NextcloudSession?,
guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD,
accountLeaseHeld: Boolean = false,
read: suspend () -> Result,
): Result = withContext(Dispatchers.IO) {
guard.withExactAccountSession(
if (accountLeaseHeld) {
check(resolveSession() == expectedSession) { "The account changed before the file read could finish." }
read()
} else guard.withExactAccountSession(
expectedSession = expectedSession,
resolveSession = resolveSession,
unavailable = { error("The account changed before the file read could finish.") },
Expand Down Expand Up @@ -135,9 +140,10 @@ internal fun openTrackedAndroidFileRangeSession(
activity: AndroidFileRangeSessionActivity,
guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD,
coordinator: AndroidFileRangeSessionCoordinator = ANDROID_FILE_RANGE_SESSION_COORDINATOR,
accountLeaseHeld: Boolean = false,
openSource: () -> NextcloudFileRangeSession,
): NextcloudFileRangeSession {
val lease = guard.acquireBlocking(NextcloudDocumentIds.accountKey(expectedSession))
val lease = if (accountLeaseHeld) null else guard.acquireBlocking(NextcloudDocumentIds.accountKey(expectedSession))
return try {
if (resolveSession() != expectedSession) {
throw FileNotFoundException("The account changed before the file range session could start.")
Expand All @@ -151,11 +157,47 @@ internal fun openTrackedAndroidFileRangeSession(
activity.close()
throw failure
} finally {
lease.close()
lease?.close()
}
}

internal fun androidFileRangeAuthorization(session: NextcloudSession): String = Base64.encodeToString(
"${session.loginName}:${session.appPassword}".toByteArray(StandardCharsets.UTF_8),
Base64.NO_WRAP,
)

internal fun AndroidNextcloudServices.openDocumentProviderFileRangeSession(
session: NextcloudSession,
userId: String,
path: String,
size: Long,
expectedEtag: String,
accountLeaseHeld: Boolean,
): NextcloudFileRangeSession = if (accountLeaseHeld) {
openFileRangeSessionWhileAccountLeaseHeld(session, userId, path, size, expectedEtag)
} else {
openFileRangeSession(session, userId, path, size, expectedEtag)
}

internal class AndroidFileRangeUnsupportedException(message: String) : Exception(message)

internal suspend fun probeSeekableExternalHandoffGeneration(
file: NextcloudFile,
verifyEmptyGeneration: suspend () -> Unit,
openRangeSession: (size: Long, etag: String) -> NextcloudFileRangeSession,
): Boolean {
val size = file.size ?: return false
val etag = file.etag?.takeIf(String::isNotBlank) ?: return false
if (size == 0L) {
verifyEmptyGeneration()
return true
}
val rangeSession = openRangeSession(size, etag)
return try {
rangeSession.read(0L, 1).size == 1
} catch (_: AndroidFileRangeUnsupportedException) {
false
} finally {
rangeSession.close()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,33 @@ internal suspend fun <Result> withAndroidAccountRemovalLease(
action = action,
)

internal suspend fun <Result> withPreparedAndroidAccountRemovalLease(
accountIdentity: String,
guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD,
prepare: suspend () -> Unit,
revalidate: suspend () -> Unit,
action: suspend () -> Result,
): Result {
prepare()
return withAndroidAccountRemovalLease(accountIdentity, guard) {
Comment on lines +40 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Hold the account lease while quiescing range reads

When ordinary account removal races a newly opened seekable DocumentsProvider file, prepare() now runs before the exclusive removal lease. AndroidFileRangeSessionCoordinator.quiesce() snapshots existing registrations, while openTrackedAndroidFileRangeSession() holds the account guard only until it registers the range session; an open that registers after that snapshot can release the guard before removal calls tryWithAccount. The quiesce pass then misses (and removes the registry entry for) this session, allowing credential deletion to proceed while the descriptor can continue issuing authenticated range requests with the retained app password. Keep range-session admission fenced through quiescing and credential removal, or re-quiesce under the final lease.

Useful? React with 👍 / 👎.

revalidate()
action()
}
}

internal suspend fun <Result> withUnavailableAndroidAccountRemovalLease(
accountIdentity: String,
guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD,
preflight: suspend () -> Unit,
action: suspend () -> Result,
): Result = withPreparedAndroidAccountRemovalLease(
accountIdentity = accountIdentity,
guard = guard,
prepare = preflight,
revalidate = preflight,
action = action,
)

internal suspend fun revokeAndroidSessionAfterRemovalPreflight(
preflight: suspend () -> Unit,
revoke: suspend () -> Unit,
Expand Down Expand Up @@ -64,11 +91,55 @@ internal suspend fun revokeAndroidSessionAfterRemovalPreflight(
internal suspend fun revokeAndroidSessionWithAccountLease(
accountIdentity: String,
guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD,
preflight: suspend () -> Unit,
prepare: suspend () -> Unit,
revalidate: suspend () -> Unit,
revoke: suspend () -> Unit,
removeLocalAccount: suspend () -> Unit,
) = withAndroidAccountRemovalLease(accountIdentity, guard) {
revokeAndroidSessionAfterRemovalPreflight(preflight, revoke, removeLocalAccount)
) = withPreparedAndroidAccountRemovalLease(accountIdentity, guard, prepare, revalidate) {
revokeAndroidSessionAfterRemovalPreflight({}, revoke, removeLocalAccount)
}

internal class AndroidAccountRemovalLeaseCoordinator(
context: Context,
private val guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD,
) {
private val appContext = context.applicationContext

suspend fun <Result> withLease(
session: NextcloudSession,
action: suspend () -> Result,
): Result = withPreparedAndroidAccountRemovalLease(
accountIdentity = NextcloudDocumentIds.accountKey(session),
guard = guard,
prepare = { prepareAndroidAccountRemoval(appContext, session) },
revalidate = { preflightAndroidAccountRemoval(appContext, session) },
action = action,
Comment on lines +114 to +116

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 Recheck SAF ownership under the removal lease

If a folder-sync run starts after prepare() releases its recovery lease and finishes before removal acquires the next lease, it can fail after persisting a new local SAF download transaction. Fresh evidence after adding in-lease revalidation is that preflightAndroidAccountRemoval checks document writebacks and fileSyncOwnedUploads only, so this new local ownership row is invisible and credentials can still be committed as removed, leaving subsequent self-provider cleanup without a usable session. Reconcile or fail closed on pending SAF ownership again while holding the final removal lease.

AGENTS.md reference: AGENTS.md:L428-L430

Useful? React with 👍 / 👎.

)

// Missing credentials cannot safely repair legacy self-provider downloads before removal.
// Commit first; durable owned-state cleanup remains fail-closed and can resume after re-add.
Comment on lines +119 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Bind deferred recovery to the re-added session

When an unavailable account is removed with a legacy self-provider download pending, the promised recovery after re-add cannot run: saveSession invokes retryPendingAccountRemovalCleanup before persisting the new session, and that retry reaches retireAndroidFileSyncAccountPairs without a providerRecoverySession; meanwhile the cleanup journal itself prevents loadSession from restoring this account for NextcloudDocumentsProvider. Fresh evidence after the credential-free commit change is that the newly supplied credential is never bound to the deferred provider recovery, so the retry fails and blocks the account from being added again.

AGENTS.md reference: AGENTS.md:L428-L430

Useful? React with 👍 / 👎.

suspend fun <Result> withUnavailableLease(
session: NextcloudSession,
action: suspend () -> Result,
): Result = withUnavailableAndroidAccountRemovalLease(
accountIdentity = NextcloudDocumentIds.accountKey(session),
guard = guard,
preflight = { preflightAndroidAccountRemoval(appContext, session) },
action = action,
)

suspend fun revoke(
session: NextcloudSession,
revoke: suspend () -> Unit,
removeLocalAccount: suspend () -> Unit,
) = revokeAndroidSessionWithAccountLease(
accountIdentity = NextcloudDocumentIds.accountKey(session),
guard = guard,
prepare = { prepareAndroidAccountRemoval(appContext, session) },
revalidate = { preflightAndroidAccountRemoval(appContext, session) },
revoke = revoke,
removeLocalAccount = removeLocalAccount,
)
}

internal enum class AndroidAccountDocumentGrantScope(val pathSegment: String) {
Expand All @@ -88,6 +159,11 @@ internal suspend fun preflightAndroidAccountRemoval(context: Context, session: N

internal suspend fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) {
preflightAndroidAccountRemoval(context, session)
reconcileAndroidFileSyncAccountDownloadsBeforeCredentialRemoval(
context,
NextcloudDocumentIds.accountKey(session),
session,
)
Comment thread
veryCrunchy marked this conversation as resolved.
Comment thread
veryCrunchy marked this conversation as resolved.
ANDROID_FILE_RANGE_SESSION_COORDINATOR.quiesce(NextcloudDocumentIds.accountKey(session))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,10 @@ internal fun acquireAndroidDocumentMutationAccountLease(
internal inline fun <Result> withAndroidDocumentMutation(
session: NextcloudSession,
noinline loadCurrentSession: () -> NextcloudSession?,
guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD,
action: (NextcloudSession) -> Result,
): Result {
val lease = acquireAndroidDocumentMutationAccountLease(session, loadCurrentSession)
val lease = acquireAndroidDocumentMutationAccountLease(session, loadCurrentSession, guard)
return try {
action(session)
} finally {
Expand Down
Loading