diff --git a/ROADMAP.md b/ROADMAP.md index 6c481e98d..b52062020 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 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 ba538af3c..865a911cb 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,18 +56,15 @@ 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 { val registry = readRegistryForCredentialLoad() ?: return@serialize null loadSession(accountId, registry) } - private fun loadSession( accountId: NextcloudAccountId, registry: NextcloudAccountRegistry, @@ -107,6 +104,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 +115,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 +128,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, @@ -139,7 +139,6 @@ internal class AndroidAccountCredentialController( } requireNotNull(loadSession(session.accountId)) } - suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { val (current, suspectEncrypted) = recoverAndroidAccountCredentialStateForSelection( @@ -151,18 +150,18 @@ 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) 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( active = active, - prepareAccountRemoval = { prepareAccountRemoval(session) }, + prepareAccountRemoval = { documentRetirement = prepareAccountRemoval(session) }, removeQueuedUploads = { removeQueuedUploads(session) }, clearActiveAccount = { clearSession(current, pendingCleanup) }, rollbackActiveRemoval = { @@ -171,20 +170,24 @@ 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) }, + onInactiveRemovalCommitted = notifyDocumentRootsChanged, + completeCommittedCleanup = { + accountRemovalCleanupJournal.completeDocumentRetirement(appContext, documentRetirement, accountId.storageKey) + }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) } true } - private suspend fun removeUnavailableAccount( accountId: NextcloudAccountId, recovered: AndroidAccountCredentialState, @@ -193,11 +196,12 @@ 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, active = target.wasActive, - prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, + prepareAccountRemoval = { documentRetirement = prepareAccountRemoval(unavailableSession) }, removeAccountOwnedWorkWithoutCredentials = { identity -> retryQueuedUploadsCleanupWithoutCredentials( pendingCleanup.accountStorageKey, @@ -212,17 +216,21 @@ 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) }, + onInactiveRemovalCommitted = notifyDocumentRootsChanged, + completeCommittedCleanup = { + accountRemovalCleanupJournal.completeDocumentRetirement(appContext, documentRetirement, accountId.storageKey) + }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) } - notifyDocumentRootsChanged() return true } - suspend fun revokeSession( expectedSession: NextcloudSession, revokeRemoteSession: suspend () -> Unit, @@ -231,11 +239,11 @@ 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, - preflight = { prepareAccountRemoval(expectedSession) }, + expectedSession = expectedSession, + preflight = { documentRetirement = prepareAccountRemoval(expectedSession) }, revoke = revokeRemoteSession, removeLocalAccount = { removeAndroidAccountCredentialData( @@ -244,19 +252,19 @@ internal class AndroidAccountCredentialController( clearActiveAccount = { clearSession(current, pendingCleanup) }, rollbackActiveRemoval = { replaceActiveStateWhileOperationsIdle(current, previousSession = null, suspectEncrypted = null) + rollbackAndroidAccountRemoval(appContext, requireNotNull(documentRetirement)) accountRemovalCleanupJournal.clear(expectedSession.accountId.storageKey) }, persistInactiveRemoval = {}, rollbackInactiveRemoval = {}, completeCommittedCleanup = { - accountRemovalCleanupJournal.clear(expectedSession.accountId.storageKey) + accountRemovalCleanupJournal.completeDocumentRetirement(appContext, documentRetirement, expectedSession.accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) }, ) } - suspend fun clearSession() = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { when (val read = readStore()) { is AndroidAccountCredentialStoreRead.Available -> { @@ -265,12 +273,12 @@ 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, - prepareAccountRemoval = { prepareAccountRemoval(session) }, + prepareAccountRemoval = { documentRetirement = prepareAccountRemoval(session) }, removeQueuedUploads = { removeQueuedUploads(session) }, clearActiveAccount = { clearSession(read.state, pendingCleanup) }, rollbackActiveRemoval = { @@ -279,12 +287,13 @@ internal class AndroidAccountCredentialController( previousSession = null, suspectEncrypted = null, ) + rollbackAndroidAccountRemoval(appContext, requireNotNull(documentRetirement)) accountRemovalCleanupJournal.clear(session.accountId.storageKey) }, persistInactiveRemoval = {}, rollbackInactiveRemoval = {}, completeCommittedCleanup = { - accountRemovalCleanupJournal.clear(session.accountId.storageKey) + accountRemovalCleanupJournal.completeDocumentRetirement(appContext, documentRetirement, session.accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) @@ -306,7 +315,6 @@ internal class AndroidAccountCredentialController( is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } } - private suspend fun clearSession( current: AndroidAccountCredentialState, pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, activeFallback: NextcloudSession? = null, @@ -317,22 +325,20 @@ 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?) { - 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() + 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, @@ -340,11 +346,11 @@ 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 = { prepareAccountRemoval(activeSession) }, + prepareAccountRemoval = { documentRetirement = prepareAccountRemoval(activeSession) }, removeQueuedUploads = { removeQueuedUploads(activeSession) }, clearRecoveredAccount = { persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted, pendingCleanup) @@ -355,10 +361,11 @@ internal class AndroidAccountCredentialController( previousSession = null, suspectEncrypted = suspectEncrypted, ) + rollbackAndroidAccountRemoval(appContext, requireNotNull(documentRetirement)) accountRemovalCleanupJournal.clear(activeSession.accountId.storageKey) }, completeCommittedCleanup = { - accountRemovalCleanupJournal.clear(activeSession.accountId.storageKey) + accountRemovalCleanupJournal.completeDocumentRetirement(appContext, documentRetirement, activeSession.accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) @@ -367,7 +374,6 @@ internal class AndroidAccountCredentialController( persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted) } } - private suspend fun persistRecoveredInvalidStoreAfterClear( current: AndroidAccountCredentialState, suspectEncrypted: String, @@ -383,9 +389,8 @@ internal class AndroidAccountCredentialController( suspectEncrypted, pendingCleanup, ) - notifyDocumentRootsChanged() + notifyAndroidDocumentRootsAfterCommittedTransition(notifyDocumentRootsChanged, ::recordAccountRemovalCleanupFailure) } - private suspend fun clearPersistedSession( encodedReplacement: String?, replacement: AndroidAccountCredentialState, @@ -429,7 +434,6 @@ internal class AndroidAccountCredentialController( ) } } - private suspend fun replaceActiveState( replacement: AndroidAccountCredentialState, previousSession: NextcloudSession?, @@ -534,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? = @@ -557,7 +561,7 @@ internal class AndroidAccountCredentialController( ) } state.registry - } + }?.let { registry -> reconcileAndroidDocumentProviderAccountRemovals(appContext, registry) } } private fun recordCredentialFreeRegistryDiagnostic(restored: RestoredAndroidCredentialFreeRegistry) { @@ -610,10 +614,10 @@ 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)) } } @@ -724,7 +728,6 @@ internal class AndroidAccountCredentialController( throw failure } } - private fun encryptState(state: AndroidAccountCredentialState): String = try { sessionCipher.encrypt(encodeAndroidAccountCredentialState(state)) } catch (failure: Exception) { @@ -734,7 +737,6 @@ internal class AndroidAccountCredentialController( ) throw failure } - private fun encryptCredentialSlot(session: NextcloudSession): String = try { sessionCipher.encrypt(encodeAndroidPersistedSession(session)) } catch (failure: Exception) { @@ -744,7 +746,6 @@ internal class AndroidAccountCredentialController( ) throw failure } - private fun prepareCredentialSlotEdit( editor: SharedPreferences.Editor, state: AndroidAccountCredentialState, @@ -796,5 +797,4 @@ internal class AndroidAccountCredentialController( component = SupportDiagnosticComponent.Cache, failure = failure, ) - } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index c5713fd0e..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 = {}, @@ -95,6 +108,7 @@ internal suspend fun removeAndroidAccountCredentialData( rollbackActiveRemoval: suspend () -> Unit, persistInactiveRemoval: suspend () -> Unit, rollbackInactiveRemoval: suspend () -> Unit, + onInactiveRemovalCommitted: () -> Unit = {}, completeCommittedCleanup: suspend () -> Unit = {}, recordCommittedCleanupFailure: (Exception) -> Unit = {}, ) { @@ -126,6 +140,10 @@ internal suspend fun removeAndroidAccountCredentialData( } throw failure } + notifyAndroidDocumentRootsAfterCommittedTransition( + onInactiveRemovalCommitted, + recordCommittedCleanupFailure, + ) finishCommittedAndroidAccountRemovalCleanup( removeQueuedUploads, completeCommittedCleanup, @@ -141,6 +159,7 @@ internal suspend fun removeUnavailableAndroidAccountCredentialData( persistRemoval: suspend () -> Unit, clearActiveAccount: suspend () -> Unit = persistRemoval, rollbackRemoval: suspend () -> Unit, + onInactiveRemovalCommitted: () -> Unit = {}, completeCommittedCleanup: suspend () -> Unit = {}, recordCommittedCleanupFailure: (Exception) -> Unit = {}, ) { @@ -153,6 +172,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/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/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/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 6cbb56f40..21107a939 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -4,8 +4,12 @@ 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.sync.withLock import kotlinx.coroutines.withContext internal val NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS: Int = @@ -23,12 +27,29 @@ internal fun rejectAndroidAccountRemovalForPendingDocumentChanges(): Nothing = 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 = guard.tryWithAccount( - accountId = accountIdentity, - unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, - action = action, -) +): Result = lifetimeGuard.withRemoval(lifetimeAccountIdentity) { + guard.tryWithAccount( + accountId = accountIdentity, + unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, + action = action, + ) +} + +internal suspend fun withAndroidAccountRemovalLease( + session: NextcloudSession, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, + action: suspend () -> Result, +): Result = lifetimeGuard.withRemoval(session.documentProviderIncarnationAccountIdentity()) { + guard.tryWithAccounts( + accountIds = androidAccountOperationIdentities(session), + unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, + action = action, + ) +} internal suspend fun revokeAndroidSessionAfterRemovalPreflight( preflight: suspend () -> Unit, @@ -62,15 +83,150 @@ 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) { +) = withAndroidAccountRemovalLease(expectedSession, guard, lifetimeGuard) { revokeAndroidSessionAfterRemovalPreflight(preflight, revoke, removeLocalAccount) } +internal class AndroidAccountRemovalLifetimeGuard { + private val monitor = Any() + private val accounts = mutableMapOf() + private val resetAdmissionGate = Mutex() + + fun acquireReadBlocking(accountIdentity: String): AndroidAccountOperationLease = + runBlocking { acquireRead(accountIdentity) } + + suspend fun withRemoval(accountIdentity: String, action: suspend () -> Result): Result { + 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 { + leases.asReversed().forEach(AndroidAccountOperationLease::close) + } + } + + 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 = referenceWithResetAdmission(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 = 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() + 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 suspend fun referenceWithResetAdmission(accountIdentity: String): AccountLifetime = + resetAdmissionGate.withLock { reference(accountIdentity) } + + 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"), @@ -86,17 +242,39 @@ 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)) + return AndroidDocumentProviderIncarnationStore(context) + .retireForRemoval(session.documentProviderIncarnationAccountIdentity()) } -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, - ) +internal fun rollbackAndroidAccountRemoval( + context: Context, + retirement: AndroidDocumentProviderIncarnationRetirement, +) = AndroidDocumentProviderIncarnationStore(context).rollback(retirement) + +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), + 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..e1edf8aa5 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStore.kt @@ -0,0 +1,591 @@ +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.nio.charset.CharacterCodingException +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 + + data class Active( + override val incarnation: NextcloudDocumentIncarnation, + ) : AndroidDocumentProviderIncarnationRecord + + data class Retired( + override val incarnation: NextcloudDocumentIncarnation, + ) : 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?, + val retiredEncoded: String, + 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("-", "")) + }, +) { + 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().apply { + 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 + is AndroidDocumentProviderIncarnationRecord.Retired -> + error("The document provider account incarnation is retired.") + } + } + + fun prepareForAccountSave( + accountIdentity: String, + accountAlreadyStored: Boolean, + ): NextcloudDocumentIncarnation = synchronized(LOCK) { + requireNoPendingRetirement(accountIdentity) + 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 = + retireForRemoval(accountIdentity).incarnation + + 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 + is AndroidDocumentProviderIncarnationRecord.Active -> record.incarnation + is AndroidDocumentProviderIncarnationRecord.Retired -> record.incarnation + } + val retiredEncoded = encodeAndroidDocumentProviderIncarnationRecord( + AndroidDocumentProviderIncarnationRecord.Retired(incarnation), + ) + val retirement = AndroidDocumentProviderIncarnationRetirement( + accountIdentity, + previousEncoded, + retiredEncoded, + incarnation, + ) + persistEncoded(retirementJournalKey(accountIdentity), encodeAndroidDocumentProviderRetirement(retirement)) + persistEncoded(accountIdentity, retiredEncoded) + retirement + } + + fun rollback(retirement: AndroidDocumentProviderIncarnationRetirement) = synchronized(LOCK) { + if (!hasStoredRetirement(retirement)) { + check(read(retirement.accountIdentity) == retirement.previousEncoded) { + "The document provider removal rollback is not recoverable." + } + return@synchronized + } + 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, + onMalformedJournal: (Exception) -> Unit = { failure -> throw failure }, + ) = synchronized(LOCK) { + keys().asSequence() + .filter { key -> key.startsWith(RETIREMENT_JOURNAL_KEY_PREFIX) } + .sorted() + .forEach { key -> + 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(recovery.second, ownership(recovery.first)) + } + } + + fun retiredIncarnation(accountIdentity: String): NextcloudDocumentIncarnation? = synchronized(LOCK) { + (readRecord(accountIdentity) as? AndroidDocumentProviderIncarnationRecord.Retired)?.incarnation + } + + fun accountIdentitiesForCredentialReset(): List = synchronized(LOCK) { + keys().asSequence() + .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 prepareForCredentialReset( + accountIdentities: Collection, + ): List = synchronized(LOCK) { + val retirements = mutableListOf() + try { + accountIdentities.distinct().sorted().forEach { accountIdentity -> + requireAccountIdentity(accountIdentity) + val pending = readPendingRetirementForCredentialReset(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 + } catch (failure: Throwable) { + retirements.asReversed().forEach { retirement -> + try { + rollback(retirement) + } catch (rollbackFailure: Throwable) { + failure.addSuppressed(rollbackFailure) + } + } + throw failure + } + } + + 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 -> + 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) + } + + private fun decodeRecordOrNullOnMalformed(encoded: String?): AndroidDocumentProviderIncarnationRecord? = + try { + encoded?.let(::decodeAndroidDocumentProviderIncarnationRecord) + } catch (_: IllegalArgumentException) { + null + } catch (_: ClassCastException) { + null + } + + private fun persist(accountIdentity: String, record: AndroidDocumentProviderIncarnationRecord) { + persistEncoded(accountIdentity, encodeAndroidDocumentProviderIncarnationRecord(record)) + } + + private fun persistEncoded(accountIdentity: String, encoded: String?) { + check(commit(accountIdentity, encoded)) { + "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 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]{64}") + val LOCK = Any() + + fun retirementJournalKey(accountIdentity: String): String = + "$RETIREMENT_JOURNAL_KEY_PREFIX$accountIdentity" + + fun quarantinedRetirementJournalKey(accountIdentity: String): String = + "quarantined-$RETIREMENT_JOURNAL_KEY_PREFIX$accountIdentity" + } +} + +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) { + rangeCoordinator.withAllQuiesced { + 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 -> + try { + store.complete(retirement) + } catch (failure: Exception) { + recordCompletionFailure(failure) + } + } + } + } + } +} + +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(":").also { encoded -> + require(encoded.length <= MAX_RETIREMENT_JOURNAL_LENGTH) { + "The document provider retirement journal is too large." + } + } +} + +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 = 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." + } + return decodedText +} + +private const val MAX_RETIREMENT_JOURNAL_LENGTH = 16_384 + +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, +) { + val store = AndroidDocumentProviderIncarnationStore(context) + store.reconcilePendingForCredentialAccess(current.registry) + store.prepareForAccountSave( + session.documentProviderIncarnationAccountIdentity(), + session.accountId in current.sessions, + ) +} + +internal fun reconcileAndroidDocumentProviderAccountRemovals( + context: Context, + registry: NextcloudAccountRegistry, +): NextcloudAccountRegistry = registry.also { + 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 AndroidAccountRemovalCleanupJournal.completeDocumentRetirement( + context: Context, + retirement: AndroidDocumentProviderIncarnationRetirement?, + accountStorageKey: String, +) { + AndroidDocumentProviderIncarnationStore(context).complete(requireNotNull(retirement)) + clear(accountStorageKey) +} + +private fun NextcloudAccountRegistry.documentProviderAccountOwnership( + accountIdentity: String, +): AndroidDocumentProviderAccountOwnership = if ( + accounts.any { account -> account.id.storageKey == accountIdentity } +) { + AndroidDocumentProviderAccountOwnership.Present +} else { + AndroidDocumentProviderAccountOwnership.Absent +} + +internal fun notifyAndroidDocumentChanged(context: Context, session: NextcloudSession, path: String) { + val appContext = context.applicationContext + val incarnation = runCatching { + AndroidDocumentProviderIncarnationStore(appContext) + .activeIncarnation(session.documentProviderIncarnationAccountIdentity()) + }.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, + ) +} + +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 new file mode 100644 index 000000000..59df1a643 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccess.kt @@ -0,0 +1,164 @@ +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 + +internal fun acquireAndroidDocumentProviderReadLease( + expectedSession: NextcloudSession, + expectedIncarnation: NextcloudDocumentIncarnation, + loadCurrentSession: () -> NextcloudSession?, + loadCurrentIncarnation: (String) -> NextcloudDocumentIncarnation, + operationGuard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, +): AndroidAccountOperationLease { + 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, + ) + operationLease.close() + lifetimeLease + } catch (failure: Throwable) { + operationLease.close() + lifetimeLease.close() + throw failure + } +} + +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 fun withAndroidDocumentProviderReadAccess( + expectedSession: NextcloudSession, + expectedIncarnation: NextcloudDocumentIncarnation, + loadCurrentSession: () -> NextcloudSession?, + loadCurrentIncarnation: (String) -> NextcloudDocumentIncarnation, + operationGuard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + lifetimeGuard: AndroidAccountRemovalLifetimeGuard = ANDROID_ACCOUNT_REMOVAL_LIFETIME_GUARD, + action: (NextcloudSession) -> Result, +): Result { + 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 { + try { + operationLease.close() + } finally { + lifetimeLease.close() + } + } +} + +private fun checkAndroidDocumentProviderReadAccess( + expectedSession: NextcloudSession, + expectedIncarnation: NextcloudDocumentIncarnation, + loadCurrentSession: () -> NextcloudSession?, + loadCurrentIncarnation: (String) -> NextcloudDocumentIncarnation, +) { + val accountIdentity = expectedSession.documentProviderIncarnationAccountIdentity() + if ( + loadCurrentSession() != expectedSession || + loadCurrentIncarnation(accountIdentity) != expectedIncarnation + ) { + throw FileNotFoundException("This Nextcloud document belongs to a removed account.") + } +} + +internal fun openAndroidDocumentAccountLeasedContent( + content: File, + accountLease: AndroidAccountOperationLease, + storageManager: StorageManager, + handler: Handler, + 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) { + 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, + 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 6795c95c9..cbf59da20 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt @@ -43,19 +43,51 @@ 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 lifetimeLease = lifetimeGuard.acquireReadBlocking(session.documentProviderIncarnationAccountIdentity()) + val operationLease = try { + guard.acquireBlocking(androidAccountOperationIdentities(session)) + } 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(androidAccountOperationIdentities(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/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/AndroidLocalFileProxyCallback.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidLocalFileProxyCallback.kt index 6254b7f4b..7607e13df 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, @@ -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/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/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 2d1344f27..b9ea961cc 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( @@ -1235,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( @@ -1251,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 @@ -1297,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") @@ -1336,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, @@ -1375,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, @@ -1385,7 +1362,6 @@ internal class AndroidNextcloudServices( NextcloudFileListing(it.files, NextcloudFileListingSource.Cache) } } - override suspend fun searchFiles( session: NextcloudSession, userId: String, @@ -1406,7 +1382,6 @@ internal class AndroidNextcloudServices( .distinctBy(NextcloudFile::path) .take(maximumResults) } - override suspend fun listFavoriteFiles( session: NextcloudSession, userId: String, @@ -1423,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/NextcloudDocumentIds.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt index a2d4d5799..6c8239814 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt @@ -6,13 +6,34 @@ import java.util.Base64 internal data class NextcloudDocumentReference( val accountKey: String, + val incarnation: NextcloudDocumentIncarnation, val path: String, ) { val isRoot: Boolean get() = path.isEmpty() } +internal data class NextcloudDocumentRootReference( + val accountKey: String, + val incarnation: NextcloudDocumentIncarnation, +) + +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() @@ -27,42 +48,88 @@ 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) .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 -> documentAccountKey(session) + is NextcloudDocumentIncarnation.Versioned -> "${documentAccountKey(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(accountKey: String): String { + fun rootId(session: NextcloudSession, incarnation: NextcloudDocumentIncarnation): String = + rootId(documentAccountKey(session), incarnation) + + 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:${documentAccountKey(session)}:$encodedPath" + is NextcloudDocumentIncarnation.Versioned -> + "$VERSIONED_PREFIX:${documentAccountKey(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.accountKey in setOf(documentAccountKey(session), 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 { @@ -70,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 new file mode 100644 index 000000000..2913e3f75 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolver.kt @@ -0,0 +1,87 @@ +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 { record -> record.id.storageKey.take(DOCUMENT_ACCOUNT_KEY_CHARACTERS) } + .eachCount() + .filterValues { count -> count == 1 } + .keys + return records.mapNotNull { record -> + record.takeIf { it.canonicalDocumentAccountKey() 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 -> 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." + } + } + + 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 + } ?: return null + return ResolvedNextcloudDocumentsAccount(session, loadIncarnation(record.id.storageKey)) + } +} + +internal fun nextcloudDocumentsAccountResolver( + services: AndroidNextcloudServices, + incarnations: AndroidDocumentProviderIncarnationStore, +) = NextcloudDocumentsAccountResolver( + services::listAccounts, + services::loadSession, + incarnations::activeIncarnation, +) + +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/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index 7cbb908a3..b327035ba 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -25,20 +25,16 @@ 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 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. @@ -48,7 +44,8 @@ class NextcloudDocumentsProvider : DocumentsProvider() { private lateinit var offline: AndroidFileOfflineRepository 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,6 +53,8 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val providerContext = context ?: return false cleanupIncompleteAndroidDocumentWritebacks(providerContext) services = AndroidNextcloudServices(providerContext) + documentIncarnations = AndroidDocumentProviderIncarnationStore(providerContext) + accountResolver = nextcloudDocumentsAccountResolver(services, documentIncarnations) AndroidExternalFileHandoffRegistry.bind(AndroidExternalFileHandoffStore(providerContext)) offline = AndroidFileOfflineRepository(providerContext) virtualFiles = AndroidVirtualFileCache(providerContext) @@ -71,53 +70,37 @@ 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 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_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() 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) - if (reference.isRoot) { - cursor.addDocumentRow(session, null) - return cursor + return withDocumentRead(documentId) { session, reference -> + cursor.addNextcloudDocumentRow( + session, + reference.incarnation, + reference.takeUnless(NextcloudDocumentReference::isRoot) + ?.let { findDocumentWithOfflineFallback(session, it.path) }, + documentsRootTitle(), + ) + cursor } - - cursor.addDocumentRow(session, findDocumentWithOfflineFallback(session, reference.path)) - return cursor } - override fun queryChildDocuments( parentDocumentId: String, projection: Array?, @@ -125,25 +108,21 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ): Cursor { val columns = projection?.copyOf() ?: DEFAULT_DOCUMENT_PROJECTION val cursor = MatrixCursor(columns) - val session = requireSession() - val parent = requireReference(parentDocumentId, session) - 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.addDocumentRow(session, it) } - return cursor } - override fun querySearchDocuments( rootId: String, query: String, @@ -151,31 +130,27 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ): Cursor { val columns = projection?.copyOf() ?: DEFAULT_DOCUMENT_PROJECTION val cursor = MatrixCursor(columns) - val session = requireSession() - require(rootId == NextcloudDocumentIds.accountKey(session)) { - "The document root belongs to another account." - } - 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.addDocumentRow(session, 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() + 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 + "/") } - override fun openDocument( documentId: String, mode: String, @@ -185,49 +160,54 @@ class NextcloudDocumentsProvider : DocumentsProvider() { throw SecurityException("Unsupported document mode: $mode") } 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 -> - 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, storageManager(), WRITE_HANDLER, signal) + } } - } - 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, storageManager(), WRITE_HANDLER, signal) + } } + 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, storageManager(), WRITE_HANDLER, signal) } - throw failure - } - if (file.isDirectory) throw FileNotFoundException("Folders cannot be opened as files.") - if (mode != "r") return openWritableDocument(session, 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.", @@ -239,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 openVirtualFileLease(lease) + return openAndroidDocumentVirtualFileLease(lease, accountLease, storageManager(), WRITE_HANDLER, signal) } } if (!empty.exists()) empty = virtualFiles.createHydrationStagingFile() - return ParcelFileDescriptor.open(empty, ParcelFileDescriptor.MODE_READ_ONLY, WRITE_HANDLER) { - virtualFiles.discardHydrationStagingFile(empty) - } + return openAndroidDocumentAccountLeasedContent( + empty, accountLease, storageManager(), WRITE_HANDLER, signal, + onReleased = { virtualFiles.discardHydrationStagingFile(empty) }, + ) } val rangeSession = services.openFileRangeSession( session = session, @@ -285,15 +266,11 @@ 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( session: NextcloudSession, documentId: String, @@ -410,7 +387,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { throw failure } } - private fun openExternalLocalContent( content: File, handoffLease: AndroidExternalFileHandoffLease, @@ -450,21 +426,8 @@ 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 = - 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)) @@ -478,29 +441,28 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } } } - notifyDocumentChanged(session, path) - NextcloudDocumentIds.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) - NextcloudDocumentIds.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) @@ -515,18 +477,16 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ) } } - notifyDocumentChanged(session, reference.path) + notifyDocumentChanged(session, reference.incarnation, reference.path) } - override fun moveDocument( sourceDocumentId: String, 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." @@ -535,31 +495,30 @@ 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) - NextcloudDocumentIds.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, signal: CancellationSignal?, + accountLease: AndroidAccountOperationLease, ): ParcelFileDescriptor { - val accountLease = acquireAndroidDocumentWritebackAccountLease( - session, - file.path, - services::loadSession, - ) val recovered: AndroidDocumentPendingWriteback? val writeback: AndroidDocumentPendingWriteback + var pathReserved = false try { + reserveAndroidDocumentWritebackPath(session, file.path) + pathReserved = true recovered = claimAndroidDocumentPendingWriteback(context, session, file.path) if (recovered?.conflict == true) { recovered.releaseActive() @@ -567,9 +526,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 @@ -607,19 +566,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, 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) @@ -643,14 +607,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, @@ -743,12 +705,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) @@ -763,40 +734,17 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val resolver = providerContext.contentResolver val authority = nextcloudDocumentsAuthority(providerContext.packageName) resolver.notifyChange( - DocumentsContract.buildDocumentUri( - authority, - NextcloudDocumentIds.documentId(session, path), - ), + DocumentsContract.buildDocumentUri(authority, NextcloudDocumentIds.documentId(session, incarnation, path)), null, ) resolver.notifyChange( DocumentsContract.buildChildDocumentsUri( authority, - NextcloudDocumentIds.documentId(session, NextcloudDocumentIds.parentPath(path)), + NextcloudDocumentIds.documentId(session, incarnation, NextcloudDocumentIds.parentPath(path)), ), null, ) } - - private fun MatrixCursor.addDocumentRow(session: NextcloudSession, 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_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(), - ), - ) - } - private fun MatrixCursor.addExternalHandoffRow(record: AndroidExternalFileHandoffRecord) { val file = record.file addNamedRow( @@ -811,42 +759,98 @@ 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 storageManager(): StorageManager = + requireNotNull(context?.getSystemService(StorageManager::class.java)) + + private fun requireActiveSession(): NextcloudSession = services.loadSession() ?: throw FileNotFoundException("Sign in to nati.ve to browse files.") - private fun requireReference(documentId: String, session: NextcloudSession): NextcloudDocumentReference = + 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 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 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, + incarnation: NextcloudDocumentIncarnation, + ): 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 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(session.documentProviderIncarnationAccountIdentity()) == 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 } @@ -879,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) @@ -935,10 +939,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/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 3f2fc3801..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 @@ -227,6 +228,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 +237,7 @@ class AndroidAccountOperationGuardTest { val removal = async { revokeAndroidSessionWithAccountLease( - accountIdentity = "account-a", + expectedSession = session, guard = guard, preflight = {}, revoke = { remoteRevoked.complete(Unit) }, @@ -246,7 +249,7 @@ class AndroidAccountOperationGuardTest { } remoteRevoked.await() val mutation = async { - guard.withAccount("account-a") { + guard.withAccount(accountIdentity) { mutationObservedCommittedRemoval = localRemovalCommitted } } @@ -335,33 +338,27 @@ 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(session, 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) } @@ -536,9 +533,67 @@ 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 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() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() val original = NextcloudSession("https://cloud.example.test", "alice", "original-password") assertFailsWith { @@ -546,11 +601,16 @@ class AndroidAccountOperationGuardTest { session = original, loadCurrentSession = { original.copy(appPassword = "replacement-password") }, guard = guard, + lifetimeGuard = lifetimeGuard, ) } withTimeout(1_000L) { - guard.withAccount(NextcloudDocumentIds.accountKey(original)) { } + withAndroidAccountRemovalLease( + original, + guard, + lifetimeGuard, + ) { } } } 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/AndroidDocumentProviderIncarnationStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt new file mode 100644 index 000000000..7bcfa9182 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderIncarnationStoreTest.kt @@ -0,0 +1,841 @@ +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 +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class AndroidDocumentProviderIncarnationStoreTest { + private val accountIdentity = "a".repeat(64) + + @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) } + 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 + fun everyRemovalAndReaddChangesTheIncarnationAgain() { + val fixture = fixture(incarnations = listOf("1".repeat(32), "2".repeat(32))) + fixture.store.complete(fixture.store.retireForRemoval(accountIdentity)) + val first = fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false) + fixture.store.complete(fixture.store.retireForRemoval(accountIdentity)) + val replacement = fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = false) + + assertNotEquals(first, replacement) + 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( + 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 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))) + fixture.store.retire(accountIdentity) + + assertFailsWith { + fixture.store.prepareForAccountSave(accountIdentity, accountAlreadyStored = true) + } + 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 = AndroidDocumentProviderIncarnationRecord.Active( + NextcloudDocumentIncarnation.Versioned("1".repeat(32)), + ) + fixture.records[accountIdentity] = encodeAndroidDocumentProviderIncarnationRecord(replacement) + + assertFailsWith { fixture.store.rollback(retirement) } + 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 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", "1:$accountIdentity:present:_w:_w").forEach { malformed -> + val otherAccount = "b".repeat(64) + 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") + 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 + 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)) + } + + @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 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() + 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 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")) + 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) + 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(), + ): Fixture { + val available = ArrayDeque(incarnations) + return Fixture( + records = records, + store = AndroidDocumentProviderIncarnationStore( + read = records::get, + commit = { key, value -> + if (value == null) records.remove(key) else records[key] = value + true + }, + keys = { records.keys }, + createIncarnation = { + NextcloudDocumentIncarnation.Versioned(available.removeFirst()) + }, + ), + ) + } + + private data class Fixture( + val records: MutableMap, + val store: AndroidDocumentProviderIncarnationStore, + ) + + private fun ownership( + value: AndroidDocumentProviderAccountOwnership, + ): (String) -> AndroidDocumentProviderAccountOwnership = { value } +} 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..58d23d8d5 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDocumentProviderReadAccessTest.kt @@ -0,0 +1,535 @@ +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 +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.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 + +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 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() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val lease = readLease(guard, lifetimeGuard) + var removalEntered = false + val removal = async(Dispatchers.Default) { + withAndroidAccountRemovalLease( + original, + guard, + lifetimeGuard, + ) { removalEntered = true } + } + yield() + + assertFalse(removalEntered) + lease.close() + removal.await() + assertTrue(removalEntered) + } + + @Test + fun removalFencesDocumentMutationsBeforeWaitingForAnOpenDescriptor() = runBlocking { + val guard = AndroidAccountOperationGuard() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val openDescriptor = readLease(guard, lifetimeGuard) + val removalEntered = CompletableDeferred() + val finishRemoval = CompletableDeferred() + var currentSession: NextcloudSession? = original + val removal = async(start = CoroutineStart.UNDISPATCHED) { + withAndroidAccountRemovalLease(original, guard, lifetimeGuard) { + currentSession = null + removalEntered.complete(Unit) + finishRemoval.await() + } + } + assertFalse(removal.isCompleted) + + var mutationEntered = false + val mutation = async(Dispatchers.Default) { + runCatching { + acquireAndroidDocumentMutationAccountLease( + original, + { currentSession }, + guard, + lifetimeGuard, + ).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 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( + 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( + 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( + 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( + original, + guard, + lifetimeGuard, + ) {} + } + } + + @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() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val removalEntered = CompletableDeferred() + val finishRemoval = CompletableDeferred() + var currentIncarnation = originalIncarnation + val removal = async { + withAndroidAccountRemovalLease( + original, + guard, + lifetimeGuard, + ) { + currentIncarnation = replacementIncarnation + removalEntered.complete(Unit) + finishRemoval.await() + } + } + removalEntered.await() + val open = async(Dispatchers.Default) { + runCatching { + acquireAndroidDocumentProviderReadLease( + original, + originalIncarnation, + { original }, + { currentIncarnation }, + guard, + lifetimeGuard, + ) + } + } + 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 lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + val searchEntered = CompletableDeferred() + val finishSearch = CompletableDeferred() + var removalEntered = false + val search = async(Dispatchers.Default) { + withAndroidDocumentProviderReadAccess( + original, + originalIncarnation, + { original }, + { originalIncarnation }, + guard, + lifetimeGuard, + ) { + searchEntered.complete(Unit) + runBlocking { finishSearch.await() } + } + } + searchEntered.await() + val removal = async { + withAndroidAccountRemovalLease( + original, + guard, + lifetimeGuard, + ) { removalEntered = true } + } + yield() + + assertFalse(removalEntered) + finishSearch.complete(Unit) + search.await() + removal.await() + 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() + val lifetimeGuard = AndroidAccountRemovalLifetimeGuard() + + assertFailsWith { + withAndroidDocumentProviderReadAccess( + original, + originalIncarnation, + { original }, + { originalIncarnation }, + guard, + lifetimeGuard, + ) { error("synthetic read failure") } + } + + withTimeout(1_000L) { + withAndroidAccountRemovalLease( + 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() + } + + @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///") + 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 + + 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, + ) = acquireAndroidDocumentProviderReadLease( + original, + originalIncarnation, + { original }, + { originalIncarnation }, + guard, + lifetimeGuard, + ) + + private fun incarnation(digit: String) = NextcloudDocumentIncarnation.Versioned(digit.repeat(32)) +} 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}", diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index fc14f0f07..3afc9c93c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -1114,13 +1114,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) { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallbackTest.kt index 8326f95f5..13ce88722 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() @@ -301,6 +302,7 @@ class AndroidVirtualFileProxyCallbackTest { allowed = false callback.cancel() + assertEquals(1, released) assertFailsWith { callback.onRead(0L, 1, ByteArray(1)) } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt index 678ae76f8..cfdb2423b 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,43 @@ 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"), + ) + } + + @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, ) } @@ -65,24 +102,82 @@ 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 providerRootIdsRoundTripTheirAccountAndIncarnation() { + val versioned = NextcloudDocumentIncarnation.Versioned("1".repeat(32)) + + assertEquals( + NextcloudDocumentRootReference(NextcloudDocumentIds.documentAccountKey(session), legacy), + NextcloudDocumentIds.parseProviderRootId(NextcloudDocumentIds.providerRootId(session, legacy)), + ) + assertEquals( + NextcloudDocumentRootReference(NextcloudDocumentIds.documentAccountKey(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..bffa171c7 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsAccountResolverTest.kt @@ -0,0 +1,196 @@ +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 +import kotlin.test.assertNotEquals + +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 = { accountIdentity -> + if (accountIdentity == bob.accountId.storageKey) bobIncarnation else error("unreadable") + }, + ) + + assertEquals( + listOf(ResolvedNextcloudDocumentsAccount(bob, bobIncarnation)), + resolver.resolvableAccounts(), + ) + } + + @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 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( + 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 = { accountIdentity -> + when (accountIdentity) { + alice.accountId.storageKey -> aliceIncarnation + bob.accountId.storageKey -> 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..caa41dc56 --- /dev/null +++ b/changes/unreleased/android-documents-account-resolution.md @@ -0,0 +1,7 @@ +category: fix +issue: 122 +pull: 447 +platforms: android +user-facing: yes + +Files shared with other Android apps keep using their owning Nextcloud account after you switch to another account. diff --git a/changes/unreleased/document-grant-incarnation.md b/changes/unreleased/document-grant-incarnation.md new file mode 100644 index 000000000..dbb7b61eb --- /dev/null +++ b/changes/unreleased/document-grant-incarnation.md @@ -0,0 +1,7 @@ +category: fix +issue: 122 +pull: 447 +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. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 7d0074d62..59736cb94 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|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 @@ -28,7 +28,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt 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/NextcloudPhotoEditor.kt|808 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1724 +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 @@ -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/desktopTest/kotlin/dev/obiente/nextcloudnative/nativeui/model/PantryLiveContractCompatibilityTest.kt|1202 ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt|2543 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 diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 8100381e8..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", @@ -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",