Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
52745b0
fix(android): invalidate retained document grants
veryCrunchy Sep 5, 2026
9854b90
fix(android): roll back document grant retirement
veryCrunchy Sep 5, 2026
fef9040
fix(android): recover interrupted document grant retirement
veryCrunchy Sep 5, 2026
51766b1
fix(android): bound document retirement recovery
veryCrunchy Sep 5, 2026
85c8fa2
fix(android): resolve documents by owning account
veryCrunchy Sep 5, 2026
a6f583a
refactor(android): keep credential controller bounded
veryCrunchy Sep 5, 2026
a5c5daa
fix(android): bind document reads to account lifetime
veryCrunchy Sep 5, 2026
e03f899
chore(changelog): link retained document fixes
veryCrunchy Sep 5, 2026
ab22172
fix(android): isolate document read leases
veryCrunchy Sep 5, 2026
f2a7d1c
refactor(android): own document retirement completion
veryCrunchy Sep 5, 2026
58ddf29
fix(android): canonicalize document grant incarnations
veryCrunchy Sep 5, 2026
7f8ae8f
fix(android): fence document removal transitions
veryCrunchy Sep 5, 2026
e21a2dc
fix(android): revalidate document writeback sessions
veryCrunchy Sep 6, 2026
4090172
refactor(android): split document removal coverage
veryCrunchy Sep 6, 2026
1fd8b4d
chore: lower Kotlin file size baselines
veryCrunchy Sep 6, 2026
cc4cad1
fix(android): fence document credential resets
veryCrunchy Sep 6, 2026
3bc5f33
fix(android): harden document credential resets
veryCrunchy Sep 6, 2026
53b82a5
refactor(android): keep document provider bounded
veryCrunchy Sep 6, 2026
85a73c0
chore: align rebased Kotlin baselines
veryCrunchy Sep 6, 2026
b0d2c2a
fix(android): preserve document account fences
veryCrunchy Sep 6, 2026
935b7c3
refactor(groupware): preserve contacts size boundary
veryCrunchy Sep 6, 2026
dfe0fc6
chore(website): refresh marketing captures
obiente-automations[bot] Sep 6, 2026
0181031
fix(android): recover malformed document resets
veryCrunchy Sep 6, 2026
b0b2b4e
fix(android): release cancelled document reads
veryCrunchy Sep 6, 2026
7417cec
refactor(android): preserve credential size boundary
veryCrunchy Sep 6, 2026
42bbe52
fix(android): compose document account retirement
veryCrunchy Sep 6, 2026
e1d6b7f
fix(android): quarantine invalid retirement text
veryCrunchy Sep 6, 2026
049513d
fix(android): preserve document provider transitions
veryCrunchy Sep 9, 2026
8a804d3
fix(android): reuse document read leases
veryCrunchy Sep 9, 2026
c8f5f32
fix(android): compile retained document access wrappers
veryCrunchy Sep 9, 2026
91eaac0
Merge branch 'feature/account-credential-slots' into fix/document-gra…
veryCrunchy Sep 9, 2026
22a87be
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
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ DocumentsProvider alone is not advertised as sufficient for Obsidian until teste
- Create, rename, move, copy, and delete update local state only after the remote result is known, or enter a visible pending/unknown state when the result is ambiguous.
- `isChildDocument`, document path, recent, and search behavior are implemented and tested against the Android system picker.
- App lock behavior is explicit: hiding roots is not enough if other apps retain URI grants. The security design documents which previously granted files remain readable and offers account removal/revocation guidance.
- Account removal journals and commits a document-ID incarnation tombstone before deleting credentials. Credential recovery restores interrupted pre-commit retirements, while committed removals keep the tombstone. A later account with the same server and login receives new opaque IDs, so retained file and subtree grants cannot regain access.

### Acceptance criteria

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ internal suspend fun replaceAndroidActiveStateWithAccountLeases(
) {
val replacementSession = requireNotNull(replacement.activeSession)
val accountIdentities = listOfNotNull(previousSession, replacementSession, replacedSession)
.map(NextcloudDocumentIds::accountKey)
.flatMap(::androidAccountOperationIdentities)
.distinct()
.sorted()
guard.withAccounts(accountIdentities) {
quiesceAndroidFileRangesBeforeCredentialReplacement(replacedSession, replacementSession, coordinator)
replace(replacement, previousSession, suspectEncrypted, replacedSession)
Expand Down Expand Up @@ -87,6 +89,17 @@ internal suspend fun resumeAndroidQueuedUploadsAfterSelection(
}
}

internal fun notifyAndroidDocumentRootsAfterCommittedTransition(
notify: () -> Unit,
recordFailure: (Exception) -> Unit,
) {
try {
notify()
} catch (failure: Exception) {
recordFailure(failure)
}
}

internal suspend fun removeAndroidAccountCredentialData(
active: Boolean,
prepareAccountRemoval: suspend () -> Unit = {},
Expand All @@ -95,6 +108,7 @@ internal suspend fun removeAndroidAccountCredentialData(
rollbackActiveRemoval: suspend () -> Unit,
persistInactiveRemoval: suspend () -> Unit,
rollbackInactiveRemoval: suspend () -> Unit,
onInactiveRemovalCommitted: () -> Unit = {},
completeCommittedCleanup: suspend () -> Unit = {},
recordCommittedCleanupFailure: (Exception) -> Unit = {},
) {
Expand Down Expand Up @@ -126,6 +140,10 @@ internal suspend fun removeAndroidAccountCredentialData(
}
throw failure
}
notifyAndroidDocumentRootsAfterCommittedTransition(
onInactiveRemovalCommitted,
recordCommittedCleanupFailure,
)
finishCommittedAndroidAccountRemovalCleanup(
removeQueuedUploads,
completeCommittedCleanup,
Expand All @@ -141,6 +159,7 @@ internal suspend fun removeUnavailableAndroidAccountCredentialData(
persistRemoval: suspend () -> Unit,
clearActiveAccount: suspend () -> Unit = persistRemoval,
rollbackRemoval: suspend () -> Unit,
onInactiveRemovalCommitted: () -> Unit = {},
completeCommittedCleanup: suspend () -> Unit = {},
recordCommittedCleanupFailure: (Exception) -> Unit = {},
) {
Expand All @@ -153,6 +172,7 @@ internal suspend fun removeUnavailableAndroidAccountCredentialData(
rollbackActiveRemoval = rollbackRemoval,
persistInactiveRemoval = persistRemoval,
rollbackInactiveRemoval = rollbackRemoval,
onInactiveRemovalCommitted = onInactiveRemovalCommitted,
completeCommittedCleanup = completeCommittedCleanup,
recordCommittedCleanupFailure = recordCommittedCleanupFailure,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ internal class AndroidFileRangeSessionActivity {
internal class AndroidFileRangeSessionCoordinator {
private val monitor = Any()
private val registrations = mutableMapOf<String, MutableSet<Registration>>()
private var credentialResetInProgress = false

fun register(
accountIdentity: String,
Expand All @@ -77,7 +78,10 @@ internal class AndroidFileRangeSessionCoordinator {
whenDrained = activity::whenDrained,
unregister = { unregister(accountIdentity, registration) },
)
synchronized(monitor) { registrations.getOrPut(accountIdentity, ::linkedSetOf) += registration }
synchronized(monitor) {
check(!credentialResetInProgress) { "File range sessions are unavailable during credential reset." }
registrations.getOrPut(accountIdentity, ::linkedSetOf) += registration
}
return registration
}

Expand All @@ -88,6 +92,26 @@ internal class AndroidFileRangeSessionCoordinator {
synchronized(monitor) { registrations.remove(accountIdentity) }
}

suspend fun <Result> withAllQuiesced(action: suspend () -> Result): Result {
val current = synchronized(monitor) {
check(!credentialResetInProgress) { "A credential reset is already in progress." }
credentialResetInProgress = true
registrations.values.flatten()
}
return try {
current.forEach(Registration::cancel)
current.forEach { registration -> registration.awaitDrained() }
val completed = current.toSet()
synchronized(monitor) {
registrations.values.forEach { accountRegistrations -> accountRegistrations.removeAll(completed) }
registrations.entries.removeAll { (_, accountRegistrations) -> accountRegistrations.isEmpty() }
}
action()
} finally {
synchronized(monitor) { credentialResetInProgress = false }
}
}

private fun unregister(accountIdentity: String, registration: Registration) = synchronized(monitor) {
registrations[accountIdentity]?.let { current ->
current -= registration
Expand Down Expand Up @@ -143,9 +167,12 @@ internal fun openTrackedAndroidFileRangeSession(
throw FileNotFoundException("The account changed before the file range session could start.")
}
val source = openSource()
val registration = coordinator.register(
NextcloudDocumentIds.accountKey(expectedSession), activity, source::close,
)
val registration = try {
coordinator.register(NextcloudDocumentIds.accountKey(expectedSession), activity, source::close)
} catch (failure: Throwable) {
runCatching(source::close).exceptionOrNull()?.let(failure::addSuppressed)
throw failure
}
NextcloudFileRangeSession(source.size, source::read, registration::close, activity::start)
} catch (failure: Throwable) {
activity.close()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,28 @@ internal class AndroidAccountOperationGuard {
}
}

suspend fun <Result> tryWithAccounts(
accountIds: Collection<String>,
unavailable: suspend () -> Result,
action: suspend () -> Result,
): Result {
currentCoroutineContext().ensureActive()
val leases = mutableListOf<AndroidAccountOperationLease>()
accountIds.distinct().sorted().forEach { accountId ->
val lease = tryAcquire(accountId)
if (lease == null) {
leases.asReversed().forEach(AndroidAccountOperationLease::close)
return unavailable()
}
leases += lease
}
try {
return action()
} finally {
leases.asReversed().forEach(AndroidAccountOperationLease::close)
}
}

suspend fun <Result> withAccounts(accountIds: Collection<String>, action: suspend () -> Result): Result {
val leases = mutableListOf<AndroidAccountOperationLease>()
try {
Expand All @@ -46,6 +68,17 @@ internal class AndroidAccountOperationGuard {

fun acquireBlocking(accountId: String): AndroidAccountOperationLease = runBlocking { acquire(accountId) }

fun acquireBlocking(accountIds: Collection<String>): AndroidAccountOperationLease = runBlocking {
val leases = mutableListOf<AndroidAccountOperationLease>()
try {
accountIds.distinct().sorted().forEach { accountId -> leases += acquire(accountId) }
AndroidAccountOperationLease { leases.asReversed().forEach(AndroidAccountOperationLease::close) }
} catch (failure: Throwable) {
leases.asReversed().forEach(AndroidAccountOperationLease::close)
throw failure
}
}

suspend fun <Result> withAccountSession(
accountId: String,
resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?,
Expand Down Expand Up @@ -127,6 +160,10 @@ internal class AndroidAccountOperationLease(

internal val ANDROID_ACCOUNT_OPERATION_GUARD = AndroidAccountOperationGuard()

internal fun androidAccountOperationIdentities(
session: dev.obiente.nextcloudnative.app.NextcloudSession,
): Set<String> = setOf(NextcloudDocumentIds.accountKey(session), session.accountId.storageKey)

internal fun androidAccountOperationSessionIsCurrent(
expectedAccountId: String,
currentSession: dev.obiente.nextcloudnative.app.NextcloudSession?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ internal class AndroidAccountOwnedStateCleanup(
legacyAndroidAccountPersistenceScopeDigest(session),
)
},
{ revokeAndroidAccountDocumentGrants(appContext, accountIdentity) },
{ revokeAndroidAccountDocumentGrants(appContext, accountIdentity, session.accountId.storageKey) },
{ fileOffline.removeForAccount(accountIdentity) },
{ incomingShares.removeForAccount(session) },
{ durableUploads.removeForAccount(accountIdentity) },
Expand Down Expand Up @@ -96,7 +96,7 @@ internal class AndroidAccountOwnedStateCleanup(
legacyAccountScopeDigest,
)
},
{ revokeAndroidAccountDocumentGrants(appContext, accountIdentity) },
{ revokeAndroidAccountDocumentGrants(appContext, accountIdentity, session.accountId.storageKey) },
{ fileOffline.removeForAccount(accountIdentity) },
{ incomingShares.removeForAccount(accountIdentity, session) },
{ durableUploads.removeForAccount(accountIdentity) },
Expand Down Expand Up @@ -139,7 +139,7 @@ internal class AndroidAccountOwnedStateCleanup(
legacyAccountScopeDigest,
)
},
{ revokeAndroidAccountDocumentGrants(appContext, accountIdentity) },
{ revokeAndroidAccountDocumentGrants(appContext, accountIdentity, accountStorageKey) },
{ fileOffline.removeForAccount(accountIdentity) },
{ incomingShares.removeForAccount(accountIdentity) },
{ durableUploads.removeForAccount(accountIdentity) },
Expand Down
Loading
Loading