Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
223 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
3bdd646
fix(android): preserve account-owned background uploads
veryCrunchy Sep 1, 2026
a770194
chore(changelog): link account background upload fix
veryCrunchy Sep 1, 2026
1843de9
docs(platform): bind uploads to supplied sessions
veryCrunchy Sep 4, 2026
2f7800b
refactor(platform): keep upload contract compact
veryCrunchy Sep 4, 2026
3de794e
fix(android): honor durable upload account lease
veryCrunchy Sep 4, 2026
f63bda7
refactor(android): own durable upload execution
veryCrunchy Sep 4, 2026
d400a48
fix(uploads): recover worker account metadata
veryCrunchy Sep 4, 2026
5cfe4d8
fix(uploads): defer ambiguous account recovery
veryCrunchy Sep 4, 2026
47a915e
fix(uploads): retire removed account work
veryCrunchy Sep 5, 2026
41ade6c
refactor(accounts): keep registry adapter bounded
veryCrunchy Sep 5, 2026
7b9e6cc
chore(website): refresh marketing captures
obiente-automations[bot] Sep 5, 2026
d5c6611
refactor(accounts): keep Android services bounded
veryCrunchy Sep 5, 2026
c7a93dc
refactor(android): keep upload resolution bounded
veryCrunchy Sep 5, 2026
b04f8b6
fix(uploads): contain corrupt registry preference
veryCrunchy Sep 5, 2026
93752cf
fix(uploads): retry corrupt registry preference
veryCrunchy Sep 5, 2026
54386f6
refactor(platform): keep recovery contract bounded
veryCrunchy Sep 5, 2026
7367e96
fix(accounts): preserve background recovery state
veryCrunchy Sep 6, 2026
d815af9
chore(website): refresh marketing captures
obiente-automations[bot] Sep 6, 2026
58ee098
chore(website): refresh marketing captures
obiente-automations[bot] Sep 6, 2026
147e3c4
fix(platform): honor the source size ceiling
veryCrunchy Sep 6, 2026
bfcfd0f
fix(uploads): quarantine unreadable queues
veryCrunchy Sep 6, 2026
3f6a01a
fix(uploads): stop retrying malformed registries
veryCrunchy Sep 6, 2026
c37a6f5
fix(android): preserve queued upload scheduling
veryCrunchy Sep 1, 2026
017ac03
chore(changelog): link upload scheduling fix
veryCrunchy Sep 1, 2026
4706996
fix(android): restore queued uploads at startup
veryCrunchy Sep 1, 2026
f6c447d
fix(android): retain startup upload retries
veryCrunchy Sep 3, 2026
508eb69
fix(android): retain upload reconciliation after journal errors
veryCrunchy Sep 4, 2026
1ab9847
fix(android): defer uploads during credential recovery
veryCrunchy Sep 4, 2026
291f796
fix(uploads): skip WorkManager-owned recovery jobs
veryCrunchy Sep 4, 2026
f6f1ca9
fix(uploads): bound credential recovery retries
veryCrunchy Sep 4, 2026
01a43e8
fix(uploads): recover registry before worker rejection
veryCrunchy Sep 4, 2026
a75a06b
fix(uploads): keep credential recovery deferred
veryCrunchy Sep 4, 2026
8969213
fix(uploads): bound startup recovery diagnostics
veryCrunchy Sep 4, 2026
e2c0806
fix(uploads): wake failed scheduling recovery
veryCrunchy Sep 4, 2026
984982c
fix(uploads): wake recovery after worker failure
veryCrunchy Sep 5, 2026
6feb0d9
fix(uploads): close recovery wakeup races
veryCrunchy Sep 5, 2026
3b0c3f8
fix(uploads): centralize queued status recovery
veryCrunchy Sep 5, 2026
428514b
fix(uploads): back off worker recovery
veryCrunchy Sep 5, 2026
19aa454
fix(uploads): defer transient source failures
veryCrunchy Sep 5, 2026
4b51467
fix(uploads): fail permanently unavailable sources
veryCrunchy Sep 5, 2026
adfea37
fix(uploads): release cancelled unowned selections
veryCrunchy Sep 5, 2026
b413b94
fix(uploads): retry terminal capability cleanup
veryCrunchy Sep 5, 2026
4a1c61c
fix(uploads): retain pending capability cleanup
veryCrunchy Sep 5, 2026
2cdfd55
test(uploads): cover legacy cleanup marker
veryCrunchy Sep 5, 2026
0e2cb3e
fix(uploads): recover pending capability cleanup
veryCrunchy Sep 5, 2026
d7f9b4b
test(uploads): keep cleanup cancellation test void
veryCrunchy Sep 5, 2026
7c54894
fix(uploads): decouple terminal cleanup recovery
veryCrunchy Sep 5, 2026
c08afb5
fix(uploads): validate persisted cleanup marker
veryCrunchy Sep 5, 2026
5de864e
fix(uploads): run terminal cleanup offline
veryCrunchy Sep 5, 2026
f03c0fb
fix(uploads): preserve cleanup with corrupt registry
veryCrunchy Sep 5, 2026
06d3590
fix(uploads): retain unreadable capability metadata
veryCrunchy Sep 5, 2026
e2cd3e0
fix(uploads): clean cancelled picker grants
veryCrunchy Sep 5, 2026
baa3271
fix(uploads): release undelivered picker selections
veryCrunchy Sep 5, 2026
4a4eba7
fix(uploads): recover orphaned picker grants
veryCrunchy Sep 5, 2026
989b704
fix(uploads): preserve immediate recovery intent
veryCrunchy Sep 5, 2026
ff8222a
fix(uploads): enforce picker capability limit
veryCrunchy Sep 5, 2026
eba8536
fix(uploads): consume scheduling wakeups atomically
veryCrunchy Sep 5, 2026
29b63fd
fix(uploads): defer capability metadata read failures
veryCrunchy Sep 5, 2026
96e90b0
fix(uploads): reject malformed capability metadata
veryCrunchy Sep 5, 2026
9b60b71
fix(uploads): isolate malformed picker capabilities
veryCrunchy Sep 6, 2026
b20aceb
fix(uploads): protect owned malformed capabilities
veryCrunchy Sep 6, 2026
3af2a62
test(uploads): split account resolution coverage
veryCrunchy Sep 6, 2026
df6d4b2
fix(uploads): reject corrupt capability ciphertext
veryCrunchy Sep 6, 2026
d276894
refactor(platform): preserve service size boundary
veryCrunchy Sep 6, 2026
323ed12
chore(website): refresh marketing captures
obiente-automations[bot] Sep 6, 2026
97e529d
fix(uploads): bound capability recovery
veryCrunchy Sep 6, 2026
815a974
fix(uploads): coalesce terminal recovery
veryCrunchy Sep 6, 2026
94b3f5c
fix(uploads): quarantine unknowable capability cleanup
veryCrunchy Sep 6, 2026
f4216a3
fix(uploads): terminate quarantined cleanup
veryCrunchy Sep 6, 2026
d553758
fix(uploads): bound storage and preserve recovery backoff
veryCrunchy Sep 9, 2026
50db150
fix(uploads): finish stable malformed-peer cleanup
veryCrunchy Sep 9, 2026
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
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
8d1c884
Merge branch 'feature/account-credential-slots' into fix/account-back…
veryCrunchy Sep 9, 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
2530237
fix(android): bound upload scheduling recovery passes
veryCrunchy Sep 9, 2026
c568dca
Merge branch 'fix/account-background-isolation' into fix/durable-uplo…
veryCrunchy Sep 9, 2026
1662b7a
test(android): group scheduling monitor regressions by owner
veryCrunchy Sep 9, 2026
7f93ecb
Merge branch 'fix/durable-upload-scheduling-recovery-stack' into fix/…
veryCrunchy Sep 9, 2026
9d2c9f0
fix(android): coalesce cleanup recovery self wakeups
veryCrunchy Sep 9, 2026
606098f
Merge branch 'fix/durable-upload-scheduling-recovery-stack' into fix/…
veryCrunchy Sep 9, 2026
400adb2
fix(android): fence account removal and provider recovery reads
veryCrunchy Sep 13, 2026
fe44a9b
fix(android): integrate current provider and account recovery
veryCrunchy Sep 14, 2026
54be175
fix(android): preserve deferred provider recovery access
veryCrunchy Sep 14, 2026
9e76df9
fix(android): retain root-bound cross-account recovery
veryCrunchy Sep 14, 2026
abf3c89
fix(android): integrate current upload recovery into provider fixes
veryCrunchy Sep 14, 2026
c486859
fix(android): discover relocated owned provider recovery files
veryCrunchy Sep 14, 2026
d276e24
fix(android): isolate retirement discovery to owned recovery tokens
veryCrunchy Sep 14, 2026
f0ac4de
fix(android): authenticate recovery and avoid account lock cycles
veryCrunchy Sep 14, 2026
2654937
fix(android): authenticate relocated recovery content
veryCrunchy Sep 14, 2026
978bd2c
fix(android): bind recovery mutations to verified generations
veryCrunchy Sep 14, 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
53 changes: 53 additions & 0 deletions ADAPTER_ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,10 +300,63 @@ staging files under the sync engine lock, while keeping user originals.
These are source and deterministic-test guarantees, not claims that a published
installer already includes the behavior.

Deferred Android provider recovery uses the supplied session under the account
removal lease before credentials are persisted. A sync pair retains its SAF
grant until its pending local transactions and retirement have completed.
Provider reconciliation runs outside the sync engine lock. Before retiring an
account or removing a pair, the engine reacquires its lock and verifies that the
selected pair snapshots have not changed; unrelated account state is preserved.

Legacy self-provider trees owned by a different account recover through their
original provider URI and retained tree permission. Only a root matching the
removing account may receive its supplied recovery session or a local authority
rewrite. An unavailable original provider leaves recovery pending.

Android queued uploads retain their rows while saved credentials require recovery.
Malformed preference values, damaged ciphertext, and invalid decoded credential
records pause timed retries when no usable or temporarily inaccessible fallback
remains. Temporary keystore failures continue retrying, including for inactive
accounts. Unsupported credential versions require an upgrade. These policies are
covered by deterministic Android unit tests; they do not establish device or
release validation.

When the removal session is bound to the local provider's account, pending owned
recovery tokens are discovered from that account's root, including directories
moved outside the old sync subtree. The scan retains its depth, count, ownership,
content-authentication, and cancellation bounds. Cross-account or external
provider recovery stays within the original tree grant.

Expanded retirement discovery indexes only the selected tree's transactions and
legacy transactions not proven to belong elsewhere. Seeing another tree's token
in the same account or directory never authorizes its reconciliation.

## Recovery authentication and retirement

Android self-provider recovery bypasses unversioned offline content and cached
fallback reads. It requires a network listing before accepting generation-matched
virtual content or opening an ETag-bound range source. Account-wide recovery-token
discovery rejects multiple observed locations for an owned token without retiring
its ownership record; unrelated tokens remain outside the selected recovery scope.

Recovery through another local account's legacy provider tree tries that account's
lease without waiting and verifies its exact active session. The lease spans
content authentication and reconciliation. It retains the original tree URI,
grant and discovery scope while using authoritative provider reads. Busy,
unavailable or unverified cross-profile accounts keep recovery pending. Ordinary
external providers retain their existing grant behavior.

Relocated recovery can be attributed by either an authenticated stage or an
authenticated backup. Backup-only delete transactions do not require a stage;
both the recorded document identity and content identity are still required,
and multiple observed locations remain ambiguous.
Path-changing stage IDs require the original stage name and matching recorded
content. Renamed backup IDs require matching recorded content even when the name
contains the recovery token. Unverified token-bearing candidates preserve the
ownership row without authorizing a rename or deletion.

Self-provider SAF recovery retains the exact ETag of a successfully completed
content verification and uses it for the later delete or rename precondition.
Failed or cancelled verification invalidates prior proof; a concurrent replacement
cannot contribute its newer ETag to the mutation. Directory recovery without an
authenticated aggregate generation remains pending and preserves its content.
Ordinary external-provider access retains its original platform contract.
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 @@ -160,11 +160,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 @@ -195,11 +194,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 @@ -233,11 +232,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 @@ -271,12 +268,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 @@ -336,21 +331,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 @@ -700,7 +701,7 @@ internal class AndroidAccountCredentialController(

private suspend fun retryPendingAccountRemovalCleanup(session: NextcloudSession) =
retryAndroidCleanupBeforeActivation(session, accountRemovalCleanupJournal,
{ readCredentialFreeRegistry()?.accounts }, prepareAccountRemoval,
{ readCredentialFreeRegistry()?.accounts }, { revalidateAndroidAccountRemoval(appContext, it) },
retryQueuedUploadsCleanup, ::recordAccountRemovalCleanupFailure)
private fun commitPreferences(editor: SharedPreferences.Editor) = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize {
try {
Expand All @@ -713,7 +714,6 @@ internal class AndroidAccountCredentialController(
throw failure
}
}

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

private fun encryptCredentialSlot(session: NextcloudSession): String = try {
sessionCipher.encrypt(encodeAndroidPersistedSession(session))
} catch (failure: Exception) {
Expand Down Expand Up @@ -785,5 +784,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 Expand Up @@ -55,3 +55,26 @@ internal fun requireAndroidDocumentDirectory(
if (reference.isRoot) return
require(findDocument(reference.path).isDirectory) { "The selected parent is not a folder." }
}

internal fun NextcloudFileListing.filesForProviderRecovery(requireNetwork: Boolean): List<NextcloudFile> {
check(!requireNetwork || source == NextcloudFileListingSource.Network) {
"Folder recovery requires a confirmed server listing."
}
return files
}

internal suspend fun loadAndroidProviderChildren(
recoveryAuthorized: Boolean,
read: suspend () -> List<NextcloudFile>,
cached: () -> List<NextcloudFile>,
storedDirectory: () -> Boolean,
): List<NextcloudFile> = try {
read()
} catch (cancelled: kotlinx.coroutines.CancellationException) {
throw cancelled
} catch (failure: Exception) {
if (recoveryAuthorized) throw failure
val children = cached()
if (children.isNotEmpty() || storedDirectory()) children
else throw java.io.FileNotFoundException("Could not load this Nextcloud folder.").also { it.initCause(failure) }
}
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 @@ -85,7 +90,12 @@ internal class AndroidFileRangeSessionCoordinator {
val current = synchronized(monitor) { registrations[accountIdentity]?.toList().orEmpty() }
current.forEach(Registration::cancel)
current.forEach { registration -> registration.awaitDrained() }
synchronized(monitor) { registrations.remove(accountIdentity) }
synchronized(monitor) {
registrations[accountIdentity]?.let { remaining ->
remaining.removeAll(current.toSet())
if (remaining.isEmpty()) registrations.remove(accountIdentity)
}
}
}

private fun unregister(accountIdentity: String, registration: Registration) = synchronized(monitor) {
Expand Down Expand Up @@ -135,11 +145,13 @@ 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) {
// Recovery binds a supplied session before it exists in credential storage.
if (!accountLeaseHeld && resolveSession() != expectedSession) {
throw FileNotFoundException("The account changed before the file range session could start.")
Comment thread
veryCrunchy marked this conversation as resolved.
}
val source = openSource()
Expand All @@ -151,11 +163,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()
}
}
Loading