Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
119 commits
Select commit Hold shift + click to select a range
93631d6
feat(accounts): add platform credential slots
veryCrunchy Sep 1, 2026
cf90d81
chore(changelog): link pull request
veryCrunchy Sep 1, 2026
32f6c79
fix(accounts): serialize credential lifecycle state
veryCrunchy Sep 1, 2026
eee6c83
fix(accounts): preserve retained account recovery
veryCrunchy Sep 3, 2026
d62cea8
fix(android): defer retained-account uploads
veryCrunchy Sep 3, 2026
5f17d3f
fix(accounts): harden credential recovery races
veryCrunchy Sep 3, 2026
83ea0d9
fix(accounts): coordinate background account work
veryCrunchy Sep 3, 2026
04c2a0a
fix(accounts): close credential lifecycle races
veryCrunchy Sep 3, 2026
4f6fa2a
fix(accounts): close remaining removal races
veryCrunchy Sep 4, 2026
1c31b71
fix(accounts): preserve credential recovery invariants
veryCrunchy Sep 4, 2026
cec7136
fix(accounts): retire account-bound transfer resources
veryCrunchy Sep 4, 2026
1e404f6
fix(accounts): preserve active resource identity
veryCrunchy Sep 4, 2026
6a1f33e
fix(android): serialize account-bound writebacks
veryCrunchy Sep 4, 2026
5dcc9c0
fix(accounts): retain credential recovery paths
veryCrunchy Sep 4, 2026
30941b0
fix(android): close credential recovery gaps
veryCrunchy Sep 4, 2026
3965ead
fix(accounts): close remaining transition races
veryCrunchy Sep 4, 2026
b477088
fix(accounts): retire removed account resources
veryCrunchy Sep 4, 2026
32fd78b
fix(accounts): close recovery transition gaps
veryCrunchy Sep 4, 2026
1cbd3fc
fix(accounts): harden credential recovery boundaries
veryCrunchy Sep 4, 2026
5040557
fix(accounts): serialize account-bound mutations
veryCrunchy Sep 4, 2026
de408fa
fix(accounts): preserve removal recovery state
veryCrunchy Sep 4, 2026
44e8071
fix(accounts): preflight account-bound transitions
veryCrunchy Sep 4, 2026
4a851d9
fix(accounts): close retained-session safety gaps
veryCrunchy Sep 4, 2026
0e96293
fix(accounts): finish committed account transitions
veryCrunchy Sep 4, 2026
aa76381
fix(accounts): isolate credential recovery mutations
veryCrunchy Sep 4, 2026
6b9ff27
fix(accounts): preflight sync ownership on removal
veryCrunchy Sep 4, 2026
262127b
fix(accounts): reject unsupported desktop registries
veryCrunchy Sep 4, 2026
628d2ca
fix(accounts): retain removal cleanup ownership
veryCrunchy Sep 4, 2026
db72523
fix(accounts): finish removal recovery
veryCrunchy Sep 4, 2026
d4db556
fix(accounts): harden transition recovery
veryCrunchy Sep 4, 2026
9ecd00c
fix(accounts): harden damaged state recovery
veryCrunchy Sep 4, 2026
b54d88d
fix(accounts): complete removal isolation
veryCrunchy Sep 5, 2026
f721f91
test(accounts): verify durable root cleanup
veryCrunchy Sep 5, 2026
f198eb0
fix(accounts): harden removal recovery
veryCrunchy Sep 5, 2026
8b11831
fix(accounts): fail closed on recovery tombstones
veryCrunchy Sep 5, 2026
10157c7
test(accounts): split removal recovery coverage
veryCrunchy Sep 5, 2026
fbc8764
fix(accounts): quiesce Linux writes before removal
veryCrunchy Sep 5, 2026
5e6da1b
fix(accounts): persist desktop cleanup markers first
veryCrunchy Sep 5, 2026
0eb5e5d
fix(accounts): recover Android accounts before enumeration
veryCrunchy Sep 5, 2026
97e9e13
fix(accounts): retry Android removal cleanup
veryCrunchy Sep 5, 2026
a847037
test(desktop): inject FUSE ownership in lifecycle tests
veryCrunchy Sep 5, 2026
83e6b7b
fix(accounts): harden Android removal recovery
veryCrunchy Sep 5, 2026
d6dfd46
fix(uploads): preserve retained account queues
veryCrunchy Sep 5, 2026
8c370d3
fix(accounts): journal desktop credential transitions
veryCrunchy Sep 5, 2026
c405223
refactor(desktop): decouple Linux cleanup lifecycle
veryCrunchy Sep 5, 2026
908507d
fix(desktop): clear recovered Cloud Files failures
veryCrunchy Sep 5, 2026
5b536d7
fix(accounts): preserve distinct legacy cleanup keys
veryCrunchy Sep 5, 2026
48f99de
test(desktop): invoke lifecycle failures explicitly
veryCrunchy Sep 5, 2026
84298c4
fix(desktop): count lazy hydration as live
veryCrunchy Sep 5, 2026
260d2bd
fix(accounts): quarantine malformed removal journals
veryCrunchy Sep 5, 2026
1daa249
fix(accounts): close Android removal races
veryCrunchy Sep 5, 2026
2d6fcb6
fix(desktop): finish sign-out after revocation
veryCrunchy Sep 5, 2026
9d9e98f
fix(sync): retire account SAF state safely
veryCrunchy Sep 5, 2026
dc2635c
fix(sharing): defer uploads for unreadable accounts
veryCrunchy Sep 5, 2026
99ab7be
fix(desktop): quiesce Cloud Files on sign-out
veryCrunchy Sep 5, 2026
1f03da9
refactor(accounts): share retained identity policy
veryCrunchy Sep 5, 2026
eb003fb
fix(desktop): return quiescence outcome
veryCrunchy Sep 5, 2026
d8bb7af
test(desktop): prove callback drain ordering
veryCrunchy Sep 5, 2026
3008c67
fix(desktop): disable reads after failed FUSE unmount
veryCrunchy Sep 5, 2026
9963216
fix(android): recover credential slots before selection
veryCrunchy Sep 5, 2026
ee428b0
fix(android): complete account retirement cleanup
veryCrunchy Sep 5, 2026
7f37455
fix(desktop): harden account removal recovery
veryCrunchy Sep 5, 2026
e75f752
fix(accounts): preserve unreadable retained slots
veryCrunchy Sep 5, 2026
2253d2e
fix(android): quiesce account file cache reads
veryCrunchy Sep 5, 2026
4f13f42
fix(accounts): preserve desktop rollback selection
veryCrunchy Sep 5, 2026
aa4e460
fix(accounts): retry desktop removal cleanup
veryCrunchy Sep 5, 2026
036511d
fix(desktop): remove accounts without loading secrets
veryCrunchy Sep 5, 2026
81e33d9
fix(windows): purge removed Cloud Files roots
veryCrunchy Sep 5, 2026
bc5a26e
fix(android): revoke handoffs across account transitions
veryCrunchy Sep 5, 2026
b2b1f83
fix(accounts): retain preview cleanup recovery
veryCrunchy Sep 5, 2026
9065961
test(accounts): fix preview cleanup fixtures
veryCrunchy Sep 5, 2026
1b0a940
fix(accounts): clear unreadable active ownership
veryCrunchy Sep 5, 2026
56d4800
fix(accounts): preserve unreadable removal rollback
veryCrunchy Sep 5, 2026
396609f
fix(accounts): retain original preview retry identity
veryCrunchy Sep 5, 2026
d447aa4
fix(uploads): retain jobs for unreadable account registry
veryCrunchy Sep 5, 2026
5221e8d
fix(desktop): recover malformed cleanup phases
veryCrunchy Sep 5, 2026
a37b2c4
fix(android): finish committed account selection
veryCrunchy Sep 5, 2026
ea9acbd
fix(accounts): purge retained mutation recovery
veryCrunchy Sep 5, 2026
330ed9f
fix(accounts): persist mutation cleanup retries
veryCrunchy Sep 5, 2026
ba32b37
fix(account): purge private state after removal
veryCrunchy Sep 5, 2026
139c220
chore(website): refresh marketing captures
obiente-automations[bot] Sep 5, 2026
8858e08
fix(account): serialize private-state publication
veryCrunchy Sep 5, 2026
e04fd66
fix(account): purge removal residual state
veryCrunchy Sep 5, 2026
a2d4b09
fix(desktop): clear legacy handoffs on removal
veryCrunchy Sep 5, 2026
6c240a4
test(android): make media cleanup deterministic
veryCrunchy Sep 5, 2026
71c60c8
fix(account): close residual cleanup races
veryCrunchy Sep 5, 2026
8c51d43
fix(desktop): reserve external handoff cache budget
veryCrunchy Sep 5, 2026
3ea964b
chore(website): refresh marketing captures
obiente-automations[bot] Sep 5, 2026
ecaf99e
fix(desktop): recover credential removals
veryCrunchy Sep 5, 2026
cb6c6e6
fix(desktop): validate handoff paths portably
veryCrunchy Sep 5, 2026
bcd1836
fix(accounts): retire deck draft state
veryCrunchy Sep 5, 2026
bf4f21f
fix(desktop): delete read-only handoff copies
veryCrunchy Sep 5, 2026
5598786
fix(accounts): harden retained account recovery
veryCrunchy Sep 5, 2026
8e55759
fix(accounts): gate credential recovery transitions
veryCrunchy Sep 6, 2026
c167e28
chore(architecture): lower Android service baseline
veryCrunchy Sep 6, 2026
f501e08
fix(android): serialize conditional file creation
veryCrunchy Sep 6, 2026
bc317fa
test(android): cover offline account recovery policy
veryCrunchy Sep 6, 2026
449489f
refactor(desktop): preserve compact session boundary
veryCrunchy Sep 6, 2026
f3a26a1
chore(website): refresh marketing captures
obiente-automations[bot] Sep 6, 2026
6383844
fix(accounts): purge removed private state
veryCrunchy Sep 6, 2026
ca9b1d9
chore(website): refresh marketing captures
obiente-automations[bot] Sep 6, 2026
c4b9068
fix(accounts): purge private state on removal
veryCrunchy Sep 6, 2026
c54a0ad
chore(website): refresh marketing captures
obiente-automations[bot] Sep 6, 2026
7055dd5
fix(accounts): fence private cache retirement
veryCrunchy Sep 6, 2026
f2c5359
chore(website): refresh marketing captures
obiente-automations[bot] Sep 6, 2026
2f09ad2
fix(accounts): finish private state cleanup
veryCrunchy Sep 6, 2026
32f97e9
fix(desktop): clear removed account state
veryCrunchy Sep 6, 2026
fd10112
fix(ui): validate workspace cleanup identity
veryCrunchy Sep 6, 2026
a626ade
chore(website): refresh marketing captures
obiente-automations[bot] Sep 6, 2026
f3c0647
fix(accounts): close remaining retirement races
veryCrunchy Sep 6, 2026
412f36b
fix(accounts): close desktop cleanup gaps
veryCrunchy Sep 6, 2026
5d63343
fix(accounts): quiesce android private activity
veryCrunchy Sep 6, 2026
31c7c7e
chore(website): refresh marketing captures
obiente-automations[bot] Sep 6, 2026
eadef3d
fix(android): reuse account lease for provider mutation lookups
veryCrunchy Sep 9, 2026
fc550cf
fix(accounts): fence complete dynamic read lifetimes
veryCrunchy Sep 9, 2026
ef0f432
fix(accounts): persist credential rollback completion
veryCrunchy Sep 9, 2026
6fe80d0
fix(deck): retire legacy drafts before replacement
veryCrunchy Sep 9, 2026
a64f83b
fix(deck): retain legacy submission tombstones during cleanup
veryCrunchy Sep 9, 2026
d3a3482
chore(website): refresh marketing captures
obiente-automations[bot] 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

Large diffs are not rendered by default.

Large diffs are not rendered by default.

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

import dev.obiente.nextcloudnative.app.NextcloudSession
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext

internal fun removeActiveAndroidAccountCredentialState(
state: AndroidAccountCredentialState,
): AndroidAccountCredentialState = state.registry.activeAccountId?.let(state::remove) ?: state

internal suspend fun replaceAndroidActiveStateWithAccountLeases(
replacement: AndroidAccountCredentialState,
previousSession: NextcloudSession?,
replacedSession: NextcloudSession?,
suspectEncrypted: String?,
guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD,
coordinator: AndroidFileRangeSessionCoordinator = ANDROID_FILE_RANGE_SESSION_COORDINATOR,
replace: suspend (AndroidAccountCredentialState, NextcloudSession?, String?, NextcloudSession?) -> Unit,
) {
val replacementSession = requireNotNull(replacement.activeSession)
val accountIdentities = listOfNotNull(previousSession, replacementSession, replacedSession)
.map(NextcloudDocumentIds::accountKey)
guard.withAccounts(accountIdentities) {
quiesceAndroidFileRangesBeforeCredentialReplacement(replacedSession, replacementSession, coordinator)
replace(replacement, previousSession, suspectEncrypted, replacedSession)
}
}

internal suspend fun rollbackUnavailableAndroidAccountRemoval(
active: Boolean = false,
recovered: AndroidAccountCredentialState,
persistRecovered: suspend (AndroidAccountCredentialState) -> Unit,
clearCleanup: suspend () -> Unit,
) {
if (!active) persistRecovered(recovered)
clearCleanup()
}

internal suspend fun retryAndroidAccountRemovalCleanup(
accountOwnedByRegistry: Boolean?,
removeAccountOwnedWork: suspend () -> Unit,
clearCleanup: suspend () -> Unit,
) {
when (accountOwnedByRegistry) {
true -> clearCleanup()
false -> {
removeAccountOwnedWork()
clearCleanup()
}
null -> error("Account ownership is unavailable; pending cleanup cannot run safely.")
}
}

internal fun androidAccountRemovalCleanupRetryFailure(failure: Exception) = IllegalStateException(
"Previous account cleanup must finish before this account can be added again.",
failure,
)

internal suspend fun retryAndroidAccountOwnedStateCleanup(
session: NextcloudSession,
pending: AndroidPendingAccountRemovalCleanup,
retry: suspend (NextcloudSession, String, String?, String?, String?) -> Unit,
) {
retry(
session,
pending.workIdentity,
pending.previewCacheIdentity,
pending.durableMutationIdentity,
pending.legacyAccountScopeDigest,
)
}

internal suspend fun resumeAndroidQueuedUploadsAfterSelection(
resume: suspend () -> Unit,
notifyDocumentRootsChanged: () -> Unit,
recordFailure: () -> Unit,
) {
try {
resume()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
recordFailure()
} finally {
notifyDocumentRootsChanged()
}
}

internal suspend fun removeAndroidAccountCredentialData(
active: Boolean,
prepareAccountRemoval: suspend () -> Unit = {},
removeQueuedUploads: suspend () -> Unit,
clearActiveAccount: suspend () -> Unit,
rollbackActiveRemoval: suspend () -> Unit,
persistInactiveRemoval: suspend () -> Unit,
rollbackInactiveRemoval: suspend () -> Unit,
completeCommittedCleanup: suspend () -> Unit = {},
recordCommittedCleanupFailure: (Exception) -> Unit = {},
) {
prepareAccountRemoval()
if (active) {
try {
clearActiveAccount()
} catch (failure: Exception) {
withContext(NonCancellable) {
runCatching { rollbackActiveRemoval() }
.onFailure(failure::addSuppressed)
}
throw failure
}
finishCommittedAndroidAccountRemovalCleanup(
removeQueuedUploads,
completeCommittedCleanup,
recordCommittedCleanupFailure,
)
return
}

try {
persistInactiveRemoval()
} catch (failure: Exception) {
withContext(NonCancellable) {
runCatching { rollbackInactiveRemoval() }
.onFailure(failure::addSuppressed)
}
throw failure
}
finishCommittedAndroidAccountRemovalCleanup(
removeQueuedUploads,
completeCommittedCleanup,
recordCommittedCleanupFailure,
)
}

internal suspend fun removeUnavailableAndroidAccountCredentialData(
accountIdentity: String,
active: Boolean = false,
prepareAccountRemoval: suspend () -> Unit,
removeAccountOwnedWorkWithoutCredentials: suspend (String) -> Unit,
persistRemoval: suspend () -> Unit,
clearActiveAccount: suspend () -> Unit = persistRemoval,
rollbackRemoval: suspend () -> Unit,
completeCommittedCleanup: suspend () -> Unit = {},
recordCommittedCleanupFailure: (Exception) -> Unit = {},
) {
require(accountIdentity.isNotBlank())
removeAndroidAccountCredentialData(
active = active,
prepareAccountRemoval = prepareAccountRemoval,
removeQueuedUploads = { removeAccountOwnedWorkWithoutCredentials(accountIdentity) },
clearActiveAccount = clearActiveAccount,
rollbackActiveRemoval = rollbackRemoval,
persistInactiveRemoval = persistRemoval,
rollbackInactiveRemoval = rollbackRemoval,
completeCommittedCleanup = completeCommittedCleanup,
recordCommittedCleanupFailure = recordCommittedCleanupFailure,
)
}

private suspend fun finishCommittedAndroidAccountRemovalCleanup(
removeQueuedUploads: suspend () -> Unit,
completeCommittedCleanup: suspend () -> Unit,
recordFailure: (Exception) -> Unit,
) {
try {
removeQueuedUploads()
completeCommittedCleanup()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Exception) {
recordFailure(failure)
}
Comment thread
veryCrunchy marked this conversation as resolved.
}

internal suspend fun removeRecoveredAndroidAccountCredentialData(
prepareAccountRemoval: suspend () -> Unit = {},
removeQueuedUploads: suspend () -> Unit,
clearRecoveredAccount: suspend () -> Unit,
rollbackRecoveredAccount: suspend () -> Unit,
completeCommittedCleanup: suspend () -> Unit = {},
recordCommittedCleanupFailure: (Exception) -> Unit = {},
) = removeAndroidAccountCredentialData(
active = true,
prepareAccountRemoval = prepareAccountRemoval,
removeQueuedUploads = removeQueuedUploads,
clearActiveAccount = clearRecoveredAccount,
rollbackActiveRemoval = rollbackRecoveredAccount,
persistInactiveRemoval = {},
rollbackInactiveRemoval = {},
completeCommittedCleanup = completeCommittedCleanup,
recordCommittedCleanupFailure = recordCommittedCleanupFailure,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package dev.obiente.nextcloudnative

import dev.obiente.nextcloudnative.app.NextcloudFile
import dev.obiente.nextcloudnative.app.NextcloudFileListing
import dev.obiente.nextcloudnative.app.NextcloudFileListingHttpException
import dev.obiente.nextcloudnative.app.NextcloudFileListingSource
import dev.obiente.nextcloudnative.app.NextcloudSession
import java.io.IOException

internal data class AndroidDavFileListingResponse(val status: Int, val files: List<NextcloudFile>)

/** The caller may reuse a lease only while its enclosing account operation still owns it. */
internal suspend fun loadAndroidAccountFileListing(
session: NextcloudSession,
resolveSession: suspend () -> NextcloudSession?,
cache: AndroidFileReadCache,
path: String,
accountLeaseHeld: Boolean = false,
guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD,
request: suspend () -> AndroidDavFileListingResponse,
): NextcloudFileListing {
val read: suspend () -> NextcloudFileListing = {
readAndroidAccountFileListing(cache, NextcloudDocumentIds.accountKey(session), path, request)
}
return if (accountLeaseHeld) read() else withRetainedAndroidAccountFileRead(session, resolveSession, guard, read)
}

private suspend fun readAndroidAccountFileListing(
cache: AndroidFileReadCache,
accountId: String,
path: String,
request: suspend () -> AndroidDavFileListingResponse,
): NextcloudFileListing = try {
val response = request()
if (response.status == 207) {
val files = response.files.drop(1)
.sortedWith(compareByDescending<NextcloudFile> { it.isDirectory }.thenBy { it.name.lowercase() })
runCatching { cache.storeListing(accountId, path, files) }
NextcloudFileListing(files, NextcloudFileListingSource.Network)
} else {
val cached = if (response.status >= 500) cache.cachedListing(accountId, path) else null
cached?.let { NextcloudFileListing(it.files, NextcloudFileListingSource.Cache) }
?: throw NextcloudFileListingHttpException(response.status)
}
} catch (failure: IOException) {
cache.cachedListing(accountId, path)?.files
?.let { NextcloudFileListing(it, NextcloudFileListingSource.Cache) }
?: throw failure
}

internal fun requireAndroidDocumentDirectory(
reference: NextcloudDocumentReference,
findDocument: (String) -> NextcloudFile,
) {
if (reference.isRoot) return
require(findDocument(reference.path).isDirectory) { "The selected parent is not a folder." }
}
Loading