From 52745b0eb57890ccb329998f2d6fcbd704c3ed61 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 14:17:41 +0200 Subject: [PATCH 01/31] fix(android): invalidate retained document grants --- ROADMAP.md | 1 + .../AndroidAccountCredentialController.kt | 3 + .../nextcloudnative/AndroidAccountRemoval.kt | 19 +- ...AndroidDocumentProviderIncarnationStore.kt | 182 ++++++++++++++++++ .../AndroidFileOfflineRepository.kt | 15 +- .../AndroidNextcloudServices.kt | 15 +- .../nextcloudnative/NextcloudDocumentIds.kt | 69 +++++-- .../NextcloudDocumentsProvider.kt | 72 +++---- ...oidDocumentProviderIncarnationStoreTest.kt | 135 +++++++++++++ .../NextcloudDocumentIdsTest.kt | 62 ++++-- .../unreleased/document-grant-incarnation.md | 7 + 11 files changed, 490 insertions(+), 90 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt create mode 100644 changes/unreleased/document-grant-incarnation.md diff --git a/ROADMAP.md b/ROADMAP.md index 6c481e98d..146669b2f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 commits a document-ID incarnation tombstone before deleting credentials. A later account with the same server and login receives new opaque IDs, so retained file and subtree grants cannot regain access. ### Acceptance criteria diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index ba538af3c..ac63876fd 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -107,6 +107,7 @@ internal class AndroidAccountCredentialController( when (val read = readStore()) { is AndroidAccountCredentialStoreRead.Available -> { requireSupportedCredentialSlots(read.state.registry) + prepareAndroidDocumentProviderAccountSave(appContext, session, read.state) replaceActiveState( read.state.upsertAndSelect(session), read.state.activeSession, read.state.sessions[session.accountId], @@ -117,6 +118,7 @@ internal class AndroidAccountCredentialController( check(retained != null || !hasAndroidIndependentCredentialState(preferences)) { "The aggregate account credential store is invalid; reset it before signing in again." } + prepareAndroidDocumentProviderAccountSave(appContext, session, retained ?: AndroidAccountCredentialState.Empty) replaceActiveState( replacement = (retained ?: AndroidAccountCredentialState.Empty).upsertAndSelect(session), previousSession = retained?.activeSession, @@ -129,6 +131,7 @@ internal class AndroidAccountCredentialController( check(retained != null || !hasAndroidIndependentCredentialState(preferences)) { "The independent account credential slots could not be recovered." } + prepareAndroidDocumentProviderAccountSave(appContext, session, retained ?: AndroidAccountCredentialState.Empty) replaceActiveState( replacement = (retained ?: AndroidAccountCredentialState.Empty).upsertAndSelect(session), previousSession = retained?.activeSession, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index 6cbb56f40..cfce591c5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -89,14 +89,23 @@ internal suspend fun preflightAndroidAccountRemoval(context: Context, session: N internal suspend fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) { preflightAndroidAccountRemoval(context, session) ANDROID_FILE_RANGE_SESSION_COORDINATOR.quiesce(NextcloudDocumentIds.accountKey(session)) + AndroidDocumentProviderIncarnationStore(context).retire(NextcloudDocumentIds.accountKey(session)) } internal fun revokeAndroidAccountDocumentGrants(context: Context, accountIdentity: String) { - AndroidAccountDocumentGrantScope.entries.forEach { scope -> - context.revokeUriPermission( - scope.uri(nextcloudDocumentsAuthority(context.packageName), NextcloudDocumentIds.rootId(accountIdentity)), - NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS, - ) + val retired = AndroidDocumentProviderIncarnationStore(context).retiredIncarnation(accountIdentity) + ?: NextcloudDocumentIncarnation.Legacy + val rootIds = listOf( + NextcloudDocumentIds.rootId(accountIdentity, NextcloudDocumentIncarnation.Legacy), + NextcloudDocumentIds.rootId(accountIdentity, retired), + ).distinct() + rootIds.forEach { rootId -> + AndroidAccountDocumentGrantScope.entries.forEach { scope -> + context.revokeUriPermission( + scope.uri(nextcloudDocumentsAuthority(context.packageName), rootId), + NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS, + ) + } } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt new file mode 100644 index 000000000..cead556cf --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -0,0 +1,182 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.provider.DocumentsContract +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.util.UUID + +internal sealed interface AndroidDocumentProviderIncarnationRecord { + val incarnation: NextcloudDocumentIncarnation + + data class Active( + override val incarnation: NextcloudDocumentIncarnation, + ) : AndroidDocumentProviderIncarnationRecord + + data class Retired( + override val incarnation: NextcloudDocumentIncarnation, + ) : AndroidDocumentProviderIncarnationRecord +} + +internal class AndroidDocumentProviderIncarnationStore( + private val read: (String) -> String?, + private val commit: (String, String) -> Boolean, + private val createIncarnation: () -> NextcloudDocumentIncarnation.Versioned = { + NextcloudDocumentIncarnation.Versioned(UUID.randomUUID().toString().replace("-", "")) + }, +) { + constructor(context: Context) : this( + read = context.applicationContext + .getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)::getStringOrNull, + commit = { accountIdentity, encoded -> + context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + .edit() + .putString(accountIdentity, encoded) + .commit() + }, + ) + + fun activeIncarnation(accountIdentity: String): NextcloudDocumentIncarnation = synchronized(LOCK) { + when (val record = readRecord(accountIdentity)) { + null -> NextcloudDocumentIncarnation.Legacy + is AndroidDocumentProviderIncarnationRecord.Active -> record.incarnation + is AndroidDocumentProviderIncarnationRecord.Retired -> + error("The document provider account incarnation is retired.") + } + } + + fun prepareForAccountSave( + accountIdentity: String, + accountAlreadyStored: Boolean, + ): NextcloudDocumentIncarnation = synchronized(LOCK) { + when (val record = readRecord(accountIdentity)) { + null -> if (accountAlreadyStored) { + NextcloudDocumentIncarnation.Legacy + } else { + persistNewActiveIncarnation(accountIdentity) + } + is AndroidDocumentProviderIncarnationRecord.Active -> record.incarnation + is AndroidDocumentProviderIncarnationRecord.Retired -> if (accountAlreadyStored) { + error("The document provider account incarnation is retired.") + } else { + persistNewActiveIncarnation(accountIdentity) + } + } + } + + fun retire(accountIdentity: String): NextcloudDocumentIncarnation = synchronized(LOCK) { + val incarnation = when (val record = readRecordOrNullOnMalformed(accountIdentity)) { + null -> NextcloudDocumentIncarnation.Legacy + is AndroidDocumentProviderIncarnationRecord.Active -> record.incarnation + is AndroidDocumentProviderIncarnationRecord.Retired -> record.incarnation + } + persist(accountIdentity, AndroidDocumentProviderIncarnationRecord.Retired(incarnation)) + incarnation + } + + fun retiredIncarnation(accountIdentity: String): NextcloudDocumentIncarnation? = synchronized(LOCK) { + (readRecord(accountIdentity) as? AndroidDocumentProviderIncarnationRecord.Retired)?.incarnation + } + + private fun readRecord(accountIdentity: String): AndroidDocumentProviderIncarnationRecord? { + requireAccountIdentity(accountIdentity) + return read(accountIdentity)?.let(::decodeAndroidDocumentProviderIncarnationRecord) + } + + private fun readRecordOrNullOnMalformed(accountIdentity: String): AndroidDocumentProviderIncarnationRecord? = + try { + readRecord(accountIdentity) + } catch (_: IllegalArgumentException) { + null + } catch (_: ClassCastException) { + null + } + + private fun persist(accountIdentity: String, record: AndroidDocumentProviderIncarnationRecord) { + check(commit(accountIdentity, encodeAndroidDocumentProviderIncarnationRecord(record))) { + "Could not persist the document provider account incarnation." + } + } + + private fun persistNewActiveIncarnation(accountIdentity: String): NextcloudDocumentIncarnation.Versioned { + val replacement = createIncarnation() + persist(accountIdentity, AndroidDocumentProviderIncarnationRecord.Active(replacement)) + return replacement + } + + private fun requireAccountIdentity(accountIdentity: String) { + require(ACCOUNT_IDENTITY_PATTERN.matches(accountIdentity)) { "Invalid document account." } + } + + private companion object { + const val PREFERENCES_NAME = "documents-provider-incarnations-v1" + val ACCOUNT_IDENTITY_PATTERN = Regex("[0-9a-f]{32}") + val LOCK = Any() + } +} + +internal fun encodeAndroidDocumentProviderIncarnationRecord( + record: AndroidDocumentProviderIncarnationRecord, +): String { + val state = when (record) { + is AndroidDocumentProviderIncarnationRecord.Active -> "active" + is AndroidDocumentProviderIncarnationRecord.Retired -> "retired" + } + val incarnation = when (val value = record.incarnation) { + NextcloudDocumentIncarnation.Legacy -> "legacy" + is NextcloudDocumentIncarnation.Versioned -> value.value + } + return "1:$state:$incarnation" +} + +internal fun decodeAndroidDocumentProviderIncarnationRecord( + encoded: String, +): AndroidDocumentProviderIncarnationRecord { + val parts = encoded.split(':') + require(parts.size == 3 && parts[0] == "1") { "Unsupported document provider incarnation record." } + val incarnation = if (parts[2] == "legacy") { + NextcloudDocumentIncarnation.Legacy + } else { + NextcloudDocumentIncarnation.Versioned(parts[2]) + } + return when (parts[1]) { + "active" -> AndroidDocumentProviderIncarnationRecord.Active(incarnation) + "retired" -> AndroidDocumentProviderIncarnationRecord.Retired(incarnation) + else -> throw IllegalArgumentException("Invalid document provider incarnation state.") + } +} + +private fun android.content.SharedPreferences.getStringOrNull(key: String): String? = getString(key, null) + +internal fun prepareAndroidDocumentProviderAccountSave( + context: Context, + session: NextcloudSession, + current: AndroidAccountCredentialState, +) { + AndroidDocumentProviderIncarnationStore(context).prepareForAccountSave( + NextcloudDocumentIds.accountKey(session), + session.accountId in current.sessions, + ) +} + +internal fun notifyAndroidDocumentChanged(context: Context, session: NextcloudSession, path: String) { + val appContext = context.applicationContext + val incarnation = runCatching { + AndroidDocumentProviderIncarnationStore(appContext) + .activeIncarnation(NextcloudDocumentIds.accountKey(session)) + }.getOrNull() ?: return + val authority = nextcloudDocumentsAuthority(appContext.packageName) + appContext.contentResolver.notifyChange( + DocumentsContract.buildDocumentUri( + authority, + NextcloudDocumentIds.documentId(session, incarnation, path), + ), + null, + ) + appContext.contentResolver.notifyChange( + DocumentsContract.buildChildDocumentsUri( + authority, + NextcloudDocumentIds.documentId(session, incarnation, NextcloudDocumentIds.parentPath(path)), + ), + null, + ) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt index 29234a9ca..ba41bf243 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt @@ -573,20 +573,7 @@ internal class AndroidFileOfflineRepository(context: Context) { } private fun notifyOfflineChanged(session: NextcloudSession, path: String) { - appContext.contentResolver.notifyChange( - android.provider.DocumentsContract.buildDocumentUri( - nextcloudDocumentsAuthority(appContext.packageName), - NextcloudDocumentIds.documentId(session, path), - ), - null, - ) - appContext.contentResolver.notifyChange( - android.provider.DocumentsContract.buildChildDocumentsUri( - nextcloudDocumentsAuthority(appContext.packageName), - NextcloudDocumentIds.documentId(session, NextcloudDocumentIds.parentPath(path)), - ), - null, - ) + notifyAndroidDocumentChanged(appContext, session, path) } private fun retry( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 2d1344f27..c24dde6ea 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -1158,20 +1158,7 @@ internal class AndroidNextcloudServices( } private fun notifyDocumentsDocumentChanged(session: NextcloudSession, path: String) { - appContext.contentResolver.notifyChange( - DocumentsContract.buildDocumentUri( - nextcloudDocumentsAuthority(appContext.packageName), - NextcloudDocumentIds.documentId(session, path), - ), - null, - ) - appContext.contentResolver.notifyChange( - DocumentsContract.buildChildDocumentsUri( - nextcloudDocumentsAuthority(appContext.packageName), - NextcloudDocumentIds.documentId(session, NextcloudDocumentIds.parentPath(path)), - ), - null, - ) + notifyAndroidDocumentChanged(appContext, session, path) } override suspend fun beginLogin( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt index a2d4d5799..4d1e7dea8 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt @@ -6,13 +6,29 @@ import java.util.Base64 internal data class NextcloudDocumentReference( val accountKey: String, + val incarnation: NextcloudDocumentIncarnation, val path: String, ) { val isRoot: Boolean get() = path.isEmpty() } +internal sealed interface NextcloudDocumentIncarnation { + data object Legacy : NextcloudDocumentIncarnation + + data class Versioned(val value: String) : NextcloudDocumentIncarnation { + init { + require(VALUE_PATTERN.matches(value)) { "Invalid document incarnation." } + } + } + + companion object { + private val VALUE_PATTERN = Regex("[0-9a-f]{32}") + } +} + internal object NextcloudDocumentIds { - private const val PREFIX = "nc1" + private const val LEGACY_PREFIX = "nc1" + private const val VERSIONED_PREFIX = "nc2" private val accountKeyPattern = Regex("[0-9a-f]{32}") private val encoder = Base64.getUrlEncoder().withoutPadding() private val decoder = Base64.getUrlDecoder() @@ -32,37 +48,66 @@ internal object NextcloudDocumentIds { accountDigest(session.serverUrl, session.loginName) .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } - fun rootId(session: NextcloudSession): String = rootId(accountKey(session)) + fun providerRootId(session: NextcloudSession, incarnation: NextcloudDocumentIncarnation): String = + when (incarnation) { + NextcloudDocumentIncarnation.Legacy -> accountKey(session) + is NextcloudDocumentIncarnation.Versioned -> "${accountKey(session)}:${incarnation.value}" + } + + fun rootId(session: NextcloudSession, incarnation: NextcloudDocumentIncarnation): String = + rootId(accountKey(session), incarnation) - fun rootId(accountKey: String): String { + fun rootId(accountKey: String, incarnation: NextcloudDocumentIncarnation): String { require(accountKeyPattern.matches(accountKey)) { "Invalid document account." } - return "$PREFIX:$accountKey:" + return when (incarnation) { + NextcloudDocumentIncarnation.Legacy -> "$LEGACY_PREFIX:$accountKey:" + is NextcloudDocumentIncarnation.Versioned -> "$VERSIONED_PREFIX:$accountKey:${incarnation.value}:" + } } - fun documentId(session: NextcloudSession, path: String): String { + fun documentId( + session: NextcloudSession, + incarnation: NextcloudDocumentIncarnation, + path: String, + ): String { val normalizedPath = normalizePath(path) val encodedPath = encoder.encodeToString(normalizedPath.encodeToByteArray()) - return "$PREFIX:${accountKey(session)}:$encodedPath" + return when (incarnation) { + NextcloudDocumentIncarnation.Legacy -> "$LEGACY_PREFIX:${accountKey(session)}:$encodedPath" + is NextcloudDocumentIncarnation.Versioned -> + "$VERSIONED_PREFIX:${accountKey(session)}:${incarnation.value}:$encodedPath" + } } fun parse(documentId: String): NextcloudDocumentReference { - val parts = documentId.split(':', limit = 3) - require(parts.size == 3 && parts[0] == PREFIX) { "Unsupported document ID." } + val parts = documentId.split(':') + val incarnation = when { + parts.size == 3 && parts[0] == LEGACY_PREFIX -> NextcloudDocumentIncarnation.Legacy + parts.size == 4 && parts[0] == VERSIONED_PREFIX -> + NextcloudDocumentIncarnation.Versioned(parts[2]) + else -> throw IllegalArgumentException("Unsupported document ID.") + } val accountKey = parts[1] require(accountKeyPattern.matches(accountKey)) { "Invalid document account." } - val decodedBytes = runCatching { decoder.decode(parts[2]) } + val encodedPath = parts.last() + val decodedBytes = runCatching { decoder.decode(encodedPath) } .getOrElse { throw IllegalArgumentException("Invalid document path.", it) } - require(encoder.encodeToString(decodedBytes) == parts[2]) { "Document path encoding is not canonical." } + require(encoder.encodeToString(decodedBytes) == encodedPath) { "Document path encoding is not canonical." } val decodedPath = runCatching { decodedBytes.decodeToString(throwOnInvalidSequence = true) } .getOrElse { throw IllegalArgumentException("Document path is not valid UTF-8.", it) } val normalizedPath = normalizePath(decodedPath) require(decodedPath == normalizedPath) { "Document path is not canonical." } - return NextcloudDocumentReference(accountKey, normalizedPath) + return NextcloudDocumentReference(accountKey, incarnation, normalizedPath) } - fun requireForSession(documentId: String, session: NextcloudSession): NextcloudDocumentReference = + fun requireForSession( + documentId: String, + session: NextcloudSession, + incarnation: NextcloudDocumentIncarnation, + ): NextcloudDocumentReference = parse(documentId).also { reference -> require(reference.accountKey == accountKey(session)) { "Document belongs to another account." } + require(reference.incarnation == incarnation) { "Document belongs to an earlier account incarnation." } } private fun accountDigest(serverUrl: String, loginName: String): ByteArray { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index 7cbb908a3..9c0b0dd69 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -48,7 +48,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { private lateinit var offline: AndroidFileOfflineRepository private lateinit var virtualFiles: AndroidVirtualFileCache private lateinit var webDav: NextcloudDocumentWebDav - + private lateinit var documentIncarnations: AndroidDocumentProviderIncarnationStore @Volatile private var cachedAccount: ResolvedAccount? = null @@ -59,6 +59,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { AndroidExternalFileHandoffRegistry.bind(AndroidExternalFileHandoffStore(providerContext)) offline = AndroidFileOfflineRepository(providerContext) virtualFiles = AndroidVirtualFileCache(providerContext) + documentIncarnations = AndroidDocumentProviderIncarnationStore(providerContext) webDav = NextcloudDocumentWebDav( client = OkHttpClient.Builder() .useAndroidNextcloudCertificateTrust(providerContext) @@ -72,11 +73,12 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val columns = projection?.copyOf() ?: DEFAULT_ROOT_PROJECTION val cursor = MatrixCursor(columns) val session = services.loadSession() ?: return cursor + val incarnation = runCatching { activeIncarnation(session) }.getOrElse { return cursor } val host = runCatching { URI(session.serverUrl).host }.getOrNull().orEmpty() cursor.addNamedRow( mapOf( - DocumentsContract.Root.COLUMN_ROOT_ID to NextcloudDocumentIds.accountKey(session), - DocumentsContract.Root.COLUMN_DOCUMENT_ID to NextcloudDocumentIds.rootId(session), + DocumentsContract.Root.COLUMN_ROOT_ID to NextcloudDocumentIds.providerRootId(session, incarnation), + DocumentsContract.Root.COLUMN_DOCUMENT_ID to NextcloudDocumentIds.rootId(session, incarnation), DocumentsContract.Root.COLUMN_TITLE to context?.getString(R.string.documents_provider_root_name), DocumentsContract.Root.COLUMN_SUMMARY to buildString { append(session.loginName) @@ -97,24 +99,24 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ) return cursor } - override fun queryDocument(documentId: String, projection: Array?): Cursor { val columns = projection?.copyOf() ?: DEFAULT_DOCUMENT_PROJECTION val cursor = MatrixCursor(columns) val session = requireSession() + val incarnation = activeIncarnation(session) if (AndroidExternalFileHandoffRegistry.isHandoffDocumentId(documentId)) { val handoff = AndroidExternalFileHandoffRegistry.peek(documentId, session) ?: throw FileNotFoundException("This external file handoff has expired.") cursor.addExternalHandoffRow(handoff) return cursor } - val reference = requireReference(documentId, session) + val reference = requireReference(documentId, session, incarnation) if (reference.isRoot) { - cursor.addDocumentRow(session, null) + cursor.addDocumentRow(session, incarnation, null) return cursor } - cursor.addDocumentRow(session, findDocumentWithOfflineFallback(session, reference.path)) + cursor.addDocumentRow(session, incarnation, findDocumentWithOfflineFallback(session, reference.path)) return cursor } @@ -126,7 +128,8 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val columns = projection?.copyOf() ?: DEFAULT_DOCUMENT_PROJECTION val cursor = MatrixCursor(columns) val session = requireSession() - val parent = requireReference(parentDocumentId, session) + val incarnation = activeIncarnation(session) + val parent = requireReference(parentDocumentId, session, incarnation) val children = runCatching { val account = resolveAccount(session) runBlocking(Dispatchers.IO) { services.listFiles(session, account.userId, parent.path) } @@ -140,7 +143,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } } } - children.forEach { cursor.addDocumentRow(session, it) } + children.forEach { cursor.addDocumentRow(session, incarnation, it) } return cursor } @@ -152,7 +155,8 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val columns = projection?.copyOf() ?: DEFAULT_DOCUMENT_PROJECTION val cursor = MatrixCursor(columns) val session = requireSession() - require(rootId == NextcloudDocumentIds.accountKey(session)) { + val incarnation = activeIncarnation(session) + require(rootId == NextcloudDocumentIds.providerRootId(session, incarnation)) { "The document root belongs to another account." } val account = resolveAccount(session) @@ -162,16 +166,17 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ) { webDav.searchFiles(session, account.userId, query) } - result.files.forEach { cursor.addDocumentRow(session, it) } + result.files.forEach { cursor.addDocumentRow(session, incarnation, it) } return cursor } override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean { val session = services.loadSession() ?: return false - val parent = runCatching { NextcloudDocumentIds.requireForSession(parentDocumentId, session) }.getOrNull() - ?: return false - val child = runCatching { NextcloudDocumentIds.requireForSession(documentId, session) }.getOrNull() - ?: return false + val incarnation = runCatching { activeIncarnation(session) }.getOrNull() ?: return false + val parent = runCatching { NextcloudDocumentIds.requireForSession(parentDocumentId, session, incarnation) } + .getOrNull() ?: return false + val child = runCatching { NextcloudDocumentIds.requireForSession(documentId, session, incarnation) } + .getOrNull() ?: return false if (child.isRoot || parent.path == child.path) return false return parent.isRoot || child.path.startsWith(parent.path + "/") } @@ -479,7 +484,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } } notifyDocumentChanged(session, path) - NextcloudDocumentIds.documentId(session, path) + documentId(session, path) } override fun renameDocument(documentId: String, displayName: String): String = @@ -495,7 +500,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { mutationCall { webDav.move(session, account.userId, reference.path, destination, etag) } } notifyMove(session, reference.path, destination) - NextcloudDocumentIds.documentId(session, destination) + documentId(session, destination) } override fun deleteDocument(documentId: String) = @@ -542,7 +547,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } } notifyMove(session, source.path, destination) - NextcloudDocumentIds.documentId(session, destination) + documentId(session, destination) } private fun openWritableDocument( @@ -762,29 +767,21 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val providerContext = context ?: return val resolver = providerContext.contentResolver val authority = nextcloudDocumentsAuthority(providerContext.packageName) + resolver.notifyChange(DocumentsContract.buildDocumentUri(authority, documentId(session, path)), null) resolver.notifyChange( - DocumentsContract.buildDocumentUri( - authority, - NextcloudDocumentIds.documentId(session, path), - ), - null, - ) - resolver.notifyChange( - DocumentsContract.buildChildDocumentsUri( - authority, - NextcloudDocumentIds.documentId(session, NextcloudDocumentIds.parentPath(path)), - ), + DocumentsContract.buildChildDocumentsUri(authority, documentId(session, NextcloudDocumentIds.parentPath(path))), null, ) } - - private fun MatrixCursor.addDocumentRow(session: NextcloudSession, file: NextcloudFile?) { + private fun MatrixCursor.addDocumentRow( + session: NextcloudSession, incarnation: NextcloudDocumentIncarnation, file: NextcloudFile?, + ) { val isDirectory = file?.isDirectory ?: true val path = file?.path.orEmpty() val displayName = file?.name ?: context?.getString(R.string.documents_provider_root_name).orEmpty() addNamedRow( mapOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID to NextcloudDocumentIds.documentId(session, path), + DocumentsContract.Document.COLUMN_DOCUMENT_ID to NextcloudDocumentIds.documentId(session, incarnation, path), DocumentsContract.Document.COLUMN_DISPLAY_NAME to displayName, DocumentsContract.Document.COLUMN_MIME_TYPE to when { isDirectory -> DocumentsContract.Document.MIME_TYPE_DIR @@ -839,14 +836,21 @@ class NextcloudDocumentsProvider : DocumentsProvider() { private fun requireSession(): NextcloudSession = services.loadSession() ?: throw FileNotFoundException("Sign in to nati.ve to browse files.") - private fun requireReference(documentId: String, session: NextcloudSession): NextcloudDocumentReference = + private fun requireReference( + documentId: String, session: NextcloudSession, + incarnation: NextcloudDocumentIncarnation = activeIncarnation(session), + ): NextcloudDocumentReference = providerCall( message = "This Nextcloud document ID is no longer valid.", accountIdentity = NextcloudDocumentIds.accountKey(session), ) { - NextcloudDocumentIds.requireForSession(documentId, session) + NextcloudDocumentIds.requireForSession(documentId, session, incarnation) } + private fun activeIncarnation(session: NextcloudSession): NextcloudDocumentIncarnation = documentIncarnations + .activeIncarnation(NextcloudDocumentIds.accountKey(session)) + private fun documentId(session: NextcloudSession, path: String): String = NextcloudDocumentIds + .documentId(session, activeIncarnation(session), path) private fun resolveAccount(session: NextcloudSession): ResolvedAccount { val accountKey = NextcloudDocumentIds.accountKey(session) cachedAccount?.takeIf { it.accountKey == accountKey }?.let { return it } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt new file mode 100644 index 000000000..496cae2ec --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt @@ -0,0 +1,135 @@ +package dev.obiente.nextcloudnative + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals + +class AndroidDocumentProviderIncarnationStoreTest { + private val accountIdentity = "a".repeat(32) + + @Test + fun legacyIdentityRemainsUsableUntilItsFirstRemoval() { + val fixture = fixture() + + assertEquals( + NextcloudDocumentIncarnation.Legacy, + fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = true), + ) + assertEquals(NextcloudDocumentIncarnation.Legacy, fixture.store.activeIncarnation(accountIdentity)) + assertEquals(emptyMap(), fixture.records) + } + + @Test + fun aNewIdentityStartsWithAVersionedIncarnation() { + val fixture = fixture(incarnations = listOf("1".repeat(32))) + + assertEquals( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false), + ) + } + + @Test + fun removalPersistsALegacyTombstoneBeforeReturning() { + val fixture = fixture() + + assertEquals(NextcloudDocumentIncarnation.Legacy, fixture.store.retire(accountIdentity)) + assertEquals( + AndroidDocumentProviderIncarnationRecord.Retired(NextcloudDocumentIncarnation.Legacy), + decodeAndroidDocumentProviderIncarnationRecord(requireNotNull(fixture.records[accountIdentity])), + ) + } + + @Test + fun processRestartAfterRemovalCreatesANewIncarnationForTheReaddedIdentity() { + val records = mutableMapOf() + fixture(records = records).store.retire(accountIdentity) + + val restarted = fixture(records = records, incarnations = listOf("1".repeat(32))).store + assertFailsWith { restarted.activeIncarnation(accountIdentity) } + val replacement = restarted.prepareForAccountSave(accountIdentity, accountAlreadyStored = false) + + assertEquals(NextcloudDocumentIncarnation.Versioned("1".repeat(32)), replacement) + assertEquals( + AndroidDocumentProviderIncarnationRecord.Active(replacement), + decodeAndroidDocumentProviderIncarnationRecord(requireNotNull(records[accountIdentity])), + ) + } + + @Test + fun everyRemovalAndReaddChangesTheIncarnationAgain() { + val fixture = fixture(incarnations = listOf("1".repeat(32), "2".repeat(32))) + fixture.store.retire(accountIdentity) + val first = fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false) + fixture.store.retire(accountIdentity) + val replacement = fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false) + + assertNotEquals(first, replacement) + assertEquals(NextcloudDocumentIncarnation.Versioned("2".repeat(32)), replacement) + } + + @Test + fun interruptedTombstoneCommitBlocksRemoval() { + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val records = mutableMapOf(accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(active)) + val store = AndroidDocumentProviderIncarnationStore( + read = records::get, + commit = { _, _ -> false }, + ) + + assertFailsWith { store.retire(accountIdentity) } + assertEquals(active, decodeAndroidDocumentProviderIncarnationRecord(requireNotNull(records[accountIdentity]))) + } + + @Test + fun malformedStateCannotAuthorizeDocumentsButRemovalCanReplaceItWithATombstone() { + val records = mutableMapOf(accountIdentity to "broken") + val fixture = fixture(records = records) + + assertFailsWith { fixture.store.activeIncarnation(accountIdentity) } + assertEquals(NextcloudDocumentIncarnation.Legacy, fixture.store.retire(accountIdentity)) + assertEquals( + AndroidDocumentProviderIncarnationRecord.Retired(NextcloudDocumentIncarnation.Legacy), + decodeAndroidDocumentProviderIncarnationRecord(requireNotNull(records[accountIdentity])), + ) + } + + @Test + fun aRetiredIdentityCannotBeReactivatedWhileCredentialsStillExist() { + val fixture = fixture(incarnations = listOf("1".repeat(32))) + fixture.store.retire(accountIdentity) + + assertFailsWith { + fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = true) + } + assertFailsWith { fixture.store.activeIncarnation(accountIdentity) } + } + + private fun fixture( + records: MutableMap = mutableMapOf(), + incarnations: List = emptyList(), + ): Fixture { + val available = ArrayDeque(incarnations) + return Fixture( + records = records, + store = AndroidDocumentProviderIncarnationStore( + read = records::get, + commit = { key, value -> + records[key] = value + true + }, + createIncarnation = { + NextcloudDocumentIncarnation.Versioned(available.removeFirst()) + }, + ), + ) + } + + private data class Fixture( + val records: MutableMap, + val store: AndroidDocumentProviderIncarnationStore, + ) +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt index 678ae76f8..f4428751c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt @@ -9,6 +9,8 @@ import kotlin.test.assertNotEquals import kotlin.test.assertTrue class NextcloudDocumentIdsTest { + private val legacy = NextcloudDocumentIncarnation.Legacy + @Test fun cacheAccountIdIsAFullSha256Digest() { val digest = NextcloudDocumentIds.cacheAccountId(session) @@ -26,13 +28,13 @@ class NextcloudDocumentIdsTest { @Test fun rootAndUnicodePathsRoundTripWithoutLeakingAccountDetails() { - val root = NextcloudDocumentIds.rootId(session) - assertEquals("", NextcloudDocumentIds.requireForSession(root, session).path) + val root = NextcloudDocumentIds.rootId(session, legacy) + assertEquals("", NextcloudDocumentIds.requireForSession(root, session, legacy).path) - val id = NextcloudDocumentIds.documentId(session, "/Photos/July & August/旅行.jpg/") + val id = NextcloudDocumentIds.documentId(session, legacy, "/Photos/July & August/旅行.jpg/") assertEquals( "Photos/July & August/旅行.jpg", - NextcloudDocumentIds.requireForSession(id, session).path, + NextcloudDocumentIds.requireForSession(id, session, legacy).path, ) assertFalse(id.contains("cloud.example")) assertFalse(id.contains("alice")) @@ -43,8 +45,8 @@ class NextcloudDocumentIdsTest { fun documentIdsAreStableAcrossCredentialRotation() { val rotated = session.copy(appPassword = "new-app-password") assertEquals( - NextcloudDocumentIds.documentId(session, "Documents/report.pdf"), - NextcloudDocumentIds.documentId(rotated, "Documents/report.pdf"), + NextcloudDocumentIds.documentId(session, legacy, "Documents/report.pdf"), + NextcloudDocumentIds.documentId(rotated, legacy, "Documents/report.pdf"), ) } @@ -65,24 +67,62 @@ class NextcloudDocumentIdsTest { @Test fun accountIdentitySeparatesOtherwiseEqualPaths() { val other = session.copy(loginName = "bob") - val aliceId = NextcloudDocumentIds.documentId(session, "Documents/report.pdf") - val bobId = NextcloudDocumentIds.documentId(other, "Documents/report.pdf") + val aliceId = NextcloudDocumentIds.documentId(session, legacy, "Documents/report.pdf") + val bobId = NextcloudDocumentIds.documentId(other, legacy, "Documents/report.pdf") assertNotEquals(aliceId, bobId) assertFailsWith { - NextcloudDocumentIds.requireForSession(aliceId, other) + NextcloudDocumentIds.requireForSession(aliceId, other, legacy) } } @Test fun rejectsTraversalAndMalformedIds() { assertFailsWith { - NextcloudDocumentIds.documentId(session, "Documents/../secrets.txt") + NextcloudDocumentIds.documentId(session, legacy, "Documents/../secrets.txt") } assertFailsWith { NextcloudDocumentIds.parse("not-a-nextcloud-document") } - val rootWithNonCanonicalPadding = NextcloudDocumentIds.rootId(session) + "==" + val rootWithNonCanonicalPadding = NextcloudDocumentIds.rootId(session, legacy) + "==" assertFailsWith { NextcloudDocumentIds.parse(rootWithNonCanonicalPadding) } } + @Test + fun versionedIdsRejectEarlierFileAndSubfolderGrantIds() { + val first = NextcloudDocumentIncarnation.Versioned("1".repeat(32)) + val replacement = NextcloudDocumentIncarnation.Versioned("2".repeat(32)) + val fileId = NextcloudDocumentIds.documentId(session, first, "Documents/report.pdf") + val subfolderId = NextcloudDocumentIds.documentId(session, first, "Documents/Private") + + assertFailsWith { + NextcloudDocumentIds.requireForSession(fileId, session, replacement) + } + assertFailsWith { + NextcloudDocumentIds.requireForSession(subfolderId, session, replacement) + } + assertEquals( + "Documents/report.pdf", + NextcloudDocumentIds.requireForSession( + NextcloudDocumentIds.documentId(session, replacement, "Documents/report.pdf"), + session, + replacement, + ).path, + ) + } + + @Test + fun versionedRootIdentityChangesWithTheAccountIncarnation() { + val first = NextcloudDocumentIncarnation.Versioned("1".repeat(32)) + val replacement = NextcloudDocumentIncarnation.Versioned("2".repeat(32)) + + assertNotEquals( + NextcloudDocumentIds.rootId(session, first), + NextcloudDocumentIds.rootId(session, replacement), + ) + assertNotEquals( + NextcloudDocumentIds.providerRootId(session, first), + NextcloudDocumentIds.providerRootId(session, replacement), + ) + } + @Test fun resolvesParentPathsCanonically() { assertEquals("Documents/Reports", NextcloudDocumentIds.parentPath("Documents/Reports/2026.pdf")) diff --git a/changes/unreleased/document-grant-incarnation.md b/changes/unreleased/document-grant-incarnation.md new file mode 100644 index 000000000..2c961a295 --- /dev/null +++ b/changes/unreleased/document-grant-incarnation.md @@ -0,0 +1,7 @@ +category: fix +issue: 122 +pull: none +platforms: android +user-facing: yes + +Removing and re-adding the same account no longer restores access through file or folder grants retained by other Android apps. From 9854b90b4f21b33a93c400881df0b26445178001 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 14:49:43 +0200 Subject: [PATCH 02/31] fix(android): roll back document grant retirement --- .../AndroidAccountCredentialController.kt | 35 ++++---- .../nextcloudnative/AndroidAccountRemoval.kt | 13 ++- ...AndroidDocumentProviderIncarnationStore.kt | 55 +++++++++--- ...oidDocumentProviderIncarnationStoreTest.kt | 87 ++++++++++++++++++- 4 files changed, 160 insertions(+), 30 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index ac63876fd..48af49036 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -26,7 +26,7 @@ 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 prepareAccountRemoval: suspend (NextcloudSession) -> AndroidDocumentProviderIncarnationRetirement, 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, @@ -56,7 +56,6 @@ internal class AndroidAccountCredentialController( publishAccountIdentity(accountIdentity) }, ) - fun accountRetentionSnapshot(): AndroidAccountRetentionSnapshot = readRegistryForCredentialLoad() ?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts, registry.activeAccountId) } ?: AndroidAccountRetentionSnapshot.Unavailable @@ -67,7 +66,6 @@ internal class AndroidAccountCredentialController( val registry = readRegistryForCredentialLoad() ?: return@serialize null loadSession(accountId, registry) } - private fun loadSession( accountId: NextcloudAccountId, registry: NextcloudAccountRegistry, @@ -142,7 +140,6 @@ internal class AndroidAccountCredentialController( } requireNotNull(loadSession(session.accountId)) } - suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { val (current, suspectEncrypted) = recoverAndroidAccountCredentialStateForSelection( @@ -154,7 +151,6 @@ internal class AndroidAccountCredentialController( requireNotNull(selected.activeSession), ::retryPendingAccountRemovalCleanup, registerSessionPrivateValues, ) { replaceActiveState(selected, current.activeSession, suspectEncrypted = suspectEncrypted) } } - suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { val current = requireValidStateForAccountRemoval(accountId) @@ -163,9 +159,10 @@ internal class AndroidAccountCredentialController( val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session)) { val active = current.registry.activeAccountId == accountId + var documentRetirement: AndroidDocumentProviderIncarnationRetirement? = null removeAndroidAccountCredentialData( active = active, - prepareAccountRemoval = { prepareAccountRemoval(session) }, + prepareAccountRemoval = { documentRetirement = prepareAccountRemoval(session) }, removeQueuedUploads = { removeQueuedUploads(session) }, clearActiveAccount = { clearSession(current, pendingCleanup) }, rollbackActiveRemoval = { @@ -174,11 +171,13 @@ internal class AndroidAccountCredentialController( previousSession = null, suspectEncrypted = null, ) + rollbackAndroidAccountRemoval(appContext, requireNotNull(documentRetirement)) accountRemovalCleanupJournal.clear(accountId.storageKey) }, persistInactiveRemoval = { persistState(current.remove(accountId), pendingCleanup) }, rollbackInactiveRemoval = { persistState(current) + rollbackAndroidAccountRemoval(appContext, requireNotNull(documentRetirement)) accountRemovalCleanupJournal.clear(accountId.storageKey) }, completeCommittedCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, @@ -187,7 +186,6 @@ internal class AndroidAccountCredentialController( } true } - private suspend fun removeUnavailableAccount( accountId: NextcloudAccountId, recovered: AndroidAccountCredentialState, @@ -197,10 +195,11 @@ internal class AndroidAccountCredentialController( val accountIdentity = NextcloudDocumentIds.accountKey(unavailableSession) val pendingCleanup = pendingAndroidAccountRemovalCleanup(unavailableSession) withAndroidAccountRemovalLease(accountIdentity) { + var documentRetirement: AndroidDocumentProviderIncarnationRetirement? = null removeUnavailableAndroidAccountCredentialData( accountIdentity = accountIdentity, active = target.wasActive, - prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, + prepareAccountRemoval = { documentRetirement = prepareAccountRemoval(unavailableSession) }, removeAccountOwnedWorkWithoutCredentials = { identity -> retryQueuedUploadsCleanupWithoutCredentials( pendingCleanup.accountStorageKey, @@ -215,7 +214,10 @@ internal class AndroidAccountCredentialController( rollbackRemoval = { rollbackUnavailableAndroidAccountRemoval( active = target.wasActive, recovered = recovered, persistRecovered = { state -> persistState(state) }, - clearCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, + clearCleanup = { + rollbackAndroidAccountRemoval(appContext, requireNotNull(documentRetirement)) + accountRemovalCleanupJournal.clear(accountId.storageKey) + }, ) }, completeCommittedCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, @@ -225,7 +227,6 @@ internal class AndroidAccountCredentialController( notifyDocumentRootsChanged() return true } - suspend fun revokeSession( expectedSession: NextcloudSession, revokeRemoteSession: suspend () -> Unit, @@ -236,9 +237,10 @@ internal class AndroidAccountCredentialController( } val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession) val pendingCleanup = pendingAndroidAccountRemovalCleanup(expectedSession) + var documentRetirement: AndroidDocumentProviderIncarnationRetirement? = null revokeAndroidSessionWithAccountLease( accountIdentity = accountIdentity, - preflight = { prepareAccountRemoval(expectedSession) }, + preflight = { documentRetirement = prepareAccountRemoval(expectedSession) }, revoke = revokeRemoteSession, removeLocalAccount = { removeAndroidAccountCredentialData( @@ -247,6 +249,7 @@ internal class AndroidAccountCredentialController( clearActiveAccount = { clearSession(current, pendingCleanup) }, rollbackActiveRemoval = { replaceActiveStateWhileOperationsIdle(current, previousSession = null, suspectEncrypted = null) + rollbackAndroidAccountRemoval(appContext, requireNotNull(documentRetirement)) accountRemovalCleanupJournal.clear(expectedSession.accountId.storageKey) }, persistInactiveRemoval = {}, @@ -259,7 +262,6 @@ internal class AndroidAccountCredentialController( }, ) } - suspend fun clearSession() = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { when (val read = readStore()) { is AndroidAccountCredentialStoreRead.Available -> { @@ -271,9 +273,10 @@ internal class AndroidAccountCredentialController( val accountIdentity = NextcloudDocumentIds.accountKey(session) val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) withAndroidAccountRemovalLease(accountIdentity) { + var documentRetirement: AndroidDocumentProviderIncarnationRetirement? = null removeAndroidAccountCredentialData( active = true, - prepareAccountRemoval = { prepareAccountRemoval(session) }, + prepareAccountRemoval = { documentRetirement = prepareAccountRemoval(session) }, removeQueuedUploads = { removeQueuedUploads(session) }, clearActiveAccount = { clearSession(read.state, pendingCleanup) }, rollbackActiveRemoval = { @@ -282,6 +285,7 @@ internal class AndroidAccountCredentialController( previousSession = null, suspectEncrypted = null, ) + rollbackAndroidAccountRemoval(appContext, requireNotNull(documentRetirement)) accountRemovalCleanupJournal.clear(session.accountId.storageKey) }, persistInactiveRemoval = {}, @@ -309,7 +313,6 @@ internal class AndroidAccountCredentialController( is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } } - private suspend fun clearSession( current: AndroidAccountCredentialState, pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, activeFallback: NextcloudSession? = null, @@ -346,8 +349,9 @@ internal class AndroidAccountCredentialController( val accountIdentity = NextcloudDocumentIds.accountKey(activeSession) val pendingCleanup = pendingAndroidAccountRemovalCleanup(activeSession) withAndroidAccountRemovalLease(accountIdentity) { + var documentRetirement: AndroidDocumentProviderIncarnationRetirement? = null removeRecoveredAndroidAccountCredentialData( - prepareAccountRemoval = { prepareAccountRemoval(activeSession) }, + prepareAccountRemoval = { documentRetirement = prepareAccountRemoval(activeSession) }, removeQueuedUploads = { removeQueuedUploads(activeSession) }, clearRecoveredAccount = { persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted, pendingCleanup) @@ -358,6 +362,7 @@ internal class AndroidAccountCredentialController( previousSession = null, suspectEncrypted = suspectEncrypted, ) + rollbackAndroidAccountRemoval(appContext, requireNotNull(documentRetirement)) accountRemovalCleanupJournal.clear(activeSession.accountId.storageKey) }, completeCommittedCleanup = { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index cfce591c5..ab5bac970 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -86,12 +86,21 @@ internal suspend fun preflightAndroidAccountRemoval(context: Context, session: N requireAndroidFileSyncAccountRemovalReady(context, NextcloudDocumentIds.accountKey(session)) } -internal suspend fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) { +internal suspend fun prepareAndroidAccountRemoval( + context: Context, + session: NextcloudSession, +): AndroidDocumentProviderIncarnationRetirement { preflightAndroidAccountRemoval(context, session) ANDROID_FILE_RANGE_SESSION_COORDINATOR.quiesce(NextcloudDocumentIds.accountKey(session)) - AndroidDocumentProviderIncarnationStore(context).retire(NextcloudDocumentIds.accountKey(session)) + return AndroidDocumentProviderIncarnationStore(context) + .retireForRemoval(NextcloudDocumentIds.accountKey(session)) } +internal fun rollbackAndroidAccountRemoval( + context: Context, + retirement: AndroidDocumentProviderIncarnationRetirement, +) = AndroidDocumentProviderIncarnationStore(context).rollback(retirement) + internal fun revokeAndroidAccountDocumentGrants(context: Context, accountIdentity: String) { val retired = AndroidDocumentProviderIncarnationStore(context).retiredIncarnation(accountIdentity) ?: NextcloudDocumentIncarnation.Legacy diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt index cead556cf..00b7617ea 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -17,9 +17,16 @@ internal sealed interface AndroidDocumentProviderIncarnationRecord { ) : AndroidDocumentProviderIncarnationRecord } +internal data class AndroidDocumentProviderIncarnationRetirement( + val accountIdentity: String, + val previousEncoded: String?, + val retiredEncoded: String, + val incarnation: NextcloudDocumentIncarnation, +) + internal class AndroidDocumentProviderIncarnationStore( private val read: (String) -> String?, - private val commit: (String, String) -> Boolean, + private val commit: (String, String?) -> Boolean, private val createIncarnation: () -> NextcloudDocumentIncarnation.Versioned = { NextcloudDocumentIncarnation.Versioned(UUID.randomUUID().toString().replace("-", "")) }, @@ -28,10 +35,9 @@ internal class AndroidDocumentProviderIncarnationStore( read = context.applicationContext .getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)::getStringOrNull, commit = { accountIdentity, encoded -> - context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) - .edit() - .putString(accountIdentity, encoded) - .commit() + context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).edit().apply { + if (encoded == null) remove(accountIdentity) else putString(accountIdentity, encoded) + }.commit() }, ) @@ -63,14 +69,35 @@ internal class AndroidDocumentProviderIncarnationStore( } } - fun retire(accountIdentity: String): NextcloudDocumentIncarnation = synchronized(LOCK) { - val incarnation = when (val record = readRecordOrNullOnMalformed(accountIdentity)) { + fun retire(accountIdentity: String): NextcloudDocumentIncarnation = + retireForRemoval(accountIdentity).incarnation + + fun retireForRemoval(accountIdentity: String): AndroidDocumentProviderIncarnationRetirement = synchronized(LOCK) { + requireAccountIdentity(accountIdentity) + val previousEncoded = read(accountIdentity) + val incarnation = when (val record = decodeRecordOrNullOnMalformed(previousEncoded)) { null -> NextcloudDocumentIncarnation.Legacy is AndroidDocumentProviderIncarnationRecord.Active -> record.incarnation is AndroidDocumentProviderIncarnationRecord.Retired -> record.incarnation } - persist(accountIdentity, AndroidDocumentProviderIncarnationRecord.Retired(incarnation)) - incarnation + val retiredEncoded = encodeAndroidDocumentProviderIncarnationRecord( + AndroidDocumentProviderIncarnationRecord.Retired(incarnation), + ) + persistEncoded(accountIdentity, retiredEncoded) + AndroidDocumentProviderIncarnationRetirement( + accountIdentity, + previousEncoded, + retiredEncoded, + incarnation, + ) + } + + fun rollback(retirement: AndroidDocumentProviderIncarnationRetirement) = synchronized(LOCK) { + requireAccountIdentity(retirement.accountIdentity) + check(read(retirement.accountIdentity) == retirement.retiredEncoded) { + "The document provider account incarnation changed during removal rollback." + } + persistEncoded(retirement.accountIdentity, retirement.previousEncoded) } fun retiredIncarnation(accountIdentity: String): NextcloudDocumentIncarnation? = synchronized(LOCK) { @@ -82,9 +109,9 @@ internal class AndroidDocumentProviderIncarnationStore( return read(accountIdentity)?.let(::decodeAndroidDocumentProviderIncarnationRecord) } - private fun readRecordOrNullOnMalformed(accountIdentity: String): AndroidDocumentProviderIncarnationRecord? = + private fun decodeRecordOrNullOnMalformed(encoded: String?): AndroidDocumentProviderIncarnationRecord? = try { - readRecord(accountIdentity) + encoded?.let(::decodeAndroidDocumentProviderIncarnationRecord) } catch (_: IllegalArgumentException) { null } catch (_: ClassCastException) { @@ -92,7 +119,11 @@ internal class AndroidDocumentProviderIncarnationStore( } private fun persist(accountIdentity: String, record: AndroidDocumentProviderIncarnationRecord) { - check(commit(accountIdentity, encodeAndroidDocumentProviderIncarnationRecord(record))) { + persistEncoded(accountIdentity, encodeAndroidDocumentProviderIncarnationRecord(record)) + } + + private fun persistEncoded(accountIdentity: String, encoded: String?) { + check(commit(accountIdentity, encoded)) { "Could not persist the document provider account incarnation." } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt index 496cae2ec..6956386e8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -108,6 +109,90 @@ class AndroidDocumentProviderIncarnationStoreTest { assertFailsWith { fixture.store.activeIncarnation(accountIdentity) } } + @Test + fun rollbackRestoresTheExactActiveIncarnationAfterCredentialPersistenceFails() { + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val fixture = fixture( + records = mutableMapOf(accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(active)), + ) + + val retirement = fixture.store.retireForRemoval(accountIdentity) + fixture.store.rollback(retirement) + + assertEquals(active, decodeAndroidDocumentProviderIncarnationRecord(requireNotNull(fixture.records[accountIdentity]))) + assertEquals(active.incarnation, fixture.store.activeIncarnation(accountIdentity)) + } + + @Test + fun rollbackRemovesANewLegacyTombstoneAfterCredentialPersistenceFails() { + val fixture = fixture() + + val retirement = fixture.store.retireForRemoval(accountIdentity) + fixture.store.rollback(retirement) + + assertEquals(emptyMap(), fixture.records) + assertEquals(NextcloudDocumentIncarnation.Legacy, fixture.store.activeIncarnation(accountIdentity)) + } + + @Test + fun rollbackCannotOverwriteAnIncarnationChangedAfterRetirement() { + val fixture = fixture(incarnations = listOf("1".repeat(32))) + val retirement = fixture.store.retireForRemoval(accountIdentity) + val replacement = fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false) + + assertFailsWith { fixture.store.rollback(retirement) } + assertEquals(replacement, fixture.store.activeIncarnation(accountIdentity)) + } + + @Test + fun activeCredentialPersistenceFailureRestoresTheDocumentIncarnation() = runBlocking { + val fixture = fixture() + lateinit var retirement: AndroidDocumentProviderIncarnationRetirement + + assertFailsWith { + removeAndroidAccountCredentialData( + active = true, + prepareAccountRemoval = { retirement = fixture.store.retireForRemoval(accountIdentity) }, + removeQueuedUploads = {}, + clearActiveAccount = { error("synthetic active credential persistence failure") }, + rollbackActiveRemoval = { fixture.store.rollback(retirement) }, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + ) + } + + assertEquals(emptyMap(), fixture.records) + assertEquals(NextcloudDocumentIncarnation.Legacy, fixture.store.activeIncarnation(accountIdentity)) + } + + @Test + fun inactiveCredentialPersistenceFailureRestoresTheDocumentIncarnation() = runBlocking { + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val fixture = fixture( + records = mutableMapOf(accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(active)), + ) + lateinit var retirement: AndroidDocumentProviderIncarnationRetirement + + assertFailsWith { + removeAndroidAccountCredentialData( + active = false, + prepareAccountRemoval = { retirement = fixture.store.retireForRemoval(accountIdentity) }, + removeQueuedUploads = {}, + clearActiveAccount = {}, + rollbackActiveRemoval = {}, + persistInactiveRemoval = { error("synthetic inactive credential persistence failure") }, + rollbackInactiveRemoval = { fixture.store.rollback(retirement) }, + ) + } + + assertEquals(active, decodeAndroidDocumentProviderIncarnationRecord(requireNotNull(fixture.records[accountIdentity]))) + assertEquals(active.incarnation, fixture.store.activeIncarnation(accountIdentity)) + } + private fun fixture( records: MutableMap = mutableMapOf(), incarnations: List = emptyList(), @@ -118,7 +203,7 @@ class AndroidDocumentProviderIncarnationStoreTest { store = AndroidDocumentProviderIncarnationStore( read = records::get, commit = { key, value -> - records[key] = value + if (value == null) records.remove(key) else records[key] = value true }, createIncarnation = { From fef9040763c859d3df658a6eac925590768e5307 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 15:19:26 +0200 Subject: [PATCH 03/31] fix(android): recover interrupted document grant retirement --- ROADMAP.md | 2 +- .../AndroidAccountCredentialController.kt | 26 +- ...AndroidDocumentProviderIncarnationStore.kt | 191 +++++++++++++- ...oidDocumentProviderIncarnationStoreTest.kt | 233 +++++++++++++++++- 4 files changed, 428 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 146669b2f..b52062020 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -256,7 +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 commits a document-ID incarnation tombstone before deleting credentials. A later account with the same server and login receives new opaque IDs, so retained file and subtree grants cannot regain access. +- 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 diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 48af49036..c1c7e1009 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -180,7 +180,7 @@ internal class AndroidAccountCredentialController( rollbackAndroidAccountRemoval(appContext, requireNotNull(documentRetirement)) accountRemovalCleanupJournal.clear(accountId.storageKey) }, - completeCommittedCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, + completeCommittedCleanup = { completeDocumentRetirement(documentRetirement, accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) } @@ -220,7 +220,7 @@ internal class AndroidAccountCredentialController( }, ) }, - completeCommittedCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, + completeCommittedCleanup = { completeDocumentRetirement(documentRetirement, accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) } @@ -255,7 +255,7 @@ internal class AndroidAccountCredentialController( persistInactiveRemoval = {}, rollbackInactiveRemoval = {}, completeCommittedCleanup = { - accountRemovalCleanupJournal.clear(expectedSession.accountId.storageKey) + completeDocumentRetirement(documentRetirement, expectedSession.accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) @@ -291,7 +291,7 @@ internal class AndroidAccountCredentialController( persistInactiveRemoval = {}, rollbackInactiveRemoval = {}, completeCommittedCleanup = { - accountRemovalCleanupJournal.clear(session.accountId.storageKey) + completeDocumentRetirement(documentRetirement, session.accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) @@ -325,7 +325,6 @@ internal class AndroidAccountCredentialController( clearPersistedSession(encodedReplacement, replacement, pendingCleanup = pendingCleanup) notifyDocumentRootsChanged() } - private suspend fun clearInvalidStore(suspectEncrypted: String?) { clearPersistedSession( encodedReplacement = null, @@ -366,7 +365,7 @@ internal class AndroidAccountCredentialController( accountRemovalCleanupJournal.clear(activeSession.accountId.storageKey) }, completeCommittedCleanup = { - accountRemovalCleanupJournal.clear(activeSession.accountId.storageKey) + completeDocumentRetirement(documentRetirement, activeSession.accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) @@ -375,7 +374,6 @@ internal class AndroidAccountCredentialController( persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted) } } - private suspend fun persistRecoveredInvalidStoreAfterClear( current: AndroidAccountCredentialState, suspectEncrypted: String, @@ -393,7 +391,6 @@ internal class AndroidAccountCredentialController( ) notifyDocumentRootsChanged() } - private suspend fun clearPersistedSession( encodedReplacement: String?, replacement: AndroidAccountCredentialState, @@ -437,7 +434,6 @@ internal class AndroidAccountCredentialController( ) } } - private suspend fun replaceActiveState( replacement: AndroidAccountCredentialState, previousSession: NextcloudSession?, @@ -542,7 +538,7 @@ internal class AndroidAccountCredentialController( val encoded = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) ?: return@serialize null val restored = restoreAndroidCredentialFreeRegistry(encoded) recordCredentialFreeRegistryDiagnostic(restored) - restored.registry + restored.registry?.let { registry -> reconcileAndroidDocumentProviderAccountRemovals(appContext, registry) } } private fun readRegistryForCredentialLoad(): NextcloudAccountRegistry? = @@ -565,7 +561,7 @@ internal class AndroidAccountCredentialController( ) } state.registry - } + }?.let { registry -> reconcileAndroidDocumentProviderAccountRemovals(appContext, registry) } } private fun recordCredentialFreeRegistryDiagnostic(restored: RestoredAndroidCredentialFreeRegistry) { @@ -618,16 +614,22 @@ internal class AndroidAccountCredentialController( else -> AndroidAccountCredentialStoreRead.Invalid(encrypted) } } - private fun availableCredentialStore( state: AndroidAccountCredentialState, ): AndroidAccountCredentialStoreRead.Available { + reconcileAndroidDocumentProviderAccountRemovals(appContext, state.registry) if (preferences.contains(ANDROID_QUARANTINED_SESSION_KEY)) { runCatching { commitPreferences(preferences.edit().remove(ANDROID_QUARANTINED_SESSION_KEY)) } } return AndroidAccountCredentialStoreRead.Available(state) } + private fun completeDocumentRetirement( + retirement: AndroidDocumentProviderIncarnationRetirement?, accountStorageKey: String, + ) { + completeAndroidDocumentProviderAccountRemoval(appContext, requireNotNull(retirement)) + accountRemovalCleanupJournal.clear(accountStorageKey) + } private fun readIndependentCredentialSlotState( allowUnavailableActiveAccountId: NextcloudAccountId? = null, ): AndroidAccountCredentialState? { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt index 00b7617ea..78cfd283e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -2,7 +2,9 @@ package dev.obiente.nextcloudnative import android.content.Context import android.provider.DocumentsContract +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry import dev.obiente.nextcloudnative.app.NextcloudSession +import java.util.Base64 import java.util.UUID internal sealed interface AndroidDocumentProviderIncarnationRecord { @@ -24,9 +26,16 @@ internal data class AndroidDocumentProviderIncarnationRetirement( val incarnation: NextcloudDocumentIncarnation, ) +internal enum class AndroidDocumentProviderAccountOwnership { + Present, + Absent, + Unknown, +} + internal class AndroidDocumentProviderIncarnationStore( private val read: (String) -> String?, private val commit: (String, String?) -> Boolean, + private val keys: () -> Set = { emptySet() }, private val createIncarnation: () -> NextcloudDocumentIncarnation.Versioned = { NextcloudDocumentIncarnation.Versioned(UUID.randomUUID().toString().replace("-", "")) }, @@ -39,9 +48,13 @@ internal class AndroidDocumentProviderIncarnationStore( if (encoded == null) remove(accountIdentity) else putString(accountIdentity, encoded) }.commit() }, + keys = { + context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).all.keys + }, ) fun activeIncarnation(accountIdentity: String): NextcloudDocumentIncarnation = synchronized(LOCK) { + requireNoPendingRetirement(accountIdentity) when (val record = readRecord(accountIdentity)) { null -> NextcloudDocumentIncarnation.Legacy is AndroidDocumentProviderIncarnationRecord.Active -> record.incarnation @@ -54,6 +67,7 @@ internal class AndroidDocumentProviderIncarnationStore( accountIdentity: String, accountAlreadyStored: Boolean, ): NextcloudDocumentIncarnation = synchronized(LOCK) { + requireNoPendingRetirement(accountIdentity) when (val record = readRecord(accountIdentity)) { null -> if (accountAlreadyStored) { NextcloudDocumentIncarnation.Legacy @@ -74,6 +88,7 @@ internal class AndroidDocumentProviderIncarnationStore( fun retireForRemoval(accountIdentity: String): AndroidDocumentProviderIncarnationRetirement = synchronized(LOCK) { requireAccountIdentity(accountIdentity) + requireNoPendingRetirement(accountIdentity) val previousEncoded = read(accountIdentity) val incarnation = when (val record = decodeRecordOrNullOnMalformed(previousEncoded)) { null -> NextcloudDocumentIncarnation.Legacy @@ -83,21 +98,53 @@ internal class AndroidDocumentProviderIncarnationStore( val retiredEncoded = encodeAndroidDocumentProviderIncarnationRecord( AndroidDocumentProviderIncarnationRecord.Retired(incarnation), ) - persistEncoded(accountIdentity, retiredEncoded) - AndroidDocumentProviderIncarnationRetirement( + val retirement = AndroidDocumentProviderIncarnationRetirement( accountIdentity, previousEncoded, retiredEncoded, incarnation, ) + persistEncoded(retirementJournalKey(accountIdentity), encodeAndroidDocumentProviderRetirement(retirement)) + persistEncoded(accountIdentity, retiredEncoded) + retirement } fun rollback(retirement: AndroidDocumentProviderIncarnationRetirement) = synchronized(LOCK) { - requireAccountIdentity(retirement.accountIdentity) - check(read(retirement.accountIdentity) == retirement.retiredEncoded) { - "The document provider account incarnation changed during removal rollback." + if (!hasStoredRetirement(retirement)) { + check(read(retirement.accountIdentity) == retirement.previousEncoded) { + "The document provider removal rollback is not recoverable." + } + return@synchronized } - persistEncoded(retirement.accountIdentity, retirement.previousEncoded) + reconcile(retirement, AndroidDocumentProviderAccountOwnership.Present) + } + + fun complete(retirement: AndroidDocumentProviderIncarnationRetirement) = synchronized(LOCK) { + if (!hasStoredRetirement(retirement)) { + check(read(retirement.accountIdentity) == retirement.retiredEncoded) { + "The document provider retirement is not committed." + } + return@synchronized + } + reconcile(retirement, AndroidDocumentProviderAccountOwnership.Absent) + } + + fun reconcilePending( + ownership: (String) -> AndroidDocumentProviderAccountOwnership, + ) = synchronized(LOCK) { + keys().asSequence() + .filter { key -> key.startsWith(RETIREMENT_JOURNAL_KEY_PREFIX) } + .sorted() + .forEach { key -> + val accountIdentity = key.removePrefix(RETIREMENT_JOURNAL_KEY_PREFIX) + requireAccountIdentity(accountIdentity) + val encoded = read(key) ?: return@forEach + val retirement = decodeAndroidDocumentProviderRetirement(encoded) + require(retirement.accountIdentity == accountIdentity) { + "The document provider retirement journal has the wrong account." + } + reconcile(retirement, ownership(accountIdentity)) + } } fun retiredIncarnation(accountIdentity: String): NextcloudDocumentIncarnation? = synchronized(LOCK) { @@ -138,13 +185,117 @@ internal class AndroidDocumentProviderIncarnationStore( require(ACCOUNT_IDENTITY_PATTERN.matches(accountIdentity)) { "Invalid document account." } } + private fun hasStoredRetirement(retirement: AndroidDocumentProviderIncarnationRetirement): Boolean { + requireAccountIdentity(retirement.accountIdentity) + val encoded = read(retirementJournalKey(retirement.accountIdentity)) + ?: return false + check(decodeAndroidDocumentProviderRetirement(encoded) == retirement) { + "The document provider retirement journal changed." + } + return true + } + + private fun requireNoPendingRetirement(accountIdentity: String) { + requireAccountIdentity(accountIdentity) + check(read(retirementJournalKey(accountIdentity)) == null) { + "The document provider account retirement must be reconciled." + } + } + + private fun reconcile( + retirement: AndroidDocumentProviderIncarnationRetirement, + ownership: AndroidDocumentProviderAccountOwnership, + ) { + val currentEncoded = read(retirement.accountIdentity) + when (ownership) { + AndroidDocumentProviderAccountOwnership.Present -> when (currentEncoded) { + retirement.retiredEncoded -> persistEncoded(retirement.accountIdentity, retirement.previousEncoded) + retirement.previousEncoded -> Unit + else -> error("The document provider account incarnation changed during removal recovery.") + } + AndroidDocumentProviderAccountOwnership.Absent -> check(currentEncoded == retirement.retiredEncoded) { + "The document provider retirement is not committed." + } + AndroidDocumentProviderAccountOwnership.Unknown -> + error("Document provider account ownership is unavailable.") + } + persistEncoded(retirementJournalKey(retirement.accountIdentity), null) + } + private companion object { const val PREFERENCES_NAME = "documents-provider-incarnations-v1" + const val RETIREMENT_JOURNAL_KEY_PREFIX = "retirement:" val ACCOUNT_IDENTITY_PATTERN = Regex("[0-9a-f]{32}") val LOCK = Any() + + fun retirementJournalKey(accountIdentity: String): String = + "$RETIREMENT_JOURNAL_KEY_PREFIX$accountIdentity" } } +internal fun encodeAndroidDocumentProviderRetirement( + retirement: AndroidDocumentProviderIncarnationRetirement, +): String { + val previous = retirement.previousEncoded + val previousState = if (previous == null) "missing" else "present" + return listOf( + "1", + retirement.accountIdentity, + previousState, + encodeRetirementField(previous.orEmpty()), + encodeRetirementField(retirement.retiredEncoded), + ).joinToString(":") +} + +internal fun decodeAndroidDocumentProviderRetirement( + encoded: String, +): AndroidDocumentProviderIncarnationRetirement { + require(encoded.length <= MAX_RETIREMENT_JOURNAL_LENGTH) { + "The document provider retirement journal is too large." + } + val fields = encoded.split(':') + require(fields.size == 5 && fields[0] == "1") { + "Unsupported document provider retirement journal." + } + val previousEncoded = when (fields[2]) { + "missing" -> { + require(fields[3].isEmpty()) + null + } + "present" -> decodeRetirementField(fields[3]) + else -> throw IllegalArgumentException("Invalid document provider retirement prior state.") + } + val retiredEncoded = decodeRetirementField(fields[4]) + val retired = decodeAndroidDocumentProviderIncarnationRecord(retiredEncoded) + require(retired is AndroidDocumentProviderIncarnationRecord.Retired) { + "The document provider retirement journal is not retired." + } + return AndroidDocumentProviderIncarnationRetirement( + accountIdentity = fields[1], + previousEncoded = previousEncoded, + retiredEncoded = retiredEncoded, + incarnation = retired.incarnation, + ) +} + +private fun encodeRetirementField(value: String): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(value.encodeToByteArray()) + +private fun decodeRetirementField(value: String): String { + val decoded = try { + Base64.getUrlDecoder().decode(value) + } catch (failure: IllegalArgumentException) { + throw IllegalArgumentException("Invalid document provider retirement journal encoding.", failure) + } + val decodedText = decoded.decodeToString(throwOnInvalidSequence = true) + require(encodeRetirementField(decodedText) == value) { + "The document provider retirement journal encoding is not canonical." + } + return decodedText +} + +private const val MAX_RETIREMENT_JOURNAL_LENGTH = 16_384 + internal fun encodeAndroidDocumentProviderIncarnationRecord( record: AndroidDocumentProviderIncarnationRecord, ): String { @@ -183,12 +334,38 @@ internal fun prepareAndroidDocumentProviderAccountSave( session: NextcloudSession, current: AndroidAccountCredentialState, ) { - AndroidDocumentProviderIncarnationStore(context).prepareForAccountSave( + val store = AndroidDocumentProviderIncarnationStore(context) + store.reconcilePending(current.registry::documentProviderAccountOwnership) + store.prepareForAccountSave( NextcloudDocumentIds.accountKey(session), session.accountId in current.sessions, ) } +internal fun reconcileAndroidDocumentProviderAccountRemovals( + context: Context, + registry: NextcloudAccountRegistry, +): NextcloudAccountRegistry = registry.also { + AndroidDocumentProviderIncarnationStore(context).reconcilePending(registry::documentProviderAccountOwnership) +} + +internal fun completeAndroidDocumentProviderAccountRemoval( + context: Context, + retirement: AndroidDocumentProviderIncarnationRetirement, +) = AndroidDocumentProviderIncarnationStore(context).complete(retirement) + +private fun NextcloudAccountRegistry.documentProviderAccountOwnership( + accountIdentity: String, +): AndroidDocumentProviderAccountOwnership = if ( + accounts.any { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == accountIdentity + } +) { + AndroidDocumentProviderAccountOwnership.Present +} else { + AndroidDocumentProviderAccountOwnership.Absent +} + internal fun notifyAndroidDocumentChanged(context: Context, session: NextcloudSession, path: String) { val appContext = context.applicationContext val incarnation = runCatching { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt index 6956386e8..5e7b0525c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt @@ -5,6 +5,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals +import kotlin.test.assertTrue class AndroidDocumentProviderIncarnationStoreTest { private val accountIdentity = "a".repeat(32) @@ -49,6 +50,7 @@ class AndroidDocumentProviderIncarnationStoreTest { val restarted = fixture(records = records, incarnations = listOf("1".repeat(32))).store assertFailsWith { restarted.activeIncarnation(accountIdentity) } + restarted.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Absent)) val replacement = restarted.prepareForAccountSave(accountIdentity, accountAlreadyStored = false) assertEquals(NextcloudDocumentIncarnation.Versioned("1".repeat(32)), replacement) @@ -61,9 +63,9 @@ class AndroidDocumentProviderIncarnationStoreTest { @Test fun everyRemovalAndReaddChangesTheIncarnationAgain() { val fixture = fixture(incarnations = listOf("1".repeat(32), "2".repeat(32))) - fixture.store.retire(accountIdentity) + fixture.store.complete(fixture.store.retireForRemoval(accountIdentity)) val first = fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false) - fixture.store.retire(accountIdentity) + fixture.store.complete(fixture.store.retireForRemoval(accountIdentity)) val replacement = fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false) assertNotEquals(first, replacement) @@ -140,10 +142,228 @@ class AndroidDocumentProviderIncarnationStoreTest { fun rollbackCannotOverwriteAnIncarnationChangedAfterRetirement() { val fixture = fixture(incarnations = listOf("1".repeat(32))) val retirement = fixture.store.retireForRemoval(accountIdentity) - val replacement = fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false) + val replacement = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + fixture.records[accountIdentity] = encodeAndroidDocumentProviderIncarnationRecord(replacement) assertFailsWith { fixture.store.rollback(retirement) } - assertEquals(replacement, fixture.store.activeIncarnation(accountIdentity)) + assertEquals( + replacement, + decodeAndroidDocumentProviderIncarnationRecord(requireNotNull(fixture.records[accountIdentity])), + ) + } + + @Test + fun retirementJournalCommitsBeforeTheTombstone() { + val records = mutableMapOf() + val committedKeys = mutableListOf() + val store = AndroidDocumentProviderIncarnationStore( + read = records::get, + commit = { key, value -> + committedKeys += key + if (value == null) records.remove(key) else records[key] = value + true + }, + keys = { records.keys }, + ) + + store.retireForRemoval(accountIdentity) + + assertTrue(committedKeys.first().startsWith("retirement:")) + assertEquals(accountIdentity, committedKeys[1]) + } + + @Test + fun restartAfterJournalButBeforeRetirementKeepsThePriorIncarnation() { + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val records = mutableMapOf(accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(active)) + var commits = 0 + val interrupted = AndroidDocumentProviderIncarnationStore( + read = records::get, + commit = { key, value -> + commits += 1 + if (commits == 2) return@AndroidDocumentProviderIncarnationStore false + if (value == null) records.remove(key) else records[key] = value + true + }, + keys = { records.keys }, + ) + + assertFailsWith { interrupted.retireForRemoval(accountIdentity) } + val restarted = fixture(records = records).store + assertFailsWith { restarted.activeIncarnation(accountIdentity) } + + restarted.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Present)) + + assertEquals(active.incarnation, restarted.activeIncarnation(accountIdentity)) + assertEquals(setOf(accountIdentity), records.keys) + } + + @Test + fun restartAfterRetirementRestoresThePriorIncarnationWhenCredentialsRemain() { + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val records = mutableMapOf(accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(active)) + fixture(records = records).store.retireForRemoval(accountIdentity) + + val restarted = fixture(records = records).store + restarted.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Present)) + + assertEquals(active.incarnation, restarted.activeIncarnation(accountIdentity)) + assertEquals(setOf(accountIdentity), records.keys) + } + + @Test + fun restartAfterCredentialRemovalKeepsTheRetiredTombstone() { + val records = mutableMapOf() + fixture(records = records).store.retireForRemoval(accountIdentity) + + val restarted = fixture(records = records).store + restarted.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Absent)) + + assertFailsWith { restarted.activeIncarnation(accountIdentity) } + assertEquals(setOf(accountIdentity), records.keys) + } + + @Test + fun restartDuringRemoteRevocationRestoresAccessWhenTheLocalAccountStillExists() { + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val records = mutableMapOf(accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(active)) + fixture(records = records).store.retireForRemoval(accountIdentity) + + fixture(records = records).store.reconcilePending( + ownership(AndroidDocumentProviderAccountOwnership.Present), + ) + + assertEquals(active.incarnation, fixture(records = records).store.activeIncarnation(accountIdentity)) + } + + @Test + fun malformedJournalFailsClosedWithoutChangingTheTombstone() { + val retired = encodeAndroidDocumentProviderIncarnationRecord( + AndroidDocumentProviderIncarnationRecord.Retired(NextcloudDocumentIncarnation.Legacy), + ) + val records = mutableMapOf( + accountIdentity to retired, + "retirement:$accountIdentity" to "broken", + ) + val store = fixture(records = records).store + + assertFailsWith { + store.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Present)) + } + + assertEquals(retired, records[accountIdentity]) + assertTrue("retirement:$accountIdentity" in records) + assertFailsWith { store.activeIncarnation(accountIdentity) } + } + + @Test + fun malformedPriorStoreIsRestoredExactlyAndStillCannotAuthorizeDocuments() { + val records = mutableMapOf(accountIdentity to "broken") + fixture(records = records).store.retireForRemoval(accountIdentity) + + val restarted = fixture(records = records).store + restarted.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Present)) + + assertEquals("broken", records[accountIdentity]) + assertEquals(setOf(accountIdentity), records.keys) + assertFailsWith { restarted.activeIncarnation(accountIdentity) } + } + + @Test + fun ambiguousCredentialOwnershipLeavesTheRetirementPendingAndUnavailable() { + val records = mutableMapOf() + fixture(records = records).store.retireForRemoval(accountIdentity) + val restarted = fixture(records = records).store + + assertFailsWith { + restarted.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Unknown)) + } + + assertTrue("retirement:$accountIdentity" in records) + assertFailsWith { restarted.activeIncarnation(accountIdentity) } + } + + @Test + fun failedJournalCleanupRetriesWithoutReactivatingACommittedRemoval() { + val records = mutableMapOf() + fixture(records = records).store.retireForRemoval(accountIdentity) + var failCleanup = true + val restarted = AndroidDocumentProviderIncarnationStore( + read = records::get, + commit = { key, value -> + if (key.startsWith("retirement:") && value == null && failCleanup) { + failCleanup = false + false + } else { + if (value == null) records.remove(key) else records[key] = value + true + } + }, + keys = { records.keys }, + ) + + assertFailsWith { + restarted.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Absent)) + } + restarted.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Absent)) + + assertFailsWith { restarted.activeIncarnation(accountIdentity) } + assertEquals(setOf(accountIdentity), records.keys) + } + + @Test + fun failedRollbackJournalCleanupRetriesAfterRestoringThePriorIncarnation() { + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val records = mutableMapOf(accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(active)) + fixture(records = records).store.retireForRemoval(accountIdentity) + var failCleanup = true + val restarted = AndroidDocumentProviderIncarnationStore( + read = records::get, + commit = { key, value -> + if (key.startsWith("retirement:") && value == null && failCleanup) { + failCleanup = false + false + } else { + if (value == null) records.remove(key) else records[key] = value + true + } + }, + keys = { records.keys }, + ) + + assertFailsWith { + restarted.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Present)) + } + restarted.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Present)) + + assertEquals(active.incarnation, restarted.activeIncarnation(accountIdentity)) + assertEquals(setOf(accountIdentity), records.keys) + } + + @Test + fun committedRemovalReconcilesBeforeTheSameIdentityIsReadded() { + val records = mutableMapOf() + fixture(records = records).store.retireForRemoval(accountIdentity) + val restarted = fixture(records = records, incarnations = listOf("1".repeat(32))).store + + restarted.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Absent)) + val replacement = restarted.prepareForAccountSave(accountIdentity, accountAlreadyStored = false) + + assertEquals(NextcloudDocumentIncarnation.Versioned("1".repeat(32)), replacement) + assertEquals( + AndroidDocumentProviderIncarnationRecord.Active(replacement), + decodeAndroidDocumentProviderIncarnationRecord(requireNotNull(records[accountIdentity])), + ) } @Test @@ -206,6 +426,7 @@ class AndroidDocumentProviderIncarnationStoreTest { if (value == null) records.remove(key) else records[key] = value true }, + keys = { records.keys }, createIncarnation = { NextcloudDocumentIncarnation.Versioned(available.removeFirst()) }, @@ -217,4 +438,8 @@ class AndroidDocumentProviderIncarnationStoreTest { val records: MutableMap, val store: AndroidDocumentProviderIncarnationStore, ) + + private fun ownership( + value: AndroidDocumentProviderAccountOwnership, + ): (String) -> AndroidDocumentProviderAccountOwnership = { value } } From 51766b1a5a4f5c91444ab0eb0267e54e903677d3 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 15:21:37 +0200 Subject: [PATCH 04/31] fix(android): bound document retirement recovery --- .../AndroidDocumentProviderIncarnationStore.kt | 6 +++++- .../AndroidDocumentProviderIncarnationStoreTest.kt | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt index 78cfd283e..da19659b0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -244,7 +244,11 @@ internal fun encodeAndroidDocumentProviderRetirement( previousState, encodeRetirementField(previous.orEmpty()), encodeRetirementField(retirement.retiredEncoded), - ).joinToString(":") + ).joinToString(":").also { encoded -> + require(encoded.length <= MAX_RETIREMENT_JOURNAL_LENGTH) { + "The document provider retirement journal is too large." + } + } } internal fun decodeAndroidDocumentProviderRetirement( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt index 5e7b0525c..ec2e4d804 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt @@ -100,6 +100,18 @@ class AndroidDocumentProviderIncarnationStoreTest { ) } + @Test + fun oversizedMalformedStateCannotCreateAnUnreadableRetirementJournal() { + val malformed = "x".repeat(20_000) + val records = mutableMapOf(accountIdentity to malformed) + val store = fixture(records = records).store + + assertFailsWith { store.retireForRemoval(accountIdentity) } + + assertEquals(mapOf(accountIdentity to malformed), records) + assertFailsWith { store.activeIncarnation(accountIdentity) } + } + @Test fun aRetiredIdentityCannotBeReactivatedWhileCredentialsStillExist() { val fixture = fixture(incarnations = listOf("1".repeat(32))) From 85c8fa265cfeb6175c57a4e083462998ee54e2d9 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 14:54:29 +0200 Subject: [PATCH 05/31] fix(android): resolve documents by owning account --- .../nextcloudnative/NextcloudDocumentIds.kt | 17 ++ .../NextcloudDocumentsAccountResolver.kt | 73 +++++ .../NextcloudDocumentsProvider.kt | 257 +++++++++--------- .../NextcloudDocumentsRootRow.kt | 82 ++++++ .../NextcloudDocumentIdsTest.kt | 20 ++ .../NextcloudDocumentsAccountResolverTest.kt | 155 +++++++++++ .../android-documents-account-resolution.md | 7 + 7 files changed, 479 insertions(+), 132 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsRootRow.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt create mode 100644 changes/unreleased/android-documents-account-resolution.md diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt index 4d1e7dea8..b6d01e698 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt @@ -12,6 +12,11 @@ internal data class NextcloudDocumentReference( val isRoot: Boolean get() = path.isEmpty() } +internal data class NextcloudDocumentRootReference( + val accountKey: String, + val incarnation: NextcloudDocumentIncarnation, +) + internal sealed interface NextcloudDocumentIncarnation { data object Legacy : NextcloudDocumentIncarnation @@ -54,6 +59,18 @@ internal object NextcloudDocumentIds { is NextcloudDocumentIncarnation.Versioned -> "${accountKey(session)}:${incarnation.value}" } + fun parseProviderRootId(rootId: String): NextcloudDocumentRootReference { + val parts = rootId.split(':') + val accountKey = parts.firstOrNull().orEmpty() + require(accountKeyPattern.matches(accountKey)) { "Invalid document account." } + val incarnation = when (parts.size) { + 1 -> NextcloudDocumentIncarnation.Legacy + 2 -> NextcloudDocumentIncarnation.Versioned(parts[1]) + else -> throw IllegalArgumentException("Unsupported document root ID.") + } + return NextcloudDocumentRootReference(accountKey, incarnation) + } + fun rootId(session: NextcloudSession, incarnation: NextcloudDocumentIncarnation): String = rootId(accountKey(session), incarnation) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt new file mode 100644 index 000000000..6b1e36bb9 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt @@ -0,0 +1,73 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudAccountId +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord + +internal data class ResolvedNextcloudDocument( + val session: NextcloudSession, + val reference: NextcloudDocumentReference, +) + +internal data class ResolvedNextcloudDocumentsAccount( + val session: NextcloudSession, + val incarnation: NextcloudDocumentIncarnation, +) + +/** Resolves provider identities without changing or depending on the selected account. */ +internal class NextcloudDocumentsAccountResolver( + private val listAccounts: () -> List, + private val loadSession: (NextcloudAccountId) -> NextcloudSession?, + private val loadIncarnation: (String) -> NextcloudDocumentIncarnation, +) { + fun resolvableAccounts(): List { + val records = runCatching(listAccounts).getOrElse { return emptyList() } + val unambiguousKeys = records + .groupingBy(NextcloudAccountRecord::documentAccountKey) + .eachCount() + .filterValues { count -> count == 1 } + .keys + return records.mapNotNull { record -> + record.takeIf { it.documentAccountKey() in unambiguousKeys } + ?.let(::loadExactAccountSafely) + } + } + + fun requireDocument(documentId: String): ResolvedNextcloudDocument { + val parsed = NextcloudDocumentIds.parse(documentId) + val account = requireAccount(parsed.accountKey) + return ResolvedNextcloudDocument( + session = account.session, + reference = NextcloudDocumentIds.requireForSession(documentId, account.session, account.incarnation), + ) + } + + fun requireRoot(rootId: String): ResolvedNextcloudDocumentsAccount { + val parsed = NextcloudDocumentIds.parseProviderRootId(rootId) + val account = requireAccount(parsed.accountKey) + require(account.incarnation == parsed.incarnation) { "The document root belongs to an earlier account." } + return account + } + + private fun requireAccount(accountKey: String): ResolvedNextcloudDocumentsAccount { + val matches = listAccounts().filter { record -> record.documentAccountKey() == accountKey } + require(matches.size == 1) { "The document account is missing or ambiguous." } + return requireNotNull(loadExactAccount(matches.single())) { + "The document account credentials are unavailable." + } + } + + private fun loadExactAccountSafely(record: NextcloudAccountRecord): ResolvedNextcloudDocumentsAccount? = + runCatching { loadExactAccount(record) }.getOrNull() + + private fun loadExactAccount(record: NextcloudAccountRecord): ResolvedNextcloudDocumentsAccount? { + val session = loadSession(record.id)?.takeIf { candidate -> + candidate.accountRecord() == record && NextcloudDocumentIds.accountKey(candidate) == record.documentAccountKey() + } ?: return null + return ResolvedNextcloudDocumentsAccount(session, loadIncarnation(record.documentAccountKey())) + } +} + +private fun NextcloudAccountRecord.documentAccountKey(): String = + NextcloudDocumentIds.accountKey(serverUrl, loginName) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index 9c0b0dd69..a48bf5c43 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -25,12 +25,9 @@ import dev.obiente.nextcloudnative.app.useAndroidNextcloudCertificateTrust import java.io.File import java.io.FileOutputStream import java.io.FileNotFoundException -import java.net.URI import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.StandardCopyOption -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter import okhttp3.OkHttpClient import java.util.concurrent.atomic.AtomicInteger import org.json.JSONObject @@ -38,7 +35,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking /** - * Storage Access Framework bridge for the currently authenticated account. + * Storage Access Framework bridge for locally stored Nextcloud accounts. * * Reads use the same bounded WebDAV download path as the app. Writes are staged in app-private * storage and committed with ETag preconditions only when the caller closes the descriptor. @@ -49,6 +46,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { private lateinit var virtualFiles: AndroidVirtualFileCache private lateinit var webDav: NextcloudDocumentWebDav private lateinit var documentIncarnations: AndroidDocumentProviderIncarnationStore + private lateinit var accountResolver: NextcloudDocumentsAccountResolver @Volatile private var cachedAccount: ResolvedAccount? = null @@ -56,10 +54,15 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val providerContext = context ?: return false cleanupIncompleteAndroidDocumentWritebacks(providerContext) services = AndroidNextcloudServices(providerContext) + documentIncarnations = AndroidDocumentProviderIncarnationStore(providerContext) + accountResolver = NextcloudDocumentsAccountResolver( + services::listAccounts, + services::loadSession, + documentIncarnations::activeIncarnation, + ) AndroidExternalFileHandoffRegistry.bind(AndroidExternalFileHandoffStore(providerContext)) offline = AndroidFileOfflineRepository(providerContext) virtualFiles = AndroidVirtualFileCache(providerContext) - documentIncarnations = AndroidDocumentProviderIncarnationStore(providerContext) webDav = NextcloudDocumentWebDav( client = OkHttpClient.Builder() .useAndroidNextcloudCertificateTrust(providerContext) @@ -72,51 +75,38 @@ class NextcloudDocumentsProvider : DocumentsProvider() { override fun queryRoots(projection: Array?): Cursor { val columns = projection?.copyOf() ?: DEFAULT_ROOT_PROJECTION val cursor = MatrixCursor(columns) - val session = services.loadSession() ?: return cursor - val incarnation = runCatching { activeIncarnation(session) }.getOrElse { return cursor } - val host = runCatching { URI(session.serverUrl).host }.getOrNull().orEmpty() - cursor.addNamedRow( - mapOf( - DocumentsContract.Root.COLUMN_ROOT_ID to NextcloudDocumentIds.providerRootId(session, incarnation), - DocumentsContract.Root.COLUMN_DOCUMENT_ID to NextcloudDocumentIds.rootId(session, incarnation), - DocumentsContract.Root.COLUMN_TITLE to context?.getString(R.string.documents_provider_root_name), - DocumentsContract.Root.COLUMN_SUMMARY to buildString { - append(session.loginName) - if (host.isNotBlank()) append(" on ").append(host) - }, - DocumentsContract.Root.COLUMN_FLAGS to ( - DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD or - DocumentsContract.Root.FLAG_SUPPORTS_SEARCH or - if (context?.isReadOnlyTestMode() == true) { - 0 - } else { - DocumentsContract.Root.FLAG_SUPPORTS_CREATE - } - ), - DocumentsContract.Root.COLUMN_ICON to R.mipmap.ic_launcher, - DocumentsContract.Root.COLUMN_MIME_TYPES to "*/*", - ), - ) + accountResolver.resolvableAccounts().forEach { account -> + cursor.addNextcloudRootRow( + session = account.session, + incarnation = account.incarnation, + title = context?.getString(R.string.documents_provider_root_name).orEmpty(), + readOnly = context?.isReadOnlyTestMode() == true, + ) + } return cursor } override fun queryDocument(documentId: String, projection: Array?): Cursor { val columns = projection?.copyOf() ?: DEFAULT_DOCUMENT_PROJECTION val cursor = MatrixCursor(columns) - val session = requireSession() - val incarnation = activeIncarnation(session) if (AndroidExternalFileHandoffRegistry.isHandoffDocumentId(documentId)) { + val session = requireActiveSession() val handoff = AndroidExternalFileHandoffRegistry.peek(documentId, session) ?: throw FileNotFoundException("This external file handoff has expired.") cursor.addExternalHandoffRow(handoff) return cursor } - val reference = requireReference(documentId, session, incarnation) + val (session, reference) = requireDocument(documentId) if (reference.isRoot) { - cursor.addDocumentRow(session, incarnation, null) + cursor.addNextcloudDocumentRow(session, reference.incarnation, null, documentsRootTitle()) return cursor } - cursor.addDocumentRow(session, incarnation, findDocumentWithOfflineFallback(session, reference.path)) + cursor.addNextcloudDocumentRow( + session, + reference.incarnation, + findDocumentWithOfflineFallback(session, reference.path), + documentsRootTitle(), + ) return cursor } @@ -127,9 +117,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ): Cursor { val columns = projection?.copyOf() ?: DEFAULT_DOCUMENT_PROJECTION val cursor = MatrixCursor(columns) - val session = requireSession() - val incarnation = activeIncarnation(session) - val parent = requireReference(parentDocumentId, session, incarnation) + val (session, parent) = requireDocument(parentDocumentId) val children = runCatching { val account = resolveAccount(session) runBlocking(Dispatchers.IO) { services.listFiles(session, account.userId, parent.path) } @@ -143,7 +131,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } } } - children.forEach { cursor.addDocumentRow(session, incarnation, it) } + children.forEach { cursor.addNextcloudDocumentRow(session, parent.incarnation, it, documentsRootTitle()) } return cursor } @@ -154,11 +142,8 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ): Cursor { val columns = projection?.copyOf() ?: DEFAULT_DOCUMENT_PROJECTION val cursor = MatrixCursor(columns) - val session = requireSession() - val incarnation = activeIncarnation(session) - require(rootId == NextcloudDocumentIds.providerRootId(session, incarnation)) { - "The document root belongs to another account." - } + val root = requireRoot(rootId) + val session = root.session val account = resolveAccount(session) val result = providerCall( message = "Could not search this Nextcloud account.", @@ -166,17 +151,18 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ) { webDav.searchFiles(session, account.userId, query) } - result.files.forEach { cursor.addDocumentRow(session, incarnation, it) } + result.files.forEach { cursor.addNextcloudDocumentRow(session, root.incarnation, it, documentsRootTitle()) } return cursor } override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean { - val session = services.loadSession() ?: return false - val incarnation = runCatching { activeIncarnation(session) }.getOrNull() ?: return false - val parent = runCatching { NextcloudDocumentIds.requireForSession(parentDocumentId, session, incarnation) } - .getOrNull() ?: return false - val child = runCatching { NextcloudDocumentIds.requireForSession(documentId, session, incarnation) } - .getOrNull() ?: return false + val resolved = runCatching { accountResolver.requireDocument(parentDocumentId) }.getOrNull() ?: return false + val session = resolved.session + val parent = resolved.reference + val child = runCatching { + NextcloudDocumentIds.requireForSession(documentId, session, parent.incarnation) + }.getOrNull() + ?: return false if (child.isRoot || parent.path == child.path) return false return parent.isRoot || child.path.startsWith(parent.path + "/") } @@ -191,12 +177,12 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } signal?.throwIfCanceled() - val session = requireSession() if (AndroidExternalFileHandoffRegistry.isHandoffDocumentId(documentId)) { + val session = requireActiveSession() if (mode != "r") throw SecurityException("External file handoffs are read-only.") return openExternalHandoffDocument(session, documentId, signal) } - val reference = requireReference(documentId, session) + val (session, reference) = requireDocument(documentId) if (reference.isRoot) throw FileNotFoundException("Folders cannot be opened as files.") if (mode == "r") { offline.availableContent(session, reference.path)?.let { cached -> @@ -216,7 +202,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { throw failure } if (file.isDirectory) throw FileNotFoundException("Folders cannot be opened as files.") - if (mode != "r") return openWritableDocument(session, account, file, mode, signal) + if (mode != "r") return openWritableDocument(session, reference.incarnation, account, file, mode, signal) file.etag?.takeIf(String::isNotBlank)?.let { etag -> virtualFiles.acquire(session, reference.path, expectedRemoteEtag = etag)?.let { lease -> @@ -468,8 +454,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String = - withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> - val parent = requireReference(parentDocumentId, session) + withDocumentMutation(parentDocumentId) { session, parent -> val account = resolveAccount(session) requireAndroidDocumentDirectory(parent) { findDocument(session, account, it, accountLeaseHeld = true) } val path = childPath(parent.path, requireSafeDisplayName(displayName)) @@ -483,29 +468,30 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } } } - notifyDocumentChanged(session, path) - documentId(session, path) + notifyDocumentChanged(session, parent.incarnation, path) + NextcloudDocumentIds.documentId(session, parent.incarnation, path) } override fun renameDocument(documentId: String, displayName: String): String = - withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> - val reference = requireReference(documentId, session) + withDocumentMutation(documentId) { session, reference -> if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be renamed.") val account = resolveAccount(session) val file = findDocument(session, account, reference.path, accountLeaseHeld = true) - val destination = childPath(NextcloudDocumentIds.parentPath(reference.path), requireSafeDisplayName(displayName)) - if (destination == reference.path) return@withAndroidDocumentMutation documentId + val destination = childPath( + NextcloudDocumentIds.parentPath(reference.path), + requireSafeDisplayName(displayName), + ) + if (destination == reference.path) return@withDocumentMutation documentId val etag = requireMutationEtag(file) withNoBlockingAndroidDocumentWriteback(context, session, reference.path, destination) { mutationCall { webDav.move(session, account.userId, reference.path, destination, etag) } } - notifyMove(session, reference.path, destination) - documentId(session, destination) + notifyMove(session, reference.incarnation, reference.path, destination) + NextcloudDocumentIds.documentId(session, reference.incarnation, destination) } override fun deleteDocument(documentId: String) = - withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> - val reference = requireReference(documentId, session) + withDocumentMutation(documentId) { session, reference -> if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be deleted.") val account = resolveAccount(session) val file = findDocument(session, account, reference.path, accountLeaseHeld = true) @@ -520,7 +506,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ) } } - notifyDocumentChanged(session, reference.path) + notifyDocumentChanged(session, reference.incarnation, reference.path) } override fun moveDocument( @@ -528,10 +514,9 @@ class NextcloudDocumentsProvider : DocumentsProvider() { sourceParentDocumentId: String, targetParentDocumentId: String, ): String = - withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> - val source = requireReference(sourceDocumentId, session) - val sourceParent = requireReference(sourceParentDocumentId, session) - val targetParent = requireReference(targetParentDocumentId, session) + withDocumentMutation(sourceDocumentId) { session, source -> + val sourceParent = requireReference(sourceParentDocumentId, session, source.incarnation) + val targetParent = requireReference(targetParentDocumentId, session, source.incarnation) if (source.isRoot) throw SecurityException("The Nextcloud root cannot be moved.") require(NextcloudDocumentIds.parentPath(source.path) == sourceParent.path) { "The supplied source parent does not contain this document." @@ -540,18 +525,19 @@ class NextcloudDocumentsProvider : DocumentsProvider() { requireAndroidDocumentDirectory(targetParent) { findDocument(session, account, it, accountLeaseHeld = true) } val file = findDocument(session, account, source.path, accountLeaseHeld = true) val destination = childPath(targetParent.path, file.name) - if (destination == source.path) return@withAndroidDocumentMutation sourceDocumentId + if (destination == source.path) return@withDocumentMutation sourceDocumentId withNoBlockingAndroidDocumentWriteback(context, session, source.path, destination) { mutationCall { webDav.move(session, account.userId, source.path, destination, requireMutationEtag(file)) } } - notifyMove(session, source.path, destination) - documentId(session, destination) + notifyMove(session, source.incarnation, source.path, destination) + NextcloudDocumentIds.documentId(session, source.incarnation, destination) } private fun openWritableDocument( session: NextcloudSession, + incarnation: NextcloudDocumentIncarnation, account: ResolvedAccount, file: NextcloudFile, mode: String, @@ -560,11 +546,12 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val accountLease = acquireAndroidDocumentWritebackAccountLease( session, file.path, - services::loadSession, + { services.loadSession(session.accountId) }, ) val recovered: AndroidDocumentPendingWriteback? val writeback: AndroidDocumentPendingWriteback try { + requireCurrentIncarnation(session, incarnation) recovered = claimAndroidDocumentPendingWriteback(context, session, file.path) if (recovered?.conflict == true) { recovered.releaseActive() @@ -624,7 +611,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { expectedEtag = expectedEtag, ) writeback.complete() - notifyDocumentChanged(session, writeback.remotePath) + notifyDocumentChanged(session, incarnation, writeback.remotePath) } } catch (failure: Throwable) { retainFailedWriteback(writeback, failure) @@ -748,12 +735,21 @@ class NextcloudDocumentsProvider : DocumentsProvider() { private inline fun mutationCall(operation: () -> T): T = documentMutationCall(operation) - private fun notifyMove(session: NextcloudSession, sourcePath: String, destinationPath: String) { - notifyDocumentChanged(session, sourcePath) - notifyDocumentChanged(session, destinationPath) + private fun notifyMove( + session: NextcloudSession, + incarnation: NextcloudDocumentIncarnation, + sourcePath: String, + destinationPath: String, + ) { + notifyDocumentChanged(session, incarnation, sourcePath) + notifyDocumentChanged(session, incarnation, destinationPath) } - private fun notifyDocumentChanged(session: NextcloudSession, path: String) { + private fun notifyDocumentChanged( + session: NextcloudSession, + incarnation: NextcloudDocumentIncarnation, + path: String, + ) { runCatching { virtualFiles.invalidate(session, path) } .onFailure { failure -> Log.w(LOG_TAG, "Could not invalidate virtual file content", failure) @@ -767,33 +763,18 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val providerContext = context ?: return val resolver = providerContext.contentResolver val authority = nextcloudDocumentsAuthority(providerContext.packageName) - resolver.notifyChange(DocumentsContract.buildDocumentUri(authority, documentId(session, path)), null) resolver.notifyChange( - DocumentsContract.buildChildDocumentsUri(authority, documentId(session, NextcloudDocumentIds.parentPath(path))), + DocumentsContract.buildDocumentUri(authority, NextcloudDocumentIds.documentId(session, incarnation, path)), null, ) - } - private fun MatrixCursor.addDocumentRow( - session: NextcloudSession, incarnation: NextcloudDocumentIncarnation, file: NextcloudFile?, - ) { - val isDirectory = file?.isDirectory ?: true - val path = file?.path.orEmpty() - val displayName = file?.name ?: context?.getString(R.string.documents_provider_root_name).orEmpty() - addNamedRow( - mapOf( - DocumentsContract.Document.COLUMN_DOCUMENT_ID to NextcloudDocumentIds.documentId(session, incarnation, path), - DocumentsContract.Document.COLUMN_DISPLAY_NAME to displayName, - DocumentsContract.Document.COLUMN_MIME_TYPE to when { - isDirectory -> DocumentsContract.Document.MIME_TYPE_DIR - else -> file.mimeType ?: "application/octet-stream" - }, - DocumentsContract.Document.COLUMN_FLAGS to documentFlags(file), - DocumentsContract.Document.COLUMN_SIZE to file?.size, - DocumentsContract.Document.COLUMN_LAST_MODIFIED to file?.lastModified?.toEpochMilliseconds(), + resolver.notifyChange( + DocumentsContract.buildChildDocumentsUri( + authority, + NextcloudDocumentIds.documentId(session, incarnation, NextcloudDocumentIds.parentPath(path)), ), + null, ) } - private fun MatrixCursor.addExternalHandoffRow(record: AndroidExternalFileHandoffRecord) { val file = record.file addNamedRow( @@ -808,37 +789,35 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ) } - private fun documentFlags(file: NextcloudFile?): Int { - if (file == null) { - return DocumentsContract.Document.FLAG_DIR_PREFERS_GRID or - DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE - } - var flags = if (file.isDirectory) { - DocumentsContract.Document.FLAG_DIR_PREFERS_GRID or - DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE - } else { - 0 - } - if (!file.etag.isNullOrBlank()) { - flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME or - DocumentsContract.Document.FLAG_SUPPORTS_DELETE or - DocumentsContract.Document.FLAG_SUPPORTS_MOVE - if (!file.isDirectory) flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_WRITE - } - return flags - } - private fun MatrixCursor.addNamedRow(values: Map) { val row = newRow() columnNames.forEach { column -> row.add(values[column]) } } - private fun requireSession(): NextcloudSession = services.loadSession() + private fun documentsRootTitle(): String = + context?.getString(R.string.documents_provider_root_name).orEmpty() + + private fun requireActiveSession(): NextcloudSession = services.loadSession() ?: throw FileNotFoundException("Sign in to nati.ve to browse files.") + private fun requireDocument(documentId: String): ResolvedNextcloudDocument = providerCall( + message = "This Nextcloud document ID is no longer valid.", + accountIdentity = runCatching { NextcloudDocumentIds.parse(documentId).accountKey }.getOrNull(), + ) { + accountResolver.requireDocument(documentId) + } + + private fun requireRoot(rootId: String): ResolvedNextcloudDocumentsAccount = providerCall( + message = "This Nextcloud document root is no longer valid.", + accountIdentity = runCatching { NextcloudDocumentIds.parseProviderRootId(rootId).accountKey }.getOrNull(), + ) { + accountResolver.requireRoot(rootId) + } + private fun requireReference( - documentId: String, session: NextcloudSession, - incarnation: NextcloudDocumentIncarnation = activeIncarnation(session), + documentId: String, + session: NextcloudSession, + incarnation: NextcloudDocumentIncarnation, ): NextcloudDocumentReference = providerCall( message = "This Nextcloud document ID is no longer valid.", @@ -847,10 +826,28 @@ class NextcloudDocumentsProvider : DocumentsProvider() { NextcloudDocumentIds.requireForSession(documentId, session, incarnation) } - private fun activeIncarnation(session: NextcloudSession): NextcloudDocumentIncarnation = documentIncarnations - .activeIncarnation(NextcloudDocumentIds.accountKey(session)) - private fun documentId(session: NextcloudSession, path: String): String = NextcloudDocumentIds - .documentId(session, activeIncarnation(session), path) + private inline fun withDocumentMutation( + documentId: String, + action: (NextcloudSession, NextcloudDocumentReference) -> Result, + ): Result { + val resolved = requireDocument(documentId) + return withAndroidDocumentMutation( + session = resolved.session, + loadCurrentSession = { services.loadSession(resolved.session.accountId) }, + ) { session -> + requireCurrentIncarnation(session, resolved.reference.incarnation) + action(session, resolved.reference) + } + } + + private fun requireCurrentIncarnation( + session: NextcloudSession, + incarnation: NextcloudDocumentIncarnation, + ) { + require(documentIncarnations.activeIncarnation(NextcloudDocumentIds.accountKey(session)) == incarnation) { + "The document belongs to an earlier account incarnation." + } + } private fun resolveAccount(session: NextcloudSession): ResolvedAccount { val accountKey = NextcloudDocumentIds.accountKey(session) cachedAccount?.takeIf { it.accountKey == accountKey }?.let { return it } @@ -939,10 +936,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } } - private fun String.toEpochMilliseconds(): Long? = runCatching { - ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli() - }.getOrNull() - private fun CancellationSignal?.asDocumentCancellation(): DocumentRequestCancellation { val platformSignal = this ?: return NoDocumentRequestCancellation return object : DocumentRequestCancellation { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsRootRow.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsRootRow.kt new file mode 100644 index 000000000..7d9888a63 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsRootRow.kt @@ -0,0 +1,82 @@ +package dev.obiente.nextcloudnative + +import android.database.MatrixCursor +import android.provider.DocumentsContract +import dev.obiente.nextcloudnative.app.NextcloudFile +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.net.URI +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +internal fun MatrixCursor.addNextcloudRootRow( + session: NextcloudSession, + incarnation: NextcloudDocumentIncarnation, + title: String, + readOnly: Boolean, +) { + val host = runCatching { URI(session.serverUrl).host }.getOrNull().orEmpty() + val values = mapOf( + DocumentsContract.Root.COLUMN_ROOT_ID to NextcloudDocumentIds.providerRootId(session, incarnation), + DocumentsContract.Root.COLUMN_DOCUMENT_ID to NextcloudDocumentIds.rootId(session, incarnation), + DocumentsContract.Root.COLUMN_TITLE to title, + DocumentsContract.Root.COLUMN_SUMMARY to buildString { + append(session.loginName) + if (host.isNotBlank()) append(" on ").append(host) + }, + DocumentsContract.Root.COLUMN_FLAGS to ( + DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD or + DocumentsContract.Root.FLAG_SUPPORTS_SEARCH or + if (readOnly) 0 else DocumentsContract.Root.FLAG_SUPPORTS_CREATE + ), + DocumentsContract.Root.COLUMN_ICON to R.mipmap.ic_launcher, + DocumentsContract.Root.COLUMN_MIME_TYPES to "*/*", + ) + val row = newRow() + columnNames.forEach { column -> row.add(values[column]) } +} + +internal fun MatrixCursor.addNextcloudDocumentRow( + session: NextcloudSession, + incarnation: NextcloudDocumentIncarnation, + file: NextcloudFile?, + rootTitle: String, +) { + val isDirectory = file?.isDirectory ?: true + val path = file?.path.orEmpty() + val values = mapOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID to + NextcloudDocumentIds.documentId(session, incarnation, path), + DocumentsContract.Document.COLUMN_DISPLAY_NAME to (file?.name ?: rootTitle), + DocumentsContract.Document.COLUMN_MIME_TYPE to when { + isDirectory -> DocumentsContract.Document.MIME_TYPE_DIR + else -> file.mimeType ?: "application/octet-stream" + }, + DocumentsContract.Document.COLUMN_FLAGS to nextcloudDocumentFlags(file), + DocumentsContract.Document.COLUMN_SIZE to file?.size, + DocumentsContract.Document.COLUMN_LAST_MODIFIED to file?.lastModified?.toEpochMilliseconds(), + ) + val row = newRow() + columnNames.forEach { column -> row.add(values[column]) } +} + +private fun nextcloudDocumentFlags(file: NextcloudFile?): Int { + if (file == null) { + return DocumentsContract.Document.FLAG_DIR_PREFERS_GRID or + DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE + } + var flags = if (file.isDirectory) { + DocumentsContract.Document.FLAG_DIR_PREFERS_GRID or DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE + } else { + 0 + } + if (!file.etag.isNullOrBlank()) { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME or + DocumentsContract.Document.FLAG_SUPPORTS_DELETE or DocumentsContract.Document.FLAG_SUPPORTS_MOVE + if (!file.isDirectory) flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_WRITE + } + return flags +} + +internal fun String.toEpochMilliseconds(): Long? = runCatching { + ZonedDateTime.parse(this, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant().toEpochMilli() +}.getOrNull() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt index f4428751c..4736852a0 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt @@ -123,6 +123,26 @@ class NextcloudDocumentIdsTest { ) } + @Test + fun providerRootIdsRoundTripTheirAccountAndIncarnation() { + val versioned = NextcloudDocumentIncarnation.Versioned("1".repeat(32)) + + assertEquals( + NextcloudDocumentRootReference(NextcloudDocumentIds.accountKey(session), legacy), + NextcloudDocumentIds.parseProviderRootId(NextcloudDocumentIds.providerRootId(session, legacy)), + ) + assertEquals( + NextcloudDocumentRootReference(NextcloudDocumentIds.accountKey(session), versioned), + NextcloudDocumentIds.parseProviderRootId(NextcloudDocumentIds.providerRootId(session, versioned)), + ) + assertFailsWith { + NextcloudDocumentIds.parseProviderRootId("${NextcloudDocumentIds.accountKey(session)}:broken") + } + assertFailsWith { + NextcloudDocumentIds.parseProviderRootId("${NextcloudDocumentIds.accountKey(session)}:${"1".repeat(32)}:extra") + } + } + @Test fun resolvesParentPathsCanonically() { assertEquals("Documents/Reports", NextcloudDocumentIds.parentPath("Documents/Reports/2026.pdf")) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt new file mode 100644 index 000000000..042468383 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt @@ -0,0 +1,155 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudAccountId +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class NextcloudDocumentsAccountResolverTest { + private val alice = session("alice") + private val bob = session("bob") + private val aliceIncarnation = incarnation("1") + private val bobIncarnation = incarnation("2") + + @Test + fun `persisted account document resolves after another account becomes active`() { + var active = alice + val resolver = resolver( + accounts = listOf(alice.accountRecord(), bob.accountRecord()), + loadSession = { accountId -> + active = bob + mapOf(alice.accountId to alice, bob.accountId to bob)[accountId] + }, + ) + val aliceDocument = NextcloudDocumentIds.documentId(alice, aliceIncarnation, "Documents/report.pdf") + + val resolved = resolver.requireDocument(aliceDocument) + + assertEquals(bob, active) + assertEquals(alice, resolved.session) + assertEquals(aliceIncarnation, resolved.reference.incarnation) + assertEquals("Documents/report.pdf", resolved.reference.path) + } + + @Test + fun `missing account fails closed`() { + val resolver = resolver(listOf(bob.accountRecord()), mapOf(bob.accountId to bob)::get) + + assertFailsWith { + resolver.requireDocument(NextcloudDocumentIds.documentId(alice, aliceIncarnation, "report.pdf")) + } + } + + @Test + fun `wrong or unavailable credential slot fails closed`() { + val wrongSlot = resolver(listOf(alice.accountRecord()), loadSession = { bob }) + val missingSlot = resolver(listOf(alice.accountRecord()), loadSession = { null }) + val documentId = NextcloudDocumentIds.documentId(alice, aliceIncarnation, "report.pdf") + + assertFailsWith { wrongSlot.requireDocument(documentId) } + assertFailsWith { missingSlot.requireDocument(documentId) } + } + + @Test + fun `all accounts with exact credential slots and incarnations produce roots`() { + val sessions = mapOf(alice.accountId to alice, bob.accountId to bob) + val resolver = resolver( + accounts = listOf(alice.accountRecord(), bob.accountRecord()), + loadSession = sessions::get, + ) + + assertEquals( + listOf( + ResolvedNextcloudDocumentsAccount(alice, aliceIncarnation), + ResolvedNextcloudDocumentsAccount(bob, bobIncarnation), + ), + resolver.resolvableAccounts(), + ) + assertEquals( + ResolvedNextcloudDocumentsAccount(alice, aliceIncarnation), + resolver.requireRoot(NextcloudDocumentIds.providerRootId(alice, aliceIncarnation)), + ) + assertEquals( + ResolvedNextcloudDocumentsAccount(bob, bobIncarnation), + resolver.requireRoot(NextcloudDocumentIds.providerRootId(bob, bobIncarnation)), + ) + } + + @Test + fun `roots omit records without an exact slot or readable incarnation`() { + val mismatchedAlice = alice.copy(serverUrl = "https://other.example") + val resolver = resolver( + accounts = listOf(alice.accountRecord(), bob.accountRecord()), + loadSession = { accountId -> + when (accountId) { + alice.accountId -> mismatchedAlice + bob.accountId -> bob + else -> null + } + }, + loadIncarnation = { accountKey -> + if (accountKey == NextcloudDocumentIds.accountKey(bob)) bobIncarnation else error("unreadable") + }, + ) + + assertEquals( + listOf(ResolvedNextcloudDocumentsAccount(bob, bobIncarnation)), + resolver.resolvableAccounts(), + ) + } + + @Test + fun `retained document and root IDs fail after the same account is readded`() { + val resolver = resolver( + accounts = listOf(alice.accountRecord()), + loadSession = { alice }, + loadIncarnation = { incarnation("9") }, + ) + val retainedDocument = NextcloudDocumentIds.documentId(alice, aliceIncarnation, "report.pdf") + val retainedRoot = NextcloudDocumentIds.providerRootId(alice, aliceIncarnation) + + assertFailsWith { resolver.requireDocument(retainedDocument) } + assertFailsWith { resolver.requireRoot(retainedRoot) } + } + + @Test + fun `account removal interleaved with exact slot loading fails closed`() { + var accounts = listOf(alice.accountRecord()) + val resolver = NextcloudDocumentsAccountResolver( + listAccounts = { accounts }, + loadSession = { + accounts = emptyList() + null + }, + loadIncarnation = { aliceIncarnation }, + ) + + assertFailsWith { + resolver.requireDocument(NextcloudDocumentIds.documentId(alice, aliceIncarnation, "report.pdf")) + } + assertEquals(emptyList(), resolver.resolvableAccounts()) + } + + private fun resolver( + accounts: List, + loadSession: (NextcloudAccountId) -> NextcloudSession?, + loadIncarnation: (String) -> NextcloudDocumentIncarnation = { accountKey -> + when (accountKey) { + NextcloudDocumentIds.accountKey(alice) -> aliceIncarnation + NextcloudDocumentIds.accountKey(bob) -> bobIncarnation + else -> error("unknown account") + } + }, + ) = NextcloudDocumentsAccountResolver({ accounts }, loadSession, loadIncarnation) + + private fun session(loginName: String) = NextcloudSession( + serverUrl = "https://cloud.example", + loginName = loginName, + appPassword = "synthetic-$loginName-password", + ) + + private fun incarnation(digit: String) = NextcloudDocumentIncarnation.Versioned(digit.repeat(32)) +} diff --git a/changes/unreleased/android-documents-account-resolution.md b/changes/unreleased/android-documents-account-resolution.md new file mode 100644 index 000000000..203133bd3 --- /dev/null +++ b/changes/unreleased/android-documents-account-resolution.md @@ -0,0 +1,7 @@ +category: fix +issue: 122 +pull: none +platforms: android +user-facing: yes + +Files shared with other Android apps keep using their owning Nextcloud account after you switch to another account. From a6f583ac7e7b6d2bc0630cb50e0c7effeada765b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 17:05:36 +0200 Subject: [PATCH 06/31] refactor(android): keep credential controller bounded --- .../nextcloudnative/AndroidAccountCredentialController.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index c1c7e1009..661f7934e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -59,7 +59,6 @@ internal class AndroidAccountCredentialController( fun accountRetentionSnapshot(): AndroidAccountRetentionSnapshot = readRegistryForCredentialLoad() ?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts, registry.activeAccountId) } ?: AndroidAccountRetentionSnapshot.Unavailable - fun activeAccountId(): NextcloudAccountId? = readCredentialFreeRegistry()?.activeAccountId fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { From a5c5daae4ea01ada37fdc9b5e70e1b6a7a6664ae Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 17:33:00 +0200 Subject: [PATCH 07/31] fix(android): bind document reads to account lifetime --- .../AndroidDocumentProviderReadAccess.kt | 92 +++++++++ .../AndroidNextcloudServices.kt | 13 -- .../AndroidVirtualFileProxyCallback.kt | 1 + .../NextcloudDocumentsProvider.kt | 192 +++++++++--------- .../AndroidDocumentProviderReadAccessTest.kt | 130 ++++++++++++ .../AndroidVirtualFileProxyCallbackTest.kt | 1 + 6 files changed, 320 insertions(+), 109 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt new file mode 100644 index 000000000..14728cea0 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt @@ -0,0 +1,92 @@ +package dev.obiente.nextcloudnative + +import android.os.Handler +import android.os.ParcelFileDescriptor +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.io.File +import java.io.FileNotFoundException + +internal fun acquireAndroidDocumentProviderReadLease( + expectedSession: NextcloudSession, + expectedIncarnation: NextcloudDocumentIncarnation, + loadCurrentSession: () -> NextcloudSession?, + loadCurrentIncarnation: (String) -> NextcloudDocumentIncarnation, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, +): AndroidAccountOperationLease { + val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession) + val lease = guard.acquireBlocking(accountIdentity) + return try { + checkAndroidDocumentProviderReadAccess( + expectedSession, + expectedIncarnation, + loadCurrentSession, + loadCurrentIncarnation, + ) + lease + } catch (failure: Throwable) { + lease.close() + throw failure + } +} + +internal inline fun withAndroidDocumentProviderReadAccess( + expectedSession: NextcloudSession, + expectedIncarnation: NextcloudDocumentIncarnation, + noinline loadCurrentSession: () -> NextcloudSession?, + noinline loadCurrentIncarnation: (String) -> NextcloudDocumentIncarnation, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + action: (NextcloudSession) -> Result, +): Result { + val lease = acquireAndroidDocumentProviderReadLease( + expectedSession, + expectedIncarnation, + loadCurrentSession, + loadCurrentIncarnation, + guard, + ) + return try { + action(expectedSession) + } finally { + lease.close() + } +} + +private fun checkAndroidDocumentProviderReadAccess( + expectedSession: NextcloudSession, + expectedIncarnation: NextcloudDocumentIncarnation, + loadCurrentSession: () -> NextcloudSession?, + loadCurrentIncarnation: (String) -> NextcloudDocumentIncarnation, +) { + val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession) + if ( + loadCurrentSession() != expectedSession || + loadCurrentIncarnation(accountIdentity) != expectedIncarnation + ) { + throw FileNotFoundException("This Nextcloud document belongs to a removed account.") + } +} + +internal fun openAndroidDocumentAccountLeasedContent( + content: File, + accountLease: AndroidAccountOperationLease, + handler: Handler, +): ParcelFileDescriptor = try { + ParcelFileDescriptor.open(content, ParcelFileDescriptor.MODE_READ_ONLY, handler) { accountLease.close() } +} catch (failure: Throwable) { + accountLease.close() + throw failure +} + +internal fun openAndroidDocumentVirtualFileLease( + lease: AndroidVirtualFileLease, + accountLease: AndroidAccountOperationLease, + handler: Handler, +): ParcelFileDescriptor = try { + ParcelFileDescriptor.open(lease.content, ParcelFileDescriptor.MODE_READ_ONLY, handler) { + try { lease.release() } finally { accountLease.close() } + } +} catch (failure: Throwable) { + lease.release() + accountLease.close() + throw failure +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index c24dde6ea..b9ea961cc 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -1222,10 +1222,8 @@ internal class AndroidNextcloudServices( ), ) } - override fun trustedServerCertificate(serverUrl: String): TrustedServerCertificate? = AndroidServerCertificateTrust.trustedCertificate(appContext, serverUrl) - override fun removeTrustedServerCertificate(serverUrl: String): Boolean { val removed = AndroidServerCertificateTrust.revoke(appContext, serverUrl) recordSupportDiagnostic( @@ -1238,7 +1236,6 @@ internal class AndroidNextcloudServices( ) return removed } - override suspend fun pollLogin(challenge: LoginChallenge): LoginPollResult = withContext(Dispatchers.IO) { val formBody = "token=" + URLEncoder.encode(challenge.token, StandardCharsets.UTF_8.name()) var networkFailure: JvmNetworkFailureDiagnostic? = null @@ -1284,18 +1281,15 @@ internal class AndroidNextcloudServices( } interpretation.result } - override fun finishLoginPolling(challenge: LoginChallenge) { loginPollFallbackTokens -= challenge.token loginPollPendingTokens -= challenge.token } - override suspend fun awaitLoginNetworkAvailability() { val connectivity = appContext.getSystemService(ConnectivityManager::class.java) ?: return if (connectivity.activeNetworkIsValidated()) return awaitValidatedAndroidNetwork(connectivity) } - override suspend fun loadServerInfo(session: NextcloudSession): NextcloudServerInfo = withContext(Dispatchers.IO) { val user = ocsGet(session, "/ocs/v2.php/cloud/user").getJSONObject("ocs").getJSONObject("data") @@ -1323,19 +1317,16 @@ internal class AndroidNextcloudServices( fileSharing = parseNextcloudFileSharingCapabilities(capabilities.toString()), ) } - override suspend fun listFiles( session: NextcloudSession, userId: String, path: String, ): List = listFilesWithSource(session, userId, path).files - override suspend fun listFilesWithSource( session: NextcloudSession, userId: String, path: String, ): NextcloudFileListing = listFilesWithSource(session, userId, path, accountLeaseHeld = false) - internal suspend fun listFilesWhileAccountLeaseHeld( session: NextcloudSession, userId: String, @@ -1362,7 +1353,6 @@ internal class AndroidNextcloudServices( response.status, if (response.status == 207) parseDavFiles(response.body, userId) else emptyList(), ) } - override suspend fun listFilesCachedWithSource( session: NextcloudSession, userId: String, @@ -1372,7 +1362,6 @@ internal class AndroidNextcloudServices( NextcloudFileListing(it.files, NextcloudFileListingSource.Cache) } } - override suspend fun searchFiles( session: NextcloudSession, userId: String, @@ -1393,7 +1382,6 @@ internal class AndroidNextcloudServices( .distinctBy(NextcloudFile::path) .take(maximumResults) } - override suspend fun listFavoriteFiles( session: NextcloudSession, userId: String, @@ -1410,7 +1398,6 @@ internal class AndroidNextcloudServices( if (response.status != 207) throw NextcloudFileListingHttpException(response.status) parseDavFiles(response.body, userId).distinctBy(NextcloudFile::path) } - override suspend fun setFileFavorite( session: NextcloudSession, userId: String, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt index 93722fc4e..883161093 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt @@ -117,6 +117,7 @@ internal class AndroidVirtualFileProxyCallback( fun cancel() { cancelled.set(true) runCatching(::closeSource) + onRelease() } private fun closeSource() { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index a48bf5c43..256f5d0aa 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -33,7 +33,6 @@ import java.util.concurrent.atomic.AtomicInteger import org.json.JSONObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking - /** * Storage Access Framework bridge for locally stored Nextcloud accounts. * @@ -95,21 +94,17 @@ class NextcloudDocumentsProvider : DocumentsProvider() { cursor.addExternalHandoffRow(handoff) return cursor } - val (session, reference) = requireDocument(documentId) - if (reference.isRoot) { - cursor.addNextcloudDocumentRow(session, reference.incarnation, null, documentsRootTitle()) - return cursor + return withDocumentRead(documentId) { session, reference -> + cursor.addNextcloudDocumentRow( + session, + reference.incarnation, + reference.takeUnless(NextcloudDocumentReference::isRoot) + ?.let { findDocumentWithOfflineFallback(session, it.path) }, + documentsRootTitle(), + ) + cursor } - - cursor.addNextcloudDocumentRow( - session, - reference.incarnation, - findDocumentWithOfflineFallback(session, reference.path), - documentsRootTitle(), - ) - return cursor } - override fun queryChildDocuments( parentDocumentId: String, projection: Array?, @@ -117,24 +112,21 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ): Cursor { val columns = projection?.copyOf() ?: DEFAULT_DOCUMENT_PROJECTION val cursor = MatrixCursor(columns) - val (session, parent) = requireDocument(parentDocumentId) - val children = runCatching { - val account = resolveAccount(session) - runBlocking(Dispatchers.IO) { services.listFiles(session, account.userId, parent.path) } - }.getOrElse { failure -> - val cachedChildren = offline.availableChildren(session, parent.path) - if (cachedChildren.isNotEmpty() || offline.isStoredDirectory(session, parent.path)) { - cachedChildren - } else { - throw FileNotFoundException("Could not load this Nextcloud folder.").also { - it.initCause(failure) + return withDocumentRead(parentDocumentId) { session, parent -> + val children = runCatching { + val account = resolveAccount(session) + runBlocking(Dispatchers.IO) { + services.listFilesWhileAccountLeaseHeld(session, account.userId, parent.path) } + }.getOrElse { failure -> + val cachedChildren = offline.availableChildren(session, parent.path) + if (cachedChildren.isNotEmpty() || offline.isStoredDirectory(session, parent.path)) cachedChildren + else throw FileNotFoundException("Could not load this Nextcloud folder.").also { it.initCause(failure) } } + children.forEach { cursor.addNextcloudDocumentRow(session, parent.incarnation, it, documentsRootTitle()) } + cursor } - children.forEach { cursor.addNextcloudDocumentRow(session, parent.incarnation, it, documentsRootTitle()) } - return cursor } - override fun querySearchDocuments( rootId: String, query: String, @@ -142,19 +134,16 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ): Cursor { val columns = projection?.copyOf() ?: DEFAULT_DOCUMENT_PROJECTION val cursor = MatrixCursor(columns) - val root = requireRoot(rootId) - val session = root.session - val account = resolveAccount(session) - val result = providerCall( - message = "Could not search this Nextcloud account.", - accountIdentity = NextcloudDocumentIds.accountKey(session), - ) { - webDav.searchFiles(session, account.userId, query) + return withRootRead(rootId) { session, incarnation -> + val account = resolveAccount(session) + val result = providerCall( + message = "Could not search this Nextcloud account.", + accountIdentity = NextcloudDocumentIds.accountKey(session), + ) { webDav.searchFiles(session, account.userId, query) } + result.files.forEach { cursor.addNextcloudDocumentRow(session, incarnation, it, documentsRootTitle()) } + cursor } - result.files.forEach { cursor.addNextcloudDocumentRow(session, root.incarnation, it, documentsRootTitle()) } - return cursor } - override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean { val resolved = runCatching { accountResolver.requireDocument(parentDocumentId) }.getOrNull() ?: return false val session = resolved.session @@ -166,7 +155,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { if (child.isRoot || parent.path == child.path) return false return parent.isRoot || child.path.startsWith(parent.path + "/") } - override fun openDocument( documentId: String, mode: String, @@ -176,7 +164,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { throw SecurityException("Unsupported document mode: $mode") } signal?.throwIfCanceled() - if (AndroidExternalFileHandoffRegistry.isHandoffDocumentId(documentId)) { val session = requireActiveSession() if (mode != "r") throw SecurityException("External file handoffs are read-only.") @@ -184,41 +171,47 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } val (session, reference) = requireDocument(documentId) if (reference.isRoot) throw FileNotFoundException("Folders cannot be opened as files.") - if (mode == "r") { - offline.availableContent(session, reference.path)?.let { cached -> - signal?.throwIfCanceled() - return ParcelFileDescriptor.open(cached.content, ParcelFileDescriptor.MODE_READ_ONLY) + val accountLease = acquireDocumentReadLease(session, reference.incarnation) + try { + if (mode == "r") { + offline.availableContent(session, reference.path)?.let { cached -> + signal?.throwIfCanceled() + return openAndroidDocumentAccountLeasedContent(cached.content, accountLease, WRITE_HANDLER) + } } - } - val account = resolveAccount(session) - val file = runCatching { findDocument(session, account, reference.path) } - .getOrElse { failure -> - if (mode == "r") { - virtualFiles.acquire(session, reference.path)?.let { lease -> - signal?.throwIfCanceled() - return openVirtualFileLease(lease) + val account = resolveAccount(session) + val file = runCatching { findDocument(session, account, reference.path) } + .getOrElse { failure -> + if (mode == "r") { + virtualFiles.acquire(session, reference.path)?.let { lease -> + signal?.throwIfCanceled() + return openAndroidDocumentVirtualFileLease(lease, accountLease, WRITE_HANDLER) + } } + throw failure + } + if (file.isDirectory) throw FileNotFoundException("Folders cannot be opened as files.") + if (mode != "r") return openWritableDocument( + session, reference.incarnation, account, file, mode, signal, accountLease, + ) + file.etag?.takeIf(String::isNotBlank)?.let { etag -> + virtualFiles.acquire(session, reference.path, expectedRemoteEtag = etag)?.let { lease -> + signal?.throwIfCanceled() + return openAndroidDocumentVirtualFileLease(lease, accountLease, WRITE_HANDLER) } - throw failure - } - if (file.isDirectory) throw FileNotFoundException("Folders cannot be opened as files.") - if (mode != "r") return openWritableDocument(session, reference.incarnation, account, file, mode, signal) - - file.etag?.takeIf(String::isNotBlank)?.let { etag -> - virtualFiles.acquire(session, reference.path, expectedRemoteEtag = etag)?.let { lease -> - signal?.throwIfCanceled() - return openVirtualFileLease(lease) } + return openVirtualFileProxy(session, account.userId, file, signal, accountLease) + } catch (failure: Throwable) { + accountLease.close() + throw failure } - - return openVirtualFileProxy(session, account.userId, file, signal) } - private fun openVirtualFileProxy( session: NextcloudSession, userId: String, file: NextcloudFile, signal: CancellationSignal?, + accountLease: AndroidAccountOperationLease, ): ParcelFileDescriptor { val size = file.size ?: throw FileNotFoundException( "Nextcloud did not provide a file size for seekable access.", @@ -230,12 +223,12 @@ class NextcloudDocumentsProvider : DocumentsProvider() { var empty = virtualFiles.createHydrationStagingFile() if (runCatching { virtualFiles.publishHydration(session, file, empty) }.getOrDefault(false)) { virtualFiles.acquire(session, file.path, expectedRemoteEtag = etag)?.let { lease -> - return openVirtualFileLease(lease) + return openAndroidDocumentVirtualFileLease(lease, accountLease, WRITE_HANDLER) } } if (!empty.exists()) empty = virtualFiles.createHydrationStagingFile() return ParcelFileDescriptor.open(empty, ParcelFileDescriptor.MODE_READ_ONLY, WRITE_HANDLER) { - virtualFiles.discardHydrationStagingFile(empty) + try { virtualFiles.discardHydrationStagingFile(empty) } finally { accountLease.close() } } } val rangeSession = services.openFileRangeSession( @@ -269,6 +262,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { .getOrDefault(false) }, discardIncompleteHydration = virtualFiles::discardHydrationStagingFile, + onReleased = accountLease::close, ) } catch (failure: Throwable) { rangeSession.close() @@ -284,7 +278,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { throw failure } } - private fun openExternalHandoffDocument( session: NextcloudSession, documentId: String, @@ -401,7 +394,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { throw failure } } - private fun openExternalLocalContent( content: File, handoffLease: AndroidExternalFileHandoffLease, @@ -441,18 +433,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { throw failure } } - - private fun openVirtualFileLease(lease: AndroidVirtualFileLease): ParcelFileDescriptor = try { - ParcelFileDescriptor.open( - lease.content, - ParcelFileDescriptor.MODE_READ_ONLY, - WRITE_HANDLER, - ) { lease.release() } - } catch (failure: Throwable) { - lease.release() - throw failure - } - override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String = withDocumentMutation(parentDocumentId) { session, parent -> val account = resolveAccount(session) @@ -471,7 +451,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { notifyDocumentChanged(session, parent.incarnation, path) NextcloudDocumentIds.documentId(session, parent.incarnation, path) } - override fun renameDocument(documentId: String, displayName: String): String = withDocumentMutation(documentId) { session, reference -> if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be renamed.") @@ -489,7 +468,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { notifyMove(session, reference.incarnation, reference.path, destination) NextcloudDocumentIds.documentId(session, reference.incarnation, destination) } - override fun deleteDocument(documentId: String) = withDocumentMutation(documentId) { session, reference -> if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be deleted.") @@ -508,7 +486,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } notifyDocumentChanged(session, reference.incarnation, reference.path) } - override fun moveDocument( sourceDocumentId: String, sourceParentDocumentId: String, @@ -534,7 +511,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { notifyMove(session, source.incarnation, source.path, destination) NextcloudDocumentIds.documentId(session, source.incarnation, destination) } - private fun openWritableDocument( session: NextcloudSession, incarnation: NextcloudDocumentIncarnation, @@ -542,16 +518,14 @@ class NextcloudDocumentsProvider : DocumentsProvider() { file: NextcloudFile, mode: String, signal: CancellationSignal?, + accountLease: AndroidAccountOperationLease, ): ParcelFileDescriptor { - val accountLease = acquireAndroidDocumentWritebackAccountLease( - session, - file.path, - { services.loadSession(session.accountId) }, - ) val recovered: AndroidDocumentPendingWriteback? val writeback: AndroidDocumentPendingWriteback + var pathReserved = false try { - requireCurrentIncarnation(session, incarnation) + reserveAndroidDocumentWritebackPath(session, file.path) + pathReserved = true recovered = claimAndroidDocumentPendingWriteback(context, session, file.path) if (recovered?.conflict == true) { recovered.releaseActive() @@ -559,9 +533,9 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } writeback = recovered ?: createDurableWriteback(session, file, requireMutationEtag(file)) } catch (failure: Throwable) { - releaseAndroidDocumentWritebackSetup(accountLease) { + if (pathReserved) releaseAndroidDocumentWritebackSetup(accountLease) { releaseAndroidDocumentWritebackPath(session, file.path) - } + } else accountLease.close() throw failure } val expectedEtag = writeback.expectedRemoteEtag @@ -635,14 +609,12 @@ class NextcloudDocumentsProvider : DocumentsProvider() { throw failure } } - private fun createLocalStagingFile(): File { val providerContext = requireNotNull(context) { "Provider context is unavailable." } val directory = File(providerContext.cacheDir, STAGING_DIRECTORY).apply { mkdirs() } check(directory.isDirectory) { "Could not prepare local document staging." } return File.createTempFile("document-", ".stage", directory) } - private fun createDurableWriteback( session: NextcloudSession, file: NextcloudFile, @@ -814,6 +786,35 @@ class NextcloudDocumentsProvider : DocumentsProvider() { accountResolver.requireRoot(rootId) } + private inline fun withDocumentRead( + documentId: String, + action: (NextcloudSession, NextcloudDocumentReference) -> Result, + ): Result { + val resolved = requireDocument(documentId) + return withAndroidDocumentProviderReadAccess( + resolved.session, resolved.reference.incarnation, + { services.loadSession(resolved.session.accountId) }, documentIncarnations::activeIncarnation, + ) { session -> action(session, resolved.reference) } + } + private inline fun withRootRead( + rootId: String, + action: (NextcloudSession, NextcloudDocumentIncarnation) -> Result, + ): Result { + val resolved = requireRoot(rootId) + return withAndroidDocumentProviderReadAccess( + resolved.session, resolved.incarnation, + { services.loadSession(resolved.session.accountId) }, documentIncarnations::activeIncarnation, + ) { session -> action(session, resolved.incarnation) } + } + private fun acquireDocumentReadLease( + session: NextcloudSession, + incarnation: NextcloudDocumentIncarnation, + ) = acquireAndroidDocumentProviderReadLease( + session, + incarnation, + { services.loadSession(session.accountId) }, + documentIncarnations::activeIncarnation, + ) private fun requireReference( documentId: String, session: NextcloudSession, @@ -839,7 +840,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { action(session, resolved.reference) } } - private fun requireCurrentIncarnation( session: NextcloudSession, incarnation: NextcloudDocumentIncarnation, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt new file mode 100644 index 000000000..93332314e --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt @@ -0,0 +1,130 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.io.FileNotFoundException +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield + +class AndroidDocumentProviderReadAccessTest { + private val original = NextcloudSession("https://cloud.example.test", "alice", "original-password") + private val originalIncarnation = incarnation("1") + private val replacementIncarnation = incarnation("2") + + @Test + fun openedFileLeaseBlocksRemovalUntilTheDescriptorReleasesIt() = runBlocking { + val guard = AndroidAccountOperationGuard() + val lease = readLease(guard) + var removalEntered = false + val removal = async(Dispatchers.Default) { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { removalEntered = true } + } + yield() + + assertFalse(removalEntered) + lease.close() + removal.await() + assertTrue(removalEntered) + } + + @Test + fun fileOpenWaitingForRemovalRejectsTheReplacementIncarnation() = runBlocking { + val guard = AndroidAccountOperationGuard() + val removalEntered = CompletableDeferred() + val finishRemoval = CompletableDeferred() + var currentIncarnation = originalIncarnation + val removal = async { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { + currentIncarnation = replacementIncarnation + removalEntered.complete(Unit) + finishRemoval.await() + } + } + removalEntered.await() + val open = async(Dispatchers.Default) { + runCatching { + acquireAndroidDocumentProviderReadLease( + original, + originalIncarnation, + { original }, + { currentIncarnation }, + guard, + ) + } + } + yield() + assertFalse(open.isCompleted) + + finishRemoval.complete(Unit) + removal.await() + val result = open.await() + result.getOrNull()?.close() + assertIs(result.exceptionOrNull()) + guard.withAccount(NextcloudDocumentIds.accountKey(original)) {} + } + + @Test + fun searchKeepsRemovalBlockedThroughTheAuthenticatedRead() = runBlocking { + val guard = AndroidAccountOperationGuard() + val searchEntered = CompletableDeferred() + val finishSearch = CompletableDeferred() + var removalEntered = false + val search = async(Dispatchers.Default) { + withAndroidDocumentProviderReadAccess( + original, + originalIncarnation, + { original }, + { originalIncarnation }, + guard, + ) { + searchEntered.complete(Unit) + runBlocking { finishSearch.await() } + } + } + searchEntered.await() + val removal = async { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { removalEntered = true } + } + yield() + + assertFalse(removalEntered) + finishSearch.complete(Unit) + search.await() + removal.await() + assertTrue(removalEntered) + } + + @Test + fun failedReadReleasesTheAccountLease() = runBlocking { + val guard = AndroidAccountOperationGuard() + + assertFailsWith { + withAndroidDocumentProviderReadAccess( + original, + originalIncarnation, + { original }, + { originalIncarnation }, + guard, + ) { error("synthetic read failure") } + } + + guard.withAccount(NextcloudDocumentIds.accountKey(original)) {} + } + + private fun readLease(guard: AndroidAccountOperationGuard) = acquireAndroidDocumentProviderReadLease( + original, + originalIncarnation, + { original }, + { originalIncarnation }, + guard, + ) + + private fun incarnation(digit: String) = NextcloudDocumentIncarnation.Versioned(digit.repeat(32)) +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt index 8326f95f5..08b6c585a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt @@ -275,6 +275,7 @@ class AndroidVirtualFileProxyCallbackTest { callback.onRead(0L, 1, ByteArray(1)) } callback.cancel() + assertEquals(1, released) callback.onRelease() callback.onRelease() From e03f899e8c8c8d6ef4cac101e67416f604005110 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 17:38:23 +0200 Subject: [PATCH 08/31] chore(changelog): link retained document fixes --- changes/unreleased/android-documents-account-resolution.md | 2 +- changes/unreleased/document-grant-incarnation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/changes/unreleased/android-documents-account-resolution.md b/changes/unreleased/android-documents-account-resolution.md index 203133bd3..caa41dc56 100644 --- a/changes/unreleased/android-documents-account-resolution.md +++ b/changes/unreleased/android-documents-account-resolution.md @@ -1,6 +1,6 @@ category: fix issue: 122 -pull: none +pull: 447 platforms: android user-facing: yes diff --git a/changes/unreleased/document-grant-incarnation.md b/changes/unreleased/document-grant-incarnation.md index 2c961a295..dbb7b61eb 100644 --- a/changes/unreleased/document-grant-incarnation.md +++ b/changes/unreleased/document-grant-incarnation.md @@ -1,6 +1,6 @@ category: fix issue: 122 -pull: none +pull: 447 platforms: android user-facing: yes From ab2217261a7ba6a8aa0d8af6d45aff0788236b5f Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 00:30:41 +0200 Subject: [PATCH 09/31] fix(android): isolate document read leases --- .../nextcloudnative/AndroidAccountRemoval.kt | 111 +++++++++++++++++- ...AndroidDocumentProviderIncarnationStore.kt | 57 +++++++-- .../AndroidDocumentProviderReadAccess.kt | 18 ++- ...oidDocumentProviderIncarnationStoreTest.kt | 59 ++++++++++ .../AndroidDocumentProviderReadAccessTest.kt | 59 +++++++++- 5 files changed, 277 insertions(+), 27 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index ab5bac970..82049f66e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -4,8 +4,11 @@ import android.content.Context import android.content.Intent import android.provider.DocumentsContract import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext internal val NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS: Int = @@ -23,12 +26,15 @@ internal fun rejectAndroidAccountRemovalForPendingDocumentChanges(): Nothing = internal suspend fun withAndroidAccountRemovalLease( accountIdentity: String, guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, action: suspend () -> Result, -): Result = guard.tryWithAccount( - accountId = accountIdentity, - unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, - action = action, -) +): Result = lifetimeGuard.withRemoval(accountIdentity) { + guard.tryWithAccount( + accountId = accountIdentity, + unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, + action = action, + ) +} internal suspend fun revokeAndroidSessionAfterRemovalPreflight( preflight: suspend () -> Unit, @@ -64,13 +70,106 @@ internal suspend fun revokeAndroidSessionAfterRemovalPreflight( internal suspend fun revokeAndroidSessionWithAccountLease( accountIdentity: String, guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, preflight: suspend () -> Unit, revoke: suspend () -> Unit, removeLocalAccount: suspend () -> Unit, -) = withAndroidAccountRemovalLease(accountIdentity, guard) { +) = withAndroidAccountRemovalLease(accountIdentity, guard, lifetimeGuard) { revokeAndroidSessionAfterRemovalPreflight(preflight, revoke, removeLocalAccount) } +internal class AndroidAccountRemovalLifetimeGuard { + private val monitor = Any() + private val accounts = mutableMapOf() + + fun acquireReadBlocking(accountIdentity: String): AndroidAccountOperationLease = + runBlocking { acquireRead(accountIdentity) } + + suspend fun withRemoval(accountIdentity: String, action: suspend () -> Result): Result { + val lease = acquireRemoval(accountIdentity) + return try { + action() + } finally { + lease.close() + } + } + + private suspend fun acquireRead(accountIdentity: String): AndroidAccountOperationLease { + val lifetime = reference(accountIdentity) + var gateAcquired = false + try { + lifetime.removalGate.lock() + gateAcquired = true + synchronized(monitor) { lifetime.readers += 1 } + lifetime.removalGate.unlock() + gateAcquired = false + } catch (failure: Throwable) { + if (gateAcquired) lifetime.removalGate.unlock() + releaseReference(accountIdentity, lifetime) + throw failure + } + return AndroidAccountOperationLease { + val readersDrained = synchronized(monitor) { + lifetime.readers -= 1 + check(lifetime.readers >= 0) + lifetime.readersDrained.takeIf { lifetime.readers == 0 }?.also { + lifetime.readersDrained = null + } + } + readersDrained?.complete(Unit) + releaseReference(accountIdentity, lifetime) + } + } + + private suspend fun acquireRemoval(accountIdentity: String): AndroidAccountOperationLease { + val lifetime = reference(accountIdentity) + var gateAcquired = false + try { + lifetime.removalGate.lock() + gateAcquired = true + val readersDrained = synchronized(monitor) { + if (lifetime.readers == 0) null else CompletableDeferred().also { + check(lifetime.readersDrained == null) + lifetime.readersDrained = it + } + } + readersDrained?.await() + } catch (failure: Throwable) { + synchronized(monitor) { lifetime.readersDrained = null } + if (gateAcquired) lifetime.removalGate.unlock() + releaseReference(accountIdentity, lifetime) + throw failure + } + return AndroidAccountOperationLease { + lifetime.removalGate.unlock() + releaseReference(accountIdentity, lifetime) + } + } + + private fun reference(accountIdentity: String): AccountLifetime { + require(accountIdentity.isNotBlank()) + return synchronized(monitor) { + accounts.getOrPut(accountIdentity, ::AccountLifetime).also { it.references += 1 } + } + } + + private fun releaseReference(accountIdentity: String, lifetime: AccountLifetime) { + synchronized(monitor) { + lifetime.references -= 1 + if (lifetime.references == 0) accounts.remove(accountIdentity, lifetime) + } + } + + private class AccountLifetime( + val removalGate: Mutex = Mutex(), + var readers: Int = 0, + var readersDrained: CompletableDeferred? = null, + var references: Int = 0, + ) +} + +internal val ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD = AndroidAccountRemovalLifetimeGuard() + internal enum class AndroidAccountDocumentGrantScope(val pathSegment: String) { Document("document"), Tree("tree"), diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt index da19659b0..12620e7d3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -2,10 +2,12 @@ package dev.obiente.nextcloudnative import android.content.Context import android.provider.DocumentsContract +import android.util.Log import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry import dev.obiente.nextcloudnative.app.NextcloudSession import java.util.Base64 import java.util.UUID +import kotlinx.coroutines.sync.Mutex internal sealed interface AndroidDocumentProviderIncarnationRecord { val incarnation: NextcloudDocumentIncarnation @@ -131,19 +133,26 @@ internal class AndroidDocumentProviderIncarnationStore( fun reconcilePending( ownership: (String) -> AndroidDocumentProviderAccountOwnership, + onMalformedJournal: (Exception) -> Unit = { failure -> throw failure }, ) = synchronized(LOCK) { keys().asSequence() .filter { key -> key.startsWith(RETIREMENT_JOURNAL_KEY_PREFIX) } .sorted() .forEach { key -> - val accountIdentity = key.removePrefix(RETIREMENT_JOURNAL_KEY_PREFIX) - requireAccountIdentity(accountIdentity) - val encoded = read(key) ?: return@forEach - val retirement = decodeAndroidDocumentProviderRetirement(encoded) - require(retirement.accountIdentity == accountIdentity) { - "The document provider retirement journal has the wrong account." + val recovery = try { + val accountIdentity = key.removePrefix(RETIREMENT_JOURNAL_KEY_PREFIX) + requireAccountIdentity(accountIdentity) + val encoded = read(key) ?: return@forEach + val retirement = decodeAndroidDocumentProviderRetirement(encoded) + require(retirement.accountIdentity == accountIdentity) { + "The document provider retirement journal has the wrong account." + } + accountIdentity to retirement + } catch (failure: Exception) { + onMalformedJournal(failure) + return@forEach } - reconcile(retirement, ownership(accountIdentity)) + reconcile(recovery.second, ownership(recovery.first)) } } @@ -339,7 +348,7 @@ internal fun prepareAndroidDocumentProviderAccountSave( current: AndroidAccountCredentialState, ) { val store = AndroidDocumentProviderIncarnationStore(context) - store.reconcilePending(current.registry::documentProviderAccountOwnership) + store.reconcilePendingForCredentialAccess(current.registry) store.prepareForAccountSave( NextcloudDocumentIds.accountKey(session), session.accountId in current.sessions, @@ -350,9 +359,39 @@ internal fun reconcileAndroidDocumentProviderAccountRemovals( context: Context, registry: NextcloudAccountRegistry, ): NextcloudAccountRegistry = registry.also { - AndroidDocumentProviderIncarnationStore(context).reconcilePending(registry::documentProviderAccountOwnership) + reconcileAndroidDocumentProviderAccountRemovalsWhenCredentialMutationIdle( + ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX, + ) { + AndroidDocumentProviderIncarnationStore(context).reconcilePendingForCredentialAccess(registry) + } +} + +internal inline fun reconcileAndroidDocumentProviderAccountRemovalsWhenCredentialMutationIdle( + credentialMutationMutex: Mutex, + reconcile: () -> Unit, +): Boolean { + if (!credentialMutationMutex.tryLock()) return false + return try { + reconcile() + true + } finally { + credentialMutationMutex.unlock() + } } +private fun AndroidDocumentProviderIncarnationStore.reconcilePendingForCredentialAccess( + registry: NextcloudAccountRegistry, +) = reconcilePending( + ownership = registry::documentProviderAccountOwnership, + onMalformedJournal = { failure -> + Log.e( + "NextcloudDocuments", + "A malformed document-provider retirement journal was left unavailable.", + failure, + ) + }, +) + internal fun completeAndroidDocumentProviderAccountRemoval( context: Context, retirement: AndroidDocumentProviderIncarnationRetirement, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt index 14728cea0..2f8e217dd 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt @@ -11,10 +11,12 @@ internal fun acquireAndroidDocumentProviderReadLease( expectedIncarnation: NextcloudDocumentIncarnation, loadCurrentSession: () -> NextcloudSession?, loadCurrentIncarnation: (String) -> NextcloudDocumentIncarnation, - guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + operationGuard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, ): AndroidAccountOperationLease { val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession) - val lease = guard.acquireBlocking(accountIdentity) + val lifetimeLease = lifetimeGuard.acquireReadBlocking(accountIdentity) + val operationLease = operationGuard.acquireBlocking(accountIdentity) return try { checkAndroidDocumentProviderReadAccess( expectedSession, @@ -22,9 +24,11 @@ internal fun acquireAndroidDocumentProviderReadLease( loadCurrentSession, loadCurrentIncarnation, ) - lease + operationLease.close() + lifetimeLease } catch (failure: Throwable) { - lease.close() + operationLease.close() + lifetimeLease.close() throw failure } } @@ -34,7 +38,8 @@ internal inline fun withAndroidDocumentProviderReadAccess( expectedIncarnation: NextcloudDocumentIncarnation, noinline loadCurrentSession: () -> NextcloudSession?, noinline loadCurrentIncarnation: (String) -> NextcloudDocumentIncarnation, - guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + operationGuard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, action: (NextcloudSession) -> Result, ): Result { val lease = acquireAndroidDocumentProviderReadLease( @@ -42,7 +47,8 @@ internal inline fun withAndroidDocumentProviderReadAccess( expectedIncarnation, loadCurrentSession, loadCurrentIncarnation, - guard, + operationGuard, + lifetimeGuard, ) return try { action(expectedSession) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt index ec2e4d804..2267a6b4f 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt @@ -1,9 +1,11 @@ package dev.obiente.nextcloudnative import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertTrue @@ -276,6 +278,63 @@ class AndroidDocumentProviderIncarnationStoreTest { assertFailsWith { store.activeIncarnation(accountIdentity) } } + @Test + fun credentialReadSkipsRetirementRecoveryWhileACredentialMutationIsActive() { + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val records = mutableMapOf(accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(active)) + val store = fixture(records = records).store + store.retireForRemoval(accountIdentity) + val credentialMutations = Mutex(locked = true) + var recoveryRan = false + + assertFalse( + reconcileAndroidDocumentProviderAccountRemovalsWhenCredentialMutationIdle(credentialMutations) { + recoveryRan = true + store.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Present)) + }, + ) + + assertFalse(recoveryRan) + assertTrue("retirement:$accountIdentity" in records) + assertFailsWith { store.activeIncarnation(accountIdentity) } + credentialMutations.unlock() + assertTrue( + reconcileAndroidDocumentProviderAccountRemovalsWhenCredentialMutationIdle(credentialMutations) { + store.reconcilePending(ownership(AndroidDocumentProviderAccountOwnership.Present)) + }, + ) + assertEquals(active.incarnation, store.activeIncarnation(accountIdentity)) + } + + @Test + fun malformedAndUnsupportedJournalsStayUnavailableWhileOtherAccountsRecover() { + listOf("broken", "2:unsupported").forEach { malformed -> + val otherAccount = "b".repeat(32) + val otherActive = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("2".repeat(32)), + ) + val records = mutableMapOf( + otherAccount to encodeAndroidDocumentProviderIncarnationRecord(otherActive), + ) + val store = fixture(records = records).store + store.retireForRemoval(otherAccount) + records["retirement:$accountIdentity"] = malformed + val failures = mutableListOf() + + store.reconcilePending( + ownership = ownership(AndroidDocumentProviderAccountOwnership.Present), + onMalformedJournal = failures::add, + ) + + assertEquals(1, failures.size) + assertTrue("retirement:$accountIdentity" in records) + assertFailsWith { store.activeIncarnation(accountIdentity) } + assertEquals(otherActive.incarnation, store.activeIncarnation(otherAccount)) + } + } + @Test fun malformedPriorStoreIsRestoredExactlyAndStillCannotAuthorizeDocuments() { val records = mutableMapOf(accountIdentity to "broken") diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt index 93332314e..525896d74 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield class AndroidDocumentProviderReadAccessTest { @@ -21,10 +22,15 @@ class AndroidDocumentProviderReadAccessTest { @Test fun openedFileLeaseBlocksRemovalUntilTheDescriptorReleasesIt() = runBlocking { val guard = AndroidAccountOperationGuard() - val lease = readLease(guard) + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val lease = readLease(guard, lifetimeGuard) var removalEntered = false val removal = async(Dispatchers.Default) { - guard.withAccount(NextcloudDocumentIds.accountKey(original)) { removalEntered = true } + withAndroidAccountRemovalLease( + NextcloudDocumentIds.accountKey(original), + guard, + lifetimeGuard, + ) { removalEntered = true } } yield() @@ -37,11 +43,16 @@ class AndroidDocumentProviderReadAccessTest { @Test fun fileOpenWaitingForRemovalRejectsTheReplacementIncarnation() = runBlocking { val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() val removalEntered = CompletableDeferred() val finishRemoval = CompletableDeferred() var currentIncarnation = originalIncarnation val removal = async { - guard.withAccount(NextcloudDocumentIds.accountKey(original)) { + withAndroidAccountRemovalLease( + NextcloudDocumentIds.accountKey(original), + guard, + lifetimeGuard, + ) { currentIncarnation = replacementIncarnation removalEntered.complete(Unit) finishRemoval.await() @@ -56,6 +67,7 @@ class AndroidDocumentProviderReadAccessTest { { original }, { currentIncarnation }, guard, + lifetimeGuard, ) } } @@ -73,6 +85,7 @@ class AndroidDocumentProviderReadAccessTest { @Test fun searchKeepsRemovalBlockedThroughTheAuthenticatedRead() = runBlocking { val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() val searchEntered = CompletableDeferred() val finishSearch = CompletableDeferred() var removalEntered = false @@ -83,6 +96,7 @@ class AndroidDocumentProviderReadAccessTest { { original }, { originalIncarnation }, guard, + lifetimeGuard, ) { searchEntered.complete(Unit) runBlocking { finishSearch.await() } @@ -90,7 +104,11 @@ class AndroidDocumentProviderReadAccessTest { } searchEntered.await() val removal = async { - guard.withAccount(NextcloudDocumentIds.accountKey(original)) { removalEntered = true } + withAndroidAccountRemovalLease( + NextcloudDocumentIds.accountKey(original), + guard, + lifetimeGuard, + ) { removalEntered = true } } yield() @@ -104,6 +122,7 @@ class AndroidDocumentProviderReadAccessTest { @Test fun failedReadReleasesTheAccountLease() = runBlocking { val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() assertFailsWith { withAndroidDocumentProviderReadAccess( @@ -112,18 +131,46 @@ class AndroidDocumentProviderReadAccessTest { { original }, { originalIncarnation }, guard, + lifetimeGuard, ) { error("synthetic read failure") } } - guard.withAccount(NextcloudDocumentIds.accountKey(original)) {} + withTimeout(1_000L) { + withAndroidAccountRemovalLease( + NextcloudDocumentIds.accountKey(original), + guard, + lifetimeGuard, + ) {} + } + } + + @Test + fun openedFileLeaseDoesNotBlockAccountSelection() = runBlocking { + val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val lease = readLease(guard, lifetimeGuard) + var selectionEntered = false + + withTimeout(1_000L) { + guard.withAccounts(listOf(NextcloudDocumentIds.accountKey(original), "another-account")) { + selectionEntered = true + } + } + + assertTrue(selectionEntered) + lease.close() } - private fun readLease(guard: AndroidAccountOperationGuard) = acquireAndroidDocumentProviderReadLease( + private fun readLease( + guard: AndroidAccountOperationGuard, + lifetimeGuard: AndroidAccountRemovalLifetimeGuard, + ) = acquireAndroidDocumentProviderReadLease( original, originalIncarnation, { original }, { originalIncarnation }, guard, + lifetimeGuard, ) private fun incarnation(digit: String) = NextcloudDocumentIncarnation.Versioned(digit.repeat(32)) From f2a7d1cf0c73807ff64715ff95e33228c3a03963 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 00:48:27 +0200 Subject: [PATCH 10/31] refactor(android): own document retirement completion --- .../AndroidAccountCredentialController.kt | 20 +++++++++---------- ...AndroidDocumentProviderIncarnationStore.kt | 10 +++++++--- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 661f7934e..d726d5f4a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -179,7 +179,9 @@ internal class AndroidAccountCredentialController( rollbackAndroidAccountRemoval(appContext, requireNotNull(documentRetirement)) accountRemovalCleanupJournal.clear(accountId.storageKey) }, - completeCommittedCleanup = { completeDocumentRetirement(documentRetirement, accountId.storageKey) }, + completeCommittedCleanup = { + accountRemovalCleanupJournal.completeDocumentRetirement(appContext, documentRetirement, accountId.storageKey) + }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) } @@ -219,7 +221,9 @@ internal class AndroidAccountCredentialController( }, ) }, - completeCommittedCleanup = { completeDocumentRetirement(documentRetirement, accountId.storageKey) }, + completeCommittedCleanup = { + accountRemovalCleanupJournal.completeDocumentRetirement(appContext, documentRetirement, accountId.storageKey) + }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) } @@ -254,7 +258,7 @@ internal class AndroidAccountCredentialController( persistInactiveRemoval = {}, rollbackInactiveRemoval = {}, completeCommittedCleanup = { - completeDocumentRetirement(documentRetirement, expectedSession.accountId.storageKey) + accountRemovalCleanupJournal.completeDocumentRetirement(appContext, documentRetirement, expectedSession.accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) @@ -290,7 +294,7 @@ internal class AndroidAccountCredentialController( persistInactiveRemoval = {}, rollbackInactiveRemoval = {}, completeCommittedCleanup = { - completeDocumentRetirement(documentRetirement, session.accountId.storageKey) + accountRemovalCleanupJournal.completeDocumentRetirement(appContext, documentRetirement, session.accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) @@ -364,7 +368,7 @@ internal class AndroidAccountCredentialController( accountRemovalCleanupJournal.clear(activeSession.accountId.storageKey) }, completeCommittedCleanup = { - completeDocumentRetirement(documentRetirement, activeSession.accountId.storageKey) + accountRemovalCleanupJournal.completeDocumentRetirement(appContext, documentRetirement, activeSession.accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) @@ -623,12 +627,6 @@ internal class AndroidAccountCredentialController( return AndroidAccountCredentialStoreRead.Available(state) } - private fun completeDocumentRetirement( - retirement: AndroidDocumentProviderIncarnationRetirement?, accountStorageKey: String, - ) { - completeAndroidDocumentProviderAccountRemoval(appContext, requireNotNull(retirement)) - accountRemovalCleanupJournal.clear(accountStorageKey) - } private fun readIndependentCredentialSlotState( allowUnavailableActiveAccountId: NextcloudAccountId? = null, ): AndroidAccountCredentialState? { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt index 12620e7d3..4673c2533 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -392,10 +392,14 @@ private fun AndroidDocumentProviderIncarnationStore.reconcilePendingForCredentia }, ) -internal fun completeAndroidDocumentProviderAccountRemoval( +internal fun AndroidAccountRemovalCleanupJournal.completeDocumentRetirement( context: Context, - retirement: AndroidDocumentProviderIncarnationRetirement, -) = AndroidDocumentProviderIncarnationStore(context).complete(retirement) + retirement: AndroidDocumentProviderIncarnationRetirement?, + accountStorageKey: String, +) { + AndroidDocumentProviderIncarnationStore(context).complete(requireNotNull(retirement)) + clear(accountStorageKey) +} private fun NextcloudAccountRegistry.documentProviderAccountOwnership( accountIdentity: String, From 58ddf29d349ec76751fad287c7a1f3c19c4e0b9b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 01:17:43 +0200 Subject: [PATCH 11/31] fix(android): canonicalize document grant incarnations --- .../AndroidAccountOwnedStateCleanup.kt | 6 +-- .../nextcloudnative/AndroidAccountRemoval.kt | 10 ++-- ...AndroidDocumentProviderIncarnationStore.kt | 12 ++--- .../AndroidDocumentProviderReadAccess.kt | 2 +- .../NextcloudDocumentsAccountResolver.kt | 2 +- .../NextcloudDocumentsProvider.kt | 4 +- ...oidDocumentProviderIncarnationStoreTest.kt | 46 ++++++++++++++++++- .../AndroidDocumentProviderReadAccessTest.kt | 23 ++++++++++ .../NextcloudDocumentsAccountResolverTest.kt | 33 ++++++++++--- 9 files changed, 115 insertions(+), 23 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index f81ad074e..01e24af47 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -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) }, @@ -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) }, @@ -139,7 +139,7 @@ internal class AndroidAccountOwnedStateCleanup( legacyAccountScopeDigest, ) }, - { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, + { revokeAndroidAccountDocumentGrants(appContext, accountIdentity, accountStorageKey) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(accountIdentity) }, { durableUploads.removeForAccount(accountIdentity) }, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index 82049f66e..6195f154a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -192,7 +192,7 @@ internal suspend fun prepareAndroidAccountRemoval( preflightAndroidAccountRemoval(context, session) ANDROID_FILE_RANGE_SESSION_COORDINATOR.quiesce(NextcloudDocumentIds.accountKey(session)) return AndroidDocumentProviderIncarnationStore(context) - .retireForRemoval(NextcloudDocumentIds.accountKey(session)) + .retireForRemoval(session.documentProviderIncarnationAccountIdentity()) } internal fun rollbackAndroidAccountRemoval( @@ -200,8 +200,12 @@ internal fun rollbackAndroidAccountRemoval( retirement: AndroidDocumentProviderIncarnationRetirement, ) = AndroidDocumentProviderIncarnationStore(context).rollback(retirement) -internal fun revokeAndroidAccountDocumentGrants(context: Context, accountIdentity: String) { - val retired = AndroidDocumentProviderIncarnationStore(context).retiredIncarnation(accountIdentity) +internal fun revokeAndroidAccountDocumentGrants( + context: Context, + accountIdentity: String, + accountStorageKey: String, +) { + val retired = AndroidDocumentProviderIncarnationStore(context).retiredIncarnation(accountStorageKey) ?: NextcloudDocumentIncarnation.Legacy val rootIds = listOf( NextcloudDocumentIds.rootId(accountIdentity, NextcloudDocumentIncarnation.Legacy), diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt index 4673c2533..af3feda4f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -234,7 +234,7 @@ internal class AndroidDocumentProviderIncarnationStore( private companion object { const val PREFERENCES_NAME = "documents-provider-incarnations-v1" const val RETIREMENT_JOURNAL_KEY_PREFIX = "retirement:" - val ACCOUNT_IDENTITY_PATTERN = Regex("[0-9a-f]{32}") + val ACCOUNT_IDENTITY_PATTERN = Regex("[0-9a-f]{64}") val LOCK = Any() fun retirementJournalKey(accountIdentity: String): String = @@ -350,7 +350,7 @@ internal fun prepareAndroidDocumentProviderAccountSave( val store = AndroidDocumentProviderIncarnationStore(context) store.reconcilePendingForCredentialAccess(current.registry) store.prepareForAccountSave( - NextcloudDocumentIds.accountKey(session), + session.documentProviderIncarnationAccountIdentity(), session.accountId in current.sessions, ) } @@ -404,9 +404,7 @@ internal fun AndroidAccountRemovalCleanupJournal.completeDocumentRetirement( private fun NextcloudAccountRegistry.documentProviderAccountOwnership( accountIdentity: String, ): AndroidDocumentProviderAccountOwnership = if ( - accounts.any { account -> - NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == accountIdentity - } + accounts.any { account -> account.id.storageKey == accountIdentity } ) { AndroidDocumentProviderAccountOwnership.Present } else { @@ -417,7 +415,7 @@ internal fun notifyAndroidDocumentChanged(context: Context, session: NextcloudSe val appContext = context.applicationContext val incarnation = runCatching { AndroidDocumentProviderIncarnationStore(appContext) - .activeIncarnation(NextcloudDocumentIds.accountKey(session)) + .activeIncarnation(session.documentProviderIncarnationAccountIdentity()) }.getOrNull() ?: return val authority = nextcloudDocumentsAuthority(appContext.packageName) appContext.contentResolver.notifyChange( @@ -435,3 +433,5 @@ internal fun notifyAndroidDocumentChanged(context: Context, session: NextcloudSe null, ) } + +internal fun NextcloudSession.documentProviderIncarnationAccountIdentity(): String = accountId.storageKey diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt index 2f8e217dd..04b65532e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt @@ -63,7 +63,7 @@ private fun checkAndroidDocumentProviderReadAccess( loadCurrentSession: () -> NextcloudSession?, loadCurrentIncarnation: (String) -> NextcloudDocumentIncarnation, ) { - val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession) + val accountIdentity = expectedSession.documentProviderIncarnationAccountIdentity() if ( loadCurrentSession() != expectedSession || loadCurrentIncarnation(accountIdentity) != expectedIncarnation diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt index 6b1e36bb9..563b4d77a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt @@ -65,7 +65,7 @@ internal class NextcloudDocumentsAccountResolver( val session = loadSession(record.id)?.takeIf { candidate -> candidate.accountRecord() == record && NextcloudDocumentIds.accountKey(candidate) == record.documentAccountKey() } ?: return null - return ResolvedNextcloudDocumentsAccount(session, loadIncarnation(record.documentAccountKey())) + return ResolvedNextcloudDocumentsAccount(session, loadIncarnation(record.id.storageKey)) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index 256f5d0aa..cf879006e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -844,7 +844,9 @@ class NextcloudDocumentsProvider : DocumentsProvider() { session: NextcloudSession, incarnation: NextcloudDocumentIncarnation, ) { - require(documentIncarnations.activeIncarnation(NextcloudDocumentIds.accountKey(session)) == incarnation) { + require( + documentIncarnations.activeIncarnation(session.documentProviderIncarnationAccountIdentity()) == incarnation, + ) { "The document belongs to an earlier account incarnation." } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt index 2267a6b4f..a98cd916e 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.NextcloudSession import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlin.test.Test @@ -10,7 +11,7 @@ import kotlin.test.assertNotEquals import kotlin.test.assertTrue class AndroidDocumentProviderIncarnationStoreTest { - private val accountIdentity = "a".repeat(32) + private val accountIdentity = "a".repeat(64) @Test fun legacyIdentityRemainsUsableUntilItsFirstRemoval() { @@ -74,6 +75,47 @@ class AndroidDocumentProviderIncarnationStoreTest { assertEquals(NextcloudDocumentIncarnation.Versioned("2".repeat(32)), replacement) } + @Test + fun canonicallyEquivalentServerSpellingsCannotReactivateAnEarlierIncarnation() { + val original = NextcloudSession( + serverUrl = "https://cloud.example.test/Cloud", + loginName = "alice", + appPassword = "synthetic-password", + ) + val equivalent = original.copy(serverUrl = "HTTPS://CLOUD.EXAMPLE.TEST:443/Cloud///") + val fixture = fixture(incarnations = listOf("1".repeat(32), "2".repeat(32))) + + assertEquals(original.accountId, equivalent.accountId) + assertNotEquals(NextcloudDocumentIds.accountKey(original), NextcloudDocumentIds.accountKey(equivalent)) + val first = fixture.store.prepareForAccountSave( + original.documentProviderIncarnationAccountIdentity(), + accountAlreadyStored = false, + ) + val retainedDocumentId = NextcloudDocumentIds.documentId(original, first, "Documents/report.pdf") + assertEquals( + first, + fixture.store.prepareForAccountSave( + equivalent.documentProviderIncarnationAccountIdentity(), + accountAlreadyStored = true, + ), + ) + + fixture.store.complete( + fixture.store.retireForRemoval(equivalent.documentProviderIncarnationAccountIdentity()), + ) + val replacement = fixture.store.prepareForAccountSave( + original.documentProviderIncarnationAccountIdentity(), + accountAlreadyStored = false, + ) + + assertNotEquals(first, replacement) + assertEquals(NextcloudDocumentIncarnation.Versioned("2".repeat(32)), replacement) + assertEquals(setOf(original.accountId.storageKey), fixture.records.keys) + assertFailsWith { + NextcloudDocumentIds.requireForSession(retainedDocumentId, original, replacement) + } + } + @Test fun interruptedTombstoneCommitBlocksRemoval() { val active = AndroidDocumentProviderIncarnationRecord.Active( @@ -311,7 +353,7 @@ class AndroidDocumentProviderIncarnationStoreTest { @Test fun malformedAndUnsupportedJournalsStayUnavailableWhileOtherAccountsRecover() { listOf("broken", "2:unsupported").forEach { malformed -> - val otherAccount = "b".repeat(32) + val otherAccount = "b".repeat(64) val otherActive = AndroidDocumentProviderIncarnationRecord.Active( NextcloudDocumentIncarnation.Versioned("2".repeat(32)), ) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt index 525896d74..c2128d9af 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt @@ -3,9 +3,11 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.FileNotFoundException import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNotEquals import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -161,6 +163,27 @@ class AndroidDocumentProviderReadAccessTest { lease.close() } + @Test + fun readValidationLoadsTheIncarnationByCanonicalAccountIdentity() { + var loadedIdentity: String? = null + + val lease = acquireAndroidDocumentProviderReadLease( + original, + originalIncarnation, + { original }, + { accountIdentity -> + loadedIdentity = accountIdentity + originalIncarnation + }, + AndroidAccountOperationGuard(), + AndroidAccountRemovalLifetimeGuard(), + ) + lease.close() + + assertEquals(original.accountId.storageKey, loadedIdentity) + assertNotEquals(NextcloudDocumentIds.accountKey(original), loadedIdentity) + } + private fun readLease( guard: AndroidAccountOperationGuard, lifetimeGuard: AndroidAccountRemovalLifetimeGuard, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt index 042468383..6d2b4323a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt @@ -90,8 +90,8 @@ class NextcloudDocumentsAccountResolverTest { else -> null } }, - loadIncarnation = { accountKey -> - if (accountKey == NextcloudDocumentIds.accountKey(bob)) bobIncarnation else error("unreadable") + loadIncarnation = { accountIdentity -> + if (accountIdentity == bob.accountId.storageKey) bobIncarnation else error("unreadable") }, ) @@ -101,6 +101,27 @@ class NextcloudDocumentsAccountResolverTest { ) } + @Test + fun `incarnations load by canonical local account identity`() { + val equivalent = alice.copy(serverUrl = "HTTPS://CLOUD.EXAMPLE:443///") + var loadedIdentity: String? = null + val resolver = resolver( + accounts = listOf(equivalent.accountRecord()), + loadSession = { equivalent }, + loadIncarnation = { accountIdentity -> + loadedIdentity = accountIdentity + aliceIncarnation + }, + ) + + assertEquals(alice.accountId, equivalent.accountId) + assertEquals( + listOf(ResolvedNextcloudDocumentsAccount(equivalent, aliceIncarnation)), + resolver.resolvableAccounts(), + ) + assertEquals(alice.accountId.storageKey, loadedIdentity) + } + @Test fun `retained document and root IDs fail after the same account is readded`() { val resolver = resolver( @@ -136,10 +157,10 @@ class NextcloudDocumentsAccountResolverTest { private fun resolver( accounts: List, loadSession: (NextcloudAccountId) -> NextcloudSession?, - loadIncarnation: (String) -> NextcloudDocumentIncarnation = { accountKey -> - when (accountKey) { - NextcloudDocumentIds.accountKey(alice) -> aliceIncarnation - NextcloudDocumentIds.accountKey(bob) -> bobIncarnation + loadIncarnation: (String) -> NextcloudDocumentIncarnation = { accountIdentity -> + when (accountIdentity) { + alice.accountId.storageKey -> aliceIncarnation + bob.accountId.storageKey -> bobIncarnation else -> error("unknown account") } }, From 7f8ae8f4e65f3a825d42a43d6e9769ed00d7e601 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 01:44:55 +0200 Subject: [PATCH 12/31] fix(android): fence document removal transitions --- .../AndroidAccountCredentialController.kt | 3 +- .../AndroidAccountCredentialTransitions.kt | 4 ++ .../nextcloudnative/AndroidAccountRemoval.kt | 11 +++-- .../AndroidDocumentProviderReadAccess.kt | 7 +++- .../AndroidDocumentProviderReadAccessTest.kt | 42 +++++++++++++++++++ .../AndroidPersistedSessionTest.kt | 24 ++++++++++- 6 files changed, 82 insertions(+), 9 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index d726d5f4a..7238fb910 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -179,6 +179,7 @@ internal class AndroidAccountCredentialController( rollbackAndroidAccountRemoval(appContext, requireNotNull(documentRetirement)) accountRemovalCleanupJournal.clear(accountId.storageKey) }, + onInactiveRemovalCommitted = notifyDocumentRootsChanged, completeCommittedCleanup = { accountRemovalCleanupJournal.completeDocumentRetirement(appContext, documentRetirement, accountId.storageKey) }, @@ -221,13 +222,13 @@ internal class AndroidAccountCredentialController( }, ) }, + onInactiveRemovalCommitted = notifyDocumentRootsChanged, completeCommittedCleanup = { accountRemovalCleanupJournal.completeDocumentRetirement(appContext, documentRetirement, accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) } - notifyDocumentRootsChanged() return true } suspend fun revokeSession( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index c5713fd0e..3dd7a9054 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -95,6 +95,7 @@ internal suspend fun removeAndroidAccountCredentialData( rollbackActiveRemoval: suspend () -> Unit, persistInactiveRemoval: suspend () -> Unit, rollbackInactiveRemoval: suspend () -> Unit, + onInactiveRemovalCommitted: () -> Unit = {}, completeCommittedCleanup: suspend () -> Unit = {}, recordCommittedCleanupFailure: (Exception) -> Unit = {}, ) { @@ -126,6 +127,7 @@ internal suspend fun removeAndroidAccountCredentialData( } throw failure } + onInactiveRemovalCommitted() finishCommittedAndroidAccountRemovalCleanup( removeQueuedUploads, completeCommittedCleanup, @@ -141,6 +143,7 @@ internal suspend fun removeUnavailableAndroidAccountCredentialData( persistRemoval: suspend () -> Unit, clearActiveAccount: suspend () -> Unit = persistRemoval, rollbackRemoval: suspend () -> Unit, + onInactiveRemovalCommitted: () -> Unit = {}, completeCommittedCleanup: suspend () -> Unit = {}, recordCommittedCleanupFailure: (Exception) -> Unit = {}, ) { @@ -153,6 +156,7 @@ internal suspend fun removeUnavailableAndroidAccountCredentialData( rollbackActiveRemoval = rollbackRemoval, persistInactiveRemoval = persistRemoval, rollbackInactiveRemoval = rollbackRemoval, + onInactiveRemovalCommitted = onInactiveRemovalCommitted, completeCommittedCleanup = completeCommittedCleanup, recordCommittedCleanupFailure = recordCommittedCleanupFailure, ) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index 6195f154a..54e991e56 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -28,12 +28,11 @@ internal suspend fun withAndroidAccountRemovalLease( guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, action: suspend () -> Result, -): Result = lifetimeGuard.withRemoval(accountIdentity) { - guard.tryWithAccount( - accountId = accountIdentity, - unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, - action = action, - ) +): Result = guard.tryWithAccount( + accountId = accountIdentity, + unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, +) { + lifetimeGuard.withRemoval(accountIdentity, action) } internal suspend fun revokeAndroidSessionAfterRemovalPreflight( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt index 04b65532e..7a8c73fe3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt @@ -15,8 +15,13 @@ internal fun acquireAndroidDocumentProviderReadLease( lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, ): AndroidAccountOperationLease { val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession) - val lifetimeLease = lifetimeGuard.acquireReadBlocking(accountIdentity) val operationLease = operationGuard.acquireBlocking(accountIdentity) + val lifetimeLease = try { + lifetimeGuard.acquireReadBlocking(accountIdentity) + } catch (failure: Throwable) { + operationLease.close() + throw failure + } return try { checkAndroidDocumentProviderReadAccess( expectedSession, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt index c2128d9af..91eed63b3 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt @@ -42,6 +42,48 @@ class AndroidDocumentProviderReadAccessTest { assertTrue(removalEntered) } + @Test + fun removalOwnsDocumentMutationsBeforeWaitingForAnOpenDescriptor() = runBlocking { + val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val openDescriptor = readLease(guard, lifetimeGuard) + val removalEntered = CompletableDeferred() + val finishRemoval = CompletableDeferred() + var currentSession: NextcloudSession? = original + val accountIdentity = NextcloudDocumentIds.accountKey(original) + val removal = async(Dispatchers.Default) { + withAndroidAccountRemovalLease(accountIdentity, guard, lifetimeGuard) { + currentSession = null + removalEntered.complete(Unit) + finishRemoval.await() + } + } + withTimeout(1_000L) { + while (guard.tryWithAccount(accountIdentity, unavailable = { false }) { true }) yield() + } + + var mutationEntered = false + val mutation = async(Dispatchers.Default) { + runCatching { + acquireAndroidDocumentMutationAccountLease(original, { currentSession }, guard).use { + mutationEntered = true + } + } + } + yield() + assertFalse(mutation.isCompleted) + assertFalse(mutationEntered) + + openDescriptor.close() + removalEntered.await() + assertFalse(mutation.isCompleted) + finishRemoval.complete(Unit) + removal.await() + + assertIs(mutation.await().exceptionOrNull()) + assertFalse(mutationEntered) + } + @Test fun fileOpenWaitingForRemovalRejectsTheReplacementIncarnation() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index fc14f0f07..ac5d74d0a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -941,6 +941,27 @@ class AndroidPersistedSessionTest { assertEquals(listOf("prepare-removal", "clear-account", "remove-uploads", "complete-cleanup"), events) } + @Test + fun inactiveAccountRemovalNotifiesDocumentRootsAfterCredentialCommit() = runBlocking { + val events = mutableListOf() + + removeAndroidAccountCredentialData( + active = false, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { events += "persist-inactive" }, + rollbackInactiveRemoval = { events += "rollback-inactive" }, + onInactiveRemovalCommitted = { events += "notify-roots" }, + completeCommittedCleanup = { events += "complete-cleanup" }, + ) + + assertEquals( + listOf("persist-inactive", "notify-roots", "remove-uploads", "complete-cleanup"), + events, + ) + } + @Test fun blockedAccountRemovalDoesNotDeleteCredentialsOrQueuedWork() = runBlocking { val events = mutableListOf() @@ -1114,13 +1135,14 @@ class AndroidPersistedSessionTest { rollbackActiveRemoval = { events += "rollback-active" }, persistInactiveRemoval = { events += "persist-removal" }, rollbackInactiveRemoval = { events += "rollback" }, + onInactiveRemovalCommitted = { events += "notify-roots" }, ) } cleanupEntered.await() removal.cancelAndJoin() - assertEquals(listOf("persist-removal", "remove-uploads"), events) + assertEquals(listOf("persist-removal", "notify-roots", "remove-uploads"), events) } private fun assertDiagnosticsExcludePrivateValues(diagnostics: List) { From e21a2dc887401ee5d74f36c14564ecb8cfbf3af3 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 02:10:05 +0200 Subject: [PATCH 13/31] fix(android): revalidate document writeback sessions --- .../nextcloudnative/AndroidAccountRemoval.kt | 11 +- .../AndroidDocumentProviderReadAccess.kt | 8 +- .../AndroidDocumentWritebackRecovery.kt | 39 +++++- .../NextcloudDocumentsProvider.kt | 31 +++-- .../AndroidAccountOperationGuardTest.kt | 41 +++--- .../AndroidDocumentProviderReadAccessTest.kt | 125 +++++++++++++++++- 6 files changed, 204 insertions(+), 51 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index 54e991e56..6195f154a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -28,11 +28,12 @@ internal suspend fun withAndroidAccountRemovalLease( guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, action: suspend () -> Result, -): Result = guard.tryWithAccount( - accountId = accountIdentity, - unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, -) { - lifetimeGuard.withRemoval(accountIdentity, action) +): Result = lifetimeGuard.withRemoval(accountIdentity) { + guard.tryWithAccount( + accountId = accountIdentity, + unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, + action = action, + ) } internal suspend fun revokeAndroidSessionAfterRemovalPreflight( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt index 7a8c73fe3..85e110235 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt @@ -15,11 +15,11 @@ internal fun acquireAndroidDocumentProviderReadLease( lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, ): AndroidAccountOperationLease { val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession) - val operationLease = operationGuard.acquireBlocking(accountIdentity) - val lifetimeLease = try { - lifetimeGuard.acquireReadBlocking(accountIdentity) + val lifetimeLease = lifetimeGuard.acquireReadBlocking(accountIdentity) + val operationLease = try { + operationGuard.acquireBlocking(accountIdentity) } catch (failure: Throwable) { - operationLease.close() + lifetimeLease.close() throw failure } return try { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt index 6795c95c9..0a5c6bbfc 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt @@ -43,19 +43,52 @@ internal fun acquireAndroidDocumentMutationAccountLease( session: NextcloudSession, loadCurrentSession: () -> NextcloudSession?, guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, ): AndroidAccountOperationLease { - val lease = guard.acquireBlocking(NextcloudDocumentIds.accountKey(session)) + val accountIdentity = NextcloudDocumentIds.accountKey(session) + val lifetimeLease = lifetimeGuard.acquireReadBlocking(accountIdentity) + val operationLease = try { + guard.acquireBlocking(accountIdentity) + } catch (failure: Throwable) { + lifetimeLease.close() + throw failure + } return try { if (!androidDocumentWritebackSessionIsCurrent(session, loadCurrentSession())) { throw FileNotFoundException("The active Nextcloud account changed before the document mutation could start.") } - lease + AndroidAccountOperationLease { + try { + operationLease.close() + } finally { + lifetimeLease.close() + } + } } catch (failure: Throwable) { - lease.close() + operationLease.close() + lifetimeLease.close() throw failure } } +internal inline fun withAndroidDocumentWritebackCommitWhileLifetimeLeaseHeld( + expectedSession: NextcloudSession, + noinline loadCurrentSession: () -> NextcloudSession?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + action: (NextcloudSession) -> Result, +): Result { + val operationLease = guard.acquireBlocking(NextcloudDocumentIds.accountKey(expectedSession)) + return try { + val currentSession = loadCurrentSession() + if (!androidDocumentWritebackSessionIsCurrent(expectedSession, currentSession)) { + throw FileNotFoundException("The active Nextcloud account changed before the document writeback could commit.") + } + action(requireNotNull(currentSession)) + } finally { + operationLease.close() + } +} + internal inline fun withAndroidDocumentMutation( session: NextcloudSession, noinline loadCurrentSession: () -> NextcloudSession?, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index cf879006e..72bef4f86 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -573,19 +573,24 @@ class NextcloudDocumentsProvider : DocumentsProvider() { if (closeError != null) { retainFailedWriteback(writeback, closeError) } else { - requireAndroidDocumentStagedWritebackCapacity( - stagedBytes = staging.length(), - availableBytes = staging.parentFile?.usableSpace ?: 0L, - ) - webDav.replaceFileAtomically( - session = session, - userId = account.userId, - path = writeback.remotePath, - source = staging, - expectedEtag = expectedEtag, - ) - writeback.complete() - notifyDocumentChanged(session, incarnation, writeback.remotePath) + withAndroidDocumentWritebackCommitWhileLifetimeLeaseHeld( + expectedSession = session, + loadCurrentSession = { services.loadSession(session.accountId) }, + ) { currentSession -> + requireAndroidDocumentStagedWritebackCapacity( + stagedBytes = staging.length(), + availableBytes = staging.parentFile?.usableSpace ?: 0L, + ) + webDav.replaceFileAtomically( + session = currentSession, + userId = account.userId, + path = writeback.remotePath, + source = staging, + expectedEtag = expectedEtag, + ) + writeback.complete() + notifyDocumentChanged(currentSession, incarnation, writeback.remotePath) + } } } catch (failure: Throwable) { retainFailedWriteback(writeback, failure) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 3f2fc3801..61042dd64 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -335,33 +335,28 @@ class AndroidAccountOperationGuardTest { } @Test - fun writableDescriptorLeaseRejectsAccountRemovalWithoutWaitingForClose() = runBlocking { + fun documentMutationLeaseBlocksAccountRemovalUntilClose() = runBlocking { val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() val session = NextcloudSession("https://cloud.example.test", "alice", "password") val accountIdentity = NextcloudDocumentIds.accountKey(session) - val descriptorLease = acquireAndroidDocumentMutationAccountLease(session, { session }, guard) + val mutationLease = acquireAndroidDocumentMutationAccountLease( + session, + { session }, + guard, + lifetimeGuard, + ) var removalEntered = false - - val failure = try { - assertFailsWith { - withTimeout(1_000L) { - withAndroidAccountRemovalLease(accountIdentity, guard) { - removalEntered = true - } - } + val removal = async { + withAndroidAccountRemovalLease(accountIdentity, guard, lifetimeGuard) { + removalEntered = true } - } finally { - descriptorLease.close() } + yield() - assertEquals( - "Finish or discard pending document changes before removing this account.", - failure.message, - ) assertFalse(removalEntered) - withTimeout(1_000L) { - withAndroidAccountRemovalLease(accountIdentity, guard) { removalEntered = true } - } + mutationLease.close() + removal.await() assertTrue(removalEntered) } @@ -539,6 +534,7 @@ class AndroidAccountOperationGuardTest { @Test fun directDocumentMutationLeaseRejectsReauthenticatedSessionAndReleasesTheGuard() = runBlocking { val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() val original = NextcloudSession("https://cloud.example.test", "alice", "original-password") assertFailsWith { @@ -546,11 +542,16 @@ class AndroidAccountOperationGuardTest { session = original, loadCurrentSession = { original.copy(appPassword = "replacement-password") }, guard = guard, + lifetimeGuard = lifetimeGuard, ) } withTimeout(1_000L) { - guard.withAccount(NextcloudDocumentIds.accountKey(original)) { } + withAndroidAccountRemovalLease( + NextcloudDocumentIds.accountKey(original), + guard, + lifetimeGuard, + ) { } } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt index 91eed63b3..38df0e8bd 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt @@ -10,8 +10,11 @@ import kotlin.test.assertIs import kotlin.test.assertNotEquals import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield @@ -43,7 +46,7 @@ class AndroidDocumentProviderReadAccessTest { } @Test - fun removalOwnsDocumentMutationsBeforeWaitingForAnOpenDescriptor() = runBlocking { + fun removalFencesDocumentMutationsBeforeWaitingForAnOpenDescriptor() = runBlocking { val guard = AndroidAccountOperationGuard() val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() val openDescriptor = readLease(guard, lifetimeGuard) @@ -51,21 +54,24 @@ class AndroidDocumentProviderReadAccessTest { val finishRemoval = CompletableDeferred() var currentSession: NextcloudSession? = original val accountIdentity = NextcloudDocumentIds.accountKey(original) - val removal = async(Dispatchers.Default) { + val removal = async(start = CoroutineStart.UNDISPATCHED) { withAndroidAccountRemovalLease(accountIdentity, guard, lifetimeGuard) { currentSession = null removalEntered.complete(Unit) finishRemoval.await() } } - withTimeout(1_000L) { - while (guard.tryWithAccount(accountIdentity, unavailable = { false }) { true }) yield() - } + assertFalse(removal.isCompleted) var mutationEntered = false val mutation = async(Dispatchers.Default) { runCatching { - acquireAndroidDocumentMutationAccountLease(original, { currentSession }, guard).use { + acquireAndroidDocumentMutationAccountLease( + original, + { currentSession }, + guard, + lifetimeGuard, + ).use { mutationEntered = true } } @@ -84,6 +90,113 @@ class AndroidDocumentProviderReadAccessTest { assertFalse(mutationEntered) } + @Test + fun writableDescriptorCommitRejectsCredentialsRotatedAfterOpen() = runBlocking { + val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val openDescriptor = readLease(guard, lifetimeGuard) + var currentSession: NextcloudSession? = original + var commitEntered = false + + withTimeout(1_000L) { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { + currentSession = original.copy(appPassword = "replacement-password") + } + } + + val failure = assertFailsWith { + withAndroidDocumentWritebackCommitWhileLifetimeLeaseHeld( + expectedSession = original, + loadCurrentSession = { currentSession }, + guard = guard, + ) { + commitEntered = true + } + } + + assertEquals( + "The active Nextcloud account changed before the document writeback could commit.", + failure.message, + ) + assertFalse(commitEntered) + openDescriptor.close() + withTimeout(1_000L) { + withAndroidAccountRemovalLease( + NextcloudDocumentIds.accountKey(original), + guard, + lifetimeGuard, + ) {} + } + } + + @Test + fun writableDescriptorCommitCanFinishWhileRemovalWaitsForItsLifetimeLease() = runBlocking { + val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val openDescriptor = readLease(guard, lifetimeGuard) + var currentSession: NextcloudSession? = original + var commitEntered = false + val removal = async(start = CoroutineStart.UNDISPATCHED) { + withAndroidAccountRemovalLease( + NextcloudDocumentIds.accountKey(original), + guard, + lifetimeGuard, + ) { currentSession = null } + } + assertFalse(removal.isCompleted) + + try { + withTimeout(1_000L) { + async(Dispatchers.Default) { + withAndroidDocumentWritebackCommitWhileLifetimeLeaseHeld( + expectedSession = original, + loadCurrentSession = { currentSession }, + guard = guard, + ) { commitEntered = true } + }.await() + } + } finally { + openDescriptor.close() + } + removal.await() + + assertTrue(commitEntered) + assertEquals(null, currentSession) + } + + @Test + fun cancelledRemovalWaitDoesNotBlockDescriptorCommit() = runBlocking { + val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val openDescriptor = readLease(guard, lifetimeGuard) + var commitEntered = false + val removal = launch(start = CoroutineStart.UNDISPATCHED) { + withAndroidAccountRemovalLease( + NextcloudDocumentIds.accountKey(original), + guard, + lifetimeGuard, + ) { error("Cancelled removal must not enter") } + } + assertFalse(removal.isCompleted) + + removal.cancelAndJoin() + withAndroidDocumentWritebackCommitWhileLifetimeLeaseHeld( + expectedSession = original, + loadCurrentSession = { original }, + guard = guard, + ) { commitEntered = true } + openDescriptor.close() + + assertTrue(commitEntered) + withTimeout(1_000L) { + withAndroidAccountRemovalLease( + NextcloudDocumentIds.accountKey(original), + guard, + lifetimeGuard, + ) {} + } + } + @Test fun fileOpenWaitingForRemovalRejectsTheReplacementIncarnation() = runBlocking { val guard = AndroidAccountOperationGuard() From 4090172caaf96037347d8a7e88bc8ca0d2d776db Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 04:34:23 +0200 Subject: [PATCH 14/31] refactor(android): split document removal coverage --- .../AndroidAccountCredentialController.kt | 9 ++---- .../AndroidAccountRemovalOrderingTest.kt | 28 +++++++++++++++++++ .../AndroidPersistedSessionTest.kt | 21 -------------- 3 files changed, 31 insertions(+), 27 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalOrderingTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 7238fb910..25117d7a9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -56,9 +56,9 @@ internal class AndroidAccountCredentialController( publishAccountIdentity(accountIdentity) }, ) - fun accountRetentionSnapshot(): AndroidAccountRetentionSnapshot = readRegistryForCredentialLoad() - ?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts, registry.activeAccountId) } - ?: AndroidAccountRetentionSnapshot.Unavailable + fun accountRetentionSnapshot(): AndroidAccountRetentionSnapshot = readRegistryForCredentialLoad()?.let { registry -> + AndroidAccountRetentionSnapshot.Available(registry.accounts, registry.activeAccountId) + } ?: AndroidAccountRetentionSnapshot.Unavailable fun activeAccountId(): NextcloudAccountId? = readCredentialFreeRegistry()?.activeAccountId fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { @@ -732,7 +732,6 @@ internal class AndroidAccountCredentialController( throw failure } } - private fun encryptState(state: AndroidAccountCredentialState): String = try { sessionCipher.encrypt(encodeAndroidAccountCredentialState(state)) } catch (failure: Exception) { @@ -742,7 +741,6 @@ internal class AndroidAccountCredentialController( ) throw failure } - private fun encryptCredentialSlot(session: NextcloudSession): String = try { sessionCipher.encrypt(encodeAndroidPersistedSession(session)) } catch (failure: Exception) { @@ -752,7 +750,6 @@ internal class AndroidAccountCredentialController( ) throw failure } - private fun prepareCredentialSlotEdit( editor: SharedPreferences.Editor, state: AndroidAccountCredentialState, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalOrderingTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalOrderingTest.kt new file mode 100644 index 000000000..e20555aed --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalOrderingTest.kt @@ -0,0 +1,28 @@ +package dev.obiente.nextcloudnative + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.runBlocking + +class AndroidAccountRemovalOrderingTest { + @Test + fun inactiveAccountRemovalNotifiesDocumentRootsAfterCredentialCommit() = runBlocking { + val events = mutableListOf() + + removeAndroidAccountCredentialData( + active = false, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { events += "persist-inactive" }, + rollbackInactiveRemoval = { events += "rollback-inactive" }, + onInactiveRemovalCommitted = { events += "notify-roots" }, + completeCommittedCleanup = { events += "complete-cleanup" }, + ) + + assertEquals( + listOf("persist-inactive", "notify-roots", "remove-uploads", "complete-cleanup"), + events, + ) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index ac5d74d0a..3afc9c93c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -941,27 +941,6 @@ class AndroidPersistedSessionTest { assertEquals(listOf("prepare-removal", "clear-account", "remove-uploads", "complete-cleanup"), events) } - @Test - fun inactiveAccountRemovalNotifiesDocumentRootsAfterCredentialCommit() = runBlocking { - val events = mutableListOf() - - removeAndroidAccountCredentialData( - active = false, - removeQueuedUploads = { events += "remove-uploads" }, - clearActiveAccount = { events += "clear-account" }, - rollbackActiveRemoval = { events += "rollback-active" }, - persistInactiveRemoval = { events += "persist-inactive" }, - rollbackInactiveRemoval = { events += "rollback-inactive" }, - onInactiveRemovalCommitted = { events += "notify-roots" }, - completeCommittedCleanup = { events += "complete-cleanup" }, - ) - - assertEquals( - listOf("persist-inactive", "notify-roots", "remove-uploads", "complete-cleanup"), - events, - ) - } - @Test fun blockedAccountRemovalDoesNotDeleteCredentialsOrQueuedWork() = runBlocking { val events = mutableListOf() From 1fd8b4d9bd2de74fec1cce0a519708e8c354f359 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 06:00:33 +0200 Subject: [PATCH 15/31] chore: lower Kotlin file size baselines --- tools/kotlin-file-size-baseline.txt | 31 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 7d0074d62..959cfd1a2 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,22 +1,22 @@ -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt|851 -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4230 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt|849 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4204 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|995 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1224 -contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1883 +contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1880 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirerTest.kt|1798 ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/AndroidCompatibilityVideoPlaybackService.kt|808 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityWorkspace.kt|1157 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt|919 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt|917 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt|1809 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeActions.kt|1158 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2512 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2498 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt|2600 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt|973 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt|960 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt|1386 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt|567 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt|1070 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt|1107 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt|1105 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt|1293 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt|2646 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingDynamicUiCaptureScenario.kt|909 @@ -26,9 +26,9 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialo ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1883 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12348 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1692 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1724 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1718 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoEditing.kt|847 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoFolderBrowsing.kt|895 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoTimelinePaging.kt|860 @@ -36,7 +36,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDe ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicNavigationPlanner.kt|1271 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt|6862 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererState.kt|1316 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActions.kt|1240 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActions.kt|1231 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetSemantics.kt|1217 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeMailWorkspace.kt|1483 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecordActions.kt|2150 @@ -44,23 +44,22 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeSema ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntimeTest.kt|3181 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PhotoTimelinePagingTest.kt|2052 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicAppDescriptorCompilerTest.kt|2384 -ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicNavigationPlannerTest.kt|1807 +ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/DynamicNavigationPlannerTest.kt|1790 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRendererStateTest.kt|3115 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeCollectionActionsTest.kt|1277 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeRecordActionsTest.kt|3161 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAppUpdates.kt|925 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt|1536 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt|884 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|883 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStore.kt|808 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|857 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6236 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2759 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1085 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt|2355 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCacheTest.kt|2479 -ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt|2890 +ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt|2888 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystemTest.kt|2149 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt|3205 -ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/PantryLiveContractCompatibilityTest.kt|1203 -ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt|2543 +ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/PantryLiveContractCompatibilityTest.kt|1202 +ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt|2526 From cc4cad1f61b7c7e2c5bf723981136ad2b6e5d5d1 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 07:35:29 +0200 Subject: [PATCH 16/31] fix(android): fence document credential resets --- .../AndroidAccountCredentialController.kt | 22 ++--- .../nextcloudnative/AndroidAccountRemoval.kt | 34 ++++++- ...AndroidDocumentProviderIncarnationStore.kt | 71 ++++++++++++++ .../AndroidDocumentProviderReadAccess.kt | 4 +- .../AndroidDocumentWritebackRecovery.kt | 2 +- .../AndroidAccountOperationGuardTest.kt | 11 ++- ...oidDocumentProviderIncarnationStoreTest.kt | 93 +++++++++++++++++++ .../AndroidDocumentProviderReadAccessTest.kt | 42 +++++++-- 8 files changed, 245 insertions(+), 34 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 25117d7a9..c37b4e3fa 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -156,7 +156,7 @@ internal class AndroidAccountCredentialController( val session = current.sessions[accountId] ?: return@withLock removeUnavailableAccount(accountId, current) val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) - withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session)) { + withAndroidAccountRemovalLease(session) { val active = current.registry.activeAccountId == accountId var documentRetirement: AndroidDocumentProviderIncarnationRetirement? = null removeAndroidAccountCredentialData( @@ -196,7 +196,7 @@ internal class AndroidAccountCredentialController( val unavailableSession = NextcloudSession(target.record.serverUrl, target.record.loginName, appPassword = "") val accountIdentity = NextcloudDocumentIds.accountKey(unavailableSession) val pendingCleanup = pendingAndroidAccountRemovalCleanup(unavailableSession) - withAndroidAccountRemovalLease(accountIdentity) { + withAndroidAccountRemovalLease(unavailableSession) { var documentRetirement: AndroidDocumentProviderIncarnationRetirement? = null removeUnavailableAndroidAccountCredentialData( accountIdentity = accountIdentity, @@ -239,11 +239,10 @@ 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) var documentRetirement: AndroidDocumentProviderIncarnationRetirement? = null revokeAndroidSessionWithAccountLease( - accountIdentity = accountIdentity, + expectedSession = expectedSession, preflight = { documentRetirement = prepareAccountRemoval(expectedSession) }, revoke = revokeRemoteSession, removeLocalAccount = { @@ -274,9 +273,8 @@ internal class AndroidAccountCredentialController( if (session == null) { clearSession(read.state) } else { - val accountIdentity = NextcloudDocumentIds.accountKey(session) val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) - withAndroidAccountRemovalLease(accountIdentity) { + withAndroidAccountRemovalLease(session) { var documentRetirement: AndroidDocumentProviderIncarnationRetirement? = null removeAndroidAccountCredentialData( active = true, @@ -330,10 +328,11 @@ internal class AndroidAccountCredentialController( notifyDocumentRootsChanged() } private suspend fun clearInvalidStore(suspectEncrypted: String?) { - clearPersistedSession( - encodedReplacement = null, - replacement = AndroidAccountCredentialState.Empty, - suspectEncrypted = suspectEncrypted, + retireAndroidDocumentProviderIncarnationsForCredentialReset( + store = AndroidDocumentProviderIncarnationStore(appContext), + lifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, + clearCredentials = { clearPersistedSession(null, AndroidAccountCredentialState.Empty, suspectEncrypted) }, + recordCompletionFailure = ::recordAccountRemovalCleanupFailure, ) notifyDocumentRootsChanged() } @@ -349,9 +348,8 @@ internal class AndroidAccountCredentialController( ) { val activeSession = current.activeSession if (activeSession != null) { - val accountIdentity = NextcloudDocumentIds.accountKey(activeSession) val pendingCleanup = pendingAndroidAccountRemovalCleanup(activeSession) - withAndroidAccountRemovalLease(accountIdentity) { + withAndroidAccountRemovalLease(activeSession) { var documentRetirement: AndroidDocumentProviderIncarnationRetirement? = null removeRecoveredAndroidAccountCredentialData( prepareAccountRemoval = { documentRetirement = prepareAccountRemoval(activeSession) }, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index 6195f154a..d36ce6f27 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -27,8 +27,9 @@ internal suspend fun withAndroidAccountRemovalLease( accountIdentity: String, guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, + lifetimeAccountIdentity: String = accountIdentity, action: suspend () -> Result, -): Result = lifetimeGuard.withRemoval(accountIdentity) { +): Result = lifetimeGuard.withRemoval(lifetimeAccountIdentity) { guard.tryWithAccount( accountId = accountIdentity, unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, @@ -36,6 +37,19 @@ internal suspend fun withAndroidAccountRemovalLease( ) } +internal suspend fun withAndroidAccountRemovalLease( + session: NextcloudSession, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, + action: suspend () -> Result, +): Result = withAndroidAccountRemovalLease( + accountIdentity = NextcloudDocumentIds.accountKey(session), + guard = guard, + lifetimeGuard = lifetimeGuard, + lifetimeAccountIdentity = session.documentProviderIncarnationAccountIdentity(), + action = action, +) + internal suspend fun revokeAndroidSessionAfterRemovalPreflight( preflight: suspend () -> Unit, revoke: suspend () -> Unit, @@ -68,13 +82,13 @@ internal suspend fun revokeAndroidSessionAfterRemovalPreflight( } internal suspend fun revokeAndroidSessionWithAccountLease( - accountIdentity: String, + expectedSession: NextcloudSession, guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, preflight: suspend () -> Unit, revoke: suspend () -> Unit, removeLocalAccount: suspend () -> Unit, -) = withAndroidAccountRemovalLease(accountIdentity, guard, lifetimeGuard) { +) = withAndroidAccountRemovalLease(expectedSession, guard, lifetimeGuard) { revokeAndroidSessionAfterRemovalPreflight(preflight, revoke, removeLocalAccount) } @@ -86,11 +100,21 @@ internal class AndroidAccountRemovalLifetimeGuard { runBlocking { acquireRead(accountIdentity) } suspend fun withRemoval(accountIdentity: String, action: suspend () -> Result): Result { - val lease = acquireRemoval(accountIdentity) + return withRemovals(listOf(accountIdentity), action) + } + + suspend fun withRemovals( + accountIdentities: Collection, + action: suspend () -> Result, + ): Result { + val leases = mutableListOf() return try { + accountIdentities.distinct().sorted().forEach { accountIdentity -> + leases += acquireRemoval(accountIdentity) + } action() } finally { - lease.close() + leases.asReversed().forEach(AndroidAccountOperationLease::close) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt index af3feda4f..b344dd226 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -7,7 +7,9 @@ import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry import dev.obiente.nextcloudnative.app.NextcloudSession import java.util.Base64 import java.util.UUID +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withContext internal sealed interface AndroidDocumentProviderIncarnationRecord { val incarnation: NextcloudDocumentIncarnation @@ -160,6 +162,46 @@ internal class AndroidDocumentProviderIncarnationStore( (readRecord(accountIdentity) as? AndroidDocumentProviderIncarnationRecord.Retired)?.incarnation } + fun activeAccountIdentitiesForCredentialReset(): List = synchronized(LOCK) { + keys().asSequence() + .filter(ACCOUNT_IDENTITY_PATTERN::matches) + .filter { accountIdentity -> + try { + readRecord(accountIdentity) is AndroidDocumentProviderIncarnationRecord.Active + } catch (_: IllegalArgumentException) { + false + } catch (_: ClassCastException) { + false + } + } + .sorted() + .toList() + } + + fun retireActiveForCredentialReset( + accountIdentities: Collection, + ): List = synchronized(LOCK) { + val retirements = mutableListOf() + try { + accountIdentities.distinct().sorted().forEach { accountIdentity -> + requireAccountIdentity(accountIdentity) + if (readRecord(accountIdentity) is AndroidDocumentProviderIncarnationRecord.Active) { + retirements += retireForRemoval(accountIdentity) + } + } + retirements + } catch (failure: Throwable) { + retirements.asReversed().forEach { retirement -> + try { + rollback(retirement) + } catch (rollbackFailure: Throwable) { + failure.addSuppressed(rollbackFailure) + } + } + throw failure + } + } + private fun readRecord(accountIdentity: String): AndroidDocumentProviderIncarnationRecord? { requireAccountIdentity(accountIdentity) return read(accountIdentity)?.let(::decodeAndroidDocumentProviderIncarnationRecord) @@ -242,6 +284,35 @@ internal class AndroidDocumentProviderIncarnationStore( } } +internal suspend fun retireAndroidDocumentProviderIncarnationsForCredentialReset( + store: AndroidDocumentProviderIncarnationStore, + lifetimeGuard: AndroidAccountRemovalLifetimeGuard, + clearCredentials: suspend () -> Unit, + recordCompletionFailure: (Exception) -> Unit = {}, +) { + val accountIdentities = store.activeAccountIdentitiesForCredentialReset() + lifetimeGuard.withRemovals(accountIdentities) { + val retirements = store.retireActiveForCredentialReset(accountIdentities) + withContext(NonCancellable) { + try { + clearCredentials() + } catch (failure: Exception) { + retirements.asReversed().forEach { retirement -> + runCatching { store.rollback(retirement) }.onFailure(failure::addSuppressed) + } + throw failure + } + retirements.forEach { retirement -> + try { + store.complete(retirement) + } catch (failure: Exception) { + recordCompletionFailure(failure) + } + } + } + } +} + internal fun encodeAndroidDocumentProviderRetirement( retirement: AndroidDocumentProviderIncarnationRetirement, ): String { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt index 85e110235..95f5bccff 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt @@ -15,7 +15,9 @@ internal fun acquireAndroidDocumentProviderReadLease( lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, ): AndroidAccountOperationLease { val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession) - val lifetimeLease = lifetimeGuard.acquireReadBlocking(accountIdentity) + val lifetimeLease = lifetimeGuard.acquireReadBlocking( + expectedSession.documentProviderIncarnationAccountIdentity(), + ) val operationLease = try { operationGuard.acquireBlocking(accountIdentity) } catch (failure: Throwable) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt index 0a5c6bbfc..97c695dfb 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt @@ -46,7 +46,7 @@ internal fun acquireAndroidDocumentMutationAccountLease( lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, ): AndroidAccountOperationLease { val accountIdentity = NextcloudDocumentIds.accountKey(session) - val lifetimeLease = lifetimeGuard.acquireReadBlocking(accountIdentity) + val lifetimeLease = lifetimeGuard.acquireReadBlocking(session.documentProviderIncarnationAccountIdentity()) val operationLease = try { guard.acquireBlocking(accountIdentity) } catch (failure: Throwable) { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 61042dd64..66d5b93b1 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -227,6 +227,8 @@ class AndroidAccountOperationGuardTest { @Test fun remoteRevocationKeepsMutationsBlockedUntilLocalRemovalCommits() = runBlocking { val guard = AndroidAccountOperationGuard() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val accountIdentity = NextcloudDocumentIds.accountKey(session) val remoteRevoked = CompletableDeferred() val allowLocalRemoval = CompletableDeferred() var localRemovalCommitted = false @@ -234,7 +236,7 @@ class AndroidAccountOperationGuardTest { val removal = async { revokeAndroidSessionWithAccountLease( - accountIdentity = "account-a", + expectedSession = session, guard = guard, preflight = {}, revoke = { remoteRevoked.complete(Unit) }, @@ -246,7 +248,7 @@ class AndroidAccountOperationGuardTest { } remoteRevoked.await() val mutation = async { - guard.withAccount("account-a") { + guard.withAccount(accountIdentity) { mutationObservedCommittedRemoval = localRemovalCommitted } } @@ -339,7 +341,6 @@ class AndroidAccountOperationGuardTest { val guard = AndroidAccountOperationGuard() val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() val session = NextcloudSession("https://cloud.example.test", "alice", "password") - val accountIdentity = NextcloudDocumentIds.accountKey(session) val mutationLease = acquireAndroidDocumentMutationAccountLease( session, { session }, @@ -348,7 +349,7 @@ class AndroidAccountOperationGuardTest { ) var removalEntered = false val removal = async { - withAndroidAccountRemovalLease(accountIdentity, guard, lifetimeGuard) { + withAndroidAccountRemovalLease(session, guard, lifetimeGuard) { removalEntered = true } } @@ -548,7 +549,7 @@ class AndroidAccountOperationGuardTest { withTimeout(1_000L) { withAndroidAccountRemovalLease( - NextcloudDocumentIds.accountKey(original), + original, guard, lifetimeGuard, ) { } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt index a98cd916e..7f64391cd 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt @@ -1,8 +1,11 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.yield import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -526,6 +529,96 @@ class AndroidDocumentProviderIncarnationStoreTest { assertEquals(active.incarnation, fixture.store.activeIncarnation(accountIdentity)) } + @Test + fun credentialResetRetiresEveryActiveIncarnationBeforeCredentialsDisappear() = runBlocking { + val otherAccount = "b".repeat(64) + val first = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val second = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("2".repeat(32)), + ) + val records = mutableMapOf( + accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(first), + otherAccount to encodeAndroidDocumentProviderIncarnationRecord(second), + ) + val fixture = fixture(records, incarnations = listOf("3".repeat(32), "4".repeat(32))) + var credentialsCleared = false + + retireAndroidDocumentProviderIncarnationsForCredentialReset( + store = fixture.store, + lifetimeGuard = AndroidAccountRemovalLifetimeGuard(), + clearCredentials = { + assertFailsWith { fixture.store.activeIncarnation(accountIdentity) } + assertFailsWith { fixture.store.activeIncarnation(otherAccount) } + credentialsCleared = true + }, + ) + + assertTrue(credentialsCleared) + assertFailsWith { fixture.store.activeIncarnation(accountIdentity) } + assertFailsWith { fixture.store.activeIncarnation(otherAccount) } + assertEquals( + NextcloudDocumentIncarnation.Versioned("3".repeat(32)), + fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false), + ) + assertEquals( + NextcloudDocumentIncarnation.Versioned("4".repeat(32)), + fixture.store.prepareForAccountSave(otherAccount, accountAlreadyStored = false), + ) + } + + @Test + fun credentialResetWaitsForCanonicalDocumentLifetimeLeases() = runBlocking { + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val fixture = fixture( + records = mutableMapOf(accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(active)), + ) + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val descriptorLease = lifetimeGuard.acquireReadBlocking(accountIdentity) + var credentialsCleared = false + val reset = async(start = CoroutineStart.UNDISPATCHED) { + retireAndroidDocumentProviderIncarnationsForCredentialReset( + fixture.store, + lifetimeGuard, + clearCredentials = { credentialsCleared = true }, + ) + } + yield() + + assertFalse(credentialsCleared) + descriptorLease.close() + reset.await() + assertTrue(credentialsCleared) + } + + @Test + fun failedCredentialResetRollsBackEveryPreparedIncarnation() = runBlocking { + val otherAccount = "b".repeat(64) + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val records = mutableMapOf( + accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(active), + otherAccount to encodeAndroidDocumentProviderIncarnationRecord(active), + ) + val fixture = fixture(records) + + assertFailsWith { + retireAndroidDocumentProviderIncarnationsForCredentialReset( + fixture.store, + AndroidAccountRemovalLifetimeGuard(), + clearCredentials = { error("synthetic credential reset failure") }, + ) + } + + assertEquals(active.incarnation, fixture.store.activeIncarnation(accountIdentity)) + assertEquals(active.incarnation, fixture.store.activeIncarnation(otherAccount)) + assertEquals(setOf(accountIdentity, otherAccount), records.keys) + } + private fun fixture( records: MutableMap = mutableMapOf(), incarnations: List = emptyList(), diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt index 38df0e8bd..288b49307 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt @@ -32,7 +32,7 @@ class AndroidDocumentProviderReadAccessTest { var removalEntered = false val removal = async(Dispatchers.Default) { withAndroidAccountRemovalLease( - NextcloudDocumentIds.accountKey(original), + original, guard, lifetimeGuard, ) { removalEntered = true } @@ -53,9 +53,8 @@ class AndroidDocumentProviderReadAccessTest { val removalEntered = CompletableDeferred() val finishRemoval = CompletableDeferred() var currentSession: NextcloudSession? = original - val accountIdentity = NextcloudDocumentIds.accountKey(original) val removal = async(start = CoroutineStart.UNDISPATCHED) { - withAndroidAccountRemovalLease(accountIdentity, guard, lifetimeGuard) { + withAndroidAccountRemovalLease(original, guard, lifetimeGuard) { currentSession = null removalEntered.complete(Unit) finishRemoval.await() @@ -122,7 +121,7 @@ class AndroidDocumentProviderReadAccessTest { openDescriptor.close() withTimeout(1_000L) { withAndroidAccountRemovalLease( - NextcloudDocumentIds.accountKey(original), + original, guard, lifetimeGuard, ) {} @@ -138,7 +137,7 @@ class AndroidDocumentProviderReadAccessTest { var commitEntered = false val removal = async(start = CoroutineStart.UNDISPATCHED) { withAndroidAccountRemovalLease( - NextcloudDocumentIds.accountKey(original), + original, guard, lifetimeGuard, ) { currentSession = null } @@ -172,7 +171,7 @@ class AndroidDocumentProviderReadAccessTest { var commitEntered = false val removal = launch(start = CoroutineStart.UNDISPATCHED) { withAndroidAccountRemovalLease( - NextcloudDocumentIds.accountKey(original), + original, guard, lifetimeGuard, ) { error("Cancelled removal must not enter") } @@ -190,7 +189,7 @@ class AndroidDocumentProviderReadAccessTest { assertTrue(commitEntered) withTimeout(1_000L) { withAndroidAccountRemovalLease( - NextcloudDocumentIds.accountKey(original), + original, guard, lifetimeGuard, ) {} @@ -206,7 +205,7 @@ class AndroidDocumentProviderReadAccessTest { var currentIncarnation = originalIncarnation val removal = async { withAndroidAccountRemovalLease( - NextcloudDocumentIds.accountKey(original), + original, guard, lifetimeGuard, ) { @@ -262,7 +261,7 @@ class AndroidDocumentProviderReadAccessTest { searchEntered.await() val removal = async { withAndroidAccountRemovalLease( - NextcloudDocumentIds.accountKey(original), + original, guard, lifetimeGuard, ) { removalEntered = true } @@ -294,7 +293,7 @@ class AndroidDocumentProviderReadAccessTest { withTimeout(1_000L) { withAndroidAccountRemovalLease( - NextcloudDocumentIds.accountKey(original), + original, guard, lifetimeGuard, ) {} @@ -318,6 +317,29 @@ class AndroidDocumentProviderReadAccessTest { lease.close() } + @Test + fun canonicallyEquivalentRemovalWaitsForTheOriginalDescriptorLease() = runBlocking { + val equivalent = original.copy(serverUrl = "HTTPS://CLOUD.EXAMPLE.TEST:443///") + val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val lease = readLease(guard, lifetimeGuard) + var removalEntered = false + + assertEquals(original.accountId, equivalent.accountId) + assertNotEquals(NextcloudDocumentIds.accountKey(original), NextcloudDocumentIds.accountKey(equivalent)) + val removal = async(Dispatchers.Default) { + withAndroidAccountRemovalLease(equivalent, guard, lifetimeGuard) { + removalEntered = true + } + } + yield() + + assertFalse(removalEntered) + lease.close() + removal.await() + assertTrue(removalEntered) + } + @Test fun readValidationLoadsTheIncarnationByCanonicalAccountIdentity() { var loadedIdentity: String? = null From 3bc5f33a09611cdbcf5f29444ddf0a5fd58bd190 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 08:14:30 +0200 Subject: [PATCH 17/31] fix(android): harden document credential resets --- .../nextcloudnative/AndroidAccountRemoval.kt | 37 +++++- ...AndroidDocumentProviderIncarnationStore.kt | 78 +++++++++--- ...oidDocumentProviderIncarnationStoreTest.kt | 117 ++++++++++++++++++ .../AndroidDocumentProviderReadAccessTest.kt | 22 ++++ 4 files changed, 237 insertions(+), 17 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index d36ce6f27..45f02dfd3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext internal val NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS: Int = @@ -95,6 +96,7 @@ internal suspend fun revokeAndroidSessionWithAccountLease( internal class AndroidAccountRemovalLifetimeGuard { private val monitor = Any() private val accounts = mutableMapOf() + private val resetAdmissionGate = Mutex() fun acquireReadBlocking(accountIdentity: String): AndroidAccountOperationLease = runBlocking { acquireRead(accountIdentity) } @@ -118,8 +120,26 @@ internal class AndroidAccountRemovalLifetimeGuard { } } + suspend fun withCredentialReset( + accountIdentities: Collection, + action: suspend () -> Result, + ): Result { + resetAdmissionGate.lock() + val leases = mutableListOf() + return try { + val trackedAccountIdentities = synchronized(monitor) { accounts.keys.toList() } + (accountIdentities + trackedAccountIdentities).distinct().sorted().forEach { accountIdentity -> + leases += acquireRemovalAfterAdmission(accountIdentity) + } + action() + } finally { + leases.asReversed().forEach(AndroidAccountOperationLease::close) + resetAdmissionGate.unlock() + } + } + private suspend fun acquireRead(accountIdentity: String): AndroidAccountOperationLease { - val lifetime = reference(accountIdentity) + val lifetime = referenceWithResetAdmission(accountIdentity) var gateAcquired = false try { lifetime.removalGate.lock() @@ -146,7 +166,17 @@ internal class AndroidAccountRemovalLifetimeGuard { } private suspend fun acquireRemoval(accountIdentity: String): AndroidAccountOperationLease { - val lifetime = reference(accountIdentity) + val lifetime = referenceWithResetAdmission(accountIdentity) + return acquireRemoval(accountIdentity, lifetime) + } + + private suspend fun acquireRemovalAfterAdmission(accountIdentity: String): AndroidAccountOperationLease = + acquireRemoval(accountIdentity, reference(accountIdentity)) + + private suspend fun acquireRemoval( + accountIdentity: String, + lifetime: AccountLifetime, + ): AndroidAccountOperationLease { var gateAcquired = false try { lifetime.removalGate.lock() @@ -170,6 +200,9 @@ internal class AndroidAccountRemovalLifetimeGuard { } } + private suspend fun referenceWithResetAdmission(accountIdentity: String): AccountLifetime = + resetAdmissionGate.withLock { reference(accountIdentity) } + private fun reference(accountIdentity: String): AccountLifetime { require(accountIdentity.isNotBlank()) return synchronized(monitor) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt index b344dd226..5c3a53b7f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -23,6 +23,14 @@ internal sealed interface AndroidDocumentProviderIncarnationRecord { ) : AndroidDocumentProviderIncarnationRecord } +private sealed interface AndroidDocumentProviderCredentialResetRecord { + data object Missing : AndroidDocumentProviderCredentialResetRecord + data class Readable( + val record: AndroidDocumentProviderIncarnationRecord, + ) : AndroidDocumentProviderCredentialResetRecord + data object Unreadable : AndroidDocumentProviderCredentialResetRecord +} + internal data class AndroidDocumentProviderIncarnationRetirement( val accountIdentity: String, val previousEncoded: String?, @@ -162,31 +170,45 @@ internal class AndroidDocumentProviderIncarnationStore( (readRecord(accountIdentity) as? AndroidDocumentProviderIncarnationRecord.Retired)?.incarnation } - fun activeAccountIdentitiesForCredentialReset(): List = synchronized(LOCK) { + fun accountIdentitiesForCredentialReset(): List = synchronized(LOCK) { keys().asSequence() - .filter(ACCOUNT_IDENTITY_PATTERN::matches) - .filter { accountIdentity -> - try { - readRecord(accountIdentity) is AndroidDocumentProviderIncarnationRecord.Active - } catch (_: IllegalArgumentException) { - false - } catch (_: ClassCastException) { - false + .mapNotNull { key -> + when { + ACCOUNT_IDENTITY_PATTERN.matches(key) -> key + key.startsWith(RETIREMENT_JOURNAL_KEY_PREFIX) -> + key.removePrefix(RETIREMENT_JOURNAL_KEY_PREFIX).takeIf(ACCOUNT_IDENTITY_PATTERN::matches) + else -> null } } + .distinct() .sorted() .toList() } - fun retireActiveForCredentialReset( + fun prepareForCredentialReset( accountIdentities: Collection, ): List = synchronized(LOCK) { val retirements = mutableListOf() try { accountIdentities.distinct().sorted().forEach { accountIdentity -> requireAccountIdentity(accountIdentity) - if (readRecord(accountIdentity) is AndroidDocumentProviderIncarnationRecord.Active) { - retirements += retireForRemoval(accountIdentity) + val pending = readPendingRetirement(accountIdentity) + if (pending != null) { + resumeRetirementForCredentialReset(pending) + retirements += pending + } else { + when (val result = readRecordForCredentialReset(accountIdentity)) { + AndroidDocumentProviderCredentialResetRecord.Missing -> Unit + is AndroidDocumentProviderCredentialResetRecord.Readable -> when (result.record) { + is AndroidDocumentProviderIncarnationRecord.Active -> + retirements += retireForRemoval(accountIdentity) + is AndroidDocumentProviderIncarnationRecord.Retired -> Unit + } + AndroidDocumentProviderCredentialResetRecord.Unreadable -> persist( + accountIdentity, + AndroidDocumentProviderIncarnationRecord.Retired(NextcloudDocumentIncarnation.Legacy), + ) + } } } retirements @@ -202,6 +224,32 @@ internal class AndroidDocumentProviderIncarnationStore( } } + private fun readPendingRetirement(accountIdentity: String): AndroidDocumentProviderIncarnationRetirement? { + val encoded = read(retirementJournalKey(accountIdentity)) ?: return null + return decodeAndroidDocumentProviderRetirement(encoded).also { retirement -> + require(retirement.accountIdentity == accountIdentity) { + "The document provider retirement journal has the wrong account." + } + } + } + + private fun resumeRetirementForCredentialReset(retirement: AndroidDocumentProviderIncarnationRetirement) { + when (read(retirement.accountIdentity)) { + retirement.previousEncoded -> persistEncoded(retirement.accountIdentity, retirement.retiredEncoded) + retirement.retiredEncoded -> Unit + else -> error("The document provider account incarnation changed during credential reset.") + } + } + + private fun readRecordForCredentialReset(accountIdentity: String): AndroidDocumentProviderCredentialResetRecord = try { + readRecord(accountIdentity)?.let(AndroidDocumentProviderCredentialResetRecord::Readable) + ?: AndroidDocumentProviderCredentialResetRecord.Missing + } catch (_: IllegalArgumentException) { + AndroidDocumentProviderCredentialResetRecord.Unreadable + } catch (_: ClassCastException) { + AndroidDocumentProviderCredentialResetRecord.Unreadable + } + private fun readRecord(accountIdentity: String): AndroidDocumentProviderIncarnationRecord? { requireAccountIdentity(accountIdentity) return read(accountIdentity)?.let(::decodeAndroidDocumentProviderIncarnationRecord) @@ -290,9 +338,9 @@ internal suspend fun retireAndroidDocumentProviderIncarnationsForCredentialReset clearCredentials: suspend () -> Unit, recordCompletionFailure: (Exception) -> Unit = {}, ) { - val accountIdentities = store.activeAccountIdentitiesForCredentialReset() - lifetimeGuard.withRemovals(accountIdentities) { - val retirements = store.retireActiveForCredentialReset(accountIdentities) + val accountIdentities = store.accountIdentitiesForCredentialReset() + lifetimeGuard.withCredentialReset(accountIdentities) { + val retirements = store.prepareForCredentialReset(accountIdentities) withContext(NonCancellable) { try { clearCredentials() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt index 7f64391cd..47e1e42b9 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt @@ -594,6 +594,123 @@ class AndroidDocumentProviderIncarnationStoreTest { assertTrue(credentialsCleared) } + @Test + fun credentialResetWaitsForARecordlessLegacyDocumentLifetimeLease() = runBlocking { + val fixture = fixture() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val descriptorLease = lifetimeGuard.acquireReadBlocking(accountIdentity) + var credentialsCleared = false + val reset = async(start = CoroutineStart.UNDISPATCHED) { + retireAndroidDocumentProviderIncarnationsForCredentialReset( + fixture.store, + lifetimeGuard, + clearCredentials = { credentialsCleared = true }, + ) + } + yield() + + assertFalse(credentialsCleared) + descriptorLease.close() + reset.await() + assertTrue(credentialsCleared) + assertEquals(emptyMap(), fixture.records) + } + + @Test + fun credentialResetResumesAnInterruptedActiveRetirement() = runBlocking { + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val activeEncoded = encodeAndroidDocumentProviderIncarnationRecord(active) + val records = mutableMapOf(accountIdentity to activeEncoded) + val fixture = fixture(records, incarnations = listOf("2".repeat(32))) + fixture.store.retireForRemoval(accountIdentity) + records[accountIdentity] = activeEncoded + var credentialsCleared = false + + retireAndroidDocumentProviderIncarnationsForCredentialReset( + fixture.store, + AndroidAccountRemovalLifetimeGuard(), + clearCredentials = { credentialsCleared = true }, + ) + + assertTrue(credentialsCleared) + assertFailsWith { fixture.store.activeIncarnation(accountIdentity) } + assertEquals(setOf(accountIdentity), records.keys) + assertEquals( + NextcloudDocumentIncarnation.Versioned("2".repeat(32)), + fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false), + ) + } + + @Test + fun failedCredentialResetRollsBackAResumedRetirement() = runBlocking { + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val activeEncoded = encodeAndroidDocumentProviderIncarnationRecord(active) + val records = mutableMapOf(accountIdentity to activeEncoded) + val fixture = fixture(records) + fixture.store.retireForRemoval(accountIdentity) + records[accountIdentity] = activeEncoded + + assertFailsWith { + retireAndroidDocumentProviderIncarnationsForCredentialReset( + fixture.store, + AndroidAccountRemovalLifetimeGuard(), + clearCredentials = { error("synthetic credential reset failure") }, + ) + } + + assertEquals(active.incarnation, fixture.store.activeIncarnation(accountIdentity)) + assertEquals(setOf(accountIdentity), records.keys) + } + + @Test + fun credentialResetTombstonesMalformedStateBeforeSafeReadd() = runBlocking { + val records = mutableMapOf(accountIdentity to "broken") + val fixture = fixture(records, incarnations = listOf("2".repeat(32))) + + retireAndroidDocumentProviderIncarnationsForCredentialReset( + fixture.store, + AndroidAccountRemovalLifetimeGuard(), + clearCredentials = {}, + ) + + assertFailsWith { fixture.store.activeIncarnation(accountIdentity) } + assertEquals( + NextcloudDocumentIncarnation.Versioned("2".repeat(32)), + fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false), + ) + } + + @Test + fun credentialResetTombstonesWrongTypedStateBeforeSafeReadd() = runBlocking { + val values = mutableMapOf(accountIdentity to setOf("wrong-type")) + val incarnations = ArrayDeque(listOf("2".repeat(32))) + val store = AndroidDocumentProviderIncarnationStore( + read = { key -> values[key] as String? }, + commit = { key, value -> + if (value == null) values.remove(key) else values[key] = value + true + }, + keys = { values.keys }, + createIncarnation = { NextcloudDocumentIncarnation.Versioned(incarnations.removeFirst()) }, + ) + + retireAndroidDocumentProviderIncarnationsForCredentialReset( + store, + AndroidAccountRemovalLifetimeGuard(), + clearCredentials = {}, + ) + + assertFailsWith { store.activeIncarnation(accountIdentity) } + assertEquals( + NextcloudDocumentIncarnation.Versioned("2".repeat(32)), + store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false), + ) + } + @Test fun failedCredentialResetRollsBackEveryPreparedIncarnation() = runBlocking { val otherAccount = "b".repeat(64) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt index 288b49307..54e5b5768 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt @@ -196,6 +196,28 @@ class AndroidDocumentProviderReadAccessTest { } } + @Test + fun cancelledCredentialResetWaitDoesNotBlockNewDocumentReads() = runBlocking { + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val openDescriptor = lifetimeGuard.acquireReadBlocking(original.accountId.storageKey) + val reset = launch(start = CoroutineStart.UNDISPATCHED) { + lifetimeGuard.withCredentialReset(emptyList()) { + error("Cancelled credential reset must not enter") + } + } + assertFalse(reset.isCompleted) + + reset.cancelAndJoin() + val laterRead = withTimeout(1_000L) { + async(Dispatchers.Default) { + lifetimeGuard.acquireReadBlocking(original.accountId.storageKey) + }.await() + } + + laterRead.close() + openDescriptor.close() + } + @Test fun fileOpenWaitingForRemovalRejectsTheReplacementIncarnation() = runBlocking { val guard = AndroidAccountOperationGuard() From 53b82a5d003ac212525574ff883c083465f4fb2d Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 08:31:19 +0200 Subject: [PATCH 18/31] refactor(android): keep document provider bounded --- .../nextcloudnative/NextcloudDocumentsAccountResolver.kt | 9 +++++++++ .../nextcloudnative/NextcloudDocumentsProvider.kt | 6 +----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt index 563b4d77a..c8a3e5c31 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt @@ -69,5 +69,14 @@ internal class NextcloudDocumentsAccountResolver( } } +internal fun nextcloudDocumentsAccountResolver( + services: AndroidNextcloudServices, + incarnations: AndroidDocumentProviderIncarnationStore, +) = NextcloudDocumentsAccountResolver( + services::listAccounts, + services::loadSession, + incarnations::activeIncarnation, +) + private fun NextcloudAccountRecord.documentAccountKey(): String = NextcloudDocumentIds.accountKey(serverUrl, loginName) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index 72bef4f86..f42ed8950 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -54,11 +54,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { cleanupIncompleteAndroidDocumentWritebacks(providerContext) services = AndroidNextcloudServices(providerContext) documentIncarnations = AndroidDocumentProviderIncarnationStore(providerContext) - accountResolver = NextcloudDocumentsAccountResolver( - services::listAccounts, - services::loadSession, - documentIncarnations::activeIncarnation, - ) + accountResolver = nextcloudDocumentsAccountResolver(services, documentIncarnations) AndroidExternalFileHandoffRegistry.bind(AndroidExternalFileHandoffStore(providerContext)) offline = AndroidFileOfflineRepository(providerContext) virtualFiles = AndroidVirtualFileCache(providerContext) From 85a73c06c4161fda9066ffa7d76e154fafd05811 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 09:06:46 +0200 Subject: [PATCH 19/31] chore: align rebased Kotlin baselines --- tools/kotlin-file-size-baseline.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 959cfd1a2..f2dd947e5 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -26,9 +26,9 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialo ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1883 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12348 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1692 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1718 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1719 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoEditing.kt|847 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoFolderBrowsing.kt|895 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoTimelinePaging.kt|860 @@ -62,4 +62,4 @@ ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.k ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystemTest.kt|2149 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt|3205 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/PantryLiveContractCompatibilityTest.kt|1202 -ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt|2526 +ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt|2543 From b0d2c2aeb0c1f1406d97603c0c9cdaa36875dda7 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 10:05:07 +0200 Subject: [PATCH 20/31] fix(android): preserve document account fences --- .../AndroidAccountCredentialController.kt | 6 +- .../AndroidAccountCredentialTransitions.kt | 20 +++++- .../AndroidAccountOperationGuard.kt | 37 +++++++++++ .../nextcloudnative/AndroidAccountRemoval.kt | 14 ++-- .../AndroidDocumentProviderReadAccess.kt | 64 +++++++++++++++---- .../AndroidDocumentWritebackRecovery.kt | 5 +- .../AndroidLocalFileProxyCallback.kt | 2 +- .../NextcloudDocumentsProvider.kt | 18 ++++-- ...idAccountCredentialTransitionCommitTest.kt | 29 +++++++++ .../AndroidAccountOperationGuardTest.kt | 25 ++++++++ .../AndroidDocumentProviderReadAccessTest.kt | 38 +++++++++++ 11 files changed, 221 insertions(+), 37 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitionCommitTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index c37b4e3fa..a25e29c72 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -325,7 +325,7 @@ internal class AndroidAccountCredentialController( state.registry.accounts.isEmpty() && state.sessions.isEmpty() }?.let(::encryptState) clearPersistedSession(encodedReplacement, replacement, pendingCleanup = pendingCleanup) - notifyDocumentRootsChanged() + notifyAndroidDocumentRootsAfterCommittedTransition(notifyDocumentRootsChanged, ::recordAccountRemovalCleanupFailure) } private suspend fun clearInvalidStore(suspectEncrypted: String?) { retireAndroidDocumentProviderIncarnationsForCredentialReset( @@ -334,7 +334,7 @@ internal class AndroidAccountCredentialController( clearCredentials = { clearPersistedSession(null, AndroidAccountCredentialState.Empty, suspectEncrypted) }, recordCompletionFailure = ::recordAccountRemovalCleanupFailure, ) - notifyDocumentRootsChanged() + notifyAndroidDocumentRootsAfterCommittedTransition(notifyDocumentRootsChanged, ::recordAccountRemovalCleanupFailure) } private suspend fun clearUnregisteredIndependentCredentialSlots(suspectEncrypted: String?) = clearUnregisteredAndroidAccountCredentialSlots( @@ -391,7 +391,7 @@ internal class AndroidAccountCredentialController( suspectEncrypted, pendingCleanup, ) - notifyDocumentRootsChanged() + notifyAndroidDocumentRootsAfterCommittedTransition(notifyDocumentRootsChanged, ::recordAccountRemovalCleanupFailure) } private suspend fun clearPersistedSession( encodedReplacement: String?, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index 3dd7a9054..027b9b855 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -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) @@ -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 = {}, @@ -127,7 +140,10 @@ internal suspend fun removeAndroidAccountCredentialData( } throw failure } - onInactiveRemovalCommitted() + notifyAndroidDocumentRootsAfterCommittedTransition( + onInactiveRemovalCommitted, + recordCommittedCleanupFailure, + ) finishCommittedAndroidAccountRemovalCleanup( removeQueuedUploads, completeCommittedCleanup, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index a6e0b70f6..0b7c21453 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -34,6 +34,28 @@ internal class AndroidAccountOperationGuard { } } + suspend fun tryWithAccounts( + accountIds: Collection, + unavailable: suspend () -> Result, + action: suspend () -> Result, + ): Result { + currentCoroutineContext().ensureActive() + val leases = mutableListOf() + 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 withAccounts(accountIds: Collection, action: suspend () -> Result): Result { val leases = mutableListOf() try { @@ -46,6 +68,17 @@ internal class AndroidAccountOperationGuard { fun acquireBlocking(accountId: String): AndroidAccountOperationLease = runBlocking { acquire(accountId) } + fun acquireBlocking(accountIds: Collection): AndroidAccountOperationLease = runBlocking { + val leases = mutableListOf() + 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 withAccountSession( accountId: String, resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, @@ -127,6 +160,10 @@ internal class AndroidAccountOperationLease( internal val ANDROID_ACCOUNT_OPERATION_GUARD = AndroidAccountOperationGuard() +internal fun androidAccountOperationIdentities( + session: dev.obiente.nextcloudnative.app.NextcloudSession, +): Set = setOf(NextcloudDocumentIds.accountKey(session), session.accountId.storageKey) + internal fun androidAccountOperationSessionIsCurrent( expectedAccountId: String, currentSession: dev.obiente.nextcloudnative.app.NextcloudSession?, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index 45f02dfd3..21107a939 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -43,13 +43,13 @@ internal suspend fun withAndroidAccountRemovalLease( guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, action: suspend () -> Result, -): Result = withAndroidAccountRemovalLease( - accountIdentity = NextcloudDocumentIds.accountKey(session), - guard = guard, - lifetimeGuard = lifetimeGuard, - lifetimeAccountIdentity = session.documentProviderIncarnationAccountIdentity(), - action = action, -) +): Result = lifetimeGuard.withRemoval(session.documentProviderIncarnationAccountIdentity()) { + guard.tryWithAccounts( + accountIds = androidAccountOperationIdentities(session), + unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, + action = action, + ) +} internal suspend fun revokeAndroidSessionAfterRemovalPreflight( preflight: suspend () -> Unit, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt index 95f5bccff..58452cd49 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt @@ -1,7 +1,9 @@ package dev.obiente.nextcloudnative +import android.os.CancellationSignal import android.os.Handler import android.os.ParcelFileDescriptor +import android.os.storage.StorageManager import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.File import java.io.FileNotFoundException @@ -14,12 +16,11 @@ internal fun acquireAndroidDocumentProviderReadLease( operationGuard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, ): AndroidAccountOperationLease { - val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession) val lifetimeLease = lifetimeGuard.acquireReadBlocking( expectedSession.documentProviderIncarnationAccountIdentity(), ) val operationLease = try { - operationGuard.acquireBlocking(accountIdentity) + operationGuard.acquireBlocking(androidAccountOperationIdentities(expectedSession)) } catch (failure: Throwable) { lifetimeLease.close() throw failure @@ -82,24 +83,59 @@ private fun checkAndroidDocumentProviderReadAccess( internal fun openAndroidDocumentAccountLeasedContent( content: File, accountLease: AndroidAccountOperationLease, + storageManager: StorageManager, handler: Handler, -): ParcelFileDescriptor = try { - ParcelFileDescriptor.open(content, ParcelFileDescriptor.MODE_READ_ONLY, handler) { accountLease.close() } + signal: CancellationSignal? = null, + onReleased: () -> Unit = {}, +): ParcelFileDescriptor { + val callback = androidDocumentAccountLeasedContentCallback(content, accountLease, onReleased) + return try { + signal?.setOnCancelListener(callback::cancel) + if (signal?.isCanceled == true) throw android.os.OperationCanceledException() + storageManager.openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY, callback, handler) + } catch (failure: Throwable) { + callback.onRelease() + throw failure + } +} + +internal fun androidDocumentAccountLeasedContentCallback( + content: File, + accountLease: AndroidAccountOperationLease, + onReleased: () -> Unit = {}, +): AndroidLocalFileProxyCallback = try { + AndroidLocalFileProxyCallback( + content = content, + accessAllowed = { true }, + onReleased = { + try { onReleased() } finally { accountLease.close() } + }, + ) } catch (failure: Throwable) { - accountLease.close() + try { + onReleased() + } catch (cleanupFailure: Throwable) { + failure.addSuppressed(cleanupFailure) + } + try { + accountLease.close() + } catch (cleanupFailure: Throwable) { + failure.addSuppressed(cleanupFailure) + } throw failure } internal fun openAndroidDocumentVirtualFileLease( lease: AndroidVirtualFileLease, accountLease: AndroidAccountOperationLease, + storageManager: StorageManager, handler: Handler, -): ParcelFileDescriptor = try { - ParcelFileDescriptor.open(lease.content, ParcelFileDescriptor.MODE_READ_ONLY, handler) { - try { lease.release() } finally { accountLease.close() } - } -} catch (failure: Throwable) { - lease.release() - accountLease.close() - throw failure -} + signal: CancellationSignal? = null, +): ParcelFileDescriptor = openAndroidDocumentAccountLeasedContent( + content = lease.content, + accountLease = accountLease, + storageManager = storageManager, + handler = handler, + signal = signal, + onReleased = lease.release, +) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt index 97c695dfb..cbf59da20 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt @@ -45,10 +45,9 @@ internal fun acquireAndroidDocumentMutationAccountLease( guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, ): AndroidAccountOperationLease { - val accountIdentity = NextcloudDocumentIds.accountKey(session) val lifetimeLease = lifetimeGuard.acquireReadBlocking(session.documentProviderIncarnationAccountIdentity()) val operationLease = try { - guard.acquireBlocking(accountIdentity) + guard.acquireBlocking(androidAccountOperationIdentities(session)) } catch (failure: Throwable) { lifetimeLease.close() throw failure @@ -77,7 +76,7 @@ internal inline fun withAndroidDocumentWritebackCommitWhileLifetimeLeas guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, action: (NextcloudSession) -> Result, ): Result { - val operationLease = guard.acquireBlocking(NextcloudDocumentIds.accountKey(expectedSession)) + val operationLease = guard.acquireBlocking(androidAccountOperationIdentities(expectedSession)) return try { val currentSession = loadCurrentSession() if (!androidDocumentWritebackSessionIsCurrent(expectedSession, currentSession)) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalFileProxyCallback.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalFileProxyCallback.kt index 6254b7f4b..c720e3787 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalFileProxyCallback.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalFileProxyCallback.kt @@ -8,7 +8,7 @@ import java.io.File import java.io.RandomAccessFile import java.util.concurrent.atomic.AtomicBoolean -/** Revocable, seekable access to an exact local cache generation used by an external handoff. */ +/** Revocable, seekable access to an exact local cache generation. */ internal class AndroidLocalFileProxyCallback( content: File, private val accessAllowed: () -> Boolean, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index f42ed8950..8f10b38e2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -172,7 +172,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { if (mode == "r") { offline.availableContent(session, reference.path)?.let { cached -> signal?.throwIfCanceled() - return openAndroidDocumentAccountLeasedContent(cached.content, accountLease, WRITE_HANDLER) + return openAndroidDocumentAccountLeasedContent(cached.content, accountLease, storageManager(), WRITE_HANDLER, signal) } } val account = resolveAccount(session) @@ -181,7 +181,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { if (mode == "r") { virtualFiles.acquire(session, reference.path)?.let { lease -> signal?.throwIfCanceled() - return openAndroidDocumentVirtualFileLease(lease, accountLease, WRITE_HANDLER) + return openAndroidDocumentVirtualFileLease(lease, accountLease, storageManager(), WRITE_HANDLER, signal) } } throw failure @@ -193,7 +193,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { file.etag?.takeIf(String::isNotBlank)?.let { etag -> virtualFiles.acquire(session, reference.path, expectedRemoteEtag = etag)?.let { lease -> signal?.throwIfCanceled() - return openAndroidDocumentVirtualFileLease(lease, accountLease, WRITE_HANDLER) + return openAndroidDocumentVirtualFileLease(lease, accountLease, storageManager(), WRITE_HANDLER, signal) } } return openVirtualFileProxy(session, account.userId, file, signal, accountLease) @@ -219,13 +219,14 @@ class NextcloudDocumentsProvider : DocumentsProvider() { var empty = virtualFiles.createHydrationStagingFile() if (runCatching { virtualFiles.publishHydration(session, file, empty) }.getOrDefault(false)) { virtualFiles.acquire(session, file.path, expectedRemoteEtag = etag)?.let { lease -> - return openAndroidDocumentVirtualFileLease(lease, accountLease, WRITE_HANDLER) + return openAndroidDocumentVirtualFileLease(lease, accountLease, storageManager(), WRITE_HANDLER, signal) } } if (!empty.exists()) empty = virtualFiles.createHydrationStagingFile() - return ParcelFileDescriptor.open(empty, ParcelFileDescriptor.MODE_READ_ONLY, WRITE_HANDLER) { - try { virtualFiles.discardHydrationStagingFile(empty) } finally { accountLease.close() } - } + return openAndroidDocumentAccountLeasedContent( + empty, accountLease, storageManager(), WRITE_HANDLER, signal, + onReleased = { virtualFiles.discardHydrationStagingFile(empty) }, + ) } val rangeSession = services.openFileRangeSession( session = session, @@ -770,6 +771,9 @@ class NextcloudDocumentsProvider : DocumentsProvider() { private fun documentsRootTitle(): String = context?.getString(R.string.documents_provider_root_name).orEmpty() + private fun storageManager(): StorageManager = + requireNotNull(context?.getSystemService(StorageManager::class.java)) + private fun requireActiveSession(): NextcloudSession = services.loadSession() ?: throw FileNotFoundException("Sign in to nati.ve to browse files.") diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitionCommitTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitionCommitTest.kt new file mode 100644 index 000000000..c78a6a702 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitionCommitTest.kt @@ -0,0 +1,29 @@ +package dev.obiente.nextcloudnative + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.runBlocking + +class AndroidAccountCredentialTransitionCommitTest { + @Test + fun rootsNotificationFailureKeepsAnActiveCredentialRemovalCommitted() = runBlocking { + val events = mutableListOf() + + removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { + events += "commit-removal" + notifyAndroidDocumentRootsAfterCommittedTransition( + notify = { error("synthetic roots notification failure") }, + recordFailure = { events += "diagnose-notification" }, + ) + }, + rollbackActiveRemoval = { events += "rollback-removal" }, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + ) + + assertEquals(listOf("commit-removal", "diagnose-notification", "remove-uploads"), events) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 66d5b93b1..546bbc0db 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -532,6 +532,31 @@ class AndroidAccountOperationGuardTest { assertEquals(replacement, current) } + @Test + fun canonicallyEquivalentCredentialTransitionCannotPassAnOlderDocumentMutation() = runBlocking { + val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "password") + val equivalent = original.copy(serverUrl = "HTTPS://CLOUD.EXAMPLE.TEST:443///") + val mutationLease = acquireAndroidDocumentMutationAccountLease( + original, { original }, guard, lifetimeGuard, + ) + try { + assertFalse( + guard.tryWithAccounts( + androidAccountOperationIdentities(equivalent), unavailable = { false }, action = { true }, + ), + ) + } finally { + mutationLease.close() + } + assertTrue( + guard.tryWithAccounts( + androidAccountOperationIdentities(equivalent), unavailable = { false }, action = { true }, + ), + ) + } + @Test fun directDocumentMutationLeaseRejectsReauthenticatedSessionAndReleasesTheGuard() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt index 54e5b5768..43bfd7a67 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt @@ -2,6 +2,7 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.FileNotFoundException +import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -24,6 +25,43 @@ class AndroidDocumentProviderReadAccessTest { private val originalIncarnation = incarnation("1") private val replacementIncarnation = incarnation("2") + @Test + fun cachedDocumentContentUsesARevocableProxyCallback() { + val content = Files.createTempFile("document-cache-proxy-", ".bin").toFile().apply { + writeText("cached bytes") + } + var leaseReleased = 0 + val callback = androidDocumentAccountLeasedContentCallback( + content, + AndroidAccountOperationLease { leaseReleased += 1 }, + ) + try { + val bytes = ByteArray(6) + assertEquals(6, callback.onRead(0L, bytes.size, bytes)) + assertEquals("cached", bytes.decodeToString()) + callback.onRelease() + callback.onRelease() + assertEquals(1, leaseReleased) + } finally { + callback.onRelease() + content.delete() + } + } + + @Test + fun failedCachedDocumentProxySetupReleasesItsAccountLease() { + var leaseReleased = 0 + + assertFailsWith { + androidDocumentAccountLeasedContentCallback( + java.io.File("missing-document-cache-${System.nanoTime()}"), + AndroidAccountOperationLease { leaseReleased += 1 }, + ) + } + + assertEquals(1, leaseReleased) + } + @Test fun openedFileLeaseBlocksRemovalUntilTheDescriptorReleasesIt() = runBlocking { val guard = AndroidAccountOperationGuard() From 935b7c3a13e8a6187b28deff0d00e4914dda5b86 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 10:34:06 +0200 Subject: [PATCH 21/31] refactor(groupware): preserve contacts size boundary --- tools/kotlin-file-size-baseline.txt | 2 +- .../obiente/nextcloudnative/app/GroupwareContactsScreen.kt | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index f2dd947e5..59736cb94 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -16,7 +16,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt| ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt|1386 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt|567 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt|1070 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt|1105 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt|1104 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt|1293 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt|2646 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingDynamicUiCaptureScenario.kt|909 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt index bba0dbdee..c1e5f351c 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt @@ -209,9 +209,7 @@ fun NativeGroupwareContactsScreen( LaunchedEffect(durableMutationInProgress) { onMutationInProgressChanged(durableMutationInProgress) } - DisposableEffect(Unit) { - onDispose { onMutationInProgressChanged(false) } - } + DisposableEffect(Unit) { onDispose { onMutationInProgressChanged(false) } } LaunchedEffect(session, userId, loadAttempt, mutationRecoveryLoaded) { if (!mutationRecoveryLoaded) return@LaunchedEffect From dfe0fc6faf23f40aab245e2dca25f8b5e14ca105 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:41:24 +0000 Subject: [PATCH 22/31] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 8100381e8..f56596523 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -555,7 +555,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "404d55e3cb691609cdbde00eaaddf230ec4e1626c342512b935e7c383630c8da", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactDetailDialog.kt": "ab6826a14ac9fbf62677bed2f6e93ef0fb1a57f1f074bb1a9e79fef212f715b3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "c0b42fa450b8748281c7208385f7257d286bc1f842588bc737f3c296dd4ec1d9", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "109dc430656de69e224e9c4852b7f14d51220e6e0fd9dfbcea349a0037cfa3a6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "5ec7d1326c216a1f4af813bd5d1132063654cada621ed5ae54805ac5c63953e1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt": "88f84a03a3c2d95b130601d5aac62fad4b4e1ed559c7851d7d47a3b44d1d2bc9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavMutation.kt": "f32d31bb3564ef2e4a565f840c0db227e2632f6e15251a29151f233c0b25f718", From 01810317c92a0bea4c3afa35a70390c9c6cee587 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 11:11:22 +0200 Subject: [PATCH 23/31] fix(android): recover malformed document resets --- ...AndroidDocumentProviderIncarnationStore.kt | 29 +++++++++++++++++- ...oidDocumentProviderIncarnationStoreTest.kt | 30 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt index 5c3a53b7f..c85614488 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -192,7 +192,7 @@ internal class AndroidDocumentProviderIncarnationStore( try { accountIdentities.distinct().sorted().forEach { accountIdentity -> requireAccountIdentity(accountIdentity) - val pending = readPendingRetirement(accountIdentity) + val pending = readPendingRetirementForCredentialReset(accountIdentity) if (pending != null) { resumeRetirementForCredentialReset(pending) retirements += pending @@ -224,6 +224,30 @@ internal class AndroidDocumentProviderIncarnationStore( } } + private fun readPendingRetirementForCredentialReset( + accountIdentity: String, + ): AndroidDocumentProviderIncarnationRetirement? = try { + readPendingRetirement(accountIdentity) + } catch (_: IllegalArgumentException) { + quarantineMalformedPendingRetirement(accountIdentity) + null + } catch (_: ClassCastException) { + quarantineMalformedPendingRetirement(accountIdentity) + null + } + + private fun quarantineMalformedPendingRetirement(accountIdentity: String) { + val journalKey = retirementJournalKey(accountIdentity) + val malformed = try { + read(journalKey) + } catch (_: ClassCastException) { + null + } + malformed?.let { persistEncoded(quarantinedRetirementJournalKey(accountIdentity), it.take(MAX_RETIREMENT_JOURNAL_LENGTH)) } + persist(accountIdentity, AndroidDocumentProviderIncarnationRecord.Retired(NextcloudDocumentIncarnation.Legacy)) + persistEncoded(journalKey, null) + } + private fun readPendingRetirement(accountIdentity: String): AndroidDocumentProviderIncarnationRetirement? { val encoded = read(retirementJournalKey(accountIdentity)) ?: return null return decodeAndroidDocumentProviderRetirement(encoded).also { retirement -> @@ -329,6 +353,9 @@ internal class AndroidDocumentProviderIncarnationStore( fun retirementJournalKey(accountIdentity: String): String = "$RETIREMENT_JOURNAL_KEY_PREFIX$accountIdentity" + + fun quarantinedRetirementJournalKey(accountIdentity: String): String = + "quarantined-$RETIREMENT_JOURNAL_KEY_PREFIX$accountIdentity" } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt index 47e1e42b9..121b0207a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt @@ -684,6 +684,36 @@ class AndroidDocumentProviderIncarnationStoreTest { ) } + @Test + fun credentialResetQuarantinesMalformedRetirementJournalsBeforeSafeReadd() = runBlocking { + listOf("broken", "2:unsupported").forEach { malformed -> + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val records = mutableMapOf( + accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(active), + "retirement:$accountIdentity" to malformed, + ) + val fixture = fixture(records, incarnations = listOf("2".repeat(32))) + var credentialsCleared = false + + retireAndroidDocumentProviderIncarnationsForCredentialReset( + fixture.store, + AndroidAccountRemovalLifetimeGuard(), + clearCredentials = { credentialsCleared = true }, + ) + + assertTrue(credentialsCleared) + assertFalse("retirement:$accountIdentity" in records) + assertEquals(malformed, records["quarantined-retirement:$accountIdentity"]) + assertFailsWith { fixture.store.activeIncarnation(accountIdentity) } + assertEquals( + NextcloudDocumentIncarnation.Versioned("2".repeat(32)), + fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false), + ) + } + } + @Test fun credentialResetTombstonesWrongTypedStateBeforeSafeReadd() = runBlocking { val values = mutableMapOf(accountIdentity to setOf("wrong-type")) From b0b2b4ea82441f3176eba6853917fab8b0561b33 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 11:11:32 +0200 Subject: [PATCH 24/31] fix(android): release cancelled document reads --- .../obiente/nextcloudnative/AndroidLocalFileProxyCallback.kt | 5 +---- .../nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalFileProxyCallback.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalFileProxyCallback.kt index c720e3787..7607e13df 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalFileProxyCallback.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalFileProxyCallback.kt @@ -57,10 +57,7 @@ internal class AndroidLocalFileProxyCallback( } } - fun cancel() { - cancelled.set(true) - runCatching(source::close) - } + fun cancel() = onRelease() private fun requireAccess() { if (released.get() || cancelled.get() || !accessAllowed()) { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt index 08b6c585a..13ce88722 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt @@ -302,6 +302,7 @@ class AndroidVirtualFileProxyCallbackTest { allowed = false callback.cancel() + assertEquals(1, released) assertFailsWith { callback.onRead(0L, 1, ByteArray(1)) } From 7417cecfb49c4f2f9679b0571ebb3aaf29a560b6 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 11:52:43 +0200 Subject: [PATCH 25/31] refactor(android): preserve credential size boundary --- .../nextcloudnative/AndroidAccountCredentialController.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index a25e29c72..90bfd4d41 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -799,5 +799,4 @@ internal class AndroidAccountCredentialController( component = SupportDiagnosticComponent.Cache, failure = failure, ) - } From 42bbe52bfeee910398b7d535adac4e14c482e5cd Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 12:56:23 +0200 Subject: [PATCH 26/31] fix(android): compose document account retirement --- .../AndroidAccountCredentialController.kt | 8 +- .../AndroidDocumentProviderReadAccess.kt | 12 +++ .../AndroidMalformedCredentialReset.kt | 25 +++-- .../NextcloudDocumentsProvider.kt | 6 +- .../AndroidAccountOperationGuardTest.kt | 33 +++++++ .../AndroidDocumentProviderReadAccessTest.kt | 61 ++++++++++++ ...droidIndependentCredentialSlotResetTest.kt | 97 ++++++++++++++++++- 7 files changed, 221 insertions(+), 21 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 90bfd4d41..865a911cb 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -336,11 +336,9 @@ internal class AndroidAccountCredentialController( ) notifyAndroidDocumentRootsAfterCommittedTransition(notifyDocumentRootsChanged, ::recordAccountRemovalCleanupFailure) } - private suspend fun clearUnregisteredIndependentCredentialSlots(suspectEncrypted: String?) = - clearUnregisteredAndroidAccountCredentialSlots( - preferences, sessionCipher, accountRemovalCleanupJournal, suspectEncrypted, - prepareAccountRemoval, removeQueuedUploads, ::commitPreferences, ::recordAccountRemovalCleanupFailure, - ::clearInvalidStore) + private suspend fun clearUnregisteredIndependentCredentialSlots(suspectEncrypted: String?) = clearUnregisteredAndroidAccountCredentialSlots( + appContext, preferences, sessionCipher, accountRemovalCleanupJournal, suspectEncrypted, + prepareAccountRemoval, removeQueuedUploads, ::commitPreferences, ::recordAccountRemovalCleanupFailure, ::clearInvalidStore) private suspend fun clearRecoveredInvalidStore( current: AndroidAccountCredentialState, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt index 58452cd49..de9c52b98 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt @@ -41,6 +41,18 @@ internal fun acquireAndroidDocumentProviderReadLease( } } +internal inline fun openAndroidTrackedRangeDescriptor( + accountLease: AndroidAccountOperationLease, + onOpenFailure: () -> Unit, + openDescriptor: () -> Descriptor, +): Descriptor = try { + openDescriptor().also { accountLease.close() } +} catch (failure: Throwable) { + runCatching(onOpenFailure).exceptionOrNull()?.let(failure::addSuppressed) + accountLease.close() + throw failure +} + internal inline fun withAndroidDocumentProviderReadAccess( expectedSession: NextcloudSession, expectedIncarnation: NextcloudDocumentIncarnation, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt index f7f44e284..7438ba97d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt @@ -1,14 +1,16 @@ package dev.obiente.nextcloudnative +import android.content.Context import android.content.SharedPreferences import dev.obiente.nextcloudnative.app.NextcloudSession internal suspend fun clearUnregisteredAndroidAccountCredentialSlots( + context: Context, preferences: SharedPreferences, sessionCipher: SessionCipher, cleanupJournal: AndroidAccountRemovalCleanupJournal, suspectEncrypted: String?, - prepareAccountRemoval: suspend (NextcloudSession) -> Unit, + prepareAccountRemoval: suspend (NextcloudSession) -> AndroidDocumentProviderIncarnationRetirement, removeAccountOwnedState: suspend (NextcloudSession) -> Unit, commitPreferences: (SharedPreferences.Editor) -> Unit, recordCleanupFailure: (Exception) -> Unit, @@ -35,6 +37,10 @@ internal suspend fun clearUnregisteredAndroidAccountCredentialSlots( ) }, prepareAccountRemoval = prepareAccountRemoval, + rollbackPreparedRemoval = { retirement -> rollbackAndroidAccountRemoval(context, retirement) }, + completePreparedRemoval = { retirement, accountStorageKey -> + cleanupJournal.completeDocumentRetirement(context, retirement, accountStorageKey) + }, commitSlotRemoval = { slot, cleanup -> commitPreferences( cleanupJournal.prepareEdit(preferences.edit().remove(slot.preferenceKey), cleanup), @@ -50,12 +56,15 @@ internal suspend fun clearUnregisteredAndroidAccountCredentialSlots( clearInvalidStore(suspectEncrypted) } -internal suspend fun retireUnregisteredAndroidAccountCredentialSlots( +internal suspend fun retireUnregisteredAndroidAccountCredentialSlots( slots: List, preexistingCleanupAccountStorageKeys: Set = emptySet(), retryPreexistingCleanup: suspend (AndroidIndependentCredentialSlotReset) -> Unit = {}, guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, - prepareAccountRemoval: suspend (NextcloudSession) -> Unit, + lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, + prepareAccountRemoval: suspend (NextcloudSession) -> Retirement, + rollbackPreparedRemoval: suspend (Retirement) -> Unit, + completePreparedRemoval: suspend (Retirement, String) -> Unit, commitSlotRemoval: suspend (AndroidIndependentCredentialSlotReset, AndroidPendingAccountRemovalCleanup) -> Unit, rollbackSlotRemoval: suspend (AndroidIndependentCredentialSlotReset) -> Unit, removeAccountOwnedState: suspend (NextcloudSession) -> Unit, @@ -68,16 +77,20 @@ internal suspend fun retireUnregisteredAndroidAccountCredentialSlots( retryPreexistingCleanup(slot) } val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) - withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session), guard) { + withAndroidAccountRemovalLease(session, guard, lifetimeGuard) { + var retirement: Retirement? = null removeRecoveredAndroidAccountCredentialData( - prepareAccountRemoval = { prepareAccountRemoval(session) }, + prepareAccountRemoval = { retirement = prepareAccountRemoval(session) }, removeQueuedUploads = { removeAccountOwnedState(session) }, clearRecoveredAccount = { commitSlotRemoval(slot, pendingCleanup) }, rollbackRecoveredAccount = { rollbackSlotRemoval(slot) + rollbackPreparedRemoval(requireNotNull(retirement)) clearCleanup(session.accountId.storageKey) }, - completeCommittedCleanup = { clearCleanup(session.accountId.storageKey) }, + completeCommittedCleanup = { + completePreparedRemoval(requireNotNull(retirement), session.accountId.storageKey) + }, recordCommittedCleanupFailure = recordCleanupFailure, ) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index 8f10b38e2..129a4640b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -259,7 +259,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { .getOrDefault(false) }, discardIncompleteHydration = virtualFiles::discardHydrationStagingFile, - onReleased = accountLease::close, ) } catch (failure: Throwable) { rangeSession.close() @@ -267,12 +266,9 @@ class NextcloudDocumentsProvider : DocumentsProvider() { throw failure } signal?.setOnCancelListener(callback::cancel) - return try { + return openAndroidTrackedRangeDescriptor(accountLease, callback::onRelease) { requireNotNull(context?.getSystemService(StorageManager::class.java)) .openProxyFileDescriptor(ParcelFileDescriptor.MODE_READ_ONLY, callback, nextProxyHandler()) - } catch (failure: Throwable) { - callback.onRelease() - throw failure } } private fun openExternalHandoffDocument( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 546bbc0db..4f29ed3dd 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -10,6 +10,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking @@ -557,6 +558,38 @@ class AndroidAccountOperationGuardTest { ) } + @Test + fun replacedSessionCanonicalIdentityBlocksCredentialPublication() = runBlocking { + val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val coordinator = AndroidFileRangeSessionCoordinator() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val replaced = original.copy(serverUrl = "HTTPS://CLOUD.EXAMPLE.TEST:443///") + val replacement = replaced.copy(appPassword = "new-password") + val replacementState = AndroidAccountCredentialState.Empty.upsertAndSelect(replacement) + val mutationLease = acquireAndroidDocumentMutationAccountLease( + original, { original }, guard, lifetimeGuard, + ) + var published = false + + val transition = async(Dispatchers.Default) { + replaceAndroidActiveStateWithAccountLeases( + replacement = replacementState, + previousSession = null, + replacedSession = replaced, + suspectEncrypted = null, + guard = guard, + coordinator = coordinator, + ) { _, _, _, _ -> published = true } + } + yield() + assertFalse(published) + + mutationLease.close() + withTimeout(1_000L) { transition.await() } + assertTrue(published) + } + @Test fun directDocumentMutationLeaseRejectsReauthenticatedSessionAndReleasesTheGuard() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt index 43bfd7a67..9a33afc74 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.NextcloudFileRangeSession import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.FileNotFoundException import java.nio.file.Files @@ -377,6 +378,66 @@ class AndroidDocumentProviderReadAccessTest { lease.close() } + @Test + fun trackedRemoteRangeDoesNotHoldRemovalLifetimeUntilDescriptorRelease() = runBlocking { + val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val coordinator = AndroidFileRangeSessionCoordinator() + val activity = AndroidFileRangeSessionActivity() + val accountLease = readLease(guard, lifetimeGuard) + val range = openTrackedAndroidFileRangeSession( + expectedSession = original, + resolveSession = { original }, + activity = activity, + guard = guard, + coordinator = coordinator, + openSource = { + NextcloudFileRangeSession( + size = 8L, + readBlock = { _, length -> ByteArray(length) }, + closeBlock = activity::close, + ) + }, + ) + openAndroidTrackedRangeDescriptor(accountLease, {}) { Any() } + val finishUse = requireNotNull(range.beginUse()) + var removalEntered = false + + val removal = async(Dispatchers.Default) { + withAndroidAccountRemovalLease(original, guard, lifetimeGuard) { + coordinator.quiesce(NextcloudDocumentIds.accountKey(original)) + removalEntered = true + } + } + yield() + assertFalse(removalEntered) + + finishUse() + withTimeout(1_000L) { removal.await() } + assertTrue(removalEntered) + assertEquals(null, range.beginUse()) + range.close() + } + + @Test + fun failedTrackedRangeDescriptorSetupReleasesLifetimeAndSource() { + var leaseReleased = 0 + var sourceReleased = 0 + val failure = FileNotFoundException("synthetic descriptor setup failure") + + val thrown = assertFailsWith { + openAndroidTrackedRangeDescriptor( + accountLease = AndroidAccountOperationLease { leaseReleased += 1 }, + onOpenFailure = { sourceReleased += 1 }, + openDescriptor = { throw failure }, + ) + } + + assertEquals(failure, thrown) + assertEquals(1, leaseReleased) + assertEquals(1, sourceReleased) + } + @Test fun canonicallyEquivalentRemovalWaitsForTheOriginalDescriptorLease() = runBlocking { val equivalent = original.copy(serverUrl = "HTTPS://CLOUD.EXAMPLE.TEST:443///") diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt index 40ee694de..b3e4c482a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt @@ -6,8 +6,13 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield class AndroidIndependentCredentialSlotResetTest { @Test @@ -66,11 +71,20 @@ class AndroidIndependentCredentialSlotResetTest { val events = mutableListOf() val presentSlots = mutableSetOf(first.preferenceKey, second.preferenceKey) val tombstones = mutableSetOf() + val completedRetirements = mutableListOf() retireUnregisteredAndroidAccountCredentialSlots( slots = listOf(first, second), guard = AndroidAccountOperationGuard(), - prepareAccountRemoval = { session -> events += "prepare-${session.loginName}" }, + prepareAccountRemoval = { session -> + events += "prepare-${session.loginName}" + session.accountId.storageKey + }, + rollbackPreparedRemoval = { error("prepared retirements must not roll back") }, + completePreparedRemoval = { retirement, accountStorageKey -> + completedRetirements += retirement + tombstones -= accountStorageKey + }, commitSlotRemoval = { slot, cleanup -> events += "commit-${slot.session.loginName}" presentSlots -= slot.preferenceKey @@ -92,6 +106,7 @@ class AndroidIndependentCredentialSlotResetTest { ) assertTrue(presentSlots.isEmpty()) assertTrue(tombstones.isEmpty()) + assertEquals(listOf(first.session.accountId.storageKey, second.session.accountId.storageKey), completedRetirements) } @Test @@ -100,12 +115,18 @@ class AndroidIndependentCredentialSlotResetTest { val second = resetSlot(NextcloudSession("https://two.example.test", "bob", "second-secret")) val tombstones = mutableSetOf() val removed = mutableSetOf() + val completed = mutableSetOf() val failures = mutableListOf() retireUnregisteredAndroidAccountCredentialSlots( slots = listOf(first, second), guard = AndroidAccountOperationGuard(), - prepareAccountRemoval = {}, + prepareAccountRemoval = { it.accountId.storageKey }, + rollbackPreparedRemoval = { error("committed retirements must not roll back") }, + completePreparedRemoval = { retirement, accountStorageKey -> + completed += retirement + tombstones -= accountStorageKey + }, commitSlotRemoval = { slot, cleanup -> removed += slot.preferenceKey tombstones += cleanup.accountStorageKey @@ -120,6 +141,7 @@ class AndroidIndependentCredentialSlotResetTest { assertEquals(setOf(first.preferenceKey, second.preferenceKey), removed) assertEquals(setOf(first.session.accountId.storageKey), tombstones) + assertEquals(setOf(second.session.accountId.storageKey), completed) assertEquals(1, failures.size) } @@ -135,7 +157,9 @@ class AndroidIndependentCredentialSlotResetTest { retireUnregisteredAndroidAccountCredentialSlots( slots = listOf(first, second), guard = AndroidAccountOperationGuard(), - prepareAccountRemoval = {}, + prepareAccountRemoval = { it.accountId.storageKey }, + rollbackPreparedRemoval = { error("committed retirements must not roll back") }, + completePreparedRemoval = { _, accountStorageKey -> tombstones -= accountStorageKey }, commitSlotRemoval = { slot, cleanup -> removed += slot.preferenceKey tombstones += cleanup.accountStorageKey @@ -170,7 +194,9 @@ class AndroidIndependentCredentialSlotResetTest { tombstones -= it.session.accountId.storageKey }, guard = AndroidAccountOperationGuard(), - prepareAccountRemoval = {}, + prepareAccountRemoval = { it.accountId.storageKey }, + rollbackPreparedRemoval = {}, + completePreparedRemoval = { _, accountStorageKey -> tombstones -= accountStorageKey }, commitSlotRemoval = { _, _ -> commitAttempted = true; error("synthetic commit failure") }, rollbackSlotRemoval = { error("slot must remain untouched") }, removeAccountOwnedState = { error("cleanup must not start") }, @@ -189,7 +215,9 @@ class AndroidIndependentCredentialSlotResetTest { preexistingCleanupAccountStorageKeys = tombstones.toSet(), retryPreexistingCleanup = { tombstones -= it.session.accountId.storageKey }, guard = AndroidAccountOperationGuard(), - prepareAccountRemoval = {}, + prepareAccountRemoval = { it.accountId.storageKey }, + rollbackPreparedRemoval = {}, + completePreparedRemoval = { _, accountStorageKey -> tombstones -= accountStorageKey }, commitSlotRemoval = { _, cleanup -> commitAttempted = true tombstones += cleanup.accountStorageKey @@ -205,6 +233,65 @@ class AndroidIndependentCredentialSlotResetTest { assertTrue(tombstones.isEmpty()) } + @Test + fun malformedSlotResetUsesCanonicalDocumentLifetimeFence() = runBlocking { + val session = NextcloudSession("https://CLOUD.example.test:443/", "alice", "first-secret") + val slot = resetSlot(session) + val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val documentLease = lifetimeGuard.acquireReadBlocking(session.accountId.storageKey) + val committed = CompletableDeferred() + + val reset = async(Dispatchers.Default) { + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(slot), + guard = guard, + lifetimeGuard = lifetimeGuard, + prepareAccountRemoval = { it.accountId.storageKey }, + rollbackPreparedRemoval = {}, + completePreparedRemoval = { _, _ -> }, + commitSlotRemoval = { _, _ -> committed.complete(Unit) }, + rollbackSlotRemoval = {}, + removeAccountOwnedState = {}, + clearCleanup = {}, + recordCleanupFailure = { error("cleanup must succeed") }, + ) + } + yield() + assertFalse(committed.isCompleted) + + documentLease.close() + withTimeout(1_000L) { reset.await() } + assertTrue(committed.isCompleted) + } + + @Test + fun slotCommitFailureRollsBackItsExactPreparedRetirement() = runBlocking { + val slot = resetSlot(NextcloudSession("https://one.example.test", "alice", "first-secret")) + val token = "retirement-${slot.session.accountId.storageKey}" + val rollbacks = mutableListOf() + var slotRestored = false + + assertFailsWith { + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(slot), + guard = AndroidAccountOperationGuard(), + lifetimeGuard = AndroidAccountRemovalLifetimeGuard(), + prepareAccountRemoval = { token }, + rollbackPreparedRemoval = { rollbacks += it }, + completePreparedRemoval = { _, _ -> error("failed commit must not complete retirement") }, + commitSlotRemoval = { _, _ -> error("synthetic slot commit failure") }, + rollbackSlotRemoval = { slotRestored = true }, + removeAccountOwnedState = { error("cleanup must not start") }, + clearCleanup = {}, + recordCleanupFailure = { error("cleanup must not start") }, + ) + } + + assertTrue(slotRestored) + assertEquals(listOf(token), rollbacks) + } + private fun resetSlot(session: NextcloudSession) = AndroidIndependentCredentialSlotReset( preferenceKey = androidAccountCredentialSlotKey(session.accountId), encrypted = "encrypted-${session.accountId.storageKey}", From e1d6b7f07040ab57a6d699ca0ced62a214599994 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 13:41:15 +0200 Subject: [PATCH 27/31] fix(android): quarantine invalid retirement text --- .../AndroidDocumentProviderIncarnationStore.kt | 7 ++++++- .../AndroidDocumentProviderIncarnationStoreTest.kt | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt index c85614488..44000f932 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -5,6 +5,7 @@ import android.provider.DocumentsContract import android.util.Log import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry import dev.obiente.nextcloudnative.app.NextcloudSession +import java.nio.charset.CharacterCodingException import java.util.Base64 import java.util.UUID import kotlinx.coroutines.NonCancellable @@ -446,7 +447,11 @@ private fun decodeRetirementField(value: String): String { } catch (failure: IllegalArgumentException) { throw IllegalArgumentException("Invalid document provider retirement journal encoding.", failure) } - val decodedText = decoded.decodeToString(throwOnInvalidSequence = true) + val decodedText = try { + decoded.decodeToString(throwOnInvalidSequence = true) + } catch (failure: CharacterCodingException) { + throw IllegalArgumentException("Invalid document provider retirement journal text.", failure) + } require(encodeRetirementField(decodedText) == value) { "The document provider retirement journal encoding is not canonical." } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt index 121b0207a..964c0650f 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt @@ -355,7 +355,7 @@ class AndroidDocumentProviderIncarnationStoreTest { @Test fun malformedAndUnsupportedJournalsStayUnavailableWhileOtherAccountsRecover() { - listOf("broken", "2:unsupported").forEach { malformed -> + listOf("broken", "2:unsupported", "1:$accountIdentity:present:_w:_w").forEach { malformed -> val otherAccount = "b".repeat(64) val otherActive = AndroidDocumentProviderIncarnationRecord.Active( NextcloudDocumentIncarnation.Versioned("2".repeat(32)), From 049513dedce08b9375e9bd45769b4e70cbc0288d Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 07:37:43 +0200 Subject: [PATCH 28/31] fix(android): preserve document provider transitions --- .../nextcloudnative/AndroidAccountFileRead.kt | 35 +++++++++++++-- ...AndroidDocumentProviderIncarnationStore.kt | 29 +++++++------ .../AndroidDocumentProviderReadAccess.kt | 33 +++++++++----- .../nextcloudnative/NextcloudDocumentIds.kt | 19 +++++--- .../NextcloudDocumentsAccountResolver.kt | 17 +++++--- ...oidDocumentProviderIncarnationStoreTest.kt | 43 +++++++++++++++++++ .../AndroidDocumentProviderReadAccessTest.kt | 37 ++++++++++++++++ .../NextcloudDocumentIdsTest.kt | 39 ++++++++++++++++- .../NextcloudDocumentsAccountResolverTest.kt | 20 +++++++++ 9 files changed, 230 insertions(+), 42 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt index 48efa09f2..75e1108db 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt @@ -64,6 +64,7 @@ internal class AndroidFileRangeSessionActivity { internal class AndroidFileRangeSessionCoordinator { private val monitor = Any() private val registrations = mutableMapOf>() + private var credentialResetInProgress = false fun register( accountIdentity: String, @@ -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 } @@ -88,6 +92,26 @@ internal class AndroidFileRangeSessionCoordinator { synchronized(monitor) { registrations.remove(accountIdentity) } } + suspend fun 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 @@ -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() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt index 44000f932..e1edf8aa5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -363,26 +363,29 @@ internal class AndroidDocumentProviderIncarnationStore( internal suspend fun retireAndroidDocumentProviderIncarnationsForCredentialReset( store: AndroidDocumentProviderIncarnationStore, lifetimeGuard: AndroidAccountRemovalLifetimeGuard, + rangeCoordinator: AndroidFileRangeSessionCoordinator = ANDROID_FILE_RANGE_SESSION_COORDINATOR, clearCredentials: suspend () -> Unit, recordCompletionFailure: (Exception) -> Unit = {}, ) { val accountIdentities = store.accountIdentitiesForCredentialReset() lifetimeGuard.withCredentialReset(accountIdentities) { - val retirements = store.prepareForCredentialReset(accountIdentities) - withContext(NonCancellable) { - try { - clearCredentials() - } catch (failure: Exception) { - retirements.asReversed().forEach { retirement -> - runCatching { store.rollback(retirement) }.onFailure(failure::addSuppressed) - } - throw failure - } - retirements.forEach { retirement -> + rangeCoordinator.withAllQuiesced { + val retirements = store.prepareForCredentialReset(accountIdentities) + withContext(NonCancellable) { try { - store.complete(retirement) + clearCredentials() } catch (failure: Exception) { - recordCompletionFailure(failure) + retirements.asReversed().forEach { retirement -> + runCatching { store.rollback(retirement) }.onFailure(failure::addSuppressed) + } + throw failure + } + retirements.forEach { retirement -> + try { + store.complete(retirement) + } catch (failure: Exception) { + recordCompletionFailure(failure) + } } } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt index de9c52b98..59df1a643 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt @@ -53,27 +53,38 @@ internal inline fun openAndroidTrackedRangeDescriptor( throw failure } -internal inline fun withAndroidDocumentProviderReadAccess( +internal fun withAndroidDocumentProviderReadAccess( expectedSession: NextcloudSession, expectedIncarnation: NextcloudDocumentIncarnation, - noinline loadCurrentSession: () -> NextcloudSession?, - noinline loadCurrentIncarnation: (String) -> NextcloudDocumentIncarnation, + loadCurrentSession: () -> NextcloudSession?, + loadCurrentIncarnation: (String) -> NextcloudDocumentIncarnation, operationGuard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, action: (NextcloudSession) -> Result, ): Result { - val lease = acquireAndroidDocumentProviderReadLease( - expectedSession, - expectedIncarnation, - loadCurrentSession, - loadCurrentIncarnation, - operationGuard, - lifetimeGuard, + val lifetimeLease = lifetimeGuard.acquireReadBlocking( + expectedSession.documentProviderIncarnationAccountIdentity(), ) + val operationLease = try { + operationGuard.acquireBlocking(androidAccountOperationIdentities(expectedSession)) + } catch (failure: Throwable) { + lifetimeLease.close() + throw failure + } return try { + checkAndroidDocumentProviderReadAccess( + expectedSession, + expectedIncarnation, + loadCurrentSession, + loadCurrentIncarnation, + ) action(expectedSession) } finally { - lease.close() + try { + operationLease.close() + } finally { + lifetimeLease.close() + } } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt index b6d01e698..6c8239814 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt @@ -48,6 +48,9 @@ internal object NextcloudDocumentIds { .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } } + fun documentAccountKey(session: NextcloudSession): String = + session.accountId.storageKey.take(DOCUMENT_ACCOUNT_KEY_CHARACTERS) + /** Full digest for private caches which require a canonical SHA-256 directory key. */ fun cacheAccountId(session: NextcloudSession): String = accountDigest(session.serverUrl, session.loginName) @@ -55,8 +58,8 @@ internal object NextcloudDocumentIds { fun providerRootId(session: NextcloudSession, incarnation: NextcloudDocumentIncarnation): String = when (incarnation) { - NextcloudDocumentIncarnation.Legacy -> accountKey(session) - is NextcloudDocumentIncarnation.Versioned -> "${accountKey(session)}:${incarnation.value}" + NextcloudDocumentIncarnation.Legacy -> documentAccountKey(session) + is NextcloudDocumentIncarnation.Versioned -> "${documentAccountKey(session)}:${incarnation.value}" } fun parseProviderRootId(rootId: String): NextcloudDocumentRootReference { @@ -72,7 +75,7 @@ internal object NextcloudDocumentIds { } fun rootId(session: NextcloudSession, incarnation: NextcloudDocumentIncarnation): String = - rootId(accountKey(session), incarnation) + rootId(documentAccountKey(session), incarnation) fun rootId(accountKey: String, incarnation: NextcloudDocumentIncarnation): String { require(accountKeyPattern.matches(accountKey)) { "Invalid document account." } @@ -90,9 +93,9 @@ internal object NextcloudDocumentIds { val normalizedPath = normalizePath(path) val encodedPath = encoder.encodeToString(normalizedPath.encodeToByteArray()) return when (incarnation) { - NextcloudDocumentIncarnation.Legacy -> "$LEGACY_PREFIX:${accountKey(session)}:$encodedPath" + NextcloudDocumentIncarnation.Legacy -> "$LEGACY_PREFIX:${documentAccountKey(session)}:$encodedPath" is NextcloudDocumentIncarnation.Versioned -> - "$VERSIONED_PREFIX:${accountKey(session)}:${incarnation.value}:$encodedPath" + "$VERSIONED_PREFIX:${documentAccountKey(session)}:${incarnation.value}:$encodedPath" } } @@ -123,7 +126,9 @@ internal object NextcloudDocumentIds { incarnation: NextcloudDocumentIncarnation, ): NextcloudDocumentReference = parse(documentId).also { reference -> - require(reference.accountKey == accountKey(session)) { "Document belongs to another account." } + require(reference.accountKey in setOf(documentAccountKey(session), accountKey(session))) { + "Document belongs to another account." + } require(reference.incarnation == incarnation) { "Document belongs to an earlier account incarnation." } } @@ -132,6 +137,8 @@ internal object NextcloudDocumentIds { return MessageDigest.getInstance("SHA-256").digest(identity.encodeToByteArray()) } + private const val DOCUMENT_ACCOUNT_KEY_CHARACTERS = 32 + fun parentPath(path: String): String = normalizePath(path).substringBeforeLast('/', missingDelimiterValue = "") private fun normalizePath(path: String): String { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt index c8a3e5c31..2913e3f75 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt @@ -24,12 +24,12 @@ internal class NextcloudDocumentsAccountResolver( fun resolvableAccounts(): List { val records = runCatching(listAccounts).getOrElse { return emptyList() } val unambiguousKeys = records - .groupingBy(NextcloudAccountRecord::documentAccountKey) + .groupingBy { record -> record.id.storageKey.take(DOCUMENT_ACCOUNT_KEY_CHARACTERS) } .eachCount() .filterValues { count -> count == 1 } .keys return records.mapNotNull { record -> - record.takeIf { it.documentAccountKey() in unambiguousKeys } + record.takeIf { it.canonicalDocumentAccountKey() in unambiguousKeys } ?.let(::loadExactAccountSafely) } } @@ -51,7 +51,7 @@ internal class NextcloudDocumentsAccountResolver( } private fun requireAccount(accountKey: String): ResolvedNextcloudDocumentsAccount { - val matches = listAccounts().filter { record -> record.documentAccountKey() == accountKey } + val matches = listAccounts().filter { record -> accountKey in record.documentAccountKeys() } require(matches.size == 1) { "The document account is missing or ambiguous." } return requireNotNull(loadExactAccount(matches.single())) { "The document account credentials are unavailable." @@ -63,7 +63,7 @@ internal class NextcloudDocumentsAccountResolver( private fun loadExactAccount(record: NextcloudAccountRecord): ResolvedNextcloudDocumentsAccount? { val session = loadSession(record.id)?.takeIf { candidate -> - candidate.accountRecord() == record && NextcloudDocumentIds.accountKey(candidate) == record.documentAccountKey() + candidate.accountRecord() == record } ?: return null return ResolvedNextcloudDocumentsAccount(session, loadIncarnation(record.id.storageKey)) } @@ -78,5 +78,10 @@ internal fun nextcloudDocumentsAccountResolver( incarnations::activeIncarnation, ) -private fun NextcloudAccountRecord.documentAccountKey(): String = - NextcloudDocumentIds.accountKey(serverUrl, loginName) +private fun NextcloudAccountRecord.canonicalDocumentAccountKey(): String = + id.storageKey.take(DOCUMENT_ACCOUNT_KEY_CHARACTERS) + +private fun NextcloudAccountRecord.documentAccountKeys(): Set = + setOf(canonicalDocumentAccountKey(), NextcloudDocumentIds.accountKey(serverUrl, loginName)) + +private const val DOCUMENT_ACCOUNT_KEY_CHARACTERS = 32 diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt index 964c0650f..7bcfa9182 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt @@ -594,6 +594,49 @@ class AndroidDocumentProviderIncarnationStoreTest { assertTrue(credentialsCleared) } + @Test + fun credentialResetQuiescesRangeSessionsBeforeCredentialsDisappear() = runBlocking { + val active = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + val fixture = fixture( + records = mutableMapOf(accountIdentity to encodeAndroidDocumentProviderIncarnationRecord(active)), + ) + val coordinator = AndroidFileRangeSessionCoordinator() + val activity = AndroidFileRangeSessionActivity() + var sourceClosed = false + val registration = coordinator.register("legacy-range-account", activity) { + sourceClosed = true + activity.close() + } + val finishRead = requireNotNull(activity.start()) + var credentialsCleared = false + + val reset = async(start = CoroutineStart.UNDISPATCHED) { + retireAndroidDocumentProviderIncarnationsForCredentialReset( + store = fixture.store, + lifetimeGuard = AndroidAccountRemovalLifetimeGuard(), + rangeCoordinator = coordinator, + clearCredentials = { credentialsCleared = true }, + ) + } + + assertTrue(sourceClosed) + assertFalse(credentialsCleared) + assertFailsWith { + coordinator.register("late-range-account", AndroidFileRangeSessionActivity()) {} + } + + finishRead() + reset.await() + + assertTrue(credentialsCleared) + assertEquals(null, activity.start()) + registration.close() + val newActivity = AndroidFileRangeSessionActivity() + coordinator.register("new-range-account", newActivity, newActivity::close).close() + } + @Test fun credentialResetWaitsForARecordlessLegacyDocumentLifetimeLease() = runBlocking { val fixture = fixture() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt index 9a33afc74..58d23d8d5 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt @@ -336,6 +336,43 @@ class AndroidDocumentProviderReadAccessTest { assertTrue(removalEntered) } + @Test + fun providerReadKeepsCredentialTransitionBlockedThroughTheAuthenticatedCall() = runBlocking { + val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val readEntered = CompletableDeferred() + val finishRead = CompletableDeferred() + val replacement = original.copy(appPassword = "replacement-password") + var currentSession = original + val read = async(Dispatchers.Default) { + withAndroidDocumentProviderReadAccess( + original, + originalIncarnation, + { currentSession }, + { originalIncarnation }, + guard, + lifetimeGuard, + ) { + readEntered.complete(Unit) + runBlocking { finishRead.await() } + } + } + readEntered.await() + val transition = async(Dispatchers.Default) { + guard.withAccounts(androidAccountOperationIdentities(replacement)) { + currentSession = replacement + } + } + yield() + + assertFalse(transition.isCompleted) + assertEquals(original, currentSession) + finishRead.complete(Unit) + read.await() + transition.await() + assertEquals(replacement, currentSession) + } + @Test fun failedReadReleasesTheAccountLease() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt index 4736852a0..cfdb2423b 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt @@ -50,6 +50,41 @@ class NextcloudDocumentIdsTest { ) } + @Test + fun documentIdsAreStableAcrossCanonicalServerSpellings() { + val equivalent = session.copy(serverUrl = "HTTPS://CLOUD.EXAMPLE:443///") + val retainedDocument = NextcloudDocumentIds.documentId(session, legacy, "Documents/report.pdf") + + assertEquals(session.accountId, equivalent.accountId) + assertNotEquals(NextcloudDocumentIds.accountKey(session), NextcloudDocumentIds.accountKey(equivalent)) + assertEquals( + retainedDocument, + NextcloudDocumentIds.documentId(equivalent, legacy, "Documents/report.pdf"), + ) + assertEquals( + NextcloudDocumentIds.providerRootId(session, legacy), + NextcloudDocumentIds.providerRootId(equivalent, legacy), + ) + assertEquals( + "Documents/report.pdf", + NextcloudDocumentIds.requireForSession(retainedDocument, equivalent, legacy).path, + ) + } + + @Test + fun legacyRawDocumentIdsRemainReadableForTheirCurrentSession() { + val canonical = NextcloudDocumentIds.documentId(session, legacy, "Documents/report.pdf") + val legacyId = canonical.replaceFirst( + NextcloudDocumentIds.documentAccountKey(session), + NextcloudDocumentIds.accountKey(session), + ) + + assertEquals( + "Documents/report.pdf", + NextcloudDocumentIds.requireForSession(legacyId, session, legacy).path, + ) + } + @Test fun accountWorkIdentityRetainsThePreRegistryRawServerDigest() { val legacySession = session.copy(serverUrl = "https://CLOUD.EXAMPLE:443/") @@ -128,11 +163,11 @@ class NextcloudDocumentIdsTest { val versioned = NextcloudDocumentIncarnation.Versioned("1".repeat(32)) assertEquals( - NextcloudDocumentRootReference(NextcloudDocumentIds.accountKey(session), legacy), + NextcloudDocumentRootReference(NextcloudDocumentIds.documentAccountKey(session), legacy), NextcloudDocumentIds.parseProviderRootId(NextcloudDocumentIds.providerRootId(session, legacy)), ) assertEquals( - NextcloudDocumentRootReference(NextcloudDocumentIds.accountKey(session), versioned), + NextcloudDocumentRootReference(NextcloudDocumentIds.documentAccountKey(session), versioned), NextcloudDocumentIds.parseProviderRootId(NextcloudDocumentIds.providerRootId(session, versioned)), ) assertFailsWith { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt index 6d2b4323a..bffa171c7 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt @@ -7,6 +7,7 @@ import dev.obiente.nextcloudnative.app.accountRecord import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals class NextcloudDocumentsAccountResolverTest { private val alice = session("alice") @@ -122,6 +123,25 @@ class NextcloudDocumentsAccountResolverTest { assertEquals(alice.accountId.storageKey, loadedIdentity) } + @Test + fun `retained IDs resolve after canonically equivalent reauthentication`() { + val equivalent = alice.copy( + serverUrl = "HTTPS://CLOUD.EXAMPLE:443///", + appPassword = "replacement-password", + ) + val retainedDocument = NextcloudDocumentIds.documentId(alice, aliceIncarnation, "Documents/report.pdf") + val retainedRoot = NextcloudDocumentIds.providerRootId(alice, aliceIncarnation) + val resolver = resolver( + accounts = listOf(equivalent.accountRecord()), + loadSession = { equivalent }, + ) + + assertEquals(alice.accountId, equivalent.accountId) + assertNotEquals(NextcloudDocumentIds.accountKey(alice), NextcloudDocumentIds.accountKey(equivalent)) + assertEquals("Documents/report.pdf", resolver.requireDocument(retainedDocument).reference.path) + assertEquals(aliceIncarnation, resolver.requireRoot(retainedRoot).incarnation) + } + @Test fun `retained document and root IDs fail after the same account is readded`() { val resolver = resolver( From 8a804d331cd31b78bedccf29a2cc63d1e1017911 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 07:43:01 +0200 Subject: [PATCH 29/31] fix(android): reuse document read leases --- .../dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index 129a4640b..2d4dc1971 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -883,7 +883,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { private fun findDocumentWithOfflineFallback(session: NextcloudSession, path: String): NextcloudFile { val cached = offline.availableEntry(session, path) ?: virtualFiles.cachedEntry(session, path) - return runCatching { findDocument(session, resolveAccount(session), path) } + return runCatching { findDocument(session, resolveAccount(session), path, accountLeaseHeld = true) } .getOrElse { failure -> cached ?: throw FileNotFoundException("The requested Nextcloud document was not found.").also { it.initCause(failure) From c8f5f3283469f54948bb488a9bfdf1613959230c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 08:43:52 +0200 Subject: [PATCH 30/31] fix(android): compile retained document access wrappers --- .../dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index 2d4dc1971..b327035ba 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -787,7 +787,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { accountResolver.requireRoot(rootId) } - private inline fun withDocumentRead( + private fun withDocumentRead( documentId: String, action: (NextcloudSession, NextcloudDocumentReference) -> Result, ): Result { @@ -797,7 +797,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { { services.loadSession(resolved.session.accountId) }, documentIncarnations::activeIncarnation, ) { session -> action(session, resolved.reference) } } - private inline fun withRootRead( + private fun withRootRead( rootId: String, action: (NextcloudSession, NextcloudDocumentIncarnation) -> Result, ): Result { From 22a87be1a332c66791630a390c0f7a065b4f107d Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:58:30 +0000 Subject: [PATCH 31/31] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index f56596523..0407581d9 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -488,7 +488,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DocumentPreview.kt": "a9a8743dd7a381504282cc6ddc68034425024ccc1ae51667da9734bbfc7b1a79", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DurableMutationRecoveryDialog.kt": "e720eadb477a347762cd1894285788ac9f6820972431fe0a1953d01955667bfe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt": "2b7ef2d18b4a23615686ced0b7c9c621c58dc5edd0202104d0ca55b1ebf61d81", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "ddf80ca67d954f6e063c9e88c75794fcb81cbd6d42887c45d1a04b1cefe4f2fd", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "cff6ba11283705120375452d6d539c20581f4dd0115dd3d07049eb965242b019",