diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt new file mode 100644 index 000000000..0241a9cbc --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -0,0 +1,784 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudAccountId +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent +import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft +import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity +import dev.obiente.nextcloudnative.app.accountRecord +import dev.obiente.nextcloudnative.app.encodeNextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.toSupportDiagnosticExceptionDraft +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +internal class AndroidAccountCredentialController( + context: Context, + private val preferences: SharedPreferences, + private val sessionCipher: SessionCipher, + private val registerSessionPrivateValues: (NextcloudSession) -> Unit, + private val recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, + private val publishAccountIdentity: (String?) -> Unit, + private val clearPreviewAccount: (String) -> Unit, + private val notifyDocumentRootsChanged: () -> Unit, + private val resumeQueuedUploads: suspend (String) -> Unit, + private val prepareAccountRemoval: suspend (NextcloudSession) -> Unit, + private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, + private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String) -> Unit, +) { + private val appContext = context.applicationContext + + fun loadSession(): NextcloudSession? = ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.restorePersistedSession( + load = { + val registry = readRegistryForCredentialLoad() + registry?.activeAccountId?.let { accountId -> loadSession(accountId, registry) } + }, + accountIdOf = NextcloudDocumentIds::accountKey, + publishAccount = { session, accountIdentity -> + session?.let(registerSessionPrivateValues) + publishAccountIdentity(accountIdentity) + }, + ) + + fun listAccounts(): List = readCredentialFreeRegistry()?.accounts.orEmpty() + + 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, + ): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + if (registry.accounts.none { account -> account.id == accountId }) return@serialize null + val aggregateRead = readStore() + if (!androidCredentialStoreAllowsSessionRestore(aggregateRead)) return@serialize null + val aggregate = (aggregateRead as? AndroidAccountCredentialStoreRead.Available)?.state + val slotRead = readCredentialSlot(accountId) + if (slotRead is AndroidAccountCredentialSlotRead.Unsupported) return@serialize null + val storedSlot = (slotRead as? AndroidAccountCredentialSlotRead.Available)?.session + val restoredSlot = recoverAndroidAccountCredentialSlot(accountId, registry, storedSlot, aggregate = null) + val session = restoredSlot ?: recoverAndroidAccountCredentialSlot( + accountId, + registry, + storedSlot = null, + aggregate = aggregate, + ) ?: return@serialize null + if (storedSlot != session) { + runCatching { + commitPreferences( + preferences.edit().putString( + androidAccountCredentialSlotKey(accountId), + encryptCredentialSlot(session), + ), + ) + } + } + session.also(registerSessionPrivateValues) + } + + suspend fun saveSession(session: NextcloudSession): NextcloudSession = + ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + retryPendingAccountRemovalCleanup(session) + registerSessionPrivateValues(session) + when (val read = readStore()) { + is AndroidAccountCredentialStoreRead.Available -> { + requireSupportedCredentialSlots(read.state.registry) + replaceActiveState(read.state.upsertAndSelect(session), read.state.activeSession) + } + is AndroidAccountCredentialStoreRead.Invalid -> { + val retained = readIndependentCredentialSlotState() + check(retained != null || !hasIndependentCredentialState()) { + "The aggregate account credential store is invalid; reset it before signing in again." + } + replaceActiveState( + replacement = (retained ?: AndroidAccountCredentialState.Empty).upsertAndSelect(session), + previousSession = retained?.activeSession, + suspectEncrypted = read.encrypted, + ) + } + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> { + val retained = readIndependentCredentialSlotState() + check(retained != null || !hasIndependentCredentialState()) { + "The independent account credential slots could not be recovered." + } + replaceActiveState( + replacement = (retained ?: AndroidAccountCredentialState.Empty).upsertAndSelect(session), + previousSession = retained?.activeSession, + ) + } + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) + } + requireNotNull(loadSession(session.accountId)) + } + + suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = + ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + val current = requireValidState() + val selected = current.select(accountId) ?: return@withLock null + val session = requireNotNull(selected.activeSession) + registerSessionPrivateValues(session) + replaceActiveState(selected, current.activeSession) + session + } + + suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = + ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + val current = requireValidState() + val session = current.sessions[accountId] + ?: return@withLock removeUnavailableAccount(accountId, current) + val accountIdentity = NextcloudDocumentIds.accountKey(session) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + val active = current.registry.activeAccountId == accountId + removeAndroidAccountCredentialData( + active = active, + prepareAccountRemoval = { prepareAccountRemoval(session) }, + removeQueuedUploads = { removeQueuedUploads(session) }, + clearActiveAccount = { clearSession(current, pendingCleanup) }, + rollbackActiveRemoval = { + replaceActiveStateWhileOperationsIdle( + replacement = current, + previousSession = null, + suspectEncrypted = null, + ) + clearPendingAccountRemovalCleanup(accountId.storageKey) + }, + persistInactiveRemoval = { persistState(current.remove(accountId), pendingCleanup) }, + rollbackInactiveRemoval = { + persistState(current) + clearPendingAccountRemovalCleanup(accountId.storageKey) + }, + completeCommittedCleanup = { clearPendingAccountRemovalCleanup(accountId.storageKey) }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, + ) + if (!active) { + clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(session)) + } + } + true + } + + private suspend fun removeUnavailableAccount( + accountId: NextcloudAccountId, + recovered: AndroidAccountCredentialState, + ): Boolean { + val record = readCredentialFreeRegistry()?.accounts?.firstOrNull { account -> account.id == accountId } + ?: return false + val unavailableSession = NextcloudSession(record.serverUrl, record.loginName, appPassword = "") + val accountIdentity = NextcloudDocumentIds.accountKey(unavailableSession) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(unavailableSession) + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + removeAndroidAccountCredentialData( + active = false, + prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, + removeQueuedUploads = { retryQueuedUploadsCleanup(unavailableSession, accountIdentity) }, + clearActiveAccount = {}, + rollbackActiveRemoval = {}, + persistInactiveRemoval = { persistState(recovered, pendingCleanup) }, + rollbackInactiveRemoval = { clearPendingAccountRemovalCleanup(accountId.storageKey) }, + completeCommittedCleanup = { clearPendingAccountRemovalCleanup(accountId.storageKey) }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, + ) + } + return true + } + + suspend fun revokeSession( + expectedSession: NextcloudSession, + revokeRemoteSession: suspend () -> Unit, + ) = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + val current = requireValidState() + check(current.activeSession == expectedSession) { + "The account changed before its remote session could be revoked." + } + val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(expectedSession) + revokeAndroidSessionWithAccountLease( + accountIdentity = accountIdentity, + preflight = { prepareAccountRemoval(expectedSession) }, + revoke = revokeRemoteSession, + removeLocalAccount = { + removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = { removeQueuedUploads(expectedSession) }, + clearActiveAccount = { clearSession(current, pendingCleanup) }, + rollbackActiveRemoval = { + replaceActiveStateWhileOperationsIdle(current, previousSession = null, suspectEncrypted = null) + clearPendingAccountRemovalCleanup(expectedSession.accountId.storageKey) + }, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + completeCommittedCleanup = { + clearPendingAccountRemovalCleanup(expectedSession.accountId.storageKey) + }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, + ) + }, + ) + } + + suspend fun clearSession() = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + when (val read = readStore()) { + is AndroidAccountCredentialStoreRead.Available -> { + requireSupportedCredentialSlots(read.state.registry) + val session = read.state.activeSession + if (session == null) { + clearSession(read.state) + } else { + val accountIdentity = NextcloudDocumentIds.accountKey(session) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + removeAndroidAccountCredentialData( + active = true, + prepareAccountRemoval = { prepareAccountRemoval(session) }, + removeQueuedUploads = { removeQueuedUploads(session) }, + clearActiveAccount = { clearSession(read.state, pendingCleanup) }, + rollbackActiveRemoval = { + replaceActiveStateWhileOperationsIdle( + replacement = read.state, + previousSession = null, + suspectEncrypted = null, + ) + clearPendingAccountRemovalCleanup(session.accountId.storageKey) + }, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + completeCommittedCleanup = { + clearPendingAccountRemovalCleanup(session.accountId.storageKey) + }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, + ) + } + } + } + is AndroidAccountCredentialStoreRead.Invalid -> { + val retained = readIndependentCredentialSlotState() + when { + retained != null -> clearRecoveredInvalidStore(retained, read.encrypted) + hasIndependentCredentialState() -> + error("The independent account credential slots could not be recovered.") + else -> clearInvalidStore(read.encrypted) + } + } + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> + error("The independent account credential slots could not be recovered.") + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) + } + } + + private suspend fun clearSession( + current: AndroidAccountCredentialState, + pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, + ) { + val activeSession = current.activeSession ?: return + val replacement = current.remove(activeSession.accountId) + val encodedReplacement = replacement.takeUnless { state -> + state.registry.accounts.isEmpty() && state.sessions.isEmpty() + }?.let(::encryptState) + clearPersistedSession(encodedReplacement, replacement, pendingCleanup = pendingCleanup) + clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(activeSession)) + notifyDocumentRootsChanged() + } + + private suspend fun clearInvalidStore(suspectEncrypted: String) { + clearPersistedSession( + encodedReplacement = null, + replacement = AndroidAccountCredentialState.Empty, + suspectEncrypted = suspectEncrypted, + ) + notifyDocumentRootsChanged() + } + + private suspend fun clearRecoveredInvalidStore( + current: AndroidAccountCredentialState, + suspectEncrypted: String, + ) { + val activeSession = current.activeSession + if (activeSession != null) { + val accountIdentity = NextcloudDocumentIds.accountKey(activeSession) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(activeSession) + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + removeRecoveredAndroidAccountCredentialData( + prepareAccountRemoval = { prepareAccountRemoval(activeSession) }, + removeQueuedUploads = { removeQueuedUploads(activeSession) }, + clearRecoveredAccount = { + persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted, pendingCleanup) + }, + rollbackRecoveredAccount = { + replaceActiveStateWhileOperationsIdle( + replacement = current, + previousSession = null, + suspectEncrypted = suspectEncrypted, + ) + clearPendingAccountRemovalCleanup(activeSession.accountId.storageKey) + }, + completeCommittedCleanup = { + clearPendingAccountRemovalCleanup(activeSession.accountId.storageKey) + }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, + ) + } + } else { + persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted) + } + } + + private suspend fun persistRecoveredInvalidStoreAfterClear( + current: AndroidAccountCredentialState, + suspectEncrypted: String, + pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, + ) { + val activeSession = current.activeSession + val replacement = removeActiveAndroidAccountCredentialState(current) + val encodedReplacement = replacement.takeUnless { state -> + state.registry.accounts.isEmpty() && state.sessions.isEmpty() + }?.let(::encryptState) + clearPersistedSession( + encodedReplacement, + replacement, + suspectEncrypted, + pendingCleanup, + ) + activeSession?.let { session -> clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(session)) } + notifyDocumentRootsChanged() + } + + private suspend fun clearPersistedSession( + encodedReplacement: String?, + replacement: AndroidAccountCredentialState, + suspectEncrypted: String? = null, + pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, + ) { + val scheduler = AndroidFileSyncScheduler(appContext) + withContext(Dispatchers.IO) { + commitAndroidAccountTransitionBeforeHandoffCleanup( + commitTransition = { + ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.clearSession( + persist = { + val editor = if (suspectEncrypted == null) { + preferences.edit().apply { + if (encodedReplacement == null) remove(ANDROID_ACCOUNT_SESSION_KEY) + else putString(ANDROID_ACCOUNT_SESSION_KEY, encodedReplacement) + putString( + ANDROID_ACCOUNT_REGISTRY_KEY, + encodeNextcloudAccountRegistry(replacement.registry), + ) + remove(KEY_TEST_READ_ONLY) + }.let { editor -> prepareCredentialSlotEdit(editor, replacement) } + } else { + prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor = preferences.edit(), + replacementEncrypted = encodedReplacement, + ).putString( + ANDROID_ACCOUNT_REGISTRY_KEY, + encodeNextcloudAccountRegistry(replacement.registry), + ).let { editor -> prepareCredentialSlotEdit(editor, replacement) } + } + commitPreferences(preparePendingAccountRemovalCleanupEdit(editor, pendingCleanup)) + }, + cancelAll = scheduler::cancelAll, + clearPublishedAccount = { publishAccountIdentity(null) }, + ) + }, + clearHandoffs = AndroidExternalFileHandoffRegistry::clear, + recordFailure = ::recordAccountHandoffCleanupFailure, + ) + } + } + + private suspend fun replaceActiveState( + replacement: AndroidAccountCredentialState, + previousSession: NextcloudSession?, + suspectEncrypted: String? = null, + ) { + val replacementSession = requireNotNull(replacement.activeSession) + val affectedAccountIds = listOfNotNull(previousSession, replacementSession) + .map(NextcloudDocumentIds::accountKey) + ANDROID_ACCOUNT_OPERATION_GUARD.withAccounts(affectedAccountIds) { + replaceActiveStateWhileOperationsIdle(replacement, previousSession, suspectEncrypted) + } + } + + private suspend fun replaceActiveStateWhileOperationsIdle( + replacement: AndroidAccountCredentialState, + previousSession: NextcloudSession?, + suspectEncrypted: String?, + ) { + val session = requireNotNull(replacement.activeSession) + val encrypted = encryptState(replacement) + val scheduler = AndroidFileSyncScheduler(appContext) + withContext(Dispatchers.IO) { + commitAndroidAccountTransitionBeforeHandoffCleanup( + commitTransition = { + ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.replaceSession( + replacementAccountId = NextcloudDocumentIds.accountKey(session), + persist = { + val editor = if (suspectEncrypted == null) { + preferences.edit() + .putString(ANDROID_ACCOUNT_SESSION_KEY, encrypted) + .putString( + ANDROID_ACCOUNT_REGISTRY_KEY, + encodeNextcloudAccountRegistry(replacement.registry), + ) + .remove(KEY_TEST_READ_ONLY) + } else { + prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor = preferences.edit(), + replacementEncrypted = encrypted, + ).putString( + ANDROID_ACCOUNT_REGISTRY_KEY, + encodeNextcloudAccountRegistry(replacement.registry), + ) + } + commitPreferences(prepareCredentialSlotEdit(editor, replacement)) + }, + cancelAll = scheduler::cancelAll, + publishAccount = publishAccountIdentity, + restoreSchedules = scheduler::restorePersistedPairSchedules, + onScheduleMaintenanceFailure = { + recordCredentialFailure( + code = "FILE_SYNC_SCHEDULE_MAINTENANCE_FAILED", + operation = "account-selection.schedule-maintenance", + component = SupportDiagnosticComponent.Sync, + ) + }, + ) + }, + clearHandoffs = AndroidExternalFileHandoffRegistry::clear, + recordFailure = ::recordAccountHandoffCleanupFailure, + ) + } + clearAndroidPreviousPreviewAfterCommittedSelection( + previousSession = previousSession, + selectedSession = session, + clearPreviewAccount = clearPreviewAccount, + recordFailure = { recordAccountSelectionCacheCleanupFailure() }, + ) + resumeAndroidQueuedUploadsAfterSelection( + resume = { resumeQueuedUploads(NextcloudDocumentIds.accountKey(session)) }, + notifyDocumentRootsChanged = notifyDocumentRootsChanged, + recordFailure = { + recordCredentialFailure( + code = "DURABLE_UPLOAD_RESUME_FAILED", + operation = "account-selection.upload-resume", + component = SupportDiagnosticComponent.Storage, + ) + }, + ) + } + + private fun requireValidState(): AndroidAccountCredentialState = when (val read = readStore()) { + is AndroidAccountCredentialStoreRead.Available -> read.state.also { state -> + requireSupportedCredentialSlots(state.registry) + } + is AndroidAccountCredentialStoreRead.Invalid -> error("The account credential store is invalid.") + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> + error("The independent account credential slots could not be recovered.") + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) + } + + private fun readCredentialFreeRegistry(): NextcloudAccountRegistry? = + ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + val encoded = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) ?: return@serialize null + val restored = restoreAndroidCredentialFreeRegistry(encoded) + recordCredentialFreeRegistryDiagnostic(restored) + restored.registry + } + + private fun readRegistryForCredentialLoad(): NextcloudAccountRegistry? = + ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + val encoded = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) + val restored = encoded?.let(::restoreAndroidCredentialFreeRegistry) + restored?.let(::recordCredentialFreeRegistryDiagnostic) + recoverAndroidCredentialFreeRegistryForCredentialLoad(restored) { + val state = (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state + ?: return@recoverAndroidCredentialFreeRegistryForCredentialLoad null + runCatching { + commitPreferences( + prepareCredentialSlotEdit( + preferences.edit().putString( + ANDROID_ACCOUNT_REGISTRY_KEY, + encodeNextcloudAccountRegistry(state.registry), + ), + state, + ), + ) + } + state.registry + } + } + + private fun recordCredentialFreeRegistryDiagnostic(restored: RestoredAndroidCredentialFreeRegistry) { + restored.diagnosticCode?.let { code -> + recordCredentialFailure(code, operation = "account-registry.restore") + } + } + + private fun readStore(): AndroidAccountCredentialStoreRead = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + val encrypted = preferences.getString(ANDROID_ACCOUNT_SESSION_KEY, null) ?: return@serialize run { + val retained = readIndependentCredentialSlotState() + when { + retained != null -> availableCredentialStore(retained) + hasIndependentCredentialState() -> AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable + else -> availableCredentialStore(AndroidAccountCredentialState.Empty) + } + } + val encoded = try { + sessionCipher.decrypt(encrypted) + } catch (_: Exception) { + recordCredentialFailure( + code = "ACCOUNT_CREDENTIAL_STORE_READ_FAILED", + operation = "account-credentials.restore", + ) + return@serialize AndroidAccountCredentialStoreRead.Invalid(encrypted) + } + val restored = restoreAndroidAccountCredentialStore( + encoded = encoded, + persistMigrated = { migrated -> + val migratedState = requireNotNull(decodeAndroidAccountCredentialState(migrated).state) + commitPreferences( + prepareCredentialSlotEdit( + preferences.edit() + .putString(ANDROID_ACCOUNT_SESSION_KEY, sessionCipher.encrypt(migrated)) + .putString( + ANDROID_ACCOUNT_REGISTRY_KEY, + encodeNextcloudAccountRegistry(migratedState.registry), + ), + migratedState, + ), + ) + }, + recordDiagnostic = recordDiagnostic, + ) + return@serialize when { + restored.unsupportedVersion != null -> + AndroidAccountCredentialStoreRead.Unsupported(encrypted, restored.unsupportedVersion) + restored.state != null -> availableCredentialStore(restored.state) + else -> AndroidAccountCredentialStoreRead.Invalid(encrypted) + } + } + + private fun availableCredentialStore( + state: AndroidAccountCredentialState, + ): AndroidAccountCredentialStoreRead.Available { + if (preferences.contains(ANDROID_QUARANTINED_SESSION_KEY)) { + runCatching { commitPreferences(preferences.edit().remove(ANDROID_QUARANTINED_SESSION_KEY)) } + } + return AndroidAccountCredentialStoreRead.Available(state) + } + + private fun readIndependentCredentialSlotState(): AndroidAccountCredentialState? { + val encodedRegistry = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) ?: return null + val registry = restoreAndroidCredentialFreeRegistry(encodedRegistry).registry ?: return null + val slots = registry.accounts.associate { account -> account.id to readCredentialSlot(account.id) } + if (slots.values.any { slot -> slot is AndroidAccountCredentialSlotRead.Unsupported }) return null + return reconstructAndroidAccountCredentialState(registry) { accountId -> + (slots[accountId] as? AndroidAccountCredentialSlotRead.Available)?.session + } + } + + private fun hasIndependentCredentialState(): Boolean = + preferences.contains(ANDROID_ACCOUNT_REGISTRY_KEY) || + preferences.all.keys.any { key -> key.startsWith(ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX) } + + private fun readCredentialSlot(accountId: NextcloudAccountId): AndroidAccountCredentialSlotRead = try { + readAndroidAccountCredentialSlot( + accountId = accountId, + readEncrypted = { key -> preferences.getString(key, null) }, + decrypt = sessionCipher::decrypt, + decode = { encoded -> + restoreAndroidAccountCredentialStore( + encoded = encoded, + persistMigrated = { migrated -> + commitPreferences( + preferences.edit().putString( + androidAccountCredentialSlotKey(accountId), + sessionCipher.encrypt(migrated), + ), + ) + }, + recordDiagnostic = recordDiagnostic, + ) + }, + ) + } catch (_: Exception) { + recordCredentialFailure( + code = "ACCOUNT_CREDENTIAL_STORE_READ_FAILED", + operation = "account-credentials.restore", + ) + AndroidAccountCredentialSlotRead.Invalid + } + + private fun requireSupportedCredentialSlots(registry: NextcloudAccountRegistry) { + registry.accounts.forEach { account -> + val slot = readCredentialSlot(account.id) + if (slot is AndroidAccountCredentialSlotRead.Unsupported) { + unsupportedCredentialStoreMutation(slot.version) + } + } + } + + private suspend fun persistState( + state: AndroidAccountCredentialState, + pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, + ) = withContext(Dispatchers.IO) { + commitPreferences( + preparePendingAccountRemovalCleanupEdit( + prepareCredentialSlotEdit( + preferences.edit() + .putString(ANDROID_ACCOUNT_SESSION_KEY, encryptState(state)) + .putString(ANDROID_ACCOUNT_REGISTRY_KEY, encodeNextcloudAccountRegistry(state.registry)), + state, + ), + pendingCleanup, + ), + ) + } + + private suspend fun retryPendingAccountRemovalCleanup(session: NextcloudSession) { + val pending = pendingAndroidAccountRemovalCleanupForSession(session, pendingAccountRemovalCleanups()) ?: return + try { + retryQueuedUploadsCleanup(session, pending.workIdentity) + clearPendingAccountRemovalCleanup(pending.accountStorageKey) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + recordAccountRemovalCleanupFailure(failure) + throw IllegalStateException( + "Previous account cleanup must finish before this account can be added again.", + failure, + ) + } + } + + private fun pendingAccountRemovalCleanups(): Set = + preferences.getStringSet(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, emptySet()) + ?.mapTo(linkedSetOf()) { encoded -> + requireNotNull(decodeAndroidPendingAccountRemovalCleanup(encoded)) { + "The pending account cleanup journal is invalid." + } + } + .orEmpty() + + private fun preparePendingAccountRemovalCleanupEdit( + editor: SharedPreferences.Editor, + pendingCleanup: AndroidPendingAccountRemovalCleanup?, + ): SharedPreferences.Editor = if (pendingCleanup == null) { + editor + } else { + val retained = pendingAccountRemovalCleanups() + .filterNot { cleanup -> cleanup.accountStorageKey == pendingCleanup.accountStorageKey } + .toSet() + pendingCleanup + editor.putStringSet( + ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, + retained.mapTo(linkedSetOf(), ::encodeAndroidPendingAccountRemovalCleanup), + ) + } + + private fun clearPendingAccountRemovalCleanup(accountStorageKey: String) { + val remaining = pendingAccountRemovalCleanups() + .filterNot { cleanup -> cleanup.accountStorageKey == accountStorageKey } + val editor = preferences.edit() + if (remaining.isEmpty()) editor.remove(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY) + else editor.putStringSet( + ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, + remaining.mapTo(linkedSetOf(), ::encodeAndroidPendingAccountRemovalCleanup), + ) + commitPreferences(editor) + } + + private fun commitPreferences(editor: SharedPreferences.Editor) = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + try { + requireCommittedAndroidAccountCredentialEdit(editor) + } catch (failure: Exception) { + recordCredentialFailure( + code = "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", + operation = "account-credentials.persist", + ) + throw failure + } + } + + private fun encryptState(state: AndroidAccountCredentialState): String = try { + sessionCipher.encrypt(encodeAndroidAccountCredentialState(state)) + } catch (failure: Exception) { + recordCredentialFailure( + code = "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", + operation = "account-credentials.persist", + ) + throw failure + } + + private fun encryptCredentialSlot(session: NextcloudSession): String = try { + sessionCipher.encrypt(encodeAndroidPersistedSession(session)) + } catch (failure: Exception) { + recordCredentialFailure( + code = "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", + operation = "account-credentials.repair-slot", + ) + throw failure + } + + private fun prepareCredentialSlotEdit( + editor: SharedPreferences.Editor, + state: AndroidAccountCredentialState, + ): SharedPreferences.Editor = editor.apply { + remove(ANDROID_QUARANTINED_SESSION_KEY) + val retainedKeys = state.sessions.keys.mapTo(hashSetOf(), ::androidAccountCredentialSlotKey) + preferences.all.keys + .filter { key -> key.startsWith(ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX) && key !in retainedKeys } + .forEach(::remove) + state.sessions.forEach { (accountId, session) -> + putString( + androidAccountCredentialSlotKey(accountId), + sessionCipher.encrypt(encodeAndroidPersistedSession(session)), + ) + } + } + + private fun recordCredentialFailure( + code: String, + operation: String, + component: SupportDiagnosticComponent = SupportDiagnosticComponent.Authentication, + failure: Throwable? = null, + ) { + recordDiagnostic( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = component, + operation = operation, + outcome = "failed", + code = code, + exception = failure?.toSupportDiagnosticExceptionDraft(), + ), + ) + } + private fun recordAccountRemovalCleanupFailure(failure: Exception) = recordCredentialFailure( + code = "ACCOUNT_REMOVAL_CLEANUP_FAILED", + operation = "account.remove-cleanup", + component = SupportDiagnosticComponent.Sync, + failure = failure, + ) + private fun recordAccountSelectionCacheCleanupFailure() = recordCredentialFailure( + code = "ACCOUNT_SELECTION_CACHE_CLEANUP_FAILED", + operation = "account-selection.cache-cleanup", + component = SupportDiagnosticComponent.Cache, + ) + private fun recordAccountHandoffCleanupFailure(failure: Exception) = recordCredentialFailure( + code = "ACCOUNT_HANDOFF_CLEANUP_FAILED", + operation = "account.handoff-cleanup", + component = SupportDiagnosticComponent.Cache, + failure = failure, + ) + +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt new file mode 100644 index 000000000..b165851f5 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -0,0 +1,179 @@ +package dev.obiente.nextcloudnative + +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudAccountId +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord +import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry +import kotlinx.coroutines.sync.Mutex + +internal sealed interface AndroidAccountCredentialStoreRead { + data class Available(val state: AndroidAccountCredentialState) : AndroidAccountCredentialStoreRead + data class Invalid(val encrypted: String) : AndroidAccountCredentialStoreRead + data object IndependentRecoveryUnavailable : AndroidAccountCredentialStoreRead + data class Unsupported(val encrypted: String, val version: Int) : AndroidAccountCredentialStoreRead +} + +internal sealed interface AndroidAccountCredentialSlotRead { + data object Missing : AndroidAccountCredentialSlotRead + data class Available(val session: NextcloudSession) : AndroidAccountCredentialSlotRead + data object Invalid : AndroidAccountCredentialSlotRead + data class Unsupported(val version: Int) : AndroidAccountCredentialSlotRead +} + +internal data class AndroidPendingAccountRemovalCleanup( + val accountStorageKey: String, + val workIdentity: String, +) { + init { + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(accountStorageKey)) + require(WORK_IDENTITY_PATTERN.matches(workIdentity)) + } +} + +internal fun unsupportedCredentialStoreMutation(version: Int): Nothing = + error("The account credential store version $version is unsupported.") + +internal fun androidAccountCredentialSlotKey(accountId: NextcloudAccountId): String = + "$ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX${accountId.storageKey}" + +internal fun readAndroidAccountCredentialSlot( + accountId: NextcloudAccountId, + readEncrypted: (String) -> String?, + decrypt: (String) -> String, + decode: (String) -> RestoredAndroidAccountCredentialState, +): AndroidAccountCredentialSlotRead { + val encrypted = readEncrypted(androidAccountCredentialSlotKey(accountId)) + ?: return AndroidAccountCredentialSlotRead.Missing + val restored = decode(decrypt(encrypted)) + restored.unsupportedVersion?.let { version -> return AndroidAccountCredentialSlotRead.Unsupported(version) } + val session = restored.state?.activeSession + ?.takeIf { candidate -> candidate.accountId == accountId } + ?: return AndroidAccountCredentialSlotRead.Invalid + return AndroidAccountCredentialSlotRead.Available(session) +} + +internal fun pendingAndroidAccountRemovalCleanup( + session: NextcloudSession, +): AndroidPendingAccountRemovalCleanup = AndroidPendingAccountRemovalCleanup( + accountStorageKey = session.accountId.storageKey, + workIdentity = NextcloudDocumentIds.accountKey(session), +) + +internal fun encodeAndroidPendingAccountRemovalCleanup( + cleanup: AndroidPendingAccountRemovalCleanup, +): String = "${cleanup.accountStorageKey}:${cleanup.workIdentity}" + +internal fun decodeAndroidPendingAccountRemovalCleanup( + encoded: String, +): AndroidPendingAccountRemovalCleanup? { + val accountStorageKey = encoded.substringBefore(':', missingDelimiterValue = "") + val workIdentity = encoded.substringAfter(':', missingDelimiterValue = "") + return runCatching { AndroidPendingAccountRemovalCleanup(accountStorageKey, workIdentity) }.getOrNull() +} + +internal fun pendingAndroidAccountRemovalCleanupForSession( + session: NextcloudSession, + cleanups: Collection, +): AndroidPendingAccountRemovalCleanup? { + val matching = cleanups.filter { cleanup -> + cleanup.accountStorageKey == session.accountId.storageKey + } + check(matching.size <= 1) { "The pending account cleanup journal is ambiguous." } + return matching.singleOrNull() +} + +internal fun recoverAndroidAccountCredentialSlot( + accountId: NextcloudAccountId, + registry: NextcloudAccountRegistry, + storedSlot: NextcloudSession?, + aggregate: AndroidAccountCredentialState?, +): NextcloudSession? { + val account = registry.accounts.firstOrNull { candidate -> candidate.id == accountId } ?: return null + return storedSlot?.takeIf { session -> session.accountRecord() == account } + ?: aggregate?.sessions?.get(accountId)?.takeIf { session -> session.accountRecord() == account } +} + +internal fun reconstructAndroidAccountCredentialState( + registry: NextcloudAccountRegistry, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): AndroidAccountCredentialState? { + val sessions = linkedMapOf() + val unavailableAccounts = mutableListOf() + registry.accounts.forEach { account -> + val session = loadSession(account.id)?.takeIf { loaded -> loaded.accountRecord() == account } + if (session == null) unavailableAccounts += account.id else sessions[account.id] = session + } + if (registry.activeAccountId in unavailableAccounts) return null + val retainedRegistry = unavailableAccounts.fold(registry) { retained, accountId -> retained.remove(accountId) } + return AndroidAccountCredentialState(retainedRegistry, sessions) +} + +internal fun restoreAndroidAccountCredentialStateWithoutAggregate( + encodedRegistry: String?, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): AndroidAccountCredentialState? { + val restored = encodedRegistry + ?.let { encoded -> restoreNextcloudAccountRegistry(encoded, legacySession = null) } + ?: return null + if (restored.recoveryReason != null) return null + return reconstructAndroidAccountCredentialState(restored.registry, loadSession) +} + +internal fun androidCredentialStoreAllowsSessionRestore( + read: AndroidAccountCredentialStoreRead, +): Boolean = when (read) { + is AndroidAccountCredentialStoreRead.Available -> read.state.mutationsAllowed + is AndroidAccountCredentialStoreRead.Unsupported -> false + is AndroidAccountCredentialStoreRead.Invalid, + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable, + -> true +} + +internal class AndroidAccountCredentialStoreGuard { + private val monitor = Any() + + fun serialize(action: () -> Result): Result = synchronized(monitor, action) +} + +internal fun prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor: SharedPreferences.Editor, + replacementEncrypted: String?, +): SharedPreferences.Editor = editor.apply { + remove(ANDROID_QUARANTINED_SESSION_KEY) + if (replacementEncrypted == null) remove(ANDROID_ACCOUNT_SESSION_KEY) + else putString(ANDROID_ACCOUNT_SESSION_KEY, replacementEncrypted) + remove(KEY_TEST_READ_ONLY) +} + +internal fun requireCommittedAndroidAccountCredentialEdit(editor: SharedPreferences.Editor) { + check(editor.commit()) { "The account credential store could not be committed." } +} + +internal fun resolveStoredAndroidAccountSession( + accountIdentity: String, + listAccounts: () -> List, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): NextcloudSession? { + val accountId = listAccounts().firstOrNull { account -> + NextcloudDocumentIds.accountKey( + NextcloudSession(account.serverUrl, account.loginName, appPassword = ""), + ) == accountIdentity + }?.id ?: return null + return loadSession(accountId)?.takeIf { session -> + NextcloudDocumentIds.accountKey(session) == accountIdentity + } +} + +internal const val ANDROID_ACCOUNT_SESSION_KEY = "encrypted_session" +internal const val ANDROID_ACCOUNT_REGISTRY_KEY = "account_registry_v1" +internal const val ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX = "account_credential_v1:" +internal const val ANDROID_QUARANTINED_SESSION_KEY = "encrypted_session_quarantine" +internal const val ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY = "pending_account_removal_cleanup_v2" +internal val ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD = AndroidAccountCredentialStoreGuard() +internal val ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX = Mutex() + +private val ACCOUNT_STORAGE_KEY_PATTERN = Regex("[0-9a-f]{64}") +private val WORK_IDENTITY_PATTERN = Regex("[0-9a-f]{32}") diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt new file mode 100644 index 000000000..dee10acc2 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -0,0 +1,105 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext + +internal fun removeActiveAndroidAccountCredentialState( + state: AndroidAccountCredentialState, +): AndroidAccountCredentialState = state.registry.activeAccountId?.let(state::remove) ?: state + +internal suspend fun resumeAndroidQueuedUploadsAfterSelection( + resume: suspend () -> Unit, + notifyDocumentRootsChanged: () -> Unit, + recordFailure: () -> Unit, +) { + try { + resume() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + recordFailure() + } finally { + notifyDocumentRootsChanged() + } +} + +internal suspend fun removeAndroidAccountCredentialData( + active: Boolean, + prepareAccountRemoval: suspend () -> Unit = {}, + removeQueuedUploads: suspend () -> Unit, + clearActiveAccount: suspend () -> Unit, + rollbackActiveRemoval: suspend () -> Unit, + persistInactiveRemoval: suspend () -> Unit, + rollbackInactiveRemoval: suspend () -> Unit, + completeCommittedCleanup: suspend () -> Unit = {}, + recordCommittedCleanupFailure: (Exception) -> Unit = {}, +) { + prepareAccountRemoval() + if (active) { + try { + clearActiveAccount() + } catch (failure: Exception) { + withContext(NonCancellable) { + runCatching { rollbackActiveRemoval() } + .onFailure(failure::addSuppressed) + } + throw failure + } + finishCommittedAndroidAccountRemovalCleanup( + removeQueuedUploads, + completeCommittedCleanup, + recordCommittedCleanupFailure, + ) + return + } + + try { + persistInactiveRemoval() + } catch (failure: Exception) { + withContext(NonCancellable) { + runCatching { rollbackInactiveRemoval() } + .onFailure(failure::addSuppressed) + } + throw failure + } + finishCommittedAndroidAccountRemovalCleanup( + removeQueuedUploads, + completeCommittedCleanup, + recordCommittedCleanupFailure, + ) +} + +private suspend fun finishCommittedAndroidAccountRemovalCleanup( + removeQueuedUploads: suspend () -> Unit, + completeCommittedCleanup: suspend () -> Unit, + recordFailure: (Exception) -> Unit, +) { + try { + removeQueuedUploads() + completeCommittedCleanup() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + recordFailure(failure) + } +} + +internal suspend fun removeRecoveredAndroidAccountCredentialData( + prepareAccountRemoval: suspend () -> Unit = {}, + removeQueuedUploads: suspend () -> Unit, + clearRecoveredAccount: suspend () -> Unit, + rollbackRecoveredAccount: suspend () -> Unit, + completeCommittedCleanup: suspend () -> Unit = {}, + recordCommittedCleanupFailure: (Exception) -> Unit = {}, +) = removeAndroidAccountCredentialData( + active = true, + prepareAccountRemoval = prepareAccountRemoval, + removeQueuedUploads = removeQueuedUploads, + clearActiveAccount = clearRecoveredAccount, + rollbackActiveRemoval = rollbackRecoveredAccount, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + completeCommittedCleanup = completeCommittedCleanup, + recordCommittedCleanupFailure = recordCommittedCleanupFailure, +) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt new file mode 100644 index 000000000..6e367f633 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -0,0 +1,117 @@ +package dev.obiente.nextcloudnative + +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex + +internal class AndroidAccountOperationGuard { + private val monitor = Any() + private val accountLeases = mutableMapOf() + + suspend fun withAccount(accountId: String, action: suspend () -> Result): Result { + val lease = acquire(accountId) + return try { + action() + } finally { + lease.close() + } + } + + suspend fun withAccounts(accountIds: Collection, action: suspend () -> Result): Result { + val leases = mutableListOf() + try { + accountIds.distinct().sorted().forEach { accountId -> leases += acquire(accountId) } + return action() + } finally { + leases.asReversed().forEach(AndroidAccountOperationLease::close) + } + } + + fun acquireBlocking(accountId: String): AndroidAccountOperationLease = runBlocking { acquire(accountId) } + + suspend fun withAccountSession( + accountId: String, + resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, + unavailable: suspend () -> Result, + action: suspend (dev.obiente.nextcloudnative.app.NextcloudSession) -> Result, + ): Result = withAccount(accountId) { + val session = resolveSession() + if (androidAccountOperationSessionIsCurrent(accountId, session)) { + action(requireNotNull(session)) + } else { + unavailable() + } + } + + suspend fun withExactAccountSession( + expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, + resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, + unavailable: suspend () -> Result, + action: suspend (dev.obiente.nextcloudnative.app.NextcloudSession) -> Result, + ): Result = withAccount(NextcloudDocumentIds.accountKey(expectedSession)) { + val current = resolveSession() + if (current == expectedSession) action(current) else unavailable() + } + + private suspend fun acquire(accountId: String): AndroidAccountOperationLease { + require(accountId.isNotBlank()) + val lease = synchronized(monitor) { + accountLeases.getOrPut(accountId) { AccountLease() }.also { it.references += 1 } + } + try { + lease.mutex.lock() + } catch (failure: Throwable) { + releaseReference(accountId, lease) + throw failure + } + return AndroidAccountOperationLease { + lease.mutex.unlock() + releaseReference(accountId, lease) + } + } + + private fun releaseReference(accountId: String, lease: AccountLease) { + synchronized(monitor) { + lease.references -= 1 + if (lease.references == 0) accountLeases.remove(accountId, lease) + } + } + + private class AccountLease( + val mutex: Mutex = Mutex(), + var references: Int = 0, + ) +} + +internal class AndroidAccountOperationLease( + private val release: () -> Unit, +) : AutoCloseable { + private val closed = AtomicBoolean(false) + + override fun close() { + if (closed.compareAndSet(false, true)) release() + } +} + +internal val ANDROID_ACCOUNT_OPERATION_GUARD = AndroidAccountOperationGuard() + +internal fun androidAccountOperationSessionIsCurrent( + expectedAccountId: String, + currentSession: dev.obiente.nextcloudnative.app.NextcloudSession?, +): Boolean = currentSession != null && NextcloudDocumentIds.accountKey(currentSession) == expectedAccountId + +internal fun androidDocumentWritebackSessionIsCurrent( + expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, + currentSession: dev.obiente.nextcloudnative.app.NextcloudSession?, +): Boolean = currentSession == expectedSession + +internal suspend fun AndroidAccountOperationGuard.withAuthenticatedMutationSession( + expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, + resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, + action: suspend (dev.obiente.nextcloudnative.app.NextcloudSession) -> Result, +): Result = withExactAccountSession( + expectedSession = expectedSession, + resolveSession = resolveSession, + unavailable = { error("The account changed before the authenticated change could be sent.") }, + action = action, +) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt new file mode 100644 index 000000000..3fb794771 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -0,0 +1,36 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal class AndroidAccountOwnedStateCleanup(context: Context) { + private val appContext = context.applicationContext + private val fileOffline = AndroidFileOfflineAccountCleanup(appContext) + private val incomingShares = AndroidIncomingShareAccountCleanup(appContext) + private val durableUploads = AndroidDurableUploadAccountCleanup(appContext) + + suspend fun remove(session: NextcloudSession) { + val accountIdentity = NextcloudDocumentIds.accountKey(session) + runAndroidAccountRemovalCleanups( + listOf( + { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, + { fileOffline.removeForAccount(accountIdentity) }, + { incomingShares.removeForAccount(session) }, + { durableUploads.removeForAccount(accountIdentity) }, + { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + ), + ) + } + + suspend fun retry(session: NextcloudSession, accountIdentity: String) { + runAndroidAccountRemovalCleanups( + listOf( + { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, + { fileOffline.removeForAccount(accountIdentity) }, + { incomingShares.removeForAccount(accountIdentity, session) }, + { durableUploads.removeForAccount(accountIdentity) }, + { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + ), + ) + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt new file mode 100644 index 000000000..b487ae1d4 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -0,0 +1,81 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.content.Intent +import android.provider.DocumentsContract +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal val NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS: Int = + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + +internal fun requireAndroidAccountRemovalWritebacksResolved(resolved: Boolean) { + check(resolved) { + "Finish or discard pending document changes before removing this account." + } +} + +internal suspend fun revokeAndroidSessionAfterRemovalPreflight( + preflight: suspend () -> Unit, + revoke: suspend () -> Unit, + removeLocalAccount: suspend () -> Unit, +) { + preflight() + revoke() + removeLocalAccount() +} + +internal suspend fun revokeAndroidSessionWithAccountLease( + accountIdentity: String, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + preflight: suspend () -> Unit, + revoke: suspend () -> Unit, + removeLocalAccount: suspend () -> Unit, +) = guard.withAccount(accountIdentity) { + revokeAndroidSessionAfterRemovalPreflight(preflight, revoke, removeLocalAccount) +} + +internal enum class AndroidAccountDocumentGrantScope(val pathSegment: String) { + Document("document"), + Tree("tree"), +} + +internal fun AndroidAccountDocumentGrantScope.uri(authority: String, rootId: String) = when (this) { + AndroidAccountDocumentGrantScope.Document -> DocumentsContract.buildDocumentUri(authority, rootId) + AndroidAccountDocumentGrantScope.Tree -> DocumentsContract.buildTreeDocumentUri(authority, rootId) +} + +internal suspend fun preflightAndroidAccountRemoval(context: Context, session: NextcloudSession) { + requireAndroidAccountRemovalWritebacksResolved(androidDocumentPendingWritebacks(context, session).isEmpty()) + requireAndroidFileSyncAccountRemovalReady(context, NextcloudDocumentIds.accountKey(session)) +} + +internal suspend fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) { + preflightAndroidAccountRemoval(context, session) +} + +internal fun revokeAndroidAccountDocumentGrants(context: Context, accountIdentity: String) { + AndroidAccountDocumentGrantScope.entries.forEach { scope -> + context.revokeUriPermission( + scope.uri(nextcloudDocumentsAuthority(context.packageName), NextcloudDocumentIds.rootId(accountIdentity)), + NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS, + ) + } +} + +internal suspend fun runAndroidAccountRemovalCleanups( + cleanups: List Unit>, +) { + var firstFailure: Exception? = null + cleanups.forEach { cleanup -> + try { + cleanup() + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (failure: Exception) { + if (firstFailure == null) firstFailure = failure else firstFailure.addSuppressed(failure) + } + } + firstFailure?.let { throw it } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt new file mode 100644 index 000000000..1f68158ce --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt @@ -0,0 +1,30 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal fun commitAndroidAccountTransitionBeforeHandoffCleanup( + commitTransition: () -> Unit, + clearHandoffs: () -> Unit, + recordFailure: (Exception) -> Unit, +) { + commitTransition() + try { + clearHandoffs() + } catch (failure: Exception) { + runCatching { recordFailure(failure) } + } +} + +internal fun clearAndroidPreviousPreviewAfterCommittedSelection( + previousSession: NextcloudSession?, + selectedSession: NextcloudSession, + clearPreviewAccount: (String) -> Unit, + recordFailure: (Exception) -> Unit, +) { + if (previousSession == null || previousSession.accountId == selectedSession.accountId) return + try { + clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(previousSession)) + } catch (failure: Exception) { + runCatching { recordFailure(failure) } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentEditing.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentEditing.kt index ca9c4bcb2..e2e8a5a41 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentEditing.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentEditing.kt @@ -46,7 +46,7 @@ internal data class AndroidDocumentEditingHttpResponse( ) internal class AndroidDocumentEditingTransport( - private val execute: (NextcloudSession, AndroidDocumentEditingHttpRequest) -> AndroidDocumentEditingHttpResponse, + private val execute: suspend (NextcloudSession, AndroidDocumentEditingHttpRequest) -> AndroidDocumentEditingHttpResponse, ) { suspend fun loadCapabilities( session: NextcloudSession, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt index 627175697..6795c95c9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt @@ -1,7 +1,9 @@ package dev.obiente.nextcloudnative +import android.os.ParcelFileDescriptor import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.File +import java.io.FileNotFoundException import java.io.FileOutputStream import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files @@ -13,6 +15,71 @@ import org.json.JSONObject internal const val MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES = Long.MAX_VALUE internal const val MIN_ANDROID_DOCUMENT_FREE_BYTES = 512L * 1024L * 1024L +internal fun descriptorMode(mode: String): Int = when (mode) { + "w" -> ParcelFileDescriptor.MODE_WRITE_ONLY + "wt" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_TRUNCATE + "wa" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_APPEND + "rw" -> ParcelFileDescriptor.MODE_READ_WRITE + "rwt" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_TRUNCATE + else -> error("Unsupported writable mode: $mode") +} + +internal fun acquireAndroidDocumentWritebackAccountLease( + session: NextcloudSession, + remotePath: String, + loadCurrentSession: () -> NextcloudSession?, +): AndroidAccountOperationLease { + val lease = acquireAndroidDocumentMutationAccountLease(session, loadCurrentSession) + return try { + reserveAndroidDocumentWritebackPath(session, remotePath) + lease + } catch (failure: Throwable) { + lease.close() + throw failure + } +} + +internal fun acquireAndroidDocumentMutationAccountLease( + session: NextcloudSession, + loadCurrentSession: () -> NextcloudSession?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, +): AndroidAccountOperationLease { + val lease = guard.acquireBlocking(NextcloudDocumentIds.accountKey(session)) + return try { + if (!androidDocumentWritebackSessionIsCurrent(session, loadCurrentSession())) { + throw FileNotFoundException("The active Nextcloud account changed before the document mutation could start.") + } + lease + } catch (failure: Throwable) { + lease.close() + throw failure + } +} + +internal inline fun withAndroidDocumentMutation( + session: NextcloudSession, + noinline loadCurrentSession: () -> NextcloudSession?, + action: (NextcloudSession) -> Result, +): Result { + val lease = acquireAndroidDocumentMutationAccountLease(session, loadCurrentSession) + return try { + action(session) + } finally { + lease.close() + } +} + +internal fun releaseAndroidDocumentWritebackSetup( + accountLease: AndroidAccountOperationLease, + releasePath: () -> Unit, +) { + try { + releasePath() + } finally { + accountLease.close() + } +} + internal fun requireAndroidDocumentWritebackCapacity(remoteSize: Long, availableBytes: Long) { require(remoteSize >= 0L && availableBytes >= 0L) require(remoteSize <= (availableBytes - MIN_ANDROID_DOCUMENT_FREE_BYTES).coerceAtLeast(0L)) { @@ -204,11 +271,38 @@ internal fun withNoBlockingAndroidDocumentWriteback( vararg remotePaths: String, operation: () -> T, ): T { + val reservation = reserveAndroidDocumentMutation(context, session, remotePaths) + return try { + operation() + } finally { + releaseAndroidDocumentMutation(reservation) + } +} + +internal suspend fun withNoBlockingAndroidDocumentWritebackSuspending( + context: android.content.Context?, + session: NextcloudSession, + vararg remotePaths: String, + operation: suspend () -> T, +): T { + val reservation = reserveAndroidDocumentMutation(context, session, remotePaths) + return try { + operation() + } finally { + releaseAndroidDocumentMutation(reservation) + } +} + +private fun reserveAndroidDocumentMutation( + context: android.content.Context?, + session: NextcloudSession, + remotePaths: Array, +): ActiveAndroidDocumentMutation { val providerContext = requireNotNull(context) { "Provider context is unavailable." } val accountId = NextcloudDocumentIds.accountKey(session) val paths = remotePaths.toSet() require(paths.isNotEmpty() && paths.none(String::isBlank)) - val reservation = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + return synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { val activePaths = ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS.asSequence() .filter { active -> active.accountId == accountId } .map(ActiveAndroidDocumentWritebackPath::remotePath) @@ -227,12 +321,11 @@ internal fun withNoBlockingAndroidDocumentWriteback( } ActiveAndroidDocumentMutation(accountId, paths).also(ACTIVE_ANDROID_DOCUMENT_MUTATIONS::add) } - return try { - operation() - } finally { - synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { - check(ACTIVE_ANDROID_DOCUMENT_MUTATIONS.remove(reservation)) - } +} + +private fun releaseAndroidDocumentMutation(reservation: ActiveAndroidDocumentMutation) { + synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + check(ACTIVE_ANDROID_DOCUMENT_MUTATIONS.remove(reservation)) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index d53c82dec..9745c31a0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -30,6 +30,7 @@ import dev.obiente.nextcloudnative.app.afterProcessRecovery import dev.obiente.nextcloudnative.app.localUploadFile import dev.obiente.nextcloudnative.app.toSupportDiagnosticExceptionDraft import java.util.UUID +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray @@ -90,6 +91,18 @@ internal class AndroidDurableMultipartUploads(context: Context) { .map(AndroidDurableMultipartUploadJob::status) .toList() + suspend fun resumeQueuedForAccount(accountId: String) { + queuedDurableUploadsForAccount(store.list(), accountId).forEach { job -> + try { + schedule(job, ExistingWorkPolicy.APPEND_OR_REPLACE).await() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // The queue stays authoritative; status refresh or a later activation can retry. + } + } + } + fun dismiss(session: NextcloudSession, scope: DurableUploadScope, uploadId: String): Boolean { val job = store.find(uploadId) ?: return false if ( @@ -104,10 +117,13 @@ internal class AndroidDurableMultipartUploads(context: Context) { return true } - private fun schedule(job: AndroidDurableMultipartUploadJob): Operation = + private fun schedule( + job: AndroidDurableMultipartUploadJob, + policy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP, + ): Operation = WorkManager.getInstance(appContext).enqueueUniqueWork( - "deck-attachment-${job.id}", - ExistingWorkPolicy.KEEP, + durableUploadWorkName(job.id), + policy, OneTimeWorkRequestBuilder() .setInputData(Data.Builder().putString(DeckAttachmentUploadWorker.KEY_JOB_ID, job.id).build()) .setConstraints( @@ -123,6 +139,8 @@ internal class AndroidDurableMultipartUploads(context: Context) { } } +internal fun durableUploadWorkName(jobId: String) = "deck-attachment-$jobId" + internal class DeckAttachmentUploadWorker( appContext: Context, params: WorkerParameters, @@ -151,13 +169,48 @@ internal class DeckAttachmentUploadWorker( } if (initial.state != DurableUploadState.Queued) return@withContext Result.success() - val session = AndroidNextcloudServices(applicationContext).loadSession() + return@withContext uploadQueuedJob(store, initial, picker, jobId) + } + + private suspend fun uploadQueuedJob( + store: AndroidDurableMultipartUploadStore, + initial: AndroidDurableMultipartUploadJob, + picker: AndroidLocalUploadPicker, + jobId: String, + ): Result = ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(initial.accountId) { + performQueuedUpload(store, initial, picker, jobId) + } + + private suspend fun performQueuedUpload( + store: AndroidDurableMultipartUploadStore, + initial: AndroidDurableMultipartUploadJob, + picker: AndroidLocalUploadPicker, + jobId: String, + ): Result { + val accountServices = AndroidNextcloudServices(applicationContext) + val session = accountServices.loadSession() if (session == null || NextcloudDocumentIds.accountKey(session) != initial.accountId) { + val retainedSession = resolveStoredAndroidAccountSession( + accountIdentity = initial.accountId, + listAccounts = accountServices::listAccounts, + loadSession = { accountId -> accountServices.loadSession(accountId) }, + ) + if (durableUploadAccountMismatchOutcome(initial.accountId, retainedSession) == + DurableUploadAccountMismatchOutcome.DeferRetainedAccount + ) { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-deferred", + accountId = initial.accountId, + jobId = jobId, + ) + return Result.success() + } store.transition( jobId, expected = DurableUploadState.Queued, target = DurableUploadState.Failed, - message = "The account used for this upload is no longer active.", + message = "The account used for this upload is no longer available.", ) picker.release(initial.request.file) recordUploadDiagnostic( @@ -166,7 +219,7 @@ internal class DeckAttachmentUploadWorker( accountId = initial.accountId, jobId = jobId, ) - return@withContext Result.failure() + return Result.failure() } val capabilityReady = runCatching { picker.requirePersisted(initial.request.file) @@ -186,15 +239,19 @@ internal class DeckAttachmentUploadWorker( accountId = initial.accountId, jobId = jobId, ) - return@withContext Result.failure() + return Result.failure() } val started = store.transition( jobId, expected = DurableUploadState.Queued, target = DurableUploadState.Uploading, message = null, - ) ?: return@withContext Result.success() - val services = AndroidNextcloudServices(applicationContext, localUploadPicker = picker) + ) ?: return Result.success() + val services = AndroidNextcloudServices( + applicationContext, + localUploadPicker = picker, + accountMutationLeaseHeld = true, + ) val outcome = runCatching { services.executeNextcloudMultipartUpload(session, started.request) } @@ -252,7 +309,7 @@ internal class DeckAttachmentUploadWorker( ) picker.release(started.request.file) } - Result.success() + return Result.success() } private fun recordUploadDiagnostic( @@ -284,6 +341,28 @@ internal class DeckAttachmentUploadWorker( } } +internal enum class DurableUploadAccountMismatchOutcome { + DeferRetainedAccount, + AccountUnavailable, +} + +internal fun durableUploadAccountMismatchOutcome( + expectedAccountId: String, + retainedSession: NextcloudSession?, +): DurableUploadAccountMismatchOutcome = + if (retainedSession != null && NextcloudDocumentIds.accountKey(retainedSession) == expectedAccountId) { + DurableUploadAccountMismatchOutcome.DeferRetainedAccount + } else { + DurableUploadAccountMismatchOutcome.AccountUnavailable + } + +internal fun queuedDurableUploadsForAccount( + jobs: List, + accountId: String, +): List = jobs.filter { job -> + job.accountId == accountId && job.state == DurableUploadState.Queued +} + internal data class AndroidDurableMultipartUploadJob( val id: String, val accountId: String, @@ -381,6 +460,13 @@ internal class AndroidDurableMultipartUploadStore( writeAll(readAll().filterNot { it.id == id }) } + fun removeForAccount(accountId: String): List = synchronized(LOCK) { + val current = readAll() + val removed = current.filter { job -> job.accountId == accountId } + if (removed.isNotEmpty()) writeAll(current.filterNot { job -> job.accountId == accountId }) + removed + } + fun transition( id: String, expected: DurableUploadState, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt new file mode 100644 index 000000000..926b6c9ba --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt @@ -0,0 +1,35 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import androidx.work.WorkManager +import androidx.work.await + +internal class AndroidDurableUploadAccountCleanup(context: Context) { + private val appContext = context.applicationContext + private val store = AndroidDurableMultipartUploadStore(appContext) + + suspend fun removeForAccount(accountId: String) { + val picker = AndroidLocalUploadPicker(appContext) + removeAndroidDurableUploadJobs( + jobs = store.list().filter { job -> job.accountId == accountId }, + cancelWork = { job -> + WorkManager.getInstance(appContext).cancelUniqueWork(durableUploadWorkName(job.id)).await() + }, + releaseCapability = { job -> picker.release(job.request.file) }, + removeJob = store::remove, + ) + } +} + +internal suspend fun removeAndroidDurableUploadJobs( + jobs: List, + cancelWork: suspend (AndroidDurableMultipartUploadJob) -> Unit, + releaseCapability: (AndroidDurableMultipartUploadJob) -> Boolean, + removeJob: (String) -> Unit, +) { + jobs.forEach { job -> cancelWork(job) } + jobs.forEach { job -> + check(releaseCapability(job)) { "The durable upload source capability could not be released." } + removeJob(job.id) + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineAccountCleanup.kt new file mode 100644 index 000000000..f055e9702 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineAccountCleanup.kt @@ -0,0 +1,49 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import androidx.work.WorkManager +import androidx.work.await +import dev.obiente.nextcloudnative.app.FileOfflineQueueState +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class AndroidFileOfflineAccountCleanup(context: Context) { + private val appContext = context.applicationContext + private val store = AndroidFileOfflineQueueStore(appContext) + + suspend fun removeForAccount(accountId: String) = withContext(Dispatchers.IO) { + val pendingJobIds = synchronized(AndroidFileOfflineRepository.STATE_LOCK) { + store.load().queue.jobs.filter { job -> job.key.accountId == accountId }.map { job -> job.id } + } + val workManager = WorkManager.getInstance(appContext) + pendingJobIds.forEach { jobId -> + workManager.cancelUniqueWork(AndroidFileOfflineRepository.workName(accountId, jobId)).await() + } + synchronized(AndroidFileOfflineRepository.STATE_LOCK) { + store.save(removeAndroidFileOfflineAccountState(store.load(), accountId)) + } + val accountContent = File( + File(appContext.filesDir, AndroidFileOfflineRepository.CONTENT_DIRECTORY), + accountId, + ) + check(!accountContent.exists() || accountContent.deleteRecursively()) { + "Could not remove this account's offline files." + } + } +} + +internal fun removeAndroidFileOfflineAccountState( + current: AndroidFileOfflinePersistedState, + accountId: String, +): AndroidFileOfflinePersistedState = current.copy( + queue = FileOfflineQueueState( + records = current.queue.records.filterNot { record -> record.descriptor.key.accountId == accountId }, + jobs = current.queue.jobs.filterNot { job -> job.key.accountId == accountId }, + nextJobId = current.queue.nextJobId, + ), + folders = current.folders.copy( + directPins = current.folders.directPins.filterNotTo(linkedSetOf()) { key -> key.accountId == accountId }, + roots = current.folders.roots.filterNot { root -> root.accountId == accountId }, + ), +) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt index 604390636..04ae7f32f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt @@ -385,14 +385,26 @@ internal class AndroidFileOfflineRepository(context: Context) { return update.state.folderAvailability(accountId, folder.path) } - fun execute( + suspend fun execute( + expectedAccountId: String, + userId: String, + jobId: Long, + cancellation: DocumentRequestCancellation, + ): AndroidOfflineExecutionOutcome = ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(expectedAccountId) { + executeWhileAccountRetained(expectedAccountId, userId, jobId, cancellation) + } + + private fun executeWhileAccountRetained( expectedAccountId: String, userId: String, jobId: Long, cancellation: DocumentRequestCancellation, ): AndroidOfflineExecutionOutcome { - val session = AndroidNextcloudServices(appContext).loadSession() - if (session == null || NextcloudDocumentIds.accountKey(session) != expectedAccountId) { + val services = AndroidNextcloudServices(appContext) + val session = resolveStoredAndroidAccountSession( + expectedAccountId, services::listAccounts, services::loadSession, + ) + if (session == null) { finish( jobId, FileOfflineJobResult.PermanentFailure("Sign in to this account to finish the offline download."), @@ -693,7 +705,7 @@ internal class AndroidFileOfflineRepository(context: Context) { val record: dev.obiente.nextcloudnative.app.FileOfflinePinRecord, ) - private companion object { + internal companion object { const val CONTENT_DIRECTORY = "offline-content-v1" const val WORK_TAG = "nextcloud-native-offline-files" const val MAX_OFFLINE_CENTER_VISIBLE_ITEMS = 10_000 diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index b695249dc..6feb78ac5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -840,7 +840,7 @@ internal class AndroidFileSyncEngine(context: Context) { return FileSyncBaseline(path, localEntry.kind, localEntry.revision, remoteEntry.etag, contentHash) } - private companion object { - val ENGINE_LOCK = Mutex() + internal companion object { + internal val ENGINE_LOCK = Mutex() } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index 0f66a976c..69e4c3d60 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -229,3 +229,35 @@ internal fun releaseSafGrantAfterPairRemoval( // The pair is gone, so a later picker can release or replace this stale grant. } } + +internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, accountId: String) { + AndroidFileSyncEngine.ENGINE_LOCK.withLock { + val store = AndroidFileSyncStore(context) + val current = store.load() + val retiredPairIds = current.coordinator.pairs + .filter { pair -> pair.accountId == accountId } + .map { pair -> pair.id } + if (retiredPairIds.isEmpty()) return@withLock + val scheduler = AndroidFileSyncScheduler(context) + cancelAndroidFileSyncPairSchedulesBeforeRetirement( + pairIds = retiredPairIds, + cancelSchedule = scheduler::cancel, + persistRetirement = { store.save(removeAndroidFileSyncAccountPairs(current, accountId)) }, + ) + } +} + +internal suspend fun cancelAndroidFileSyncPairSchedulesBeforeRetirement( + pairIds: List, + cancelSchedule: suspend (String) -> Unit, + persistRetirement: () -> Unit, +) { + pairIds.forEach { pairId -> cancelSchedule(pairId) } + persistRetirement() +} + +internal suspend fun requireAndroidFileSyncAccountRemovalReady(context: Context, accountId: String) { + AndroidFileSyncEngine.ENGINE_LOCK.withLock { + requireAndroidFileSyncAccountRemovalReady(AndroidFileSyncStore(context).load(), accountId) + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt index f3973a8cb..a1e6cae67 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt @@ -5,7 +5,9 @@ import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager @@ -53,17 +55,21 @@ internal class AndroidFileSyncSessionSchedulingGuard { persist: () -> Unit, cancelAll: () -> Unit, publishAccount: (String) -> Unit = {}, + restoreSchedules: (String) -> Unit = {}, + onScheduleMaintenanceFailure: (Exception) -> Unit = {}, ) { synchronized(monitor) { val accountChanged = accountId != replacementAccountId + persist() generation += 1 - accountId = null + accountId = replacementAccountId try { - persist() - accountId = replacementAccountId publishAccount(replacementAccountId) } finally { - if (accountChanged) cancelAll() + if (accountChanged) { + runScheduleMaintenance(onScheduleMaintenanceFailure, cancelAll) + } + runScheduleMaintenance(onScheduleMaintenanceFailure) { restoreSchedules(replacementAccountId) } } } } @@ -74,10 +80,10 @@ internal class AndroidFileSyncSessionSchedulingGuard { clearPublishedAccount: () -> Unit = {}, ) { synchronized(monitor) { + persist() generation += 1 accountId = null try { - persist() clearPublishedAccount() } finally { cancelAll() @@ -103,6 +109,14 @@ internal class AndroidFileSyncSessionSchedulingGuard { true } } + + private fun runScheduleMaintenance(onFailure: (Exception) -> Unit, action: () -> Unit) { + try { + action() + } catch (failure: Exception) { + runCatching { onFailure(failure) } + } + } } internal data class DeferredFileSyncPairScheduling( @@ -165,6 +179,28 @@ internal class AndroidFileSyncScheduler(context: Context) { ) } + fun restorePersistedPairSchedules(accountId: String) { + val request = OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putString(AndroidFileSyncScheduleRestorationWorker.KEY_ACCOUNT_ID, accountId) + .build(), + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .addTag(TAG) + .build() + workManager.enqueueUniqueWork( + "file-sync-restore-$accountId", + ExistingWorkPolicy.REPLACE, + request, + ) + } + suspend fun cancel(pairId: String) { workManager.cancelUniqueWork(workName(pairId)).await() } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt index 3bfc8356e..e4f5acbff 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt @@ -4,6 +4,7 @@ import android.content.Context import dev.obiente.nextcloudnative.app.FileSyncCoordinatorState import dev.obiente.nextcloudnative.app.decodeFileSyncCoordinatorSnapshot import dev.obiente.nextcloudnative.app.encodeFileSyncCoordinatorSnapshot +import dev.obiente.nextcloudnative.app.fileSyncOwnedUploads import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.DataInputStream @@ -27,6 +28,33 @@ internal data class AndroidFileSyncPersistedState( } } +internal fun removeAndroidFileSyncAccountPairs( + state: AndroidFileSyncPersistedState, + accountId: String, +): AndroidFileSyncPersistedState { + requireAndroidFileSyncAccountRemovalReady(state, accountId) + val retainedPairs = state.coordinator.pairs.filterNot { pair -> pair.accountId == accountId } + val retainedPairIds = retainedPairs.mapTo(hashSetOf()) { pair -> pair.id } + return AndroidFileSyncPersistedState( + coordinator = FileSyncCoordinatorState(retainedPairs), + localDisplayNames = state.localDisplayNames.filterKeys(retainedPairIds::contains), + ) +} + +internal fun requireAndroidFileSyncAccountRemovalReady( + state: AndroidFileSyncPersistedState, + accountId: String, +) { + require(accountId.isNotBlank()) + state.coordinator.pairs + .filter { pair -> pair.accountId == accountId } + .forEach { pair -> + require(fileSyncOwnedUploads(pair).isEmpty()) { + "Owned remote upload state must be recovered before removing this account's sync pairs." + } + } +} + internal class AndroidFileSyncStore internal constructor( private val stateFile: File, private val maximumSnapshotBytes: Int = MAX_SNAPSHOT_BYTES, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt new file mode 100644 index 000000000..71873c417 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt @@ -0,0 +1,147 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.util.Log +import androidx.core.app.NotificationManagerCompat +import androidx.work.WorkManager +import androidx.work.await +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.useAndroidNextcloudCertificateTrust +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.job +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient + +internal data class AndroidIncomingShareAccountRequest( + val id: String, + val request: AndroidIncomingShareRequest?, +) + +internal class AndroidIncomingShareAccountCleanup(context: Context) { + private val appContext = context.applicationContext + private val store = AndroidIncomingShareStore(appContext) + + suspend fun removeForAccount(session: NextcloudSession) = + removeForAccountInternal(NextcloudDocumentIds.accountKey(session), session) + + suspend fun removeForAccount(accountId: String) = removeForAccountInternal(accountId, session = null) + + suspend fun removeForAccount(accountId: String, session: NextcloudSession) = + removeForAccountInternal(accountId, session) + + private suspend fun removeForAccountInternal( + accountId: String, + session: NextcloudSession?, + ) = withContext(Dispatchers.IO) { + val workManager = WorkManager.getInstance(appContext) + val webDav = session?.let { + NextcloudDocumentWebDav( + client = OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .retryOnConnectionFailure(false) + .useAndroidNextcloudCertificateTrust(appContext) + .build(), + cloudMutationsAllowed = appContext.cloudMutationGate(), + ) + } + removeAndroidIncomingShareRequests( + requests = store.listForAccount(accountId), + cancelWork = { requestId -> + incomingShareAccountWorkNames(requestId).forEach { workName -> + workManager.cancelUniqueWork(workName).await() + } + }, + releaseChunk = { request, uploadId -> + if (session == null || webDav == null) return@removeAndroidIncomingShareRequests + val userId = requireNotNull(request.userId?.takeIf(String::isNotBlank)) { + "The staged share chunk is missing its account owner." + } + val cancellation = CoroutineDocumentRequestCancellation(currentCoroutineContext().job) + try { + webDav.deleteChunkUpload(session, userId, uploadId, cancellation) + } finally { + cancellation.close() + } + }, + recordChunkReleaseFailure = { + Log.w(LOG_TAG, "Remote staged-share chunk cleanup deferred during account removal") + }, + removeRequest = { requestId -> + check(store.remove(requestId)) { "The staged share data could not be released." } + NotificationManagerCompat.from(appContext).apply { + cancel(incomingShareNotificationId(requestId)) + cancel(incomingShareForegroundNotificationId(requestId)) + } + }, + ) + } +} + +internal fun AndroidIncomingShareStore.listForAccount(accountId: String): List { + require(accountId.isNotBlank()) + return synchronized(AndroidIncomingShareStore.LOCK) { + root.listFiles().orEmpty() + .asSequence() + .filter { directory -> + directory.isDirectory && runCatching { UUID.fromString(directory.name) }.isSuccess + } + .mapNotNull { directory -> + val id = directory.name + when (val loaded = loadResult(id)) { + AndroidIncomingShareLoadResult.Missing -> null + is AndroidIncomingShareLoadResult.Available -> loaded.request + .takeIf { request -> request.accountId == accountId } + ?.let { request -> AndroidIncomingShareAccountRequest(id, request) } + is AndroidIncomingShareLoadResult.Corrupt -> + id.takeIf { corruptRecoveryAccountId(id) == accountId } + ?.let { AndroidIncomingShareAccountRequest(it, request = null) } + } + } + .toList() + } +} + +internal fun incomingShareAccountWorkNames(requestId: String): List = listOf( + incomingShareUploadWorkName(requestId), + incomingShareRetryWorkName(requestId), + incomingShareCleanupWorkName(requestId), + incomingShareChunkCleanupWorkName(requestId), + incomingShareReleaseWorkName(requestId), + incomingShareAbandonedStagingWorkName(requestId), +) + +internal suspend fun removeAndroidIncomingShareRequests( + requests: List, + cancelWork: suspend (String) -> Unit, + releaseChunk: suspend (AndroidIncomingShareRequest, String) -> Unit, + recordChunkReleaseFailure: (Throwable) -> Unit = {}, + removeRequest: (String) -> Unit, +) { + requests.forEach { request -> cancelWork(request.id) } + val retained = mutableSetOf() + var firstReleaseFailure: Exception? = null + requests.forEach { accountRequest -> + accountRequest.request?.chunkSession?.let { chunk -> + try { + releaseChunk(accountRequest.request, chunk.uploadId) + } catch (failure: kotlinx.coroutines.CancellationException) { + throw failure + } catch (failure: Exception) { + recordChunkReleaseFailure(failure) + retained += accountRequest.id + if (firstReleaseFailure == null) { + firstReleaseFailure = failure + } else { + firstReleaseFailure.addSuppressed(failure) + } + } + } + } + requests.filterNot { request -> request.id in retained }.forEach { request -> removeRequest(request.id) } + firstReleaseFailure?.let { throw it } +} + +private const val LOG_TAG = "IncomingShareCleanup" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt index 32084b713..bd08ca752 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt @@ -101,75 +101,78 @@ internal class AndroidIncomingShareChunkCleanupWorker( val chunk = request.chunkSession ?: return@withContext Result.success() val claimed = store.claimChunkSessionForCleanup(requestId, chunk.uploadId) ?: return@withContext Result.success() - val session = AndroidNextcloudServices(applicationContext).loadSession() - if (session == null) { - return@withContext retryOrReleaseIncomingShareChunkCleanup( + val services = AndroidNextcloudServices(applicationContext) + val unavailable = { + retryOrReleaseIncomingShareChunkCleanup( store, requestId, claimed, cleanupAttempt, ) } - if ( - request.accountId != NextcloudDocumentIds.accountKey(session) || - request.userId.isNullOrBlank() - ) { - return@withContext retryOrReleaseIncomingShareChunkCleanup( - store, - requestId, - claimed, - cleanupAttempt, - ) - } - val cancellation = CoroutineDocumentRequestCancellation(currentCoroutineContext().job) - try { - val remote = AndroidFileSyncRemoteTree( - session = session, - userId = request.userId, - remoteRootPath = request.destinationPath.orEmpty(), - webDav = NextcloudDocumentWebDav( - client = OkHttpClient.Builder() - .followRedirects(false) - .followSslRedirects(false) - .retryOnConnectionFailure(false) - .useAndroidNextcloudCertificateTrust(applicationContext) - .build(), - cloudMutationsAllowed = applicationContext.cloudMutationGate(), - ), - ) - remote.deleteChunkUpload(claimed.uploadId, cancellation) - store.clearChunkSessionForCleanup(requestId, claimed.uploadId) - releaseDiscardedIncomingShare(store, requestId) - Result.success() - } catch (failure: Throwable) { - cancellation.throwIfCancelled() - if ( - failure.isRetryableIncomingShareChunkCleanupFailure() && - canRetryIncomingShareChunkCleanup(cleanupAttempt) - ) { - val nowEpochMillis = System.currentTimeMillis() - val retryDelayMillis = failure.incomingShareChunkCleanupRetryDelayMillis(nowEpochMillis) - if (retryDelayMillis != null) { - scheduleIncomingShareChunkCleanup( - context = applicationContext, - requestId = requestId, - initialDelayMillis = retryDelayMillis, - cleanupAttempt = cleanupAttempt + 1, - policy = ExistingWorkPolicy.APPEND_OR_REPLACE, - ) - Result.success() - } else { - Result.retry() - } - } else { - // Nextcloud expires abandoned upload collections server-side. Once cleanup is - // definitively rejected or exhausts its bounded retries, release local staging. + val accountIdentity = request.accountId ?: return@withContext unavailable() + val userId = request.userId?.takeIf(String::isNotBlank) ?: return@withContext unavailable() + return@withContext ANDROID_ACCOUNT_OPERATION_GUARD.withAccountSession( + accountId = accountIdentity, + resolveSession = { + resolveStoredAndroidAccountSession( + accountIdentity = accountIdentity, + listAccounts = services::listAccounts, + loadSession = { accountId -> services.loadSession(accountId) }, + ) + }, + unavailable = unavailable, + ) { session -> + val cancellation = CoroutineDocumentRequestCancellation(currentCoroutineContext().job) + try { + val remote = AndroidFileSyncRemoteTree( + session = session, + userId = userId, + remoteRootPath = request.destinationPath.orEmpty(), + webDav = NextcloudDocumentWebDav( + client = OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .retryOnConnectionFailure(false) + .useAndroidNextcloudCertificateTrust(applicationContext) + .build(), + cloudMutationsAllowed = applicationContext.cloudMutationGate(), + ), + ) + remote.deleteChunkUpload(claimed.uploadId, cancellation) store.clearChunkSessionForCleanup(requestId, claimed.uploadId) releaseDiscardedIncomingShare(store, requestId) Result.success() + } catch (failure: Throwable) { + cancellation.throwIfCancelled() + if ( + failure.isRetryableIncomingShareChunkCleanupFailure() && + canRetryIncomingShareChunkCleanup(cleanupAttempt) + ) { + val nowEpochMillis = System.currentTimeMillis() + val retryDelayMillis = failure.incomingShareChunkCleanupRetryDelayMillis(nowEpochMillis) + if (retryDelayMillis != null) { + scheduleIncomingShareChunkCleanup( + context = applicationContext, + requestId = requestId, + initialDelayMillis = retryDelayMillis, + cleanupAttempt = cleanupAttempt + 1, + policy = ExistingWorkPolicy.APPEND_OR_REPLACE, + ) + Result.success() + } else { + Result.retry() + } + } else { + // Nextcloud expires abandoned upload collections server-side. Once cleanup is + // definitively rejected or exhausts its bounded retries, release local staging. + store.clearChunkSessionForCleanup(requestId, claimed.uploadId) + releaseDiscardedIncomingShare(store, requestId) + Result.success() + } + } finally { + cancellation.close() } - } finally { - cancellation.close() } } @@ -227,7 +230,7 @@ internal fun scheduleIncomingShareCleanup(context: Context, requestId: String) { internal fun scheduleIncomingShareAbandonedStagingCleanup(context: Context, requestId: String) { WorkManager.getInstance(context).enqueueUniqueWork( - "incoming-share-abandoned-staging-$requestId", + incomingShareAbandonedStagingWorkName(requestId), ExistingWorkPolicy.KEEP, OneTimeWorkRequestBuilder() .setInitialDelay(ABANDONED_INCOMING_SHARE_STAGING_RETENTION_MILLIS, TimeUnit.MILLISECONDS) @@ -266,6 +269,9 @@ internal fun incomingShareCleanupWorkName(requestId: String) = "incoming-share-c internal fun incomingShareChunkCleanupWorkName(requestId: String) = "incoming-share-chunk-cleanup-$requestId" +internal fun incomingShareAbandonedStagingWorkName(requestId: String) = + "incoming-share-abandoned-staging-$requestId" + internal fun incomingShareRecoveryPendingIntent(context: Context, requestId: String): PendingIntent = PendingIntent.getActivity( context, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt index b5ca68704..5a79a32c0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt @@ -80,23 +80,39 @@ internal class AndroidIncomingShareUploadWorker( scheduleIncomingShareRetry(applicationContext, request) return@withContext Result.success() } - val session = AndroidNextcloudServices(applicationContext).loadSession() - if (session == null || NextcloudDocumentIds.accountKey(session) != request.accountId) { - val failed = store.transition( - id = requestId, - expected = setOf(AndroidIncomingShareState.Queued), - target = AndroidIncomingShareState.Failed, - message = "The upload account is not active.", - ) - failed?.let { - publishTerminalNotification(it) - scheduleIncomingShareCleanup(applicationContext, it.id) - } - return@withContext Result.failure() + return@withContext uploadQueuedRequest(store, requestId, request) + } + + private suspend fun uploadQueuedRequest( + store: AndroidIncomingShareStore, + requestId: String, + request: AndroidIncomingShareRequest, + ): Result { + val accountIdentity = request.accountId ?: return failUnavailableAccount(store, requestId) + return ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + performQueuedUpload(store, requestId, request, accountIdentity) + } + } + + private suspend fun performQueuedUpload( + store: AndroidIncomingShareStore, + requestId: String, + initialRequest: AndroidIncomingShareRequest, + accountIdentity: String, + ): Result { + var request = initialRequest + val services = AndroidNextcloudServices(applicationContext) + val session = resolveStoredAndroidAccountSession( + accountIdentity = accountIdentity, + listAccounts = services::listAccounts, + loadSession = { accountId -> services.loadSession(accountId) }, + ) + if (session == null) { + return failUnavailableAccount(store, requestId) } AndroidNotificationCoordinator(applicationContext).ensureChannels() var foregroundPromotionAvailable = setForegroundIfAvailable(request) - request = store.beginUpload(requestId) ?: return@withContext Result.success() + request = store.beginUpload(requestId) ?: return Result.success() val remote = AndroidFileSyncRemoteTree( session = session, userId = requireNotNull(request.userId), @@ -113,7 +129,7 @@ internal class AndroidIncomingShareUploadWorker( ) val requestCancellation = CoroutineDocumentRequestCancellation(currentCoroutineContext().job) var mutationInFlight = false - try { + return try { val destinationSnapshot = remote.rootChildNames() val occupiedNames = destinationSnapshot.names.toMutableSet().apply { addAll(request.uploadedNames) @@ -187,12 +203,12 @@ internal class AndroidIncomingShareUploadWorker( "Nextcloud asked this upload to wait before retrying." }, retryNotBeforeEpochMillis = retryNotBefore, - ) ?: return@withContext Result.success() + ) ?: return Result.success() if (retryNotBefore != null) { scheduleIncomingShareRetry(applicationContext, queued) - return@withContext Result.success() + return Result.success() } - return@withContext Result.retry() + return Result.retry() } // A transport failure after a conditional PUT starts cannot prove whether the server // committed it. Do not replay automatically and risk a duplicate. @@ -222,6 +238,20 @@ internal class AndroidIncomingShareUploadWorker( } } + private fun failUnavailableAccount(store: AndroidIncomingShareStore, requestId: String): Result { + val failed = store.transition( + id = requestId, + expected = setOf(AndroidIncomingShareState.Queued), + target = AndroidIncomingShareState.Failed, + message = "The upload account is no longer available.", + ) + failed?.let { + publishTerminalNotification(it) + scheduleIncomingShareCleanup(applicationContext, it.id) + } + return Result.failure() + } + private fun ensureNotCanceled(requestId: String, store: AndroidIncomingShareStore) { if (store.load(requestId)?.state == AndroidIncomingShareState.Canceled) { throw CancellationException("Incoming share upload canceled") diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 4f250155e..7ecdabf5c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -55,6 +55,7 @@ import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest import dev.obiente.nextcloudnative.app.GroupwareDavRequest import dev.obiente.nextcloudnative.app.NextcloudAppEntry import dev.obiente.nextcloudnative.app.NextcloudActivity +import dev.obiente.nextcloudnative.app.NextcloudAccountId import dev.obiente.nextcloudnative.app.NextcloudConditionalRead import dev.obiente.nextcloudnative.app.NextcloudDocumentEditingCapabilities import dev.obiente.nextcloudnative.app.NextcloudDocumentEditSession @@ -88,6 +89,7 @@ import dev.obiente.nextcloudnative.app.FileSyncCenterSnapshot import dev.obiente.nextcloudnative.app.FileSyncConfiguration import dev.obiente.nextcloudnative.app.FileSyncDecisionChoice import dev.obiente.nextcloudnative.app.FileSyncLocalRoot +import dev.obiente.nextcloudnative.app.FileSyncRejectionScope import dev.obiente.nextcloudnative.app.IncomingShareRecoveryPage import dev.obiente.nextcloudnative.app.IncomingShareUploadPresentation import dev.obiente.nextcloudnative.app.VirtualFileCachePolicy @@ -404,11 +406,11 @@ internal class AndroidNextcloudServices( private val localUploadPicker: AndroidLocalUploadPicker? = null, private val requestPlatformPermissions: ((Array) -> Boolean)? = null, private val onThemePreferenceChanged: (ThemePreference) -> Unit = {}, + private val accountMutationLeaseHeld: Boolean = false, ) : NextcloudPlatformServices { private val appContext = context.applicationContext private val activity = context as? Activity private val preferences = appContext.getSharedPreferences("nextcloud_native", Context.MODE_PRIVATE) - private val sessionCipher = SessionCipher() private val httpClient = OkHttpClient.Builder() .useAndroidNextcloudCertificateTrust(appContext) .trackJvmNetworkFailures() @@ -446,6 +448,7 @@ internal class AndroidNextcloudServices( private val dynamicDiscoveryCacheDirectory = File(appContext.filesDir, "contracts/discoveries-v1") private val pendingDynamicMutationDirectory = File(appContext.filesDir, "mutations/dynamic-v1") private val fileOfflineRepository = AndroidFileOfflineRepository(appContext) + private val accountOwnedStateCleanup = AndroidAccountOwnedStateCleanup(appContext) private val fileReadCache = AndroidFileReadCache(File(appContext.cacheDir, "files-read-v1")) private val virtualFileCache = AndroidVirtualFileCache(appContext) private val dynamicApiReadCache = DynamicApiResponseCache(File(appContext.cacheDir, "dynamic-api-v1")) @@ -480,6 +483,23 @@ internal class AndroidNextcloudServices( diagnostics = supportDiagnostics, client = httpClient, ) + private val accountCredentials = AndroidAccountCredentialController( + context = appContext, + preferences = preferences, + sessionCipher = SessionCipher(), + registerSessionPrivateValues = ::registerSessionPrivateValues, + recordDiagnostic = ::recordSupportDiagnostic, + publishAccountIdentity = { accountIdentity -> + supportDiagnostics.setActiveAccountIdentity(accountIdentity) + supportIntake.setActiveAccountIdentity(accountIdentity) + }, + clearPreviewAccount = nativeMediaPreviewCache::clearAccount, + notifyDocumentRootsChanged = ::notifyDocumentsRootsChanged, + resumeQueuedUploads = durableMultipartUploads::resumeQueuedForAccount, + prepareAccountRemoval = { session -> prepareAndroidAccountRemoval(appContext, session) }, + removeQueuedUploads = accountOwnedStateCleanup::remove, + retryQueuedUploadsCleanup = accountOwnedStateCleanup::retry, + ) init { supportDiagnostics.registerPrivateValue(System.getProperty("user.home")) @@ -923,85 +943,25 @@ internal class AndroidNextcloudServices( ) } - override fun loadSession(): NextcloudSession? { - return ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.restorePersistedSession( - load = { - val encrypted = preferences.getString(KEY_SESSION, null) - ?: return@restorePersistedSession null - runCatching { - restoreAndroidPersistedSession( - encoded = sessionCipher.decrypt(encrypted), - persistMigrated = { migrated -> - preferences.edit().putString(KEY_SESSION, sessionCipher.encrypt(migrated)).commit() - }, - recordDiagnostic = ::recordSupportDiagnostic, - ) - }.onFailure { failure -> - recordSupportDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Authentication, - operation = "session.load", - outcome = "failed", - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - }.getOrNull() - }, - accountIdOf = NextcloudDocumentIds::accountKey, - publishAccount = { session, accountIdentity -> - session?.let(::registerSessionPrivateValues) - supportDiagnostics.setActiveAccountIdentity(accountIdentity) - supportIntake.setActiveAccountIdentity(accountIdentity) - }, - ) - } + override fun loadSession(): NextcloudSession? = accountCredentials.loadSession() override suspend fun prepareDeckCardDraftRecovery(session: NextcloudSession) = withContext(Dispatchers.IO) { deckCardDrafts.migrateLegacyEntries(session) } - override suspend fun saveSession(session: NextcloudSession) { - registerSessionPrivateValues(session) - val previousAccountId = loadSession()?.let(NextcloudDocumentIds::cacheAccountId) - val replacementAccountId = NextcloudDocumentIds.cacheAccountId(session) - val encrypted = runCatching { sessionCipher.encrypt(encodeAndroidPersistedSession(session)) } - .onFailure { failure -> - recordSupportDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Authentication, - operation = "session.save", - outcome = "failed", - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - } - .getOrThrow() - withContext(Dispatchers.IO) { - AndroidExternalFileHandoffRegistry.clear() - } - val scheduler = AndroidFileSyncScheduler(appContext) - ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.replaceSession( - replacementAccountId = NextcloudDocumentIds.accountKey(session), - persist = { - preferences.edit() - .putString(KEY_SESSION, encrypted) - .remove(KEY_TEST_READ_ONLY) - .apply() - }, - cancelAll = scheduler::cancelAll, - publishAccount = { accountIdentity -> - supportDiagnostics.setActiveAccountIdentity(accountIdentity) - supportIntake.setActiveAccountIdentity(accountIdentity) - }, - ) - if (previousAccountId != null && previousAccountId != replacementAccountId) { - nativeMediaPreviewCache.clearAccount(previousAccountId) - } - notifyDocumentsRootsChanged() - } + override suspend fun saveSession(session: NextcloudSession): NextcloudSession = + accountCredentials.saveSession(session) + + override fun listAccounts() = accountCredentials.listAccounts() + + override fun activeAccountId() = accountCredentials.activeAccountId() + + override fun loadSession(accountId: NextcloudAccountId) = accountCredentials.loadSession(accountId) + + override suspend fun selectAccount(accountId: NextcloudAccountId) = accountCredentials.selectAccount(accountId) + + override suspend fun removeAccount(accountId: NextcloudAccountId) = accountCredentials.removeAccount(accountId) override suspend fun loadDeckCardDraft( session: NextcloudSession, @@ -1036,41 +996,7 @@ internal class AndroidNextcloudServices( deckCardDrafts.discardAll() } - override suspend fun clearSession() { - try { - val accountId = loadSession()?.let(NextcloudDocumentIds::cacheAccountId) - withContext(Dispatchers.IO) { - AndroidExternalFileHandoffRegistry.clear() - } - val scheduler = AndroidFileSyncScheduler(appContext) - ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.clearSession( - persist = { - preferences.edit() - .remove(KEY_SESSION) - .remove(KEY_TEST_READ_ONLY) - .apply() - }, - cancelAll = scheduler::cancelAll, - clearPublishedAccount = { - supportDiagnostics.setActiveAccountIdentity(null) - supportIntake.setActiveAccountIdentity(null) - }, - ) - accountId?.let(nativeMediaPreviewCache::clearAccount) - notifyDocumentsRootsChanged() - } catch (failure: Throwable) { - recordSupportDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Authentication, - operation = "session.clear", - outcome = "failed", - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - throw failure - } - } + override suspend fun clearSession() = accountCredentials.clearSession() override fun openExternalUrl(url: String) { appContext.startActivity( @@ -1569,7 +1495,13 @@ internal class AndroidNextcloudServices( file: NextcloudFile, available: Boolean, ): FileOfflineAvailability = withContext(Dispatchers.IO) { - fileOfflineRepository.setAvailable(session, userId, file, available) + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = session, + resolveSession = ::loadSession, + unavailable = { error("The account changed before offline storage could be updated.") }, + ) { current -> + fileOfflineRepository.setAvailable(current, userId, file, available) + } } override suspend fun loadFileOfflineCenter( @@ -1584,7 +1516,13 @@ internal class AndroidNextcloudServices( userId: String, key: FileOfflineKey, ): FileOfflineCenterActionResult = withContext(Dispatchers.IO) { - fileOfflineRepository.retryCenterItem(session, userId, key) + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = session, + resolveSession = ::loadSession, + unavailable = { FileOfflineCenterActionResult.Rejected("The account changed before this retry started.") }, + ) { current -> + fileOfflineRepository.retryCenterItem(current, userId, key) + } } override suspend fun removeFileOfflineItem( @@ -1592,7 +1530,13 @@ internal class AndroidNextcloudServices( userId: String, key: FileOfflineKey, ): FileOfflineCenterActionResult = withContext(Dispatchers.IO) { - fileOfflineRepository.removeCenterItem(session, userId, key) + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = session, + resolveSession = ::loadSession, + unavailable = { FileOfflineCenterActionResult.Rejected("The account changed before offline storage was removed.") }, + ) { current -> + fileOfflineRepository.removeCenterItem(current, userId, key) + } } override suspend fun loadVirtualFileStorage( @@ -1603,64 +1547,69 @@ internal class AndroidNextcloudServices( val offline = fileOfflineRepository.loadCenter(session) val documentWritebacks = androidDocumentPendingWritebacks(appContext, session) if (documentWritebacks.isNotEmpty()) { - val webDav = NextcloudDocumentWebDav( - client = OkHttpClient.Builder() - .useAndroidNextcloudCertificateTrust(appContext) - .build(), - cloudMutationsAllowed = appContext.cloudMutationGate(), - ) - documentWritebacks.forEach { discovered -> - currentCoroutineContext().ensureActive() - val pending = claimAndroidDocumentPendingWritebackForRecovery( - appContext, - session, - discovered.remotePath, - ) ?: return@forEach - runCatching { - if (pending.conflict) { - pending.releaseActive() - return@runCatching - } - requireAndroidDocumentStagedWritebackCapacity( - stagedBytes = pending.staging.length(), - availableBytes = pending.staging.parentFile?.usableSpace ?: 0L, - ) - CoroutineDocumentRequestCancellation( - requireNotNull(currentCoroutineContext()[Job]), - ).use { cancellation -> - val remote = compareAndroidDocumentWriteback( - webDav = webDav, - session = session, - userId = userId, - pending = pending, - cancellation = cancellation, - ) - currentCoroutineContext().ensureActive() - if (remote.contentsMatch) { - virtualFileCache.invalidate(session, pending.remotePath) - notifyDocumentsDocumentChanged(session, pending.remotePath) - pending.complete() - return@runCatching - } - if (remote.etag == null || remote.etag != pending.expectedRemoteEtag) { - pending.markConflict(remote.etag) + ANDROID_ACCOUNT_OPERATION_GUARD.withAuthenticatedMutationSession( + expectedSession = session, + resolveSession = ::loadSession, + ) { current -> + val webDav = NextcloudDocumentWebDav( + client = OkHttpClient.Builder() + .useAndroidNextcloudCertificateTrust(appContext) + .build(), + cloudMutationsAllowed = appContext.cloudMutationGate(), + ) + documentWritebacks.forEach { discovered -> + currentCoroutineContext().ensureActive() + val pending = claimAndroidDocumentPendingWritebackForRecovery( + appContext, + current, + discovered.remotePath, + ) ?: return@forEach + runCatching { + if (pending.conflict) { pending.releaseActive() return@runCatching } - webDav.replaceFileAtomically( - session = session, - userId = userId, - path = pending.remotePath, - source = pending.staging, - expectedEtag = pending.expectedRemoteEtag, - cancellation = cancellation, + requireAndroidDocumentStagedWritebackCapacity( + stagedBytes = pending.staging.length(), + availableBytes = pending.staging.parentFile?.usableSpace ?: 0L, ) + CoroutineDocumentRequestCancellation( + requireNotNull(currentCoroutineContext()[Job]), + ).use { cancellation -> + val remote = compareAndroidDocumentWriteback( + webDav = webDav, + session = current, + userId = userId, + pending = pending, + cancellation = cancellation, + ) + currentCoroutineContext().ensureActive() + if (remote.contentsMatch) { + virtualFileCache.invalidate(current, pending.remotePath) + notifyDocumentsDocumentChanged(current, pending.remotePath) + pending.complete() + return@runCatching + } + if (remote.etag == null || remote.etag != pending.expectedRemoteEtag) { + pending.markConflict(remote.etag) + pending.releaseActive() + return@runCatching + } + webDav.replaceFileAtomically( + session = current, + userId = userId, + path = pending.remotePath, + source = pending.staging, + expectedEtag = pending.expectedRemoteEtag, + cancellation = cancellation, + ) + } + virtualFileCache.invalidate(current, pending.remotePath) + notifyDocumentsDocumentChanged(current, pending.remotePath) + pending.complete() + }.onFailure { failure -> + handleAndroidDocumentWritebackRecoveryFailure(failure, pending::releaseActive) } - virtualFileCache.invalidate(session, pending.remotePath) - notifyDocumentsDocumentChanged(session, pending.remotePath) - pending.complete() - }.onFailure { failure -> - handleAndroidDocumentWritebackRecoveryFailure(failure, pending::releaseActive) } } } @@ -1786,7 +1735,18 @@ internal class AndroidNextcloudServices( SupportDiagnosticFieldDraft("remote_root", remoteRootPath, SupportDiagnosticValuePrivacy.RemotePath), ) diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.pair-add", fields) { - fileSyncEngine.addPair(session, userId, localRoot, remoteRootPath, configuration) + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = session, + resolveSession = ::loadSession, + unavailable = { + FileSyncCenterActionResult.Rejected( + "The account changed before this folder sync could be added.", + FileSyncRejectionScope.Preflight, + ) + }, + ) { current -> + fileSyncEngine.addPair(current, userId, localRoot, remoteRootPath, configuration) + } }.also { result -> recordFileSyncResult(accountIdentity, "sync.pair-add", fields, result) } } @@ -1797,9 +1757,19 @@ internal class AndroidNextcloudServices( ): FileSyncCenterActionResult = withContext(Dispatchers.IO) { val accountIdentity = NextcloudDocumentIds.accountKey(session) val fields = listOf(SupportDiagnosticFieldDraft("pair", pairId, SupportDiagnosticValuePrivacy.Identifier)) - diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.pair-run", fields) { - fileSyncEngine.runPair(session, userId, pairId) - }.also { result -> recordFileSyncResult(accountIdentity, "sync.pair-run", fields, result) } + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + val current = loadSession() + if (current == null || !androidAccountOperationSessionIsCurrent(accountIdentity, current)) { + FileSyncCenterActionResult.Rejected( + "The account changed before folder sync could start.", + FileSyncRejectionScope.Preflight, + ) + } else { + diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.pair-run", fields) { + fileSyncEngine.runPair(current, userId, pairId) + }.also { result -> recordFileSyncResult(accountIdentity, "sync.pair-run", fields, result) } + } + } } override suspend fun resolveFileSyncConflict( @@ -1819,9 +1789,19 @@ internal class AndroidNextcloudServices( ), SupportDiagnosticFieldDraft("choice", choice.name.lowercase()), ) - diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.conflict-resolve", fields) { - fileSyncEngine.resolveConflictAndRun(session, userId, pairId, workId, choice) - }.also { result -> recordFileSyncResult(accountIdentity, "sync.conflict-resolve", fields, result) } + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + val current = loadSession() + if (current == null || !androidAccountOperationSessionIsCurrent(accountIdentity, current)) { + FileSyncCenterActionResult.Rejected( + "The account changed before conflict resolution could start.", + FileSyncRejectionScope.Preflight, + ) + } else { + diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.conflict-resolve", fields) { + fileSyncEngine.resolveConflictAndRun(current, userId, pairId, workId, choice) + }.also { result -> recordFileSyncResult(accountIdentity, "sync.conflict-resolve", fields, result) } + } + } } override suspend fun resolveFileSyncConflicts( @@ -1835,15 +1815,25 @@ internal class AndroidNextcloudServices( SupportDiagnosticFieldDraft("pair", pairId, SupportDiagnosticValuePrivacy.Identifier), SupportDiagnosticFieldDraft("conflict_count", resolutions.size.toString()), ) - diagnoseSupportFailure( - accountIdentity, - SupportDiagnosticComponent.Sync, - "sync.conflict-resolve-batch", - fields, - ) { - fileSyncEngine.resolveConflictsAndRun(session, userId, pairId, resolutions) - }.also { result -> - recordFileSyncResult(accountIdentity, "sync.conflict-resolve-batch", fields, result) + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + val current = loadSession() + if (current == null || !androidAccountOperationSessionIsCurrent(accountIdentity, current)) { + FileSyncCenterActionResult.Rejected( + "The account changed before conflict resolution could start.", + FileSyncRejectionScope.Preflight, + ) + } else { + diagnoseSupportFailure( + accountIdentity, + SupportDiagnosticComponent.Sync, + "sync.conflict-resolve-batch", + fields, + ) { + fileSyncEngine.resolveConflictsAndRun(current, userId, pairId, resolutions) + }.also { result -> + recordFileSyncResult(accountIdentity, "sync.conflict-resolve-batch", fields, result) + } + } } } @@ -1854,9 +1844,20 @@ internal class AndroidNextcloudServices( ): FileSyncCenterActionResult = withContext(Dispatchers.IO) { val accountIdentity = NextcloudDocumentIds.accountKey(session) val fields = listOf(SupportDiagnosticFieldDraft("pair", pairId, SupportDiagnosticValuePrivacy.Identifier)) - diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.pair-remove", fields) { - fileSyncEngine.removePair(session, userId, pairId) - }.also { result -> recordFileSyncResult(accountIdentity, "sync.pair-remove", fields, result) } + ANDROID_ACCOUNT_OPERATION_GUARD.withAccountSession( + accountId = accountIdentity, + resolveSession = { loadSession() }, + unavailable = { + FileSyncCenterActionResult.Rejected( + "The account changed before folder sync removal could start.", + FileSyncRejectionScope.Preflight, + ) + }, + ) { current -> + diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.pair-remove", fields) { + fileSyncEngine.removePair(current, userId, pairId) + }.also { result -> recordFileSyncResult(accountIdentity, "sync.pair-remove", fields, result) } + } } override suspend fun listMedia( @@ -2684,7 +2685,7 @@ internal class AndroidNextcloudServices( text: String, expectedEtag: String, ): SavedTextFile = withContext(Dispatchers.IO) { - withNoBlockingAndroidDocumentWriteback(appContext, session, path) { + withNoBlockingAndroidDocumentWritebackSuspending(appContext, session, path) { val specification = textFileDavSaveRequest(text, expectedEtag) val response = request( method = "PUT", @@ -2756,7 +2757,7 @@ internal class AndroidNextcloudServices( mutation: NextcloudFileMutation, ): NextcloudFileMutationResult = withContext(Dispatchers.IO) { val spec = mutation.toWebDavMutationSpec() - withNoBlockingAndroidDocumentWriteback( + withNoBlockingAndroidDocumentWritebackSuspending( appContext, session, *listOfNotNull(spec.sourcePath, spec.destinationPath).toTypedArray(), @@ -2927,6 +2928,7 @@ internal class AndroidNextcloudServices( streamingBody = requestBody, maxResponseBytes = safeRequest.maximumResponseBytes, client = noRedirectHttpClient, + accountMutationSerialized = accountMutationLeaseHeld, ) NextcloudApiResponse( response.status, @@ -2947,7 +2949,15 @@ internal class AndroidNextcloudServices( scope: DurableUploadScope, request: NextcloudMultipartUploadRequest, ): DurableUploadEnqueueResult = withContext(Dispatchers.IO) { - durableMultipartUploads.enqueue(session, scope, request) + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = session, + resolveSession = ::loadSession, + unavailable = { + DurableUploadEnqueueResult.Rejected("The account changed before the upload could be queued.") + }, + ) { current -> + durableMultipartUploads.enqueue(current, scope, request) + } } override suspend fun durableMultipartUploadStatuses( @@ -3440,18 +3450,19 @@ internal class AndroidNextcloudServices( check(response.status in 200..299) { "Sending the Talk message failed (HTTP ${response.status})." } Unit } - override suspend fun revokeSession(session: NextcloudSession) = withContext(Dispatchers.IO) { - request( - method = "DELETE", - url = session.serverUrl + "/ocs/v2.php/core/apppassword", - session = session, - ocsRequest = true, - ) - Unit + accountCredentials.revokeSession(session) { + request( + method = "DELETE", + url = session.serverUrl + "/ocs/v2.php/core/apppassword", + session = session, + ocsRequest = true, + accountMutationSerialized = true, + ) + } } - private fun ocsGet(session: NextcloudSession, path: String): JSONObject { + private suspend fun ocsGet(session: NextcloudSession, path: String): JSONObject { val separator = if ('?' in path) '&' else '?' val response = request( method = "GET", @@ -3463,7 +3474,7 @@ internal class AndroidNextcloudServices( return JSONObject(response.text) } - private fun loadFileEtag(session: NextcloudSession, userId: String, path: String): String? { + private suspend fun loadFileEtag(session: NextcloudSession, userId: String, path: String): String? { val response = request( method = "PROPFIND", url = buildNextcloudFileUrl(session.serverUrl, userId, path), @@ -3476,7 +3487,7 @@ internal class AndroidNextcloudServices( return SafeXmlParser.parse(response.body).documentElement.firstText(DAV_NAMESPACE, "getetag") } - private fun request( + private suspend fun request( method: String, url: String, session: NextcloudSession? = null, @@ -3493,7 +3504,16 @@ internal class AndroidNextcloudServices( onNetworkFailure: (JvmNetworkFailureDiagnostic) -> Unit = {}, onFailurePhase: (JvmNetworkFailurePhase) -> Unit = {}, diagnosticIgnoredHttpStatuses: Set = emptySet(), + accountMutationSerialized: Boolean = false, ): HttpResponse { + if (session != null && !method.isReadOnlyJvmNetworkMethod() && !accountMutationSerialized) { + return ANDROID_ACCOUNT_OPERATION_GUARD.withAuthenticatedMutationSession(session, ::loadSession) { current -> + request( + method, url, current, body, contentType, ocsRequest, headers, rawBody, maxResponseBytes, expectedSuccessResponseBytes, expectedSuccessResponseStatus, + client, streamingBody, onNetworkFailure, onFailurePhase, diagnosticIgnoredHttpStatuses, true, + ) + } + } val started = System.nanoTime() require((expectedSuccessResponseBytes == null) == (expectedSuccessResponseStatus == null)) check(appContext.isAllowedTestRequest(method, url)) { @@ -3892,8 +3912,6 @@ internal class AndroidNextcloudServices( private companion object { const val KEY_THEME = "theme_preference" const val KEY_LAST_OPENED_APP = "last_opened_app" - const val KEY_SESSION = "encrypted_session" - const val KEY_TEST_READ_ONLY = "emulator_test_read_only" const val USER_AGENT = "Nextcloud-Native/0.1.0 (Android)" const val DAV_NAMESPACE = "DAV:" const val OWNCLOUD_NAMESPACE = "http://owncloud.org/ns" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index e577ad0ca..ec4750ed1 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -1,72 +1,297 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.NextcloudAccountId +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistryRecoveryReason import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity +import dev.obiente.nextcloudnative.app.accountRecord +import dev.obiente.nextcloudnative.app.decodeNextcloudAccountRegistry import dev.obiente.nextcloudnative.app.encodeNextcloudAccountRegistry import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry import dev.obiente.nextcloudnative.app.singleAccountRegistry import dev.obiente.nextcloudnative.app.toNonSecretSupportDiagnosticExceptionDraft +import org.json.JSONArray import org.json.JSONObject +internal data class AndroidAccountCredentialState( + val registry: NextcloudAccountRegistry, + val sessions: Map, + val mutationsAllowed: Boolean = true, +) { + init { + require(sessions.size <= MAX_ANDROID_ACCOUNT_CREDENTIALS) + require(sessions.size == registry.accounts.size) + require(sessions.all { (id, session) -> + id == session.accountId && registry.accounts.any { account -> account == session.accountRecord() } + }) + require(registry.activeAccountId == null || registry.activeAccountId in sessions) + } + + val activeSession: NextcloudSession? + get() = registry.activeAccountId?.let(sessions::get) + + fun upsertAndSelect(session: NextcloudSession): AndroidAccountCredentialState { + requireMutationsAllowed() + val stableSession = sessions[session.accountId] + ?.let { retained -> session.copy(serverUrl = retained.serverUrl) } + ?: session + return copy( + registry = registry.upsertAndSelect(stableSession.accountRecord()), + sessions = sessions + (stableSession.accountId to stableSession), + ) + } + + fun select(accountId: NextcloudAccountId): AndroidAccountCredentialState? { + requireMutationsAllowed() + if (accountId !in sessions) return null + return copy(registry = requireNotNull(registry.select(accountId))) + } + + fun remove(accountId: NextcloudAccountId): AndroidAccountCredentialState { + requireMutationsAllowed() + return copy( + registry = registry.remove(accountId), + sessions = sessions - accountId, + ) + } + + private fun requireMutationsAllowed() { + check(mutationsAllowed) { "The account credential store version is unsupported." } + } + + companion object { + val Empty = AndroidAccountCredentialState(NextcloudAccountRegistry.Empty, emptyMap()) + } +} + +internal data class RestoredAndroidAccountCredentialState( + val state: AndroidAccountCredentialState?, + val needsPersistence: Boolean = false, + val diagnosticCode: String? = null, + val unsupportedVersion: Int? = null, +) + +internal fun restoreAndroidAccountCredentialState( + encoded: String, + persistMigrated: (String) -> Unit, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, +): AndroidAccountCredentialState? = restoreAndroidAccountCredentialStore( + encoded = encoded, + persistMigrated = persistMigrated, + recordDiagnostic = recordDiagnostic, +).state + +internal fun restoreAndroidAccountCredentialStore( + encoded: String, + persistMigrated: (String) -> Unit, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, +): RestoredAndroidAccountCredentialState { + val restored = decodeAndroidAccountCredentialState(encoded) + restored.diagnosticCode?.let { code -> recordAccountCredentialDiagnostic(code, recordDiagnostic) } + if (restored.needsPersistence && restored.state != null) { + runCatching { persistMigrated(encodeAndroidAccountCredentialState(restored.state)) } + .onFailure { failure -> + recordAccountCredentialDiagnostic( + code = "ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED", + recordDiagnostic = recordDiagnostic, + failure = failure, + ) + } + } + return restored +} + +internal fun decodeAndroidAccountCredentialState(encoded: String): RestoredAndroidAccountCredentialState { + if (encoded.encodeToByteArray().size > MAX_ANDROID_ACCOUNT_CREDENTIAL_STORE_BYTES) { + return malformedAndroidAccountCredentialState() + } + return try { + val json = JSONObject(encoded) + if (!json.has(KEY_VERSION)) { + restoreLegacyAndroidAccountCredentialState(json) + } else { + val version = json.getInt(KEY_VERSION) + if (version > ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION) { + return RestoredAndroidAccountCredentialState( + state = null, + diagnosticCode = "ACCOUNT_CREDENTIAL_STORE_VERSION_UNSUPPORTED", + unsupportedVersion = version, + ) + } + require(version == ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION) + val registry = requireNotNull(decodeNextcloudAccountRegistry(json.getString(KEY_ACCOUNT_REGISTRY))) + val encodedSessions = json.getJSONArray(KEY_CREDENTIALS) + require(encodedSessions.length() <= MAX_ANDROID_ACCOUNT_CREDENTIALS) + val sessions = linkedMapOf() + repeat(encodedSessions.length()) { index -> + val encodedSession = encodedSessions.getJSONObject(index) + val session = NextcloudSession( + serverUrl = encodedSession.getString(KEY_SERVER_URL), + loginName = encodedSession.getString(KEY_LOGIN_NAME), + appPassword = encodedSession.getString(KEY_APP_PASSWORD), + ) + val claimedAccountId = encodedSession.getString(KEY_ACCOUNT_ID) + if (claimedAccountId != session.accountId.storageKey) throw AndroidCredentialMismatchException() + if (sessions.put(session.accountId, session) != null) throw AndroidCredentialMismatchException() + } + if (sessions.size != registry.accounts.size || sessions.any { (_, session) -> + registry.accounts.none { account -> account == session.accountRecord() } + } + ) { + throw AndroidCredentialMismatchException() + } + if (registry.activeAccountId != null && registry.activeAccountId !in sessions) { + throw AndroidCredentialMismatchException() + } + RestoredAndroidAccountCredentialState(AndroidAccountCredentialState(registry, sessions)) + } + } catch (_: AndroidCredentialMismatchException) { + RestoredAndroidAccountCredentialState( + state = null, + diagnosticCode = "ACCOUNT_CREDENTIAL_SLOT_MISMATCH", + ) + } catch (_: Exception) { + malformedAndroidAccountCredentialState() + } +} + +internal fun encodeAndroidAccountCredentialState(state: AndroidAccountCredentialState): String = JSONObject() + .also { check(state.mutationsAllowed) { "The account credential store version is unsupported." } } + .put(KEY_VERSION, ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION) + .put(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(state.registry)) + .put( + KEY_CREDENTIALS, + JSONArray().also { credentials -> + state.sessions.values.sortedBy { session -> session.accountId.storageKey }.forEach { session -> + credentials.put( + JSONObject() + .put(KEY_ACCOUNT_ID, session.accountId.storageKey) + .put(KEY_SERVER_URL, session.serverUrl) + .put(KEY_LOGIN_NAME, session.loginName) + .put(KEY_APP_PASSWORD, session.appPassword), + ) + } + }, + ) + .toString() + .also { encoded -> + require(encoded.encodeToByteArray().size <= MAX_ANDROID_ACCOUNT_CREDENTIAL_STORE_BYTES) + } + internal fun restoreAndroidPersistedSession( encoded: String, - persistMigrated: (String) -> Boolean, + persistMigrated: (String) -> Unit, recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, -): NextcloudSession { - val json = JSONObject(encoded) +): NextcloudSession = requireNotNull( + restoreAndroidAccountCredentialState(encoded, persistMigrated, recordDiagnostic)?.activeSession, +) { "The active account credential is unavailable." } + +internal fun encodeAndroidPersistedSession(session: NextcloudSession): String = + encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(session)) + +internal fun decodeAndroidCredentialFreeRegistry(encoded: String): NextcloudAccountRegistry? = + decodeNextcloudAccountRegistry(encoded) + +internal data class RestoredAndroidCredentialFreeRegistry( + val registry: NextcloudAccountRegistry?, + val diagnosticCode: String? = null, + val credentialRecoveryRequired: Boolean = false, +) + +internal fun restoreAndroidCredentialFreeRegistry( + encoded: String, +): RestoredAndroidCredentialFreeRegistry { + val restored = restoreNextcloudAccountRegistry(encoded, legacySession = null) + val recoveryReason = restored.recoveryReason + return when (recoveryReason) { + null -> RestoredAndroidCredentialFreeRegistry(restored.registry) + NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion -> + RestoredAndroidCredentialFreeRegistry(null, recoveryReason.diagnosticCode) + else -> RestoredAndroidCredentialFreeRegistry( + registry = null, + diagnosticCode = recoveryReason.diagnosticCode, + credentialRecoveryRequired = true, + ) + } +} + +internal fun recoverAndroidCredentialFreeRegistryForCredentialLoad( + restored: RestoredAndroidCredentialFreeRegistry?, + recover: () -> NextcloudAccountRegistry?, +): NextcloudAccountRegistry? = when { + restored?.registry != null -> restored.registry + restored == null || restored.credentialRecoveryRequired -> recover() + else -> null +} + +private fun restoreLegacyAndroidAccountCredentialState( + json: JSONObject, +): RestoredAndroidAccountCredentialState { val session = NextcloudSession( - serverUrl = json.getString("serverUrl"), - loginName = json.getString("loginName"), - appPassword = json.getString("appPassword"), + serverUrl = json.getString(KEY_SERVER_URL), + loginName = json.getString(KEY_LOGIN_NAME), + appPassword = json.getString(KEY_APP_PASSWORD), ) val encodedRegistry = when (val registry = json.opt(KEY_ACCOUNT_REGISTRY)) { null -> null is String -> registry else -> "" } - val restored = restoreNextcloudAccountRegistry(encodedRegistry, session) - restored.recoveryReason?.let { reason -> - recordDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Warning, - component = SupportDiagnosticComponent.Authentication, - operation = "account-registry.restore", - outcome = "recovered", - code = reason.diagnosticCode, - ), - ) - } - if (restored.needsPersistence) { - runCatching { - val migrated = json - .put(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(restored.registry)) - .toString() - check(persistMigrated(migrated)) { - "Could not persist the migrated account registry." - } - }.onFailure { failure -> - recordDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Warning, - component = SupportDiagnosticComponent.Authentication, - operation = "account-registry.migrate", - outcome = "failed", - code = "ACCOUNT_REGISTRY_MIGRATION_FAILED", - exception = failure.toNonSecretSupportDiagnosticExceptionDraft(), - ), - ) - } - } - return session + val restoredRegistry = restoreNextcloudAccountRegistry(encodedRegistry, session) + val credentialRegistry = singleAccountRegistry(session) + return RestoredAndroidAccountCredentialState( + state = AndroidAccountCredentialState( + registry = credentialRegistry, + sessions = mapOf(session.accountId to session), + mutationsAllowed = restoredRegistry.recoveryReason != + NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion, + ), + needsPersistence = restoredRegistry.recoveryReason != + NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion, + diagnosticCode = restoredRegistry.recoveryReason?.diagnosticCode ?: if ( + restoredRegistry.registry != credentialRegistry + ) { + "ACCOUNT_CREDENTIAL_SLOT_MISMATCH" + } else { + null + }, + ) } -internal fun encodeAndroidPersistedSession(session: NextcloudSession): String = JSONObject() - .put("serverUrl", session.serverUrl) - .put("loginName", session.loginName) - .put("appPassword", session.appPassword) - .put(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(singleAccountRegistry(session))) - .toString() +private fun malformedAndroidAccountCredentialState() = RestoredAndroidAccountCredentialState( + state = null, + diagnosticCode = "ACCOUNT_CREDENTIAL_STORE_MALFORMED", +) + +private fun recordAccountCredentialDiagnostic( + code: String, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, + failure: Throwable? = null, +) { + recordDiagnostic( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Authentication, + operation = "account-credentials.restore", + outcome = "recovered", + code = code, + exception = failure?.toNonSecretSupportDiagnosticExceptionDraft(), + ), + ) +} + +private class AndroidCredentialMismatchException : IllegalArgumentException() +private const val ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION = 2 +private const val MAX_ANDROID_ACCOUNT_CREDENTIALS = 64 +private const val MAX_ANDROID_ACCOUNT_CREDENTIAL_STORE_BYTES = 512 * 1024 +private const val KEY_VERSION = "version" private const val KEY_ACCOUNT_REGISTRY = "account_registry_v1" +private const val KEY_CREDENTIALS = "credentials" +private const val KEY_ACCOUNT_ID = "accountId" +private const val KEY_SERVER_URL = "serverUrl" +private const val KEY_LOGIN_NAME = "loginName" +private const val KEY_APP_PASSWORD = "appPassword" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt index 3926b0f1a..6d1aaa453 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt @@ -180,19 +180,26 @@ class AndroidShareUploadActivity : ComponentActivity() { ?: error("Sign in to Nextcloud Native before sharing files to it.") activeAccountId = NextcloudDocumentIds.accountKey(activeSession) val staged = withContext(Dispatchers.IO) { - val restored = validatedRequestId?.let { requestId -> - store.requireAvailable(requestId) - } ?: store.stage( - sourceIntent, - NextcloudDocumentIds.accountKey(activeSession), - ).also { newlyStaged -> - unclaimedStagedRequestId = newlyStaged.id - } - require(restored.accountId == NextcloudDocumentIds.accountKey(activeSession)) { - "Switch back to the account that received this share before reviewing it." + restoreIncomingShareForActiveSession( + guard = ANDROID_ACCOUNT_OPERATION_GUARD, + expectedSession = activeSession, + resolveActiveSession = services::loadSession, + unavailable = { error("The account changed before the shared files could be prepared.") }, + ) { + val restored = validatedRequestId?.let { requestId -> + store.requireAvailable(requestId) + } ?: store.stage( + sourceIntent, + NextcloudDocumentIds.accountKey(activeSession), + ).also { newlyStaged -> + unclaimedStagedRequestId = newlyStaged.id + } + require(restored.accountId == NextcloudDocumentIds.accountKey(activeSession)) { + "Switch back to the account that received this share before reviewing it." + } + uploads.ensureQueuedRequestScheduled(restored) + restored } - uploads.ensureQueuedRequestScheduled(restored) - restored } ensureActive() if (generation != restoreGeneration) return@launch @@ -249,7 +256,13 @@ class AndroidShareUploadActivity : ComponentActivity() { queueJob = lifecycleScope.launch { val result = runCatching { withContext(Dispatchers.IO) { - uploads.enqueue(activeSession, info.userId, staged.id, destinationPath) + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = activeSession, + resolveSession = services::loadSession, + unavailable = { error("The account changed before the upload could be queued.") }, + ) { current -> + uploads.enqueue(current, info.userId, staged.id, destinationPath) + } } } if (!isCurrentIncomingShareEnqueue(generation, restoreGeneration, staged.id, request?.id)) return@launch @@ -396,6 +409,14 @@ internal fun isValidIncomingShareRequestId(value: String): Boolean = internal fun AndroidIncomingShareRequest.canReleaseForIncomingShareReplacement(): Boolean = chunkSession == null && state == AndroidIncomingShareState.Completed +internal suspend fun restoreIncomingShareForActiveSession( + guard: AndroidAccountOperationGuard, + expectedSession: NextcloudSession, + resolveActiveSession: suspend () -> NextcloudSession?, + unavailable: suspend () -> Result, + restore: suspend (NextcloudSession) -> Result, +): Result = guard.withExactAccountSession(expectedSession, resolveActiveSession, unavailable, restore) + private fun AndroidShareUploadActivity.incomingShareFolderPickerOperations( services: AndroidNextcloudServices, session: NextcloudSession, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt index dd2fa8686..a2d4d5799 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt @@ -18,17 +18,26 @@ internal object NextcloudDocumentIds { private val decoder = Base64.getUrlDecoder() fun accountKey(session: NextcloudSession): String { - return accountDigest(session) + return accountKey(session.serverUrl, session.loginName) + } + + fun accountKey(serverUrl: String, loginName: String): String { + return accountDigest(serverUrl, loginName) .take(16) .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } } /** Full digest for private caches which require a canonical SHA-256 directory key. */ fun cacheAccountId(session: NextcloudSession): String = - accountDigest(session) + accountDigest(session.serverUrl, session.loginName) .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } - fun rootId(session: NextcloudSession): String = documentId(session, "") + fun rootId(session: NextcloudSession): String = rootId(accountKey(session)) + + fun rootId(accountKey: String): String { + require(accountKeyPattern.matches(accountKey)) { "Invalid document account." } + return "$PREFIX:$accountKey:" + } fun documentId(session: NextcloudSession, path: String): String { val normalizedPath = normalizePath(path) @@ -56,8 +65,8 @@ internal object NextcloudDocumentIds { require(reference.accountKey == accountKey(session)) { "Document belongs to another account." } } - private fun accountDigest(session: NextcloudSession): ByteArray { - val identity = session.serverUrl.trimEnd('/') + "\n" + session.loginName + private fun accountDigest(serverUrl: String, loginName: String): ByteArray { + val identity = serverUrl.trimEnd('/') + "\n" + loginName return MessageDigest.getInstance("SHA-256").digest(identity.encodeToByteArray()) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index 02d758a6f..267cca33c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -462,88 +462,88 @@ class NextcloudDocumentsProvider : DocumentsProvider() { throw failure } - override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { - val session = requireSession() - val parent = requireReference(parentDocumentId, session) - val account = resolveAccount(session) - requireDirectory(session, account, parent) - val path = childPath(parent.path, requireSafeDisplayName(displayName)) - withNoBlockingAndroidDocumentWriteback(context, session, path) { - mutationCall { - if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { - webDav.createFolder(session, account.userId, path) - } else { - val empty = createLocalStagingFile() - try { webDav.createFile(session, account.userId, path, empty) } finally { empty.delete() } + override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String = + withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> + val parent = requireReference(parentDocumentId, session) + val account = resolveAccount(session) + requireDirectory(session, account, parent) + val path = childPath(parent.path, requireSafeDisplayName(displayName)) + withNoBlockingAndroidDocumentWriteback(context, session, path) { + mutationCall { + if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { + webDav.createFolder(session, account.userId, path) + } else { + val empty = createLocalStagingFile() + try { webDav.createFile(session, account.userId, path, empty) } finally { empty.delete() } + } } } + notifyDocumentChanged(session, path) + NextcloudDocumentIds.documentId(session, path) } - notifyDocumentChanged(session, path) - return NextcloudDocumentIds.documentId(session, path) - } - override fun renameDocument(documentId: String, displayName: String): String { - val session = requireSession() - val reference = requireReference(documentId, session) - if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be renamed.") - val account = resolveAccount(session) - val file = findDocument(session, account, reference.path) - val destination = childPath(NextcloudDocumentIds.parentPath(reference.path), requireSafeDisplayName(displayName)) - if (destination == reference.path) return 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) - return NextcloudDocumentIds.documentId(session, destination) - } + override fun renameDocument(documentId: String, displayName: String): String = + withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> + val reference = requireReference(documentId, session) + if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be renamed.") + val account = resolveAccount(session) + val file = findDocument(session, account, reference.path) + val destination = childPath(NextcloudDocumentIds.parentPath(reference.path), requireSafeDisplayName(displayName)) + if (destination == reference.path) return@withAndroidDocumentMutation 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) + } - override fun deleteDocument(documentId: String) { - val session = requireSession() - val reference = requireReference(documentId, session) - if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be deleted.") - val account = resolveAccount(session) - val file = findDocument(session, account, reference.path) - withNoBlockingAndroidDocumentWriteback(context, session, reference.path) { - mutationCall { - webDav.delete( - session, - account.userId, - reference.path, - requireMutationEtag(file), - isDirectory = file.isDirectory, - ) + override fun deleteDocument(documentId: String) = + withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> + val reference = requireReference(documentId, session) + if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be deleted.") + val account = resolveAccount(session) + val file = findDocument(session, account, reference.path) + withNoBlockingAndroidDocumentWriteback(context, session, reference.path) { + mutationCall { + webDav.delete( + session, + account.userId, + reference.path, + requireMutationEtag(file), + isDirectory = file.isDirectory, + ) + } } + notifyDocumentChanged(session, reference.path) } - notifyDocumentChanged(session, reference.path) - } override fun moveDocument( sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String, - ): String { - val session = requireSession() - val source = requireReference(sourceDocumentId, session) - val sourceParent = requireReference(sourceParentDocumentId, session) - val targetParent = requireReference(targetParentDocumentId, session) - 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." - } - val account = resolveAccount(session) - requireDirectory(session, account, targetParent) - val file = findDocument(session, account, source.path) - val destination = childPath(targetParent.path, file.name) - if (destination == source.path) return sourceDocumentId - withNoBlockingAndroidDocumentWriteback(context, session, source.path, destination) { - mutationCall { - webDav.move(session, account.userId, source.path, destination, requireMutationEtag(file)) + ): String = + withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> + val source = requireReference(sourceDocumentId, session) + val sourceParent = requireReference(sourceParentDocumentId, session) + val targetParent = requireReference(targetParentDocumentId, session) + 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." } + val account = resolveAccount(session) + requireDirectory(session, account, targetParent) + val file = findDocument(session, account, source.path) + val destination = childPath(targetParent.path, file.name) + if (destination == source.path) return@withAndroidDocumentMutation 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.path, destination) - return NextcloudDocumentIds.documentId(session, destination) - } private fun openWritableDocument( session: NextcloudSession, @@ -552,7 +552,11 @@ class NextcloudDocumentsProvider : DocumentsProvider() { mode: String, signal: CancellationSignal?, ): ParcelFileDescriptor { - reserveAndroidDocumentWritebackPath(session, file.path) + val accountLease = acquireAndroidDocumentWritebackAccountLease( + session, + file.path, + services::loadSession, + ) val recovered: AndroidDocumentPendingWriteback? val writeback: AndroidDocumentPendingWriteback try { @@ -563,7 +567,9 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } writeback = recovered ?: createDurableWriteback(session, file, requireMutationEtag(file)) } catch (failure: Throwable) { - releaseAndroidDocumentWritebackPath(session, file.path) + releaseAndroidDocumentWritebackSetup(accountLease) { + releaseAndroidDocumentWritebackPath(session, file.path) + } throw failure } val expectedEtag = writeback.expectedRemoteEtag @@ -619,6 +625,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { retainFailedWriteback(writeback, failure) } finally { writeback.releaseActive() + accountLease.close() } } return try { @@ -632,19 +639,11 @@ class NextcloudDocumentsProvider : DocumentsProvider() { if (writeback.manifest.isFile) { if (recovered == null) writeback.discard() else writeback.releaseActive() } + accountLease.close() throw failure } } - private fun descriptorMode(mode: String): Int = when (mode) { - "w" -> ParcelFileDescriptor.MODE_WRITE_ONLY - "wt" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_TRUNCATE - "wa" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_APPEND - "rw" -> ParcelFileDescriptor.MODE_READ_WRITE - "rwt" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_TRUNCATE - else -> error("Unsupported writable mode: $mode") - } - private fun createLocalStagingFile(): File { val providerContext = requireNotNull(context) { "Provider context is unavailable." } val directory = File(providerContext.cacheDir, STAGING_DIRECTORY).apply { mkdirs() } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt index 0cb0acd5c..0f38f29ff 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt @@ -12,6 +12,7 @@ import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import dev.obiente.nextcloudnative.app.FileSyncCenterActionResult import dev.obiente.nextcloudnative.app.FileSyncRejectionScope +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticFieldDraft @@ -36,9 +37,7 @@ internal class NextcloudFileSyncWorker( val services = AndroidNextcloudServices(applicationContext) val session = services.loadSession() ?: return@withContext Result.failure() - if (NextcloudDocumentIds.accountKey(session) != accountId) { - return@withContext Result.failure() - } + if (NextcloudDocumentIds.accountKey(session) != accountId) return@withContext Result.failure() AndroidNotificationCoordinator(applicationContext).ensureChannels() try { setForeground(createForegroundInfo(pairId)) @@ -49,8 +48,16 @@ internal class NextcloudFileSyncWorker( // WorkManager may still execute short work when the OS temporarily refuses an FGS. } val engine = AndroidFileSyncEngine(applicationContext) - val result = runCatching { engine.runPair(session, userId, pairId) } - .getOrElse { failure -> + val result = try { + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountId) { + val current = services.loadSession() + if (current == null || !androidAccountOperationSessionIsCurrent(accountId, current)) { + null + } else { + engine.runPair(current, userId, pairId) + } + } ?: return@withContext Result.failure() + } catch (failure: Throwable) { rethrowAndroidFileSyncCancellation(failure) val disposition = backgroundSyncFailureDisposition(runAttemptCount) services.recordSupportDiagnosticForAccountIdentity( @@ -77,7 +84,7 @@ internal class NextcloudFileSyncWorker( ), ) return@withContext disposition.toWorkerResult() - } + } val pair = engine.loadCenter(session, userId).pairs.firstOrNull { it.id == pairId } ?: return@withContext Result.success() pair.conflicts.firstOrNull()?.let { conflict -> @@ -168,6 +175,44 @@ internal class NextcloudFileSyncWorker( } } +internal class AndroidFileSyncScheduleRestorationWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val expectedAccountId = inputData.getString(KEY_ACCOUNT_ID)?.takeIf(String::isNotBlank) + ?: return@withContext Result.failure() + val services = AndroidNextcloudServices(applicationContext) + val session = services.loadSession() + ?.takeIf { restored -> isAndroidFileSyncScheduleRestorationCurrent(expectedAccountId, restored) } + ?: return@withContext Result.success() + runCatching { + val userId = services.loadServerInfo(session).userId + services.loadFileSyncCenter(session, userId) + }.fold( + onSuccess = { Result.success() }, + onFailure = { failure -> + rethrowAndroidFileSyncCancellation(failure) + scheduleRestorationFailureDisposition(runAttemptCount).toWorkerResult() + }, + ) + } + + internal companion object { + const val KEY_ACCOUNT_ID = "account_id" + } +} + +internal fun isAndroidFileSyncScheduleRestorationCurrent( + expectedAccountId: String, + session: NextcloudSession, +): Boolean = NextcloudDocumentIds.accountKey(session) == expectedAccountId + +internal fun scheduleRestorationFailureDisposition(runAttemptCount: Int): BackgroundSyncWorkerDisposition { + require(runAttemptCount >= 0) + return BackgroundSyncWorkerDisposition.Retry +} + internal fun syncConflictNotificationDetail(conflictCount: Int): String { require(conflictCount > 0) return "$conflictCount sync conflict" + diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt new file mode 100644 index 000000000..6cba2f2aa --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -0,0 +1,406 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.io.FileNotFoundException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield + +class AndroidAccountOperationGuardTest { + @Test + fun staleSyncSessionIsRejectedAfterAnAccountTransition() { + val previous = dev.obiente.nextcloudnative.app.NextcloudSession( + "https://first.example.test", + "alice", + "old-password", + ) + val replacement = dev.obiente.nextcloudnative.app.NextcloudSession( + "https://second.example.test", + "bob", + "new-password", + ) + + assertTrue( + androidAccountOperationSessionIsCurrent( + NextcloudDocumentIds.accountKey(previous), + previous.copy(appPassword = "rotated-password"), + ), + ) + assertFalse( + androidAccountOperationSessionIsCurrent( + NextcloudDocumentIds.accountKey(previous), + replacement, + ), + ) + assertTrue(androidDocumentWritebackSessionIsCurrent(previous, previous)) + assertFalse( + androidDocumentWritebackSessionIsCurrent( + previous, + previous.copy(appPassword = "rotated-password"), + ), + ) + } + + @Test + fun sameAccountRemovalWaitsForTheUploadLease() = runBlocking { + val guard = AndroidAccountOperationGuard() + val uploadEntered = CompletableDeferred() + val releaseUpload = CompletableDeferred() + var removalEntered = false + + val upload = async { + guard.withAccount("account-a") { + uploadEntered.complete(Unit) + releaseUpload.await() + } + } + uploadEntered.await() + val removal = async { + guard.withAccount("account-a") { removalEntered = true } + } + yield() + + assertFalse(removalEntered) + releaseUpload.complete(Unit) + upload.await() + removal.await() + assertTrue(removalEntered) + } + + @Test + fun remoteRevocationKeepsMutationsBlockedUntilLocalRemovalCommits() = runBlocking { + val guard = AndroidAccountOperationGuard() + val remoteRevoked = CompletableDeferred() + val allowLocalRemoval = CompletableDeferred() + var localRemovalCommitted = false + var mutationObservedCommittedRemoval = false + + val removal = async { + revokeAndroidSessionWithAccountLease( + accountIdentity = "account-a", + guard = guard, + preflight = {}, + revoke = { remoteRevoked.complete(Unit) }, + removeLocalAccount = { + allowLocalRemoval.await() + localRemovalCommitted = true + }, + ) + } + remoteRevoked.await() + val mutation = async { + guard.withAccount("account-a") { + mutationObservedCommittedRemoval = localRemovalCommitted + } + } + yield() + + assertFalse(mutation.isCompleted) + allowLocalRemoval.complete(Unit) + removal.await() + mutation.await() + assertTrue(mutationObservedCommittedRemoval) + } + + @Test + fun fileSyncPairCreationWaitsForRemovalAndRejectsTheReauthenticatedSession() = runBlocking { + val guard = AndroidAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "original-password") + val replacement = original.copy(appPassword = "replacement-password") + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var current = original + var pairCreated = false + val removal = async { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { + current = replacement + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + removalEntered.await() + + val result = async { + guard.withExactAccountSession( + expectedSession = original, + resolveSession = { current }, + unavailable = { "rejected" }, + ) { + pairCreated = true + "created" + } + } + yield() + + assertFalse(result.isCompleted) + releaseRemoval.complete(Unit) + removal.await() + assertEquals("rejected", result.await()) + assertFalse(pairCreated) + } + + @Test + fun differentAccountsKeepIndependentOperationLeases() = runBlocking { + val guard = AndroidAccountOperationGuard() + val uploadEntered = CompletableDeferred() + val releaseUpload = CompletableDeferred() + var otherAccountEntered = false + + val upload = async { + guard.withAccount("account-a") { + uploadEntered.complete(Unit) + releaseUpload.await() + } + } + uploadEntered.await() + guard.withAccount("account-b") { otherAccountEntered = true } + + assertTrue(otherAccountEntered) + releaseUpload.complete(Unit) + upload.await() + } + + @Test + fun writableDescriptorLeaseBlocksAccountTransitionUntilClose() = runBlocking { + val guard = AndroidAccountOperationGuard() + val descriptorLease = guard.acquireBlocking("account-a") + var transitionEntered = false + + val transition = async { + guard.withAccount("account-a") { transitionEntered = true } + } + yield() + + assertFalse(transitionEntered) + descriptorLease.close() + transition.await() + assertTrue(transitionEntered) + } + + @Test + fun directDocumentMutationLeaseRejectsReauthenticatedSessionAndReleasesTheGuard() = runBlocking { + val guard = AndroidAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "original-password") + + assertFailsWith { + acquireAndroidDocumentMutationAccountLease( + session = original, + loadCurrentSession = { original.copy(appPassword = "replacement-password") }, + guard = guard, + ) + } + + withTimeout(1_000L) { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { } + } + } + + @Test + fun failedWritebackSetupReleasesItsPathAndAccountLease() = runBlocking { + val guard = AndroidAccountOperationGuard() + val descriptorLease = guard.acquireBlocking("account-a") + var pathReleased = false + + releaseAndroidDocumentWritebackSetup(descriptorLease) { pathReleased = true } + + withTimeout(1_000L) { + guard.withAccount("account-a") { } + } + assertTrue(pathReleased) + } + + @Test + fun replacementTransitionWaitsForBothAffectedAccounts() = runBlocking { + val guard = AndroidAccountOperationGuard() + val retainedWorkEntered = CompletableDeferred() + val releaseRetainedWork = CompletableDeferred() + var transitionEntered = false + + val retainedWork = async { + guard.withAccount("account-b") { + retainedWorkEntered.complete(Unit) + releaseRetainedWork.await() + } + } + retainedWorkEntered.await() + val transition = async { + guard.withAccounts(listOf("account-b", "account-a")) { transitionEntered = true } + } + yield() + + assertFalse(transitionEntered) + releaseRetainedWork.complete(Unit) + retainedWork.await() + transition.await() + assertTrue(transitionEntered) + } + + @Test + fun retainedOfflineWorkRevalidatesItsSessionAfterAccountRemoval() = runBlocking { + val guard = AndroidAccountOperationGuard() + val removalCommitted = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var sessionAvailable = true + val removal = async { + guard.withAccount("account-a") { + sessionAvailable = false + removalCommitted.complete(Unit) + releaseRemoval.await() + } + } + removalCommitted.await() + + val offlineSessionAvailable = async { + guard.withAccount("account-a") { sessionAvailable } + } + yield() + assertFalse(offlineSessionAvailable.isCompleted) + + releaseRemoval.complete(Unit) + removal.await() + assertFalse(offlineSessionAvailable.await()) + } + + @Test + fun accountSessionResolutionWaitsForRemovalAndSkipsTheStaleOperation() = runBlocking { + val guard = AndroidAccountOperationGuard() + val session = dev.obiente.nextcloudnative.app.NextcloudSession( + "https://first.example.test", + "alice", + "old-password", + ) + val accountIdentity = NextcloudDocumentIds.accountKey(session) + val removalCommitted = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var sessionAvailable = true + var operationRan = false + val removal = async { + guard.withAccount(accountIdentity) { + sessionAvailable = false + removalCommitted.complete(Unit) + releaseRemoval.await() + } + } + removalCommitted.await() + + val cleanup = async { + guard.withAccountSession( + accountId = accountIdentity, + resolveSession = { session.takeIf { sessionAvailable } }, + unavailable = { "unavailable" }, + ) { + operationRan = true + "deleted" + } + } + yield() + assertFalse(cleanup.isCompleted) + + releaseRemoval.complete(Unit) + removal.await() + assertEquals("unavailable", cleanup.await()) + assertFalse(operationRan) + } + + @Test + fun uploadCreationWaitsForRemovalAndRejectsAReplacementCredential() = runBlocking { + val guard = AndroidAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val replacement = original.copy(appPassword = "new-password") + val accountIdentity = NextcloudDocumentIds.accountKey(original) + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var currentSession: NextcloudSession? = original + var uploadCreated = false + val removal = async { + guard.withAccount(accountIdentity) { + currentSession = replacement + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + removalEntered.await() + + val upload = async { + guard.withExactAccountSession( + expectedSession = original, + resolveSession = { currentSession }, + unavailable = { false }, + ) { + uploadCreated = true + true + } + } + yield() + assertFalse(upload.isCompleted) + + releaseRemoval.complete(Unit) + removal.await() + assertFalse(upload.await()) + assertFalse(uploadCreated) + } + + @Test + fun incomingShareRestoreRejectsARetainedCredentialAfterAnotherAccountBecomesActive() = runBlocking { + val guard = AndroidAccountOperationGuard() + val retained = NextcloudSession("https://first.example.test", "alice", "old-password") + val active = NextcloudSession("https://second.example.test", "bob", "new-password") + var restored = false + + val accepted = restoreIncomingShareForActiveSession( + guard = guard, + expectedSession = retained, + resolveActiveSession = { active }, + unavailable = { false }, + ) { + restored = true + true + } + + assertFalse(accepted) + assertFalse(restored) + } + + @Test + fun authenticatedMutationWaitsForSelectionAndRejectsTheStaleSession() = runBlocking { + val guard = AndroidAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val replacement = NextcloudSession("https://other.example.test", "bob", "new-password") + val selectionEntered = CompletableDeferred() + val releaseSelection = CompletableDeferred() + var current = original + var requestSent = false + val selection = async { + guard.withAccounts( + listOf(NextcloudDocumentIds.accountKey(original), NextcloudDocumentIds.accountKey(replacement)), + ) { + current = replacement + selectionEntered.complete(Unit) + releaseSelection.await() + } + } + selectionEntered.await() + + val mutation = async { + runCatching { + guard.withAuthenticatedMutationSession(original, { current }) { + requestSent = true + } + } + } + yield() + + assertFalse(mutation.isCompleted) + releaseSelection.complete(Unit) + selection.await() + assertTrue(mutation.await().isFailure) + assertFalse(requestSent) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 8960cc28a..dff53b021 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -4,9 +4,11 @@ import dev.obiente.nextcloudnative.app.DurableUploadScope import dev.obiente.nextcloudnative.app.DurableUploadState import dev.obiente.nextcloudnative.app.NextcloudApiMethod import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.afterProcessRecovery import dev.obiente.nextcloudnative.app.localUploadFile import java.io.IOException +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -15,6 +17,49 @@ import kotlin.test.assertTrue import org.json.JSONArray class AndroidDurableMultipartUploadPolicyTest { + @Test + fun `account cleanup removes a row only after its source capability is released`() = runBlocking { + val first = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val second = fixtureJob(index = 2, account = ACCOUNT_A, cardId = 43) + val events = mutableListOf() + + assertFailsWith { + removeAndroidDurableUploadJobs( + jobs = listOf(first, second), + cancelWork = { job -> events += "cancel:${job.id}" }, + releaseCapability = { job -> + events += "release:${job.id}" + job == first + }, + removeJob = { jobId -> events += "remove:$jobId" }, + ) + } + + assertEquals( + listOf( + "cancel:${first.id}", + "cancel:${second.id}", + "release:${first.id}", + "remove:${first.id}", + "release:${second.id}", + ), + events, + ) + } + + @Test + fun `removing an account deletes only its queued upload recovery rows`() { + val storage = FakeDurableUploadEncryptedStorage() + val store = AndroidDurableMultipartUploadStore(storage, FakeDurableUploadCipher()) + val first = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val second = fixtureJob(index = 2, account = ACCOUNT_B, cardId = 43) + store.add(first) + store.add(second) + + assertEquals(listOf(first), store.removeForAccount(ACCOUNT_A)) + assertEquals(listOf(second), store.list()) + } + @Test fun `encrypted queue read and decryption failures preserve recoverable jobs`() { listOf("read", "decrypt").forEach { failureMode -> @@ -268,6 +313,49 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(DurableUploadState.OutcomeUnknown, durableUploadStateForHttpResponse(500)) } + @Test + fun `retained background account is deferred instead of becoming unavailable`() { + val retainedSession = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "alice", + appPassword = "fixture-password", + ) + val accountId = NextcloudDocumentIds.accountKey(retainedSession) + + assertEquals( + DurableUploadAccountMismatchOutcome.DeferRetainedAccount, + durableUploadAccountMismatchOutcome(accountId, retainedSession), + ) + assertEquals( + DurableUploadAccountMismatchOutcome.AccountUnavailable, + durableUploadAccountMismatchOutcome(accountId, null), + ) + assertEquals( + DurableUploadAccountMismatchOutcome.AccountUnavailable, + durableUploadAccountMismatchOutcome( + accountId, + retainedSession.copy(loginName = "another-account"), + ), + ) + } + + @Test + fun `account activation resumes only its queued uploads`() { + val queuedForA = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val queuedForB = fixtureJob(index = 2, account = ACCOUNT_B, cardId = 43) + val completedForA = fixtureJob( + index = 3, + account = ACCOUNT_A, + cardId = 44, + state = DurableUploadState.Completed, + ) + + assertEquals( + listOf(queuedForA), + queuedDurableUploadsForAccount(listOf(queuedForA, queuedForB, completedForA), ACCOUNT_A), + ) + } + private fun fixtureJob( index: Int, account: String, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index ccf2c2a5c..4cf18f25c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -6,7 +6,9 @@ import dev.obiente.nextcloudnative.app.FileSyncCoordinatorState import dev.obiente.nextcloudnative.app.FileSyncDirection import dev.obiente.nextcloudnative.app.FileSyncOperation import dev.obiente.nextcloudnative.app.FileSyncPair +import dev.obiente.nextcloudnative.app.FileSyncPendingUploadCleanup import dev.obiente.nextcloudnative.app.LocalSyncEntry +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.RemoteSyncEntry import dev.obiente.nextcloudnative.app.SyncEntryKind import dev.obiente.nextcloudnative.app.scanFileSyncPair @@ -58,6 +60,87 @@ class AndroidFileSyncEngineInvariantTest { assertEquals(scanHashes.getValue(unverified.relativePath), reconciled[1].contentHash) } + @Test + fun accountRetirementRemovesOnlyItsPersistedSyncPairsAndLabels() { + val first = FileSyncPair( + id = "first-pair", + accountId = "first-account", + localRootId = "first-root", + remoteRootPath = "Pictures", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) + val retained = FileSyncPair( + id = "retained-pair", + accountId = "retained-account", + localRootId = "retained-root", + remoteRootPath = "Documents", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) + val state = AndroidFileSyncPersistedState( + coordinator = FileSyncCoordinatorState(listOf(first, retained)), + localDisplayNames = mapOf(first.id to "Camera", retained.id to "Documents"), + ) + + val retired = removeAndroidFileSyncAccountPairs(state, first.accountId) + + assertEquals(listOf(retained), retired.coordinator.pairs) + assertEquals(mapOf(retained.id to "Documents"), retired.localDisplayNames) + } + + @Test + fun accountRetirementPreservesPairsThatStillOwnRemoteUploadRecovery() { + val pair = FileSyncPair( + id = "pair", + accountId = "account", + localRootId = "root", + remoteRootPath = "Pictures", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + pendingUploadCleanups = listOf( + FileSyncPendingUploadCleanup( + uploadId = "123e4567-e89b-12d3-a456-426614174000", + relativePath = "photo.jpg", + ), + ), + ) + val state = AndroidFileSyncPersistedState( + coordinator = FileSyncCoordinatorState(listOf(pair)), + localDisplayNames = mapOf(pair.id to "Camera"), + ) + + assertFailsWith { + removeAndroidFileSyncAccountPairs(state, pair.accountId) + } + assertEquals(listOf(pair), state.coordinator.pairs) + assertEquals(mapOf(pair.id to "Camera"), state.localDisplayNames) + } + + @Test + fun scheduleRestorationRejectsAStaleAccountSwitch() { + val selected = NextcloudSession("https://cloud.example.test/nextcloud", "alice", "secret") + val other = NextcloudSession("https://cloud.example.test/nextcloud", "bob", "other-secret") + + assertTrue( + isAndroidFileSyncScheduleRestorationCurrent( + NextcloudDocumentIds.accountKey(selected), + selected, + ), + ) + assertFalse( + isAndroidFileSyncScheduleRestorationCurrent( + NextcloudDocumentIds.accountKey(selected), + other, + ), + ) + } + + @Test + fun scheduleRestorationStopsImmediateRetriesAfterTheBoundedBudget() { + assertEquals(BackgroundSyncWorkerDisposition.Retry, scheduleRestorationFailureDisposition(0)) + assertEquals(BackgroundSyncWorkerDisposition.Retry, scheduleRestorationFailureDisposition(1)) + assertEquals(BackgroundSyncWorkerDisposition.Retry, scheduleRestorationFailureDisposition(2)) + assertEquals(BackgroundSyncWorkerDisposition.Retry, scheduleRestorationFailureDisposition(20)) + } + @Test fun largeFileDirectoryReplacementKeepsTheDirectoryUntilProtectedPublication() { val directory = RemoteSyncEntry("archive.bin", SyncEntryKind.Directory, "directory-etag") @@ -461,6 +544,24 @@ class AndroidFileSyncEngineInvariantTest { assertTrue(reconciled) } + @Test + fun accountRetirementKeepsPairIdsUntilEveryScheduleCancellationCompletes() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + cancelAndroidFileSyncPairSchedulesBeforeRetirement( + pairIds = listOf("pair-a", "pair-b"), + cancelSchedule = { pairId -> + events += "cancel-$pairId" + if (pairId == "pair-b") error("synthetic WorkManager cancellation failure") + }, + persistRetirement = { events += "persist-retirement" }, + ) + } + + assertEquals(listOf("cancel-pair-a", "cancel-pair-b"), events) + } + @Test fun pairRemovalRecoveryPropagatesCancellationBeforeAnyMutation() = runBlocking { val events = mutableListOf() @@ -720,17 +821,105 @@ class AndroidFileSyncEngineInvariantTest { replacementAccountId = "account-new", persist = { events += "save-new-session" }, cancelAll = { events += "cancel-old-work" }, + restoreSchedules = { events += "restore-$it-work" }, ) val newToken = requireNotNull(guard.capture("account-new")) assertFalse(guard.runIfCurrent(oldToken) { events += "schedule-old-account" }) assertTrue(guard.runIfCurrent(newToken) { events += "schedule-new-account" }) assertEquals( - listOf("save-new-session", "cancel-old-work", "schedule-new-account"), + listOf( + "save-new-session", + "cancel-old-work", + "restore-account-new-work", + "schedule-new-account", + ), events, ) } + @Test + fun failedSessionReplacementPreservesOldAuthorityAndSchedules() { + val guard = AndroidFileSyncSessionSchedulingGuard() + guard.restorePersistedSession(load = { "account-old" }, accountIdOf = { it }) + val oldToken = requireNotNull(guard.capture("account-old")) + val events = mutableListOf() + + assertFailsWith { + guard.replaceSession( + replacementAccountId = "account-new", + persist = { + events += "save-new-session" + error("synthetic persistence failure") + }, + cancelAll = { events += "cancel-old-work" }, + publishAccount = { events += "publish-$it" }, + restoreSchedules = { events += "restore-$it-work" }, + ) + } + + assertTrue(guard.runIfCurrent(oldToken) { events += "old-account-still-current" }) + assertEquals(listOf("save-new-session", "old-account-still-current"), events) + assertEquals(null, guard.capture("account-new")) + } + + @Test + fun postCommitScheduleFailureDoesNotRejectTheSelectedAccount() { + val guard = AndroidFileSyncSessionSchedulingGuard() + guard.restorePersistedSession(load = { "account-old" }, accountIdOf = { it }) + val events = mutableListOf() + + guard.replaceSession( + replacementAccountId = "account-new", + persist = { events += "save-new-session" }, + cancelAll = { + events += "cancel-old-work" + error("synthetic WorkManager failure") + }, + publishAccount = { events += "publish-$it" }, + restoreSchedules = { + events += "restore-$it-work" + error("synthetic enqueue failure") + }, + onScheduleMaintenanceFailure = { events += "diagnose" }, + ) + + assertEquals( + listOf( + "save-new-session", + "publish-account-new", + "cancel-old-work", + "diagnose", + "restore-account-new-work", + "diagnose", + ), + events, + ) + assertTrue(guard.capture("account-new") != null) + } + + @Test + fun failedSessionClearPreservesOldAuthorityAndSchedules() { + val guard = AndroidFileSyncSessionSchedulingGuard() + guard.restorePersistedSession(load = { "account-old" }, accountIdOf = { it }) + val oldToken = requireNotNull(guard.capture("account-old")) + val events = mutableListOf() + + assertFailsWith { + guard.clearSession( + persist = { + events += "clear-session" + error("synthetic persistence failure") + }, + cancelAll = { events += "cancel-all" }, + clearPublishedAccount = { events += "publish-none" }, + ) + } + + assertTrue(guard.runIfCurrent(oldToken) { events += "old-account-still-current" }) + assertEquals(listOf("clear-session", "old-account-still-current"), events) + } + @Test fun newSessionGenerationCanScheduleWhileOldDedupeEntryFinishes() { val guard = AndroidFileSyncSessionSchedulingGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt index c6475eec7..00ceed34a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt @@ -203,6 +203,24 @@ class AndroidFileSyncStoreTest { } } + @Test + fun `owned uploads block account removal before pair deletion`() { + val accountPair = pair().copy( + pendingUploadCleanups = listOf( + FileSyncPendingUploadCleanup( + uploadId = "01234567-89ab-cdef-0123-456789abcdef", + relativePath = "Archive/large.bin", + ), + ), + ) + val state = AndroidFileSyncPersistedState(FileSyncCoordinatorState(listOf(accountPair))) + + assertFailsWith { + requireAndroidFileSyncAccountRemovalReady(state, accountPair.accountId) + } + assertEquals(listOf(accountPair), state.coordinator.pairs) + } + private fun pair() = FileSyncPair( id = "pair-1", accountId = "account-1", diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt index af194ad05..e24285cb9 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt @@ -13,6 +13,8 @@ import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking class AndroidIncomingShareStateTest { @Test @@ -708,6 +710,100 @@ class AndroidIncomingShareStateTest { } } + @Test + fun accountRemovalCancelsEveryShareWorkerBeforeReleasingChunksAndStaging() = runBlocking { + val uploadId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + val staged = request(AndroidIncomingShareState.Uploading).copy( + userId = "alice", + destinationPath = "Shared", + chunkSession = AndroidIncomingShareChunkSession( + fileIndex = 0, + targetName = "first.txt", + uploadId = uploadId, + ), + ) + val corruptId = "fedcba98-7654-3210-fedc-ba9876543210" + val events = mutableListOf() + + removeAndroidIncomingShareRequests( + requests = listOf( + AndroidIncomingShareAccountRequest(staged.id, staged), + AndroidIncomingShareAccountRequest(corruptId, request = null), + ), + cancelWork = { requestId -> events += "cancel:$requestId" }, + releaseChunk = { request, chunkId -> events += "release:${request.id}:$chunkId" }, + removeRequest = { requestId -> events += "remove:$requestId" }, + ) + + assertEquals( + listOf( + "cancel:${staged.id}", + "cancel:$corruptId", + "release:${staged.id}:$uploadId", + "remove:${staged.id}", + "remove:$corruptId", + ), + events, + ) + assertEquals( + setOf( + incomingShareUploadWorkName(staged.id), + incomingShareRetryWorkName(staged.id), + incomingShareCleanupWorkName(staged.id), + incomingShareChunkCleanupWorkName(staged.id), + incomingShareReleaseWorkName(staged.id), + incomingShareAbandonedStagingWorkName(staged.id), + ), + incomingShareAccountWorkNames(staged.id).toSet(), + ) + } + + @Test + fun accountRemovalRetainsLocalShareAfterRemoteChunkCleanupFails() = runBlocking { + val staged = request(AndroidIncomingShareState.Uploading).copy( + chunkSession = AndroidIncomingShareChunkSession( + fileIndex = 0, + targetName = "first.txt", + uploadId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + ) + val events = mutableListOf() + + assertFailsWith { + removeAndroidIncomingShareRequests( + requests = listOf(AndroidIncomingShareAccountRequest(staged.id, staged)), + cancelWork = { events += "cancel" }, + releaseChunk = { _, _ -> error("synthetic offline cleanup failure") }, + recordChunkReleaseFailure = { events += "release-failed" }, + removeRequest = { events += "remove" }, + ) + } + + assertEquals(listOf("cancel", "release-failed"), events) + } + + @Test + fun accountRemovalPreservesChunkCleanupCancellation() = runBlocking { + val staged = request(AndroidIncomingShareState.Uploading).copy( + chunkSession = AndroidIncomingShareChunkSession( + fileIndex = 0, + targetName = "first.txt", + uploadId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + ) + var removed = false + + assertFailsWith { + removeAndroidIncomingShareRequests( + requests = listOf(AndroidIncomingShareAccountRequest(staged.id, staged)), + cancelWork = {}, + releaseChunk = { _, _ -> throw CancellationException("synthetic cancellation") }, + removeRequest = { removed = true }, + ) + } + assertFalse(removed) + } + private fun request(state: AndroidIncomingShareState) = AndroidIncomingShareRequest( id = "01234567-89ab-cdef-0123-456789abcdef", files = listOf( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidOfflineFolderPlanningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidOfflineFolderPlanningTest.kt index 796fcb09d..e990f72b6 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidOfflineFolderPlanningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidOfflineFolderPlanningTest.kt @@ -165,6 +165,49 @@ class AndroidOfflineFolderPlanningTest { assertTrue(AndroidOfflineFolderState(roots = listOf(root)).offlineDirectories("other").isEmpty()) } + @Test + fun accountRemovalPurgesOnlyThatAccountsOfflineQueueAndFolders() { + val first = planAndroidOfflineFolderPin( + current = AndroidFileOfflinePersistedState(), + accountId = "account-a", + inventory = planAndroidOfflineFolder(directory("First")) { + listOf(file("First/a.txt", 1, "\"a\"")) + }, + nowEpochMillis = 10, + localGenerationExists = { _, _ -> false }, + ) + val both = planAndroidOfflineFolderPin( + current = first, + accountId = "account-b", + inventory = planAndroidOfflineFolder(directory("Second")) { + listOf(file("Second/b.txt", 2, "\"b\"")) + }, + nowEpochMillis = 20, + localGenerationExists = { _, _ -> false }, + ).copy( + folders = first.folders.copy( + directPins = setOf(FileOfflineKey("account-a", "First/a.txt")), + roots = first.folders.roots + planAndroidOfflineFolderPin( + current = AndroidFileOfflinePersistedState(), + accountId = "account-b", + inventory = planAndroidOfflineFolder(directory("Second")) { + listOf(file("Second/b.txt", 2, "\"b\"")) + }, + nowEpochMillis = 20, + localGenerationExists = { _, _ -> false }, + ).folders.roots, + ), + ) + + val retained = removeAndroidFileOfflineAccountState(both, "account-a") + + assertTrue(retained.queue.records.all { it.descriptor.key.accountId == "account-b" }) + assertTrue(retained.queue.jobs.all { it.key.accountId == "account-b" }) + assertTrue(retained.folders.directPins.isEmpty()) + assertEquals(listOf("account-b"), retained.folders.roots.map { it.accountId }) + assertEquals(both.queue.nextJobId, retained.queue.nextJobId) + } + private fun directory(path: String) = NextcloudFile( path = path, name = path.substringAfterLast('/'), diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 57c5e0b72..cb2a7bec8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -1,164 +1,1044 @@ package dev.obiente.nextcloudnative -import dev.obiente.nextcloudnative.app.NextcloudAccountRegistrySource +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft -import dev.obiente.nextcloudnative.app.decodeNextcloudAccountRegistry -import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.accountRecord +import dev.obiente.nextcloudnative.app.encodeNextcloudAccountRegistry +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import org.json.JSONObject +import java.lang.reflect.Proxy +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread class AndroidPersistedSessionTest { + @Test + fun credentialStoreGuardKeepsMigrationAndMutationWritesOrdered() { + val guard = AndroidAccountCredentialStoreGuard() + val migrationEntered = CountDownLatch(1) + val releaseMigration = CountDownLatch(1) + val mutationAttempted = CountDownLatch(1) + val mutationEntered = CountDownLatch(1) + + val migration = thread { + guard.serialize { + migrationEntered.countDown() + check(releaseMigration.await(5, TimeUnit.SECONDS)) + } + } + check(migrationEntered.await(5, TimeUnit.SECONDS)) + val mutation = thread { + mutationAttempted.countDown() + guard.serialize { mutationEntered.countDown() } + } + check(mutationAttempted.await(5, TimeUnit.SECONDS)) + + assertFalse(mutationEntered.await(100, TimeUnit.MILLISECONDS)) + releaseMigration.countDown() + migration.join() + mutation.join() + assertEquals(0L, mutationEntered.count) + } + + @Test + fun invalidCredentialStoreRecoveryPurgesCredentialBearingQuarantine() { + val replacementWrites = linkedMapOf() + val replacementRemovals = linkedSetOf() + prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor = recoveryRecordingEditor(replacementWrites, replacementRemovals), + replacementEncrypted = "new-encrypted-session", + ) + + assertFalse("encrypted_session_quarantine" in replacementWrites) + assertTrue("encrypted_session_quarantine" in replacementRemovals) + assertEquals("new-encrypted-session", replacementWrites["encrypted_session"]) + assertTrue("emulator_test_read_only" in replacementRemovals) + + val resetWrites = linkedMapOf() + val resetRemovals = linkedSetOf() + prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor = recoveryRecordingEditor(resetWrites, resetRemovals), + replacementEncrypted = null, + ) + + assertFalse("encrypted_session_quarantine" in resetWrites) + assertTrue("encrypted_session_quarantine" in resetRemovals) + assertTrue("encrypted_session" in resetRemovals) + assertTrue("emulator_test_read_only" in resetRemovals) + } + + @Test + fun retainedAccountSessionResolvesWithoutSelectingIt() { + val first = firstSession() + val second = secondSession() + val sessions = mapOf(first.accountId to first, second.accountId to second) + + val resolved = resolveStoredAndroidAccountSession( + accountIdentity = NextcloudDocumentIds.accountKey(second), + listAccounts = { listOf(first.accountRecord(), second.accountRecord()) }, + loadSession = sessions::get, + ) + + assertEquals(second, resolved) + } + + @Test + fun clearingRecoveredIndependentStateRemovesOnlyItsActiveAccount() { + val first = firstSession() + val second = secondSession() + val recovered = requireNotNull( + AndroidAccountCredentialState.Empty + .upsertAndSelect(first) + .upsertAndSelect(second) + .select(first.accountId), + ) + + val cleared = removeActiveAndroidAccountCredentialState(recovered) + + assertNull(cleared.activeSession) + assertEquals(listOf(second.accountRecord()), cleared.registry.accounts) + assertEquals(second, cleared.sessions[second.accountId]) + } + + @Test + fun retainedAccountResolutionRejectsMismatchedCredential() { + val first = firstSession() + val second = secondSession() + + val resolved = resolveStoredAndroidAccountSession( + accountIdentity = NextcloudDocumentIds.accountKey(second), + listAccounts = { listOf(second.accountRecord()) }, + loadSession = { first }, + ) + + assertNull(resolved) + } + @Test + fun accountCredentialEditsUseCheckedSynchronousCommit() { + val successfulCalls = mutableListOf() + requireCommittedAndroidAccountCredentialEdit(recordingEditor(commitResult = true, successfulCalls)) + assertEquals(listOf("commit"), successfulCalls) + + val failedCalls = mutableListOf() + assertFailsWith { + requireCommittedAndroidAccountCredentialEdit(recordingEditor(commitResult = false, failedCalls)) + } + assertEquals(listOf("commit"), failedCalls) + } + @Test fun legacyPayloadMigratesOnceAndRestartsWithTheSameActiveAccount() { val diagnostics = mutableListOf() var migrated: String? = null - val first = restoreAndroidPersistedSession( - encoded = legacyPayload(), - persistMigrated = { encoded -> - migrated = encoded - true - }, + val first = restoreAndroidAccountCredentialState( + encoded = legacyPayload(firstSession()), + persistMigrated = { encoded -> migrated = encoded }, recordDiagnostic = diagnostics::add, ) val migratedPayload = requireNotNull(migrated) - val registry = decodeNextcloudAccountRegistry( - JSONObject(migratedPayload).getString(ACCOUNT_REGISTRY_KEY), - ) - assertEquals(first.accountId, requireNotNull(registry).activeAccountId) + assertEquals(firstSession(), requireNotNull(first).activeSession) + assertEquals(2, JSONObject(migratedPayload).getInt("version")) assertTrue(diagnostics.isEmpty()) var unexpectedSecondMigration = false - val restarted = restoreAndroidPersistedSession( + val restarted = restoreAndroidAccountCredentialState( encoded = migratedPayload, - persistMigrated = { - unexpectedSecondMigration = true - true - }, + persistMigrated = { unexpectedSecondMigration = true }, recordDiagnostic = diagnostics::add, ) + assertEquals(first, restarted) assertFalse(unexpectedSecondMigration) assertTrue(diagnostics.isEmpty()) } @Test - fun malformedRegistryFallsBackWithoutDiscardingTheLegacySession() { + fun versionlessRegistryPayloadFromAccountFoundationMigratesToCredentialSlots() { + val session = firstSession() + val versionless = JSONObject(legacyPayload(session)) + .put( + "account_registry_v1", + encodeNextcloudAccountRegistry(NextcloudAccountRegistry.Empty.upsertAndSelect(session.accountRecord())), + ) + .toString() + var migrated: String? = null + + val restored = restoreAndroidAccountCredentialState( + encoded = versionless, + persistMigrated = { migrated = it }, + recordDiagnostic = {}, + ) + + assertEquals(session, requireNotNull(restored).activeSession) + assertEquals(2, JSONObject(requireNotNull(migrated)).getInt("version")) + } + + @Test + fun twoCredentialSlotsRestartAndSelectTheExactAccount() { + val first = firstSession() + val second = secondSession() + val state = AndroidAccountCredentialState.Empty + .upsertAndSelect(first) + .upsertAndSelect(second) + val restarted = requireNotNull( + decodeAndroidAccountCredentialState(encodeAndroidAccountCredentialState(state)).state, + ) + + assertEquals( + listOf(first.accountId, second.accountId).sortedBy { it.storageKey }, + restarted.sessions.keys.sortedBy { it.storageKey }, + ) + assertEquals(second, restarted.activeSession) + assertEquals(first, requireNotNull(restarted.select(first.accountId)).activeSession) + } + + @Test + fun equivalentReauthenticationRetainsThePersistedAndroidWorkIdentity() { + val original = firstSession().copy(serverUrl = "https://CLOUD.EXAMPLE.TEST:443/") + val reauthenticated = firstSession().copy(appPassword = "rotated-private-password") + assertEquals(original.accountId, reauthenticated.accountId) + + val updated = AndroidAccountCredentialState.Empty + .upsertAndSelect(original) + .upsertAndSelect(reauthenticated) + + val active = requireNotNull(updated.activeSession) + assertEquals(original.serverUrl, active.serverUrl) + assertEquals(reauthenticated.appPassword, active.appPassword) + assertEquals(NextcloudDocumentIds.accountKey(original), NextcloudDocumentIds.accountKey(active)) + assertEquals(NextcloudDocumentIds.cacheAccountId(original), NextcloudDocumentIds.cacheAccountId(active)) + } + + @Test + fun encodingIsDeterministicAcrossCredentialInsertionOrder() { + val firstThenSecond = AndroidAccountCredentialState.Empty + .upsertAndSelect(firstSession()) + .upsertAndSelect(secondSession()) + .select(firstSession().accountId) + val secondThenFirst = AndroidAccountCredentialState.Empty + .upsertAndSelect(secondSession()) + .upsertAndSelect(firstSession()) + + assertEquals( + encodeAndroidAccountCredentialState(requireNotNull(firstThenSecond)), + encodeAndroidAccountCredentialState(secondThenFirst), + ) + } + + @Test + fun malformedStoreDoesNotExposeOrOverwriteCredentialValues() { + val diagnostics = mutableListOf() + var persisted = false + val malformed = "{\"appPassword\":\"private-app-password\",\"version\":2" + + val restored = restoreAndroidAccountCredentialState( + encoded = malformed, + persistMigrated = { persisted = true }, + recordDiagnostic = diagnostics::add, + ) + + assertNull(restored) + assertFalse(persisted) + assertEquals(listOf("ACCOUNT_CREDENTIAL_STORE_MALFORMED"), diagnostics.mapNotNull { it.code }) + assertDiagnosticsExcludePrivateValues(diagnostics) + } + + @Test + fun claimedAccountMismatchRejectsTheWholeCredentialStore() { + val encoded = JSONObject( + encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(firstSession())), + ) + encoded.getJSONArray("credentials").getJSONObject(0) + .put("accountId", secondSession().accountId.storageKey) + val diagnostics = mutableListOf() + + val restored = restoreAndroidAccountCredentialState( + encoded = encoded.toString(), + persistMigrated = {}, + recordDiagnostic = diagnostics::add, + ) + + assertNull(restored) + assertEquals(listOf("ACCOUNT_CREDENTIAL_SLOT_MISMATCH"), diagnostics.mapNotNull { it.code }) + assertDiagnosticsExcludePrivateValues(diagnostics) + } + + @Test + fun duplicateCredentialIdentityIsRejected() { + val encoded = JSONObject( + encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(firstSession())), + ) + val credentials = encoded.getJSONArray("credentials") + credentials.put(JSONObject(credentials.getJSONObject(0).toString())) + + val restored = decodeAndroidAccountCredentialState(encoded.toString()) + + assertNull(restored.state) + assertEquals("ACCOUNT_CREDENTIAL_SLOT_MISMATCH", restored.diagnosticCode) + } + + @Test + fun registryEntryWithoutCredentialIsRejected() { + val first = firstSession() + val second = secondSession() + val state = AndroidAccountCredentialState.Empty.upsertAndSelect(first) + val encoded = JSONObject(encodeAndroidAccountCredentialState(state)) + .put( + "account_registry_v1", + encodeNextcloudAccountRegistry( + NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()), + ), + ) + + val restored = decodeAndroidAccountCredentialState(encoded.toString()) + + assertNull(restored.state) + assertEquals("ACCOUNT_CREDENTIAL_SLOT_MISMATCH", restored.diagnosticCode) + } + + @Test + fun removingTheActiveSlotRetainsOtherCredentialsWithoutSelectingOne() { + val first = firstSession() + val second = secondSession() + val state = AndroidAccountCredentialState.Empty + .upsertAndSelect(first) + .upsertAndSelect(second) + .remove(second.accountId) + val restarted = requireNotNull( + decodeAndroidAccountCredentialState(encodeAndroidAccountCredentialState(state)).state, + ) + + assertNull(restarted.activeSession) + assertEquals(setOf(first.accountId), restarted.sessions.keys) + assertNull(restarted.registry.activeAccountId) + assertFalse(restarted.sessions.values.any { session -> session.appPassword == second.appPassword }) + } + + @Test + fun malformedLegacyRegistryFallsBackWithoutDiscardingTheValidSession() { val diagnostics = mutableListOf() var migrated: String? = null - val malformed = JSONObject(legacyPayload()) - .put(ACCOUNT_REGISTRY_KEY, "{not-json") + val malformed = JSONObject(legacyPayload(firstSession())) + .put("account_registry_v1", "{not-json") .toString() - val session = restoreAndroidPersistedSession( + val restored = restoreAndroidAccountCredentialState( encoded = malformed, - persistMigrated = { encoded -> - migrated = encoded - true - }, + persistMigrated = { migrated = it }, recordDiagnostic = diagnostics::add, ) - val restoredRegistry = restoreNextcloudAccountRegistry( - JSONObject(requireNotNull(migrated)).getString(ACCOUNT_REGISTRY_KEY), - session, - ) - assertEquals(NextcloudAccountRegistrySource.Persisted, restoredRegistry.source) - assertEquals(session.accountId, restoredRegistry.registry.activeAccountId) + assertEquals(firstSession(), requireNotNull(restored).activeSession) + assertNotNull(migrated) assertEquals(listOf("ACCOUNT_REGISTRY_MALFORMED"), diagnostics.mapNotNull { it.code }) - val renderedDiagnostics = diagnostics.joinToString() - assertFalse(renderedDiagnostics.contains("private-app-password")) - assertFalse(renderedDiagnostics.contains("alice")) - assertFalse(renderedDiagnostics.contains("cloud.example.test")) + assertDiagnosticsExcludePrivateValues(diagnostics) } @Test - fun unsupportedFutureRegistryIsNotPersistedOver() { + fun unsupportedFutureLegacyRegistryIsNotPersistedOver() { val diagnostics = mutableListOf() var migrated = false val futureRegistry = """{"version":2,"futureAccounts":[]}""" - val payload = JSONObject(legacyPayload()) - .put(ACCOUNT_REGISTRY_KEY, futureRegistry) + val payload = JSONObject(legacyPayload(firstSession())) + .put("account_registry_v1", futureRegistry) .toString() - val session = restoreAndroidPersistedSession( + val restored = restoreAndroidAccountCredentialState( encoded = payload, - persistMigrated = { - migrated = true - true - }, + persistMigrated = { migrated = true }, recordDiagnostic = diagnostics::add, ) - assertEquals("alice", session.loginName) + val readOnly = requireNotNull(restored) + assertEquals(firstSession(), readOnly.activeSession) assertFalse(migrated) assertEquals(listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }) + assertFailsWith { readOnly.upsertAndSelect(secondSession()) } + assertFailsWith { readOnly.select(firstSession().accountId) } + assertFailsWith { readOnly.remove(firstSession().accountId) } + assertFailsWith { encodeAndroidAccountCredentialState(readOnly) } } @Test - fun savedPayloadKeepsCredentialsOutsideTheRegistry() { - val session = restoreAndroidPersistedSession( - encoded = legacyPayload(), - persistMigrated = { true }, - recordDiagnostic = {}, - ) + fun unsupportedFutureCredentialStoreIsReadOnlyAndNeverMigrated() { + val diagnostics = mutableListOf() + var migrated = false + val future = JSONObject( + encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(firstSession())), + ).put("version", 3).toString() - val payload = JSONObject(encodeAndroidPersistedSession(session)) - val encodedRegistry = payload.getString(ACCOUNT_REGISTRY_KEY) + val restored = restoreAndroidAccountCredentialStore( + encoded = future, + persistMigrated = { migrated = true }, + recordDiagnostic = diagnostics::add, + ) - assertEquals("private-app-password", payload.getString("appPassword")) - assertFalse(encodedRegistry.contains("private-app-password")) - assertFalse(encodedRegistry.contains("appPassword")) + assertNull(restored.state) + assertEquals(3, restored.unsupportedVersion) + assertFalse(migrated) + assertEquals( + listOf("ACCOUNT_CREDENTIAL_STORE_VERSION_UNSUPPORTED"), + diagnostics.mapNotNull { it.code }, + ) + assertFalse( + androidCredentialStoreAllowsSessionRestore( + AndroidAccountCredentialStoreRead.Unsupported("encrypted-future-store", 3), + ), + ) + assertTrue( + androidCredentialStoreAllowsSessionRestore( + AndroidAccountCredentialStoreRead.Invalid("encrypted-malformed-store"), + ), + ) + assertFalse( + androidCredentialStoreAllowsSessionRestore( + AndroidAccountCredentialStoreRead.Available( + AndroidAccountCredentialState.Empty.upsertAndSelect(firstSession()) + .copy(mutationsAllowed = false), + ), + ), + ) + assertTrue( + androidCredentialStoreAllowsSessionRestore( + AndroidAccountCredentialStoreRead.Available( + AndroidAccountCredentialState.Empty.upsertAndSelect(firstSession()), + ), + ), + ) } @Test - fun migrationFailureAttachesABoundedCauseWithoutPrivateValues() { + fun migrationFailureUsesABoundedCauseWithoutPrivateValues() { val diagnostics = mutableListOf() - val session = restoreAndroidPersistedSession( - encoded = legacyPayload(), + val restored = restoreAndroidAccountCredentialState( + encoded = legacyPayload(firstSession()), persistMigrated = { error("private-app-password at cloud.example.test for alice") }, recordDiagnostic = diagnostics::add, ) - assertEquals("alice", session.loginName) + assertEquals(firstSession(), requireNotNull(restored).activeSession) val diagnostic = diagnostics.single() - assertEquals("ACCOUNT_REGISTRY_MIGRATION_FAILED", diagnostic.code) + assertEquals("ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED", diagnostic.code) val exception = assertNotNull(diagnostic.exception) assertNull(exception.message) - val rendered = diagnostic.toString() - assertFalse(rendered.contains("private-app-password")) - assertFalse(rendered.contains("cloud.example.test")) - assertFalse(rendered.contains("alice")) + assertDiagnosticsExcludePrivateValues(diagnostics) } @Test - fun rejectedMigrationCommitIsReportedWithoutDiscardingTheLegacySession() { - val diagnostics = mutableListOf() + fun accountRegistryInsideTheStoreContainsNoCredential() { + val session = firstSession() + val payload = JSONObject( + encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(session)), + ) + val registry = payload.getString("account_registry_v1") - val session = restoreAndroidPersistedSession( - encoded = legacyPayload(), - persistMigrated = { false }, - recordDiagnostic = diagnostics::add, + assertFalse(registry.contains(session.appPassword)) + assertFalse(registry.contains("appPassword")) + assertEquals(1, payload.getJSONArray("credentials").length()) + } + + @Test + fun accountListingDecodesTheCredentialFreeRegistryWithoutASecretPayload() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + val encoded = encodeNextcloudAccountRegistry(registry) + + assertFalse(encoded.contains(first.appPassword)) + assertFalse(encoded.contains(second.appPassword)) + assertEquals(registry, decodeAndroidCredentialFreeRegistry(encoded)) + } + + @Test + fun malformedCredentialFreeRegistryDefersCredentialBearingRecovery() { + val registry = NextcloudAccountRegistry.Empty.upsertAndSelect(firstSession().accountRecord()) + var recoveryAttempted = false + + val restored = restoreAndroidCredentialFreeRegistry("{not-json") + + assertFalse(recoveryAttempted) + assertNull(restored.registry) + assertTrue(restored.credentialRecoveryRequired) + assertEquals("ACCOUNT_REGISTRY_MALFORMED", restored.diagnosticCode) + + val recovered = recoverAndroidCredentialFreeRegistryForCredentialLoad(restored) { + recoveryAttempted = true + registry + } + + assertTrue(recoveryAttempted) + assertEquals(registry, recovered) + } + + @Test + fun missingCredentialFreeRegistryIsRecoveredOnlyForCredentialLoad() { + val registry = NextcloudAccountRegistry.Empty.upsertAndSelect(firstSession().accountRecord()) + var recoveryAttempted = false + + val recovered = recoverAndroidCredentialFreeRegistryForCredentialLoad(restored = null) { + recoveryAttempted = true + registry + } + + assertTrue(recoveryAttempted) + assertEquals(registry, recovered) + } + + @Test + fun futureCredentialFreeRegistryIsNeverRebuiltFromAnOlderAggregate() { + var recoveryAttempted = false + + val restored = restoreAndroidCredentialFreeRegistry("""{"version":99,"accounts":[]}""") + val recovered = recoverAndroidCredentialFreeRegistryForCredentialLoad(restored) { + recoveryAttempted = true + NextcloudAccountRegistry.Empty + } + + assertFalse(recoveryAttempted) + assertNull(recovered) + assertFalse(restored.credentialRecoveryRequired) + assertEquals("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED", restored.diagnosticCode) + } + + @Test + fun credentialSlotReadDecryptsOnlyTheRequestedAccount() { + val first = firstSession() + val second = secondSession() + val encryptedByKey = mapOf( + androidAccountCredentialSlotKey(first.accountId) to "encrypted-first", + androidAccountCredentialSlotKey(second.accountId) to "encrypted-second", + ) + val requestedKeys = mutableListOf() + val decryptedValues = mutableListOf() + + val restored = readAndroidAccountCredentialSlot( + accountId = second.accountId, + readEncrypted = { key -> + requestedKeys += key + encryptedByKey[key] + }, + decrypt = { encrypted -> + decryptedValues += encrypted + "decoded-second" + }, + decode = { decoded -> + RestoredAndroidAccountCredentialState( + AndroidAccountCredentialState.Empty.upsertAndSelect(second) + .takeIf { decoded == "decoded-second" }, + ) + }, ) - assertEquals("alice", session.loginName) - assertEquals(listOf("ACCOUNT_REGISTRY_MIGRATION_FAILED"), diagnostics.mapNotNull { it.code }) + assertEquals(AndroidAccountCredentialSlotRead.Available(second), restored) + assertEquals(listOf(androidAccountCredentialSlotKey(second.accountId)), requestedKeys) + assertEquals(listOf("encrypted-second"), decryptedValues) } - private fun legacyPayload(): String = JSONObject() - .put("serverUrl", "https://cloud.example.test") - .put("loginName", "alice") - .put("appPassword", "private-app-password") - .toString() + @Test + fun credentialSlotReadRejectsASecretForAnotherAccount() { + val first = firstSession() + val second = secondSession() + + val restored = readAndroidAccountCredentialSlot( + accountId = second.accountId, + readEncrypted = { "encrypted-first" }, + decrypt = { "decoded-first" }, + decode = { + RestoredAndroidAccountCredentialState( + AndroidAccountCredentialState.Empty.upsertAndSelect(first), + ) + }, + ) + + assertEquals(AndroidAccountCredentialSlotRead.Invalid, restored) + } + + @Test + fun futureCredentialSlotBlocksAggregateFallbackAndRepair() { + val session = firstSession() + val future = JSONObject(encodeAndroidPersistedSession(session)).put("version", 3).toString() + + val restored = readAndroidAccountCredentialSlot( + accountId = session.accountId, + readEncrypted = { "encrypted-future-slot" }, + decrypt = { future }, + decode = ::decodeAndroidAccountCredentialState, + ) + + assertEquals(AndroidAccountCredentialSlotRead.Unsupported(3), restored) + } + + @Test + fun pendingCleanupMatchesCanonicalAccountAndRetainsOriginalWorkIdentity() { + val original = firstSession().copy(serverUrl = "HTTPS://CLOUD.EXAMPLE.TEST:443/") + val replacement = firstSession().copy(serverUrl = "https://cloud.example.test") + assertEquals(original.accountId, replacement.accountId) + assertFalse(NextcloudDocumentIds.accountKey(original) == NextcloudDocumentIds.accountKey(replacement)) + val encoded = encodeAndroidPendingAccountRemovalCleanup(pendingAndroidAccountRemovalCleanup(original)) + val decoded = requireNotNull(decodeAndroidPendingAccountRemovalCleanup(encoded)) + + val pending = pendingAndroidAccountRemovalCleanupForSession(replacement, listOf(decoded)) + + assertEquals(NextcloudDocumentIds.accountKey(original), requireNotNull(pending).workIdentity) + assertEquals(original.accountId.storageKey, pending.accountStorageKey) + } + + @Test + fun damagedCredentialSlotRecoversFromTheMatchingAggregateCredential() { + val session = firstSession() + val aggregate = AndroidAccountCredentialState.Empty.upsertAndSelect(session) + + val recovered = recoverAndroidAccountCredentialSlot( + accountId = session.accountId, + registry = aggregate.registry, + storedSlot = null, + aggregate = aggregate, + ) + + assertEquals(session, recovered) + } + + @Test + fun credentialSlotRecoveryRejectsAnAggregateThatDoesNotMatchTheVisibleRegistry() { + val original = firstSession().copy(serverUrl = "https://CLOUD.EXAMPLE:443/") + val aggregate = AndroidAccountCredentialState.Empty.upsertAndSelect(firstSession()) + val visibleRegistry = NextcloudAccountRegistry.Empty.upsertAndSelect(original.accountRecord()) + + val recovered = recoverAndroidAccountCredentialSlot( + accountId = original.accountId, + registry = visibleRegistry, + storedSlot = null, + aggregate = aggregate, + ) + + assertNull(recovered) + } + + @Test + fun validIndependentSlotsCanRecoverAroundAMalformedAggregateStore() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + val slots = mapOf(first.accountId to first, second.accountId to second) + + val restored = reconstructAndroidAccountCredentialState(registry, slots::get) + + assertEquals(slots, requireNotNull(restored).sessions) + assertEquals(second, restored.activeSession) + } + + @Test + fun corruptInactiveCredentialSlotDoesNotHideTheHealthyActiveAccount() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + .select(first.accountId) + .let(::requireNotNull) + + val restored = reconstructAndroidAccountCredentialState(registry) { accountId -> + first.takeIf { accountId == first.accountId } + } + + assertEquals(mapOf(first.accountId to first), requireNotNull(restored).sessions) + assertEquals(listOf(first.accountRecord()), restored.registry.accounts) + assertEquals(first, restored.activeSession) + } + + @Test + fun corruptActiveCredentialSlotStillFailsClosed() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + + assertNull( + reconstructAndroidAccountCredentialState(registry) { accountId -> + first.takeIf { accountId == first.accountId } + }, + ) + } + + @Test + fun validIndependentSlotsRecoverWhenTheAggregateKeyIsAbsent() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + val slots = mapOf(first.accountId to first, second.accountId to second) + + val restored = restoreAndroidAccountCredentialStateWithoutAggregate( + encodedRegistry = encodeNextcloudAccountRegistry(registry), + loadSession = slots::get, + ) + + assertEquals(slots, requireNotNull(restored).sessions) + assertEquals(second, restored.activeSession) + } + + @Test + fun independentSlotRecoveryRejectsRegistryCredentialMismatch() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty.upsertAndSelect(second.accountRecord()) + + assertNull(reconstructAndroidAccountCredentialState(registry) { first }) + } + + @Test + fun queuedUploadResumeFailureDoesNotHideACommittedAccountSelection() = runBlocking { + val events = mutableListOf() + + resumeAndroidQueuedUploadsAfterSelection( + resume = { + events += "resume" + error("Synthetic unreadable upload queue") + }, + notifyDocumentRootsChanged = { events += "notify" }, + recordFailure = { events += "diagnose" }, + ) + + assertEquals(listOf("resume", "diagnose", "notify"), events) + } + + @Test + fun previewCleanupFailureDoesNotHideACommittedAccountSelection() { + val previous = firstSession() + val selected = secondSession() + val events = mutableListOf() + + clearAndroidPreviousPreviewAfterCommittedSelection( + previousSession = previous, + selectedSession = selected, + clearPreviewAccount = { + events += "clear-preview" + error("synthetic preview cleanup failure") + }, + recordFailure = { events += "diagnose-cleanup" }, + ) + + assertEquals(listOf("clear-preview", "diagnose-cleanup"), events) + } + + @Test + fun failedAccountTransitionDoesNotClearExternalHandoffs() { + val events = mutableListOf() + + assertFailsWith { + commitAndroidAccountTransitionBeforeHandoffCleanup( + commitTransition = { + events += "commit-transition" + error("synthetic credential persistence failure") + }, + clearHandoffs = { events += "clear-handoffs" }, + recordFailure = { events += "diagnose-cleanup" }, + ) + } - private companion object { - const val ACCOUNT_REGISTRY_KEY = "account_registry_v1" + assertEquals(listOf("commit-transition"), events) } + + @Test + fun handoffCleanupFailureDoesNotHideACommittedAccountTransition() { + val events = mutableListOf() + + commitAndroidAccountTransitionBeforeHandoffCleanup( + commitTransition = { events += "commit-transition" }, + clearHandoffs = { + events += "clear-handoffs" + error("synthetic handoff cleanup failure") + }, + recordFailure = { events += "diagnose-cleanup" }, + ) + + assertEquals(listOf("commit-transition", "clear-handoffs", "diagnose-cleanup"), events) + } + + @Test + fun queuedUploadResumeCancellationNotifiesBeforePropagating() { + val events = mutableListOf() + + assertFailsWith { + runBlocking { + resumeAndroidQueuedUploadsAfterSelection( + resume = { throw CancellationException("Selection owner stopped") }, + notifyDocumentRootsChanged = { events += "notify" }, + recordFailure = { events += "diagnose" }, + ) + } + } + assertEquals(listOf("notify"), events) + } + + @Test + fun parentCancellationStopsQueuedUploadResumeAndStillNotifies() = runBlocking { + val resumeEntered = CompletableDeferred() + val events = mutableListOf() + val selection = launch { + resumeAndroidQueuedUploadsAfterSelection( + resume = { + events += "resume" + resumeEntered.complete(Unit) + awaitCancellation() + }, + notifyDocumentRootsChanged = { events += "notify" }, + recordFailure = { events += "diagnose" }, + ) + } + resumeEntered.await() + + selection.cancelAndJoin() + + assertEquals(listOf("resume", "notify"), events) + } + + @Test + fun activeAccountRemovalDeletesTheCredentialBeforeIrreversibleUploadCleanup() = runBlocking { + val events = mutableListOf() + + removeAndroidAccountCredentialData( + active = true, + prepareAccountRemoval = { events += "prepare-removal" }, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { events += "persist-inactive" }, + rollbackInactiveRemoval = { events += "rollback-inactive" }, + completeCommittedCleanup = { events += "complete-cleanup" }, + ) + + assertEquals(listOf("prepare-removal", "clear-account", "remove-uploads", "complete-cleanup"), events) + } + + @Test + fun blockedAccountRemovalDoesNotDeleteCredentialsOrQueuedWork() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + removeAndroidAccountCredentialData( + active = true, + prepareAccountRemoval = { + events += "prepare-removal" + error("pending document writeback") + }, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { events += "persist-inactive" }, + rollbackInactiveRemoval = { events += "rollback-inactive" }, + ) + } + + assertEquals(listOf("prepare-removal"), events) + } + + @Test + fun recoveredInvalidStoreRemovalAlsoCleansQueuedAccountWork() = runBlocking { + val events = mutableListOf() + + removeRecoveredAndroidAccountCredentialData( + removeQueuedUploads = { events += "remove-queued-work" }, + clearRecoveredAccount = { events += "clear-recovered-account" }, + rollbackRecoveredAccount = { events += "rollback-recovered-account" }, + ) + + assertEquals(listOf("clear-recovered-account", "remove-queued-work"), events) + } + + @Test + fun activeSignOutDeletesQueuedUploadsAfterTheCredentialIsCleared() = runBlocking { + val events = mutableListOf() + + removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { events += "clear-session" }, + rollbackActiveRemoval = { events += "rollback-session" }, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + ) + + assertEquals(listOf("clear-session", "remove-uploads"), events) + } + + @Test + fun failedActiveUploadCleanupKeepsTheCredentialRemovalCommitted() = runBlocking { + val events = mutableListOf() + + removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = { + events += "remove-uploads" + error("synthetic cleanup failure") + }, + clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { events += "persist-inactive" }, + rollbackInactiveRemoval = { events += "rollback-inactive" }, + completeCommittedCleanup = { events += "complete-cleanup" }, + recordCommittedCleanupFailure = { events += "diagnose-cleanup" }, + ) + + assertEquals(listOf("clear-account", "remove-uploads", "diagnose-cleanup"), events) + } + + @Test + fun accountRemovalCleanupAttemptsEveryOwnerBeforeReportingFailure() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + runAndroidAccountRemovalCleanups( + listOf( + { + events += "remove-offline" + error("synthetic offline cleanup failure") + }, + { events += "remove-shares" }, + { events += "remove-uploads" }, + { events += "remove-sync-pairs" }, + ), + ) + } + + assertEquals( + listOf("remove-offline", "remove-shares", "remove-uploads", "remove-sync-pairs"), + events, + ) + } + + @Test + fun failedActiveCredentialRemovalDoesNotStartUploadCleanupAndAttemptsRollback() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { + events += "clear-account" + error("synthetic credential persistence failure") + }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { events += "persist-inactive" }, + rollbackInactiveRemoval = { events += "rollback-inactive" }, + ) + } + + assertEquals(listOf("clear-account", "rollback-active"), events) + } + + @Test + fun cancelledInactiveAccountCleanupKeepsTheCredentialRemovalCommitted() = runBlocking { + val cleanupEntered = CompletableDeferred() + val events = mutableListOf() + val removal = launch { + removeAndroidAccountCredentialData( + active = false, + removeQueuedUploads = { + events += "remove-uploads" + cleanupEntered.complete(Unit) + awaitCancellation() + }, + clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { events += "persist-removal" }, + rollbackInactiveRemoval = { events += "rollback" }, + ) + } + cleanupEntered.await() + + removal.cancelAndJoin() + + assertEquals(listOf("persist-removal", "remove-uploads"), events) + } + + private fun assertDiagnosticsExcludePrivateValues(diagnostics: List) { + val rendered = diagnostics.joinToString() + assertFalse(rendered.contains("private-app-password")) + assertFalse(rendered.contains("second-private-password")) + assertFalse(rendered.contains("alice")) + assertFalse(rendered.contains("cloud.example.test")) + } + + private fun recordingEditor( + commitResult: Boolean, + calls: MutableList, + ): SharedPreferences.Editor = Proxy.newProxyInstance( + SharedPreferences.Editor::class.java.classLoader, + arrayOf(SharedPreferences.Editor::class.java), + ) { proxy, method, _ -> + calls += method.name + when (method.name) { + "commit" -> commitResult + "apply" -> Unit + else -> proxy + } + } as SharedPreferences.Editor + + private fun recoveryRecordingEditor( + writes: MutableMap, + removals: MutableSet, + ): SharedPreferences.Editor = Proxy.newProxyInstance( + SharedPreferences.Editor::class.java.classLoader, + arrayOf(SharedPreferences.Editor::class.java), + ) { proxy, method, arguments -> + val callArguments = arguments.orEmpty() + when (method.name) { + "putString" -> { + writes[callArguments[0] as String] = callArguments[1] as String + proxy + } + "remove" -> { + removals += callArguments[0] as String + proxy + } + "commit" -> true + "apply" -> Unit + else -> proxy + } + } as SharedPreferences.Editor + + private fun legacyPayload(session: NextcloudSession): String = JSONObject() + .put("serverUrl", session.serverUrl) + .put("loginName", session.loginName) + .put("appPassword", session.appPassword) + .toString() + + private fun firstSession() = NextcloudSession( + serverUrl = "https://cloud.example.test", + loginName = "alice", + appPassword = "private-app-password", + ) + + private fun secondSession() = NextcloudSession( + serverUrl = "https://second.example.test/nextcloud", + loginName = "bob", + appPassword = "second-private-password", + ) } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt index 862dca5ff..678ae76f8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt @@ -48,6 +48,20 @@ class NextcloudDocumentIdsTest { ) } + @Test + fun accountWorkIdentityRetainsThePreRegistryRawServerDigest() { + val legacySession = session.copy(serverUrl = "https://CLOUD.EXAMPLE:443/") + + assertEquals( + "c21f46fbb8dbbf9611423baaaf1dd45a", + NextcloudDocumentIds.accountKey(legacySession), + ) + assertEquals( + "c21f46fbb8dbbf9611423baaaf1dd45a664f9593a1d14bb41d486e01b0e54c24", + NextcloudDocumentIds.cacheAccountId(legacySession), + ) + } + @Test fun accountIdentitySeparatesOtherwiseEqualPaths() { val other = session.copy(loginName = "bob") diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt index fc8562de6..13b615351 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt @@ -3,6 +3,9 @@ package dev.obiente.nextcloudnative import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking class NextcloudDocumentsContractTest { @Test @@ -21,4 +24,60 @@ class NextcloudDocumentsContractTest { fun `documents authority rejects a missing application id`() { assertFailsWith { nextcloudDocumentsAuthority(" ") } } + + @Test + fun `account removal rejects retained document writebacks`() { + requireAndroidAccountRemovalWritebacksResolved(resolved = true) + + val failure = assertFailsWith { + requireAndroidAccountRemovalWritebacksResolved(resolved = false) + } + + assertTrue(failure.message.orEmpty().contains("pending document changes")) + } + + @Test + fun `document grant revocation covers reads writes and descendants`() { + assertTrue(NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS and android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) + assertTrue(NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS and android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) + assertTrue(NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS and android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION != 0) + } + + @Test + fun `account removal revokes both document and tree grant scopes`() { + assertEquals( + listOf("document", "tree"), + AndroidAccountDocumentGrantScope.entries.map(AndroidAccountDocumentGrantScope::pathSegment), + ) + } + + @Test + fun `account removal preflight runs before remote credential revocation`() = runBlocking { + var revoked = false + var removed = false + + assertFailsWith { + revokeAndroidSessionAfterRemovalPreflight( + preflight = { error("pending account-owned recovery") }, + revoke = { revoked = true }, + removeLocalAccount = { removed = true }, + ) + } + + assertFalse(revoked) + assertFalse(removed) + } + + @Test + fun `remote revocation and local removal share one ordered operation`() = runBlocking { + val events = mutableListOf() + + revokeAndroidSessionAfterRemovalPreflight( + preflight = { events += "preflight" }, + revoke = { events += "revoke" }, + removeLocalAccount = { events += "remove-local" }, + ) + + assertEquals(listOf("preflight", "revoke", "remove-local"), events) + } } diff --git a/changes/unreleased/172-account-credential-slots.md b/changes/unreleased/172-account-credential-slots.md new file mode 100644 index 000000000..9c6ca4370 --- /dev/null +++ b/changes/unreleased/172-account-credential-slots.md @@ -0,0 +1,7 @@ +category: internal +issue: 172 +pull: 436 +platforms: android, desktop +user-facing: no + +Store bounded credentials for each local account, migrate existing Android and desktop sessions durably, and keep account selection, removal, and background sync aligned across account switches. diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt new file mode 100644 index 000000000..c9fba7504 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt @@ -0,0 +1,32 @@ +package dev.obiente.nextcloudnative.app + +interface NextcloudAccountCredentialServices { + fun loadSession(): NextcloudSession? + + /** Persists and returns the exact session identity published to account-scoped resources. */ + suspend fun saveSession(session: NextcloudSession): NextcloudSession + + suspend fun clearSession() + + /** Lists credential-free local account records without loading their secrets. */ + fun listAccounts(): List = loadSession()?.let { session -> + listOf(session.accountRecord()) + }.orEmpty() + + /** Returns the selected local account identity, or null when no account is selected. */ + fun activeAccountId(): NextcloudAccountId? = loadSession()?.accountId + + /** Loads one account's credentials without changing the active selection. */ + fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = + loadSession()?.takeIf { session -> session.accountId == accountId } + + /** Selects a stored account and returns its session after the selection is durable. */ + suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = loadSession(accountId) + + /** Removes one stored account. The compatibility default supports only the active account. */ + suspend fun removeAccount(accountId: NextcloudAccountId): Boolean { + if (activeAccountId() != accountId) return false + clearSession() + return true + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt index e9c6a28ed..9632a2903 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt @@ -185,7 +185,7 @@ fun encodeNextcloudAccountRegistry(registry: NextcloudAccountRegistry): String = fun decodeNextcloudAccountRegistry(encoded: String): NextcloudAccountRegistry? = (decodeNextcloudAccountRegistryResult(encoded) as? NextcloudAccountRegistryDecodeResult.Valid)?.registry -private fun decodeNextcloudAccountRegistryResult(encoded: String): NextcloudAccountRegistryDecodeResult { +internal fun decodeNextcloudAccountRegistryResult(encoded: String): NextcloudAccountRegistryDecodeResult { val envelopeVersionToken = accountRegistryVersionEnvelope .find(encoded.take(MAX_ACCOUNT_REGISTRY_VERSION_ENVELOPE_CHARACTERS)) ?.groupValues @@ -255,7 +255,7 @@ private enum class AccountRegistryVersionClassification { Malformed, } -private sealed interface NextcloudAccountRegistryDecodeResult { +internal sealed interface NextcloudAccountRegistryDecodeResult { data class Valid(val registry: NextcloudAccountRegistry) : NextcloudAccountRegistryDecodeResult data object Malformed : NextcloudAccountRegistryDecodeResult @@ -288,7 +288,7 @@ private val accountRegistryVersionEnvelope = Regex( private const val ACCOUNT_REGISTRY_VERSION = 1 internal const val MAX_LOCAL_ACCOUNTS = 64 -private const val MAX_ACCOUNT_REGISTRY_BYTES = 256 * 1024 +internal const val MAX_ACCOUNT_REGISTRY_BYTES = 256 * 1024 private const val MAX_ACCOUNT_REGISTRY_VERSION_ENVELOPE_CHARACTERS = 512 internal const val MAX_ACCOUNT_SERVER_URL_LENGTH = 8 * 1024 internal const val MAX_ACCOUNT_LOGIN_NAME_LENGTH = 1024 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 6931dd137..015dba88f 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -574,8 +574,7 @@ fun NextcloudNativeApp( LoginScreen( services = services, onLoggedIn = { authenticated -> - services.saveSession(authenticated) - session = authenticated + session = services.saveSession(authenticated) }, ) } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index 7ba660ad3..c034cb47a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -428,8 +428,7 @@ data class NextcloudPerson( val coverEtag: String?, val backend: String, ) - -interface NextcloudPlatformServices : DeckCardDraftPlatformServices { +interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCardDraftPlatformServices { /** Loads public project news from the fixed Obiente feed, with a bounded platform cache. */ suspend fun loadProjectNews(forceRefresh: Boolean = false): ProjectNewsResult = error("Project news is unavailable on this platform.") @@ -675,12 +674,6 @@ interface NextcloudPlatformServices : DeckCardDraftPlatformServices { targetRecordId: String, ) = Unit - fun loadSession(): NextcloudSession? - - suspend fun saveSession(session: NextcloudSession) - - suspend fun clearSession() - fun openExternalUrl(url: String) /** Opens the one-time browser login URL without blocking the UI dispatcher. */ diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt new file mode 100644 index 000000000..5618aadd2 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -0,0 +1,635 @@ +package dev.obiente.nextcloudnative.app + +import java.util.prefs.Preferences +import kotlinx.coroutines.CancellationException + +internal class DesktopAccountCredentialPersistence( + private val preferences: Preferences, + private val secretStore: DesktopSecretStore, + private val recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, + private val flushPreferences: () -> Unit = preferences::flush, +) { + private val registryStore = DesktopAccountRegistryPreferenceStore(preferences, flushPreferences) + + fun loadActiveSession(): NextcloudSession? { + retryPendingCredentialSave() + retryPendingCredentialRemoval() + retryPendingLegacyCredentialCleanup() + val read = readRegistry() + if (read.registry == null) { + return restoreLegacySession(read) + } + val active = read.registry.activeAccount ?: return null + return loadSession(active.id) + } + + fun listAccounts(): List { + val read = readRegistry() + if (read.registry != null) return read.registry.accounts + if (read.unsupportedVersion) return emptyList() + return readLegacyAccountRecord()?.let(::listOf).orEmpty() + } + + fun activeAccountId(): NextcloudAccountId? { + val read = readRegistry() + if (read.registry != null) return read.registry.activeAccountId + if (read.unsupportedVersion) return null + return readLegacyAccountRecord()?.id + } + + fun loadSession(accountId: NextcloudAccountId): NextcloudSession? { + retryPendingCredentialSave() + retryPendingCredentialRemoval() + retryPendingLegacyCredentialCleanup() + val registry = readRegistry().registry ?: return null + val record = registry.accounts.firstOrNull { account -> account.id == accountId } ?: return null + val secret = loadSecret(desktopAccountSecretReference(accountId)) + if (secret != null) return record.toSession(secret) + + val legacy = loadLegacySession() ?: return null + if (legacy.accountId != accountId || legacy.accountRecord() != record) { + recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_ACTIVE_MISMATCH", "account-credentials.restore") + return null + } + migrateLegacyCredential(legacy) + return legacy + } + + fun saveSession(session: NextcloudSession): NextcloudSession { + retryPendingCredentialSave() + retryPendingCredentialRemoval() + retryPendingLegacyCredentialCleanup() + val read = readRegistry() + val registry = read.registry + ?: restoreLegacySession(read)?.let { requireNotNull(readRegistry().registry) } + ?: if (read.encoded == null) NextcloudAccountRegistry.Empty else throw invalidRegistryForMutation() + val previousRecord = registry.accounts.firstOrNull { account -> account.id == session.accountId } + val persistedSession = previousRecord + ?.let { record -> session.copy(serverUrl = record.serverUrl, loginName = record.loginName) } + ?: session + val updatedRegistry = registry.upsertAndSelect(persistedSession.accountRecord()) + val encodedRegistry = prepareRegistry(updatedRegistry) + val secretReference = desktopAccountSecretReference(persistedSession.accountId) + val previousSecret = loadSecretForRollback(secretReference) + val journalNewCredential = previousRecord == null + if (journalNewCredential) persistPendingCredentialSave(persistedSession) + try { + saveSecret(persistedSession) + persistAccountState(encodedRegistry, updatedRegistry.activeAccount) + } catch (failure: Exception) { + var credentialRollbackCompleted = false + try { + if (previousSecret == null) { + secretStore.clear(secretReference) + } else { + secretStore.save( + secretReference, + previousRecord?.loginName, + previousSecret, + ) + } + credentialRollbackCompleted = true + } catch (rollbackFailure: Exception) { + failure.addSuppressed(rollbackFailure) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.persist", + rollbackFailure, + ) + } + if (journalNewCredential && credentialRollbackCompleted) clearPendingCredentialSave() + throw failure + } + if (journalNewCredential) clearPendingCredentialSave() + return persistedSession + } + + fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? { + retryPendingCredentialSave() + retryPendingLegacyCredentialCleanup() + val registry = readRegistry().registry ?: return null + val session = loadSession(accountId) ?: return null + val selected = requireNotNull(registry.select(accountId)) + persistAccountState(prepareRegistry(selected), selected.activeAccount) + return session + } + + fun removeAccount(accountId: NextcloudAccountId): Boolean { + retryPendingCredentialSave() + retryPendingCredentialRemoval() + retryPendingLegacyCredentialCleanup() + val registry = readRegistry().registry ?: return false + val record = registry.accounts.firstOrNull { account -> account.id == accountId } ?: return false + val clearLegacyCredential = legacyMetadataMatches(record) + val updated = registry.remove(accountId) + persistAccountState( + encodedRegistry = prepareRegistry(updated), + activeAccount = updated.activeAccount, + pendingLegacyCleanupAccount = record.takeIf { clearLegacyCredential }, + pendingCredentialRemoval = accountId, + ) + retryPendingCredentialRemoval() + if (clearLegacyCredential) retryPendingLegacyCredentialCleanup() + return true + } + + private fun restoreLegacySession(read: DesktopRegistryRead): NextcloudSession? { + val legacy = loadLegacySession() + val restored = restoreNextcloudAccountRegistry( + encoded = read.encoded, + legacySession = legacy, + ) + restored.recoveryReason?.diagnosticCode?.let { code -> + recordCredentialDiagnostic(code, "account-registry.restore") + } + if (read.unsupportedVersion) return null + legacy ?: return null + if (!restored.needsPersistence) return legacy + try { + val encodedRegistry = prepareRegistry(restored.registry) + saveSecret(legacy) + persistAccountState( + encodedRegistry, + restored.registry.activeAccount, + pendingLegacyCleanupAccount = legacy.accountRecord(), + ) + } catch (failure: Exception) { + recordCredentialDiagnostic( + code = "ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED", + operation = "account-credentials.migrate", + failure = failure, + ) + return legacy + } + clearLegacyCredentialAfterMigration(legacy) + return legacy + } + + private fun loadLegacySession(): NextcloudSession? { + val server = preferences.get(KEY_SERVER, null) ?: return null + val login = preferences.get(KEY_LOGIN, null) ?: return null + val password = loadSecret(desktopSessionSecretReference(server, login)) ?: return null + return NextcloudSession(server, login, password) + } + + private fun readLegacyAccountRecord(): NextcloudAccountRecord? { + val server = preferences.get(KEY_SERVER, null) ?: return null + val login = preferences.get(KEY_LOGIN, null) ?: return null + return runCatching { NextcloudSession(server, login, appPassword = "").accountRecord() }.getOrNull() + } + + private fun migrateLegacyCredential(session: NextcloudSession) { + persistPendingLegacyCredentialCleanup(session) + saveSecret(session) + clearLegacyCredentialAfterMigration(session) + } + + private fun clearLegacyCredentialAfterMigration(session: NextcloudSession) { + retryPendingLegacyCredentialCleanup(session) + } + + private fun retryPendingCredentialSave() { + val server = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_SERVER, null) + val login = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, null) + if (server == null && login == null) return + if (server.isNullOrBlank() || login.isNullOrBlank()) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + ) + return + } + val accountId = try { + deriveNextcloudAccountId(server, login) + } catch (failure: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + failure, + ) + return + } + val registryRead = readRegistry() + if (registryRead.encoded != null && registryRead.registry == null) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + ) + return + } + val credentialCommitted = registryRead.registry + ?.accounts + ?.any { account -> account.id == accountId } == true + if (!credentialCommitted) { + try { + secretStore.clear(desktopAccountSecretReference(accountId)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + failure, + ) + return + } + } + clearPendingCredentialSave() + } + + private fun retryPendingCredentialRemoval() { + pendingCredentialRemovalIds().forEach { accountId -> + val registry = readRegistry().registry + if (registry == null) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_REMOVAL_JOURNAL_INVALID", + "account-credentials.recover", + ) + return@forEach + } + if (registry.accounts.any { account -> account.id == accountId }) { + clearPendingCredentialRemoval(accountId) + return@forEach + } + try { + secretStore.clear(desktopAccountSecretReference(accountId)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_CLEAR_FAILED", + "account-credentials.remove", + failure, + ) + return@forEach + } + clearPendingCredentialRemoval(accountId) + } + } + + private fun pendingCredentialRemovalIds(): Set { + val encoded = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null) ?: return emptySet() + if (encoded.isBlank()) return emptySet() + return encoded.split(',').mapNotNullTo(linkedSetOf()) { storageKey -> + try { + NextcloudAccountId(storageKey) + } catch (_: IllegalArgumentException) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_REMOVAL_JOURNAL_INVALID", + "account-credentials.recover", + ) + null + } + } + } + + private fun clearPendingCredentialRemoval(accountId: NextcloudAccountId) { + val previous = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null) + val remaining = pendingCredentialRemovalIds() - accountId + try { + preferences.putOrRemove( + KEY_PENDING_CREDENTIAL_REMOVALS, + if (remaining.isEmpty()) null else remaining.joinToString(",") { pending -> pending.storageKey }, + ) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_REMOVALS, previous) + runCatching(flushPreferences) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_CLEAR_FAILED", + "account-credentials.recover", + failure, + ) + } + } + + private fun persistPendingCredentialSave(session: NextcloudSession) { + val previousServer = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_SERVER, null) + val previousLogin = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, null) + try { + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_SERVER, session.serverUrl) + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, session.loginName) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_SERVER, previousServer) + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, previousLogin) + runCatching(flushPreferences) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", + "account-credentials.persist", + failure, + ) + throw failure + } + } + + private fun clearPendingCredentialSave() { + val server = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_SERVER, null) + val login = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, null) + if (server == null && login == null) return + try { + preferences.remove(KEY_PENDING_CREDENTIAL_SAVE_SERVER) + preferences.remove(KEY_PENDING_CREDENTIAL_SAVE_LOGIN) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_SERVER, server) + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, login) + runCatching(flushPreferences) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + failure, + ) + } + } + + private fun retryPendingLegacyCredentialCleanup(expected: NextcloudSession? = null) { + val server = preferences.get(KEY_PENDING_LEGACY_CLEANUP_SERVER, null) + val login = preferences.get(KEY_PENDING_LEGACY_CLEANUP_LOGIN, null) + if (server == null && login == null) return + if (server.isNullOrBlank() || login.isNullOrBlank()) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", + "account-credentials.migrate", + ) + return + } + if (expected != null && (expected.serverUrl != server || expected.loginName != login)) return + val cleanupAllowed = try { + val accountId = deriveNextcloudAccountId(server, login) + val registry = readRegistry().registry + registry?.accounts?.none { account -> account.id == accountId } == true || + loadSecret(desktopAccountSecretReference(accountId)) != null + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", + "account-credentials.migrate", + ) + false + } + if (!cleanupAllowed) return + try { + secretStore.clear(desktopSessionSecretReference(server, login)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", + "account-credentials.migrate", + ) + return + } + try { + preferences.remove(KEY_PENDING_LEGACY_CLEANUP_SERVER) + preferences.remove(KEY_PENDING_LEGACY_CLEANUP_LOGIN) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + preferences.put(KEY_PENDING_LEGACY_CLEANUP_SERVER, server) + preferences.put(KEY_PENDING_LEGACY_CLEANUP_LOGIN, login) + try { + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // The in-memory marker remains available for another retry in this process. + } + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", + "account-credentials.migrate", + ) + } + } + + private fun persistPendingLegacyCredentialCleanup(session: NextcloudSession) { + val previousServer = preferences.get(KEY_PENDING_LEGACY_CLEANUP_SERVER, null) + val previousLogin = preferences.get(KEY_PENDING_LEGACY_CLEANUP_LOGIN, null) + try { + preferences.put(KEY_PENDING_LEGACY_CLEANUP_SERVER, session.serverUrl) + preferences.put(KEY_PENDING_LEGACY_CLEANUP_LOGIN, session.loginName) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_SERVER, previousServer) + preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_LOGIN, previousLogin) + try { + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // The next load will retry from the last durable marker state. + } + throw DesktopSecretDeletionRecoveryUnavailableException(failure) + } + } + + private fun saveSecret(session: NextcloudSession) { + try { + secretStore.save( + reference = desktopAccountSecretReference(session.accountId), + username = session.loginName, + secret = session.appPassword.encodeToByteArray(), + ) + } catch (failure: Exception) { + recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", "account-credentials.persist") + throw failure + } + } + + private fun loadSecret(reference: DesktopSecretReference): String? = try { + secretStore.load(reference) + ?.decodeToString() + ?.takeIf(String::isNotBlank) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: NextcloudSessionStorageUnavailableException) { + recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_STORE_READ_FAILED", "account-credentials.restore") + throw failure + } catch (failure: Exception) { + recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_STORE_READ_FAILED", "account-credentials.restore") + throw DesktopSecretStoreUnavailableException( + "The desktop secure credential store could not be read.", + cause = failure, + ) + } + + private fun loadSecretForRollback(reference: DesktopSecretReference): ByteArray? = try { + secretStore.load(reference) + } catch (failure: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_READ_FAILED", + "account-credentials.persist", + failure, + ) + throw failure + } + + private fun clearSecret(reference: DesktopSecretReference) { + try { + secretStore.clear(reference) + } catch (failure: Exception) { + recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_STORE_CLEAR_FAILED", "account-credentials.remove") + throw failure + } + } + + private fun readRegistry(): DesktopRegistryRead { + val encoded = registryStore.read() + val decoded = encoded?.let(::decodeNextcloudAccountRegistryResult) + return DesktopRegistryRead( + encoded = encoded, + registry = (decoded as? NextcloudAccountRegistryDecodeResult.Valid)?.registry, + unsupportedVersion = decoded == NextcloudAccountRegistryDecodeResult.UnsupportedVersion, + ) + } + + private fun prepareRegistry(registry: NextcloudAccountRegistry): String = + encodeNextcloudAccountRegistry(registry) + + private fun persistAccountState( + encodedRegistry: String, + activeAccount: NextcloudAccountRecord?, + pendingLegacyCleanupAccount: NextcloudAccountRecord? = null, + pendingCredentialRemoval: NextcloudAccountId? = null, + ) { + val previous = DesktopAccountPreferenceSnapshot( + registry = registryStore.read(), + server = preferences.get(KEY_SERVER, null), + login = preferences.get(KEY_LOGIN, null), + pendingLegacyCleanupServer = preferences.get(KEY_PENDING_LEGACY_CLEANUP_SERVER, null), + pendingLegacyCleanupLogin = preferences.get(KEY_PENDING_LEGACY_CLEANUP_LOGIN, null), + pendingCredentialRemovals = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null), + ) + try { + registryStore.write(encodedRegistry) + preferences.putOrRemove(KEY_SERVER, activeAccount?.serverUrl) + preferences.putOrRemove(KEY_LOGIN, activeAccount?.loginName) + pendingLegacyCleanupAccount?.let { account -> + preferences.put(KEY_PENDING_LEGACY_CLEANUP_SERVER, account.serverUrl) + preferences.put(KEY_PENDING_LEGACY_CLEANUP_LOGIN, account.loginName) + } + pendingCredentialRemoval?.let { accountId -> + val removals = pendingCredentialRemovalIds() + accountId + preferences.put( + KEY_PENDING_CREDENTIAL_REMOVALS, + removals.joinToString(",") { pending -> pending.storageKey }, + ) + } + flushPreferences() + } catch (failure: Exception) { + runCatching { previous.restore(preferences, registryStore) } + runCatching(flushPreferences) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", + "account-credentials.persist", + failure, + ) + throw failure + } + } + + private fun legacyMetadataMatches(record: NextcloudAccountRecord): Boolean = + preferences.get(KEY_SERVER, null) == record.serverUrl && + preferences.get(KEY_LOGIN, null) == record.loginName + + private fun invalidRegistryForMutation(): IllegalStateException { + recordCredentialDiagnostic("ACCOUNT_REGISTRY_MALFORMED", "account-registry.persist") + return IllegalStateException("The local account registry is invalid.") + } + + private fun recordCredentialDiagnostic( + code: String, + operation: String, + failure: Throwable? = null, + ) { + recordDiagnostic( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Authentication, + operation = operation, + outcome = "failed", + code = code, + exception = failure?.toNonSecretSupportDiagnosticExceptionDraft(), + ), + ) + } + + private data class DesktopRegistryRead( + val encoded: String?, + val registry: NextcloudAccountRegistry?, + val unsupportedVersion: Boolean, + ) + + private data class DesktopAccountPreferenceSnapshot( + val registry: String?, + val server: String?, + val login: String?, + val pendingLegacyCleanupServer: String?, + val pendingLegacyCleanupLogin: String?, + val pendingCredentialRemovals: String?, + ) { + fun restore(preferences: Preferences, registryStore: DesktopAccountRegistryPreferenceStore) { + registryStore.write(registry) + preferences.putOrRemove(KEY_SERVER, server) + preferences.putOrRemove(KEY_LOGIN, login) + preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_SERVER, pendingLegacyCleanupServer) + preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_LOGIN, pendingLegacyCleanupLogin) + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_REMOVALS, pendingCredentialRemovals) + } + } + + private companion object { + const val KEY_SERVER = "server" + const val KEY_LOGIN = "login" + const val KEY_PENDING_LEGACY_CLEANUP_SERVER = "accountLegacyCleanupServer" + const val KEY_PENDING_LEGACY_CLEANUP_LOGIN = "accountLegacyCleanupLogin" + const val KEY_PENDING_CREDENTIAL_SAVE_SERVER = "accountCredentialSaveServer" + const val KEY_PENDING_CREDENTIAL_SAVE_LOGIN = "accountCredentialSaveLogin" + const val KEY_PENDING_CREDENTIAL_REMOVALS = "accountCredentialRemovals" + } +} + +private fun Preferences.putOrRemove(key: String, value: String?) { + if (value == null) remove(key) else put(key, value) +} + +private fun NextcloudAccountRecord.toSession(appPassword: String) = NextcloudSession( + serverUrl = serverUrl, + loginName = loginName, + appPassword = appPassword, +) + +internal fun desktopFileCacheAccountId(account: NextcloudAccountRecord): String = + desktopFileCacheAccountId(account.toSession(appPassword = "")) + +internal class DesktopAccountSessionPublication( + private val registerPrivateValue: (String) -> Unit, + private val publishAccountIdentity: (String) -> Unit, +) { + fun register(session: NextcloudSession) { + listOf(session.serverUrl, session.loginName, session.appPassword).forEach(registerPrivateValue) + } + + fun publish(session: NextcloudSession) { + register(session) + publishAccountIdentity(desktopFileCacheAccountId(session)) + } +} + +internal fun desktopAccountSelectionBlockedDiagnostic() = SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Authentication, + operation = "account.select", + outcome = "blocked", + code = "ACCOUNT_SELECTION_ACTIVE_RESOURCES", +) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt new file mode 100644 index 000000000..de81e1f28 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -0,0 +1,148 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class DesktopAccountOperationGuard { + private val accountMutationMutex = Mutex() + private val syncRunMutex = Mutex() + private val resourceActivationMonitor = Any() + private var accountMutationActive = false + + suspend fun serialize(action: suspend () -> Result): Result = + accountMutationMutex.withLock { + synchronized(resourceActivationMonitor) { accountMutationActive = true } + try { + action() + } finally { + synchronized(resourceActivationMonitor) { accountMutationActive = false } + } + } + + suspend fun serializeWhenSyncIdle(action: suspend () -> Result): Result = serialize { + withSyncRunLock(action) + } + + suspend fun serializeResourceActivation(action: suspend () -> Result): Result = serialize(action) + + fun tryActivateResource(action: () -> Boolean): Boolean = synchronized(resourceActivationMonitor) { + !accountMutationActive && action() + } + + suspend fun withSyncRunLock(action: suspend () -> Result): Result = syncRunMutex.withLock { action() } +} + +internal class DesktopSessionPublicationGuard { + private val monitor = Any() + + fun serialize(action: () -> Result): Result = synchronized(monitor, action) +} + +internal fun closeVirtualFileProviderForReplacement( + provider: AutoCloseable?, + detach: () -> Unit, +): Throwable? = runCatching { provider?.close() } + .onSuccess { detach() } + .exceptionOrNull() + +internal fun desktopAccountDiagnosticFields(accountId: String?): List = + accountId?.let { + listOf( + SupportDiagnosticFieldDraft("account", it, SupportDiagnosticValuePrivacy.Identifier), + ) + }.orEmpty() + +internal fun desktopSessionSaveSwitchesAccount( + activeAccountId: NextcloudAccountId?, + savedAccountId: NextcloudAccountId, +): Boolean = activeAccountId != null && activeAccountId != savedAccountId + +internal fun desktopSessionSaveReplacesActiveCredential( + activeSession: NextcloudSession?, + savedSession: NextcloudSession, +): Boolean = activeSession?.accountId == savedSession.accountId && + activeSession.appPassword != savedSession.appPassword + +internal fun desktopResourceActivationMatchesActiveSession( + activeSession: NextcloudSession?, + requestedSession: NextcloudSession, +): Boolean = activeSession == requestedSession + +internal fun desktopResourceDeactivationTargetsCurrentProvider( + activeSession: NextcloudSession?, + requestedSession: NextcloudSession, + providerAccountId: String?, +): Boolean = desktopResourceActivationMatchesActiveSession(activeSession, requestedSession) && + providerAccountId == desktopFileCacheAccountId(requestedSession) + +internal fun desktopSyncRunMatchesActiveSession( + activeSession: NextcloudSession?, + requestedSession: NextcloudSession, +): Boolean = activeSession == requestedSession + +internal suspend fun DesktopAccountOperationGuard.withAuthenticatedMutationSession( + expectedSession: NextcloudSession, + resolveSession: suspend () -> NextcloudSession?, + action: suspend (NextcloudSession) -> Result, +): Result = serialize { + val current = resolveSession() + check(desktopSyncRunMatchesActiveSession(current, expectedSession)) { + "The account changed before the authenticated change could be sent." + } + action(requireNotNull(current)) +} + +internal fun requireDesktopAccountRemovalWritebacksResolved(pendingWritebackCount: Int) { + check(pendingWritebackCount == 0) { + "Finish or discard pending virtual file changes before removing this account." + } +} + +internal fun removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled: Boolean, + clearProviderPreference: () -> Unit, + restoreProviderPreference: (Boolean) -> Unit, + removalCommitted: () -> Boolean = { false }, + removeCredential: () -> Boolean, +): Boolean { + clearProviderPreference() + return try { + removeCredential().also { removed -> + if (!removed) restoreProviderPreference(providerWasEnabled) + } + } catch (failure: Throwable) { + val committed = runCatching(removalCommitted).getOrElse { statusFailure -> + failure.addSuppressed(statusFailure) + true + } + if (!committed) { + runCatching { restoreProviderPreference(providerWasEnabled) } + .exceptionOrNull() + ?.let(failure::addSuppressed) + } + throw failure + } +} + +internal fun requireDesktopSessionSaveAllowed( + allowed: Boolean, + recordBlocked: (SupportDiagnosticEventDraft) -> Unit, +) { + if (allowed) return + recordBlocked(desktopAccountSelectionBlockedDiagnostic()) + error("Close files and virtual folders before switching accounts or replacing credentials.") +} + +internal inline fun reopenDesktopSessionAfterSelection( + selected: Session?, + reopen: () -> Unit, +): Session? = selected.also { if (it != null) reopen() } + +internal suspend inline fun restartDesktopSyncAfterSelection( + select: () -> Session?, + restart: () -> Unit, +): Session? = try { + select() +} finally { + restart() +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt index 24d20eb4c..c3ee677bd 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt @@ -7,7 +7,8 @@ internal fun restoreDesktopAccountRegistry( session: NextcloudSession, recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, ) { - val restored = restoreNextcloudAccountRegistry(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null), session) + val registryStore = DesktopAccountRegistryPreferenceStore(preferences) + val restored = restoreNextcloudAccountRegistry(registryStore.read(), session) restored.recoveryReason?.let { reason -> recordDiagnostic( SupportDiagnosticEventDraft( @@ -45,19 +46,14 @@ internal fun prepareDesktopAccountRegistry(session: NextcloudSession): String = prepareDesktopAccountRegistry(singleAccountRegistry(session)) internal fun prepareDesktopAccountRegistry(registry: NextcloudAccountRegistry): String = - encodeNextcloudAccountRegistry(registry).also { encoded -> - require(encoded.length <= Preferences.MAX_VALUE_LENGTH) { - "The account registry exceeds the desktop preference value limit." - } - } + encodeNextcloudAccountRegistry(registry) internal fun persistDesktopAccountRegistry(preferences: Preferences, encodedRegistry: String) { - require(encodedRegistry.length <= Preferences.MAX_VALUE_LENGTH) - preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodedRegistry) + DesktopAccountRegistryPreferenceStore(preferences).write(encodedRegistry) } internal fun clearDesktopAccountRegistry(preferences: Preferences) { - preferences.remove(DESKTOP_ACCOUNT_REGISTRY_KEY) + DesktopAccountRegistryPreferenceStore(preferences).write(null) } internal const val DESKTOP_ACCOUNT_REGISTRY_KEY = "account_registry_v1" diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPreferenceStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPreferenceStore.kt new file mode 100644 index 000000000..7ba56d23f --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPreferenceStore.kt @@ -0,0 +1,117 @@ +package dev.obiente.nextcloudnative.app + +import java.util.prefs.Preferences + +/** + * Stores account metadata without exceeding the per-value limit of [Preferences]. + * + * Small registries retain the original single-value format. Larger registries are written to an + * inactive chunk generation before one pointer switches readers to the complete new value. + */ +internal class DesktopAccountRegistryPreferenceStore( + private val preferences: Preferences, + private val flushPreferences: () -> Unit = preferences::flush, +) { + @Synchronized + fun read(): String? { + val generation = preferences.get(KEY_ACTIVE_GENERATION, null) + ?: return preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null) + if (generation != GENERATION_A && generation != GENERATION_B) return MALFORMED_REGISTRY + val chunkCount = preferences.getInt(countKey(generation), -1) + if (chunkCount !in 1..MAX_CHUNKS) return MALFORMED_REGISTRY + val encoded = buildString { + repeat(chunkCount) { index -> + val chunk = preferences.get(chunkKey(generation, index), null) + ?: return MALFORMED_REGISTRY + if (chunk.length > CHUNK_CHARACTER_LIMIT) return MALFORMED_REGISTRY + append(chunk) + } + } + return encoded.takeIf { value -> value.encodeToByteArray().size <= MAX_ACCOUNT_REGISTRY_BYTES } + ?: MALFORMED_REGISTRY + } + + @Synchronized + fun write(encoded: String?) { + if (encoded == null) { + clear() + } else if (encoded.length <= Preferences.MAX_VALUE_LENGTH) { + writeSingleValue(encoded) + } else { + writeChunked(encoded) + } + } + + private fun writeSingleValue(encoded: String) { + require(encoded.encodeToByteArray().size <= MAX_ACCOUNT_REGISTRY_BYTES) + val previousGeneration = preferences.get(KEY_ACTIVE_GENERATION, null) + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encoded) + flushPreferences() + if (previousGeneration == null) return + preferences.remove(KEY_ACTIVE_GENERATION) + flushPreferences() + clearGenerationBestEffort(GENERATION_A) + clearGenerationBestEffort(GENERATION_B) + } + + private fun writeChunked(encoded: String) { + require(encoded.encodeToByteArray().size <= MAX_ACCOUNT_REGISTRY_BYTES) + val previousGeneration = preferences.get(KEY_ACTIVE_GENERATION, null) + val targetGeneration = if (previousGeneration == GENERATION_A) GENERATION_B else GENERATION_A + val chunks = encoded.chunked(CHUNK_CHARACTER_LIMIT) + require(chunks.size in 1..MAX_CHUNKS) + + clearGeneration(targetGeneration) + chunks.forEachIndexed { index, chunk -> + preferences.put(chunkKey(targetGeneration, index), chunk) + } + preferences.putInt(countKey(targetGeneration), chunks.size) + flushPreferences() + + preferences.put(KEY_ACTIVE_GENERATION, targetGeneration) + preferences.remove(DESKTOP_ACCOUNT_REGISTRY_KEY) + flushPreferences() + + previousGeneration + ?.takeIf { generation -> generation != targetGeneration } + ?.let(::clearGenerationBestEffort) + } + + private fun clear() { + val hadActiveGeneration = preferences.get(KEY_ACTIVE_GENERATION, null) != null + val hadSingleValue = preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null) != null + if (!hadActiveGeneration && !hadSingleValue) return + preferences.remove(DESKTOP_ACCOUNT_REGISTRY_KEY) + preferences.remove(KEY_ACTIVE_GENERATION) + flushPreferences() + clearGenerationBestEffort(GENERATION_A) + clearGenerationBestEffort(GENERATION_B) + } + + private fun clearGenerationBestEffort(generation: String) { + runCatching { + clearGeneration(generation) + flushPreferences() + } + } + + private fun clearGeneration(generation: String) { + preferences.remove(countKey(generation)) + repeat(MAX_CHUNKS) { index -> preferences.remove(chunkKey(generation, index)) } + } + + private fun countKey(generation: String) = "$KEY_GENERATION_PREFIX.$generation.count" + + private fun chunkKey(generation: String, index: Int) = + "$KEY_GENERATION_PREFIX.$generation.${index.toString().padStart(2, '0')}" + + private companion object { + const val KEY_ACTIVE_GENERATION = "account_registry_v2_active" + const val KEY_GENERATION_PREFIX = "account_registry_v2" + const val GENERATION_A = "a" + const val GENERATION_B = "b" + const val CHUNK_CHARACTER_LIMIT = 8_000 + const val MAX_CHUNKS = (MAX_ACCOUNT_REGISTRY_BYTES / CHUNK_CHARACTER_LIMIT) + 1 + const val MALFORMED_REGISTRY = "{malformed-chunked-account-registry" + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt new file mode 100644 index 000000000..af507dd66 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -0,0 +1,220 @@ +package dev.obiente.nextcloudnative.app + +import java.util.prefs.Preferences +import kotlinx.coroutines.CancellationException + +internal enum class DesktopAccountSyncPairCleanupPhase { + Prepared, + Committed, +} + +internal data class DesktopAccountSyncPairCleanup( + val accountId: String, + val phase: DesktopAccountSyncPairCleanupPhase, +) + +internal class DesktopAccountSyncPairCleanupJournal( + private val preferences: Preferences, +) { + fun prepare(accountId: String) = persist(accountId, DesktopAccountSyncPairCleanupPhase.Prepared) + + fun commit(accountId: String) = persist(accountId, DesktopAccountSyncPairCleanupPhase.Committed) + + fun clear(accountId: String) { + validateDesktopSyncPairCleanupAccountId(accountId) + preferences.remove(cleanupKey(accountId)) + preferences.flush() + } + + fun pending(): List = preferences.keys() + .asSequence() + .filter { key -> key.startsWith(KEY_PREFIX) } + .map { key -> + val accountId = key.removePrefix(KEY_PREFIX) + validateDesktopSyncPairCleanupAccountId(accountId) + val phase = when (preferences.get(key, null)) { + PREPARED -> DesktopAccountSyncPairCleanupPhase.Prepared + COMMITTED -> DesktopAccountSyncPairCleanupPhase.Committed + else -> error("The desktop account sync cleanup journal is invalid.") + } + DesktopAccountSyncPairCleanup(accountId, phase) + } + .toList() + .also { cleanups -> + check(cleanups.size <= MAX_LOCAL_ACCOUNTS) { + "The desktop account sync cleanup journal is too large." + } + } + + private fun persist(accountId: String, phase: DesktopAccountSyncPairCleanupPhase) { + validateDesktopSyncPairCleanupAccountId(accountId) + val pending = pending() + check(pending.any { cleanup -> cleanup.accountId == accountId } || pending.size < MAX_LOCAL_ACCOUNTS) { + "The desktop account sync cleanup journal is too large." + } + preferences.put( + cleanupKey(accountId), + if (phase == DesktopAccountSyncPairCleanupPhase.Prepared) PREPARED else COMMITTED, + ) + preferences.flush() + } + + private fun cleanupKey(accountId: String): String = "$KEY_PREFIX$accountId".also { key -> + check(key.length <= Preferences.MAX_KEY_LENGTH) + } + + private companion object { + const val KEY_PREFIX = "fsac." + const val PREPARED = "prepared" + const val COMMITTED = "committed" + } +} + +private fun validateDesktopSyncPairCleanupAccountId(accountId: String) { + require(accountId.length == 64 && accountId.all { character -> + character in '0'..'9' || character in 'a'..'f' + }) { "The desktop account sync cleanup identity is invalid." } +} + +internal fun requireDesktopAccountRemovalReady(accountId: String, linuxDesktop: Boolean) { + if (linuxDesktop) { + requireDesktopAccountRemovalWritebacksResolved( + defaultDesktopLinuxWritebackStore(accountId).pendingWritebacks().size, + ) + } +} + +internal fun removeDesktopAccountCredential( + preferences: Preferences, + providerAccountId: String?, + credentialStillExists: () -> Boolean, + removeCredential: () -> Boolean, +): Boolean { + val providerKey = providerAccountId?.let(::virtualFileProviderPreferenceKey) + val providerWasEnabled = providerKey?.let { key -> preferences.getBoolean(key, false) } == true + return removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = providerWasEnabled, + clearProviderPreference = { + providerKey?.let(preferences::remove) + preferences.flush() + }, + restoreProviderPreference = { enabled -> + providerKey?.let { key -> + if (enabled) preferences.putBoolean(key, true) else preferences.remove(key) + } + preferences.flush() + }, + removalCommitted = { !credentialStillExists() }, + removeCredential = removeCredential, + ) +} + +internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( + accountId: String, + prepareCleanup: suspend (String) -> Unit, + commitCleanup: suspend (String) -> Unit, + clearCleanup: suspend (String) -> Unit, + accountStillExists: (String) -> Boolean, + removeCredential: suspend () -> Boolean, + removeSyncPairs: suspend () -> Unit, + recordCleanupFailure: suspend (Exception) -> Unit, +): Boolean { + prepareCleanup(accountId) + val removed = try { + removeCredential() + } catch (failure: Throwable) { + runCatching { + if (accountStillExists(accountId)) clearCleanup(accountId) else commitCleanup(accountId) + }.exceptionOrNull()?.let(failure::addSuppressed) + throw failure + } + if (!removed) { + clearCleanup(accountId) + return false + } + try { + commitCleanup(accountId) + removeSyncPairs() + clearCleanup(accountId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + runCatching { recordCleanupFailure(failure) } + } + return true +} + +internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( + accountId: String?, + cleanupJournal: DesktopAccountSyncPairCleanupJournal, + accountStillExists: (String) -> Boolean, + commitRemoval: suspend () -> Unit, + removeSyncPairs: suspend (String) -> Unit, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, +) { + if (accountId == null) { + commitRemoval() + return + } + removeDesktopAccountBeforeSyncPairCleanup( + accountId = accountId, + prepareCleanup = cleanupJournal::prepare, + commitCleanup = cleanupJournal::commit, + clearCleanup = cleanupJournal::clear, + accountStillExists = accountStillExists, + removeCredential = { + commitRemoval() + true + }, + removeSyncPairs = { removeSyncPairs(accountId) }, + recordCleanupFailure = { failure -> + recordDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(accountId, failure)) + }, + ) +} + +internal suspend fun retryDesktopAccountSyncPairCleanup( + cleanup: DesktopAccountSyncPairCleanup, + accountStillExists: (String) -> Boolean, + removeSyncPairs: suspend (String) -> Unit, + clearCleanup: suspend (String) -> Unit, +) { + if (cleanup.phase == DesktopAccountSyncPairCleanupPhase.Prepared && accountStillExists(cleanup.accountId)) { + clearCleanup(cleanup.accountId) + return + } + removeSyncPairs(cleanup.accountId) + clearCleanup(cleanup.accountId) +} + +internal suspend fun retryPendingDesktopAccountSyncPairCleanups( + cleanupJournal: DesktopAccountSyncPairCleanupJournal, + accountStillExists: (String) -> Boolean, + removeSyncPairs: suspend (String) -> Unit, + recordCleanupFailure: (String, Exception) -> Unit, +) { + cleanupJournal.pending().forEach { cleanup -> + try { + retryDesktopAccountSyncPairCleanup( + cleanup, + accountStillExists, + removeSyncPairs, + cleanupJournal::clear, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + runCatching { recordCleanupFailure(cleanup.accountId, failure) } + } + } +} + +internal fun desktopAccountSyncPairCleanupFailureDiagnostic(accountId: String, failure: Exception) = + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.Sync, + operation = "account.remove-sync-cleanup", + outcome = "failed", + fields = desktopAccountDiagnosticFields(accountId), + exception = failure.toSupportDiagnosticExceptionDraft(), + ) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountSecretReference.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountSecretReference.kt new file mode 100644 index 000000000..12c3806b4 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountSecretReference.kt @@ -0,0 +1,13 @@ +package dev.obiente.nextcloudnative.app + +internal fun desktopAccountSecretReference(accountId: NextcloudAccountId): DesktopSecretReference = + DesktopSecretReference( + targetName = "Obiente/NextcloudNative/session/v2/${accountId.storageKey}", + label = "Nextcloud Native account credential", + attributes = linkedMapOf( + "application" to "dev.obiente.nextcloudnative", + "purpose" to "account-session", + "account" to accountId.storageKey, + "schema" to "2", + ), + ) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCachePolicy.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCachePolicy.kt new file mode 100644 index 000000000..b65726ed4 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCachePolicy.kt @@ -0,0 +1,52 @@ +package dev.obiente.nextcloudnative.app + +internal suspend fun executeDesktopDynamicApiGet( + accountId: String, + requestIdentity: String, + cachePolicy: NextcloudApiCachePolicy, + coalescer: DynamicApiRequestCoalescer, + loadCached: () -> NextcloudApiResponse?, + invalidateCached: () -> Unit, + executeNetwork: suspend () -> NextcloudApiResponse, + commit: (NextcloudApiResponse) -> Unit, +): NextcloudApiResponse { + when (cachePolicy) { + NextcloudApiCachePolicy.PreferCache -> loadCached()?.let { return it } + NextcloudApiCachePolicy.RefreshNetwork -> + coalescer.invalidateRequest(accountId, requestIdentity) {} + NextcloudApiCachePolicy.ForceNetwork -> + coalescer.invalidateRequest(accountId, requestIdentity, invalidateCached) + } + return coalescer.execute( + accountId = accountId, + requestIdentity = requestIdentity, + load = { + if (cachePolicy != NextcloudApiCachePolicy.PreferCache) { + executeNetwork() + } else { + loadCached() ?: executeNetwork() + } + }, + commit = commit, + ) +} + +internal fun combinedAutomaticCacheExcess( + maximumBytes: Long, + completeFileBytes: Long, + rangeBytes: Long, + windowsCachedBytes: Long, + windowsPinnedBytes: Long, +): Long { + require(maximumBytes > 0L) + require(listOf(completeFileBytes, rangeBytes, windowsCachedBytes, windowsPinnedBytes).all { it >= 0L }) + require(windowsPinnedBytes <= windowsCachedBytes) + val total = listOf( + completeFileBytes, + rangeBytes, + windowsCachedBytes - windowsPinnedBytes, + ).fold(0L) { accumulated, bytes -> + if (bytes > Long.MAX_VALUE - accumulated) Long.MAX_VALUE else accumulated + bytes + } + return (total - maximumBytes).coerceAtLeast(0L) +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt new file mode 100644 index 000000000..d7fc25da2 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt @@ -0,0 +1,31 @@ +package dev.obiente.nextcloudnative.app + +internal fun DesktopFileSyncStore.requireDesktopFileSyncAccountRemovalReady(accountId: String) { + require(accountId.isNotBlank() && accountId.length <= 256) + withExclusiveAccess { + check( + load().coordinator.pairs + .filter { pair -> pair.accountId == accountId } + .none { pair -> fileSyncOwnedUploads(pair).isNotEmpty() }, + ) { "Owned remote upload state must be recovered before removing this account." } + } +} + +internal fun DesktopFileSyncStore.removeDesktopFileSyncAccountPairs(accountId: String) { + require(accountId.isNotBlank() && accountId.length <= 256) + withExclusiveAccess { + val current = load() + val removed = current.coordinator.pairs.filter { pair -> pair.accountId == accountId } + check(removed.none { pair -> fileSyncOwnedUploads(pair).isNotEmpty() }) + val retainedRootIds = current.coordinator.pairs.asSequence() + .filterNot { pair -> pair.accountId == accountId } + .mapTo(mutableSetOf(), FileSyncPair::localRootId) + removed.forEach { pair -> + deletePair( + pairId = pair.id, + rootId = pair.localRootId, + deleteRoot = pair.localRootId !in retainedRootIds, + ) + } + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt index 85ab5173a..785976bbd 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext - /** Durable manual desktop executor. The common coordinator owns all planning and conflict rules. */ internal class DesktopFileSyncEngine( private val store: DesktopFileSyncStore = DesktopFileSyncStore(), @@ -23,7 +22,6 @@ internal class DesktopFileSyncEngine( ) { private val selectedRoots = ConcurrentHashMap() private val lock = Mutex() - suspend fun chooseLocalRoot(initialRootHint: String?): FileSyncLocalRoot? = withContext(Dispatchers.IO) { val initialDirectory = initialRootHint?.let(selectedRoots::get)?.takeIf(File::isDirectory) val chosen = folderPicker.choose(initialDirectory) ?: return@withContext null @@ -216,7 +214,10 @@ internal class DesktopFileSyncEngine( FileSyncCenterActionResult.Completed("Folder sync pair removed. No local or server files were deleted.") } } - + suspend fun removeAccountPairs(accountId: String) = lock.withLock { store.removeDesktopFileSyncAccountPairs(accountId) } + suspend fun requireAccountRemovalReady(accountId: String) = lock.withLock { + store.requireDesktopFileSyncAccountRemovalReady(accountId) + } suspend fun runPair( session: NextcloudSession, userId: String, @@ -826,7 +827,6 @@ internal class DesktopFileSyncEngine( private fun filesMatch(first: File, second: File): Boolean = first.length() == second.length() && Files.mismatch(first.toPath(), second.toPath()) == -1L - private fun synchronizedResult( path: String, local: DesktopFileSyncLocalTree, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileVersionDav.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileVersionDav.kt new file mode 100644 index 000000000..b68646405 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileVersionDav.kt @@ -0,0 +1,81 @@ +package dev.obiente.nextcloudnative.app + +import java.io.ByteArrayInputStream +import javax.xml.parsers.DocumentBuilderFactory + +internal fun handleDesktopFileVersionRestoreStatus(status: Int, onRestored: () -> Unit) { + when (status) { + in 200..299 -> onRestored() + 403 -> error("You do not have permission to restore this file version.") + 404 -> error("This historical version no longer exists.") + 409 -> error("The server could not restore this version to the current file.") + else -> error("Restoring the file version failed (HTTP $status).") + } +} + +internal fun parseDesktopFileVersionDavRecords(xml: ByteArray): List { + val factory = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeature("http://xml.org/sax/features/external-general-entities", false) + setFeature("http://xml.org/sax/features/external-parameter-entities", false) + } + val responses = factory.newDocumentBuilder().parse(ByteArrayInputStream(xml)) + .getElementsByTagNameNS(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "response") + return buildList { + for (index in 0 until responses.length) { + val response = responses.item(index) + val properties = response.successfulFileVersionPropertyRoot() ?: continue + add( + FileVersionDavRecord( + href = response.fileVersionFirstText(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "href").orEmpty(), + contentLength = properties.fileVersionFirstText( + FILE_VERSION_DESKTOP_DAV_NAMESPACE, + "getcontentlength", + ), + lastModified = properties.fileVersionFirstText( + FILE_VERSION_DESKTOP_DAV_NAMESPACE, + "getlastmodified", + ), + etag = properties.fileVersionFirstText(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "getetag"), + author = properties.fileVersionFirstText(FILE_VERSION_DESKTOP_NC_NAMESPACE, "version-author"), + label = properties.fileVersionFirstText(FILE_VERSION_DESKTOP_NC_NAMESPACE, "version-label"), + ), + ) + } + } +} + +private fun org.w3c.dom.Node.successfulFileVersionPropertyRoot(): org.w3c.dom.Node? { + val element = this as? org.w3c.dom.Element ?: return null + val propstats = element.getElementsByTagNameNS(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "propstat") + if (propstats.length > 0) { + for (index in 0 until propstats.length) { + val propstat = propstats.item(index) + val status = propstat.fileVersionFirstText(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "status").orEmpty() + if (status.isFileVersionDavSuccessStatus()) return propstat + } + return null + } + return if ( + element.getElementsByTagNameNS(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "status") + .item(0)?.textContent.orEmpty().isFileVersionDavSuccessStatus() + ) { + element + } else { + null + } +} + +private fun String.isFileVersionDavSuccessStatus(): Boolean = + trim().split(' ').any { token -> token.toIntOrNull()?.let { it in 200..299 } == true } + +private fun org.w3c.dom.Node.fileVersionFirstText(namespace: String, localName: String): String? = + (this as? org.w3c.dom.Element) + ?.getElementsByTagNameNS(namespace, localName) + ?.item(0) + ?.textContent + ?.takeIf(String::isNotBlank) + +private const val FILE_VERSION_DESKTOP_DAV_NAMESPACE = "DAV:" +private const val FILE_VERSION_DESKTOP_NC_NAMESPACE = "http://nextcloud.org/ns" diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStore.kt index b697fddfc..c59fab5c2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStore.kt @@ -367,10 +367,15 @@ internal fun linuxWritebackGrowthFitsCapacity( } internal fun defaultDesktopLinuxWritebackStore(session: NextcloudSession): DesktopLinuxVirtualFileWritebackStore { + return defaultDesktopLinuxWritebackStore(desktopFileCacheAccountId(session)) +} + +internal fun defaultDesktopLinuxWritebackStore(accountId: String): DesktopLinuxVirtualFileWritebackStore { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) val xdgData = System.getenv("XDG_DATA_HOME")?.takeIf(String::isNotBlank) val dataRoot = xdgData?.let(::File) ?: File(System.getProperty("user.home"), ".local/share") return DesktopLinuxVirtualFileWritebackStore( - File(dataRoot, "nextcloud-native/vfs-writeback/${desktopFileCacheAccountId(session)}"), + File(dataRoot, "nextcloud-native/vfs-writeback/$accountId"), ) } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index f4dc6515c..85bba7dc2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -923,70 +923,6 @@ internal fun writePrivatePendingMutationFile( } } -internal suspend fun executeDesktopDynamicApiGet( - accountId: String, - requestIdentity: String, - cachePolicy: NextcloudApiCachePolicy, - coalescer: DynamicApiRequestCoalescer, - loadCached: () -> NextcloudApiResponse?, - invalidateCached: () -> Unit, - executeNetwork: suspend () -> NextcloudApiResponse, - commit: (NextcloudApiResponse) -> Unit, -): NextcloudApiResponse { - when (cachePolicy) { - NextcloudApiCachePolicy.PreferCache -> loadCached()?.let { return it } - NextcloudApiCachePolicy.RefreshNetwork -> - coalescer.invalidateRequest(accountId, requestIdentity) {} - NextcloudApiCachePolicy.ForceNetwork -> - coalescer.invalidateRequest(accountId, requestIdentity, invalidateCached) - } - return coalescer.execute( - accountId = accountId, - requestIdentity = requestIdentity, - load = { - if (cachePolicy != NextcloudApiCachePolicy.PreferCache) { - executeNetwork() - } else { - loadCached() ?: executeNetwork() - } - }, - commit = commit, - ) -} - -internal fun combinedAutomaticCacheExcess( - maximumBytes: Long, - completeFileBytes: Long, - rangeBytes: Long, - windowsCachedBytes: Long, - windowsPinnedBytes: Long, -): Long { - require(maximumBytes > 0L) - require(listOf(completeFileBytes, rangeBytes, windowsCachedBytes, windowsPinnedBytes).all { it >= 0L }) - require(windowsPinnedBytes <= windowsCachedBytes) - val total = listOf( - completeFileBytes, - rangeBytes, - windowsCachedBytes - windowsPinnedBytes, - ).fold(0L) { accumulated, bytes -> - if (bytes > Long.MAX_VALUE - accumulated) Long.MAX_VALUE else accumulated + bytes - } - return (total - maximumBytes).coerceAtLeast(0L) -} - -internal class DesktopSessionPublicationGuard { - private val monitor = Any() - - fun serialize(action: () -> Result): Result = synchronized(monitor, action) -} - -internal fun closeVirtualFileProviderForReplacement( - provider: AutoCloseable?, - detach: () -> Unit, -): Throwable? = runCatching { provider?.close() } - .onSuccess { detach() } - .exceptionOrNull() - class DesktopNextcloudServices( private val onThemePreferenceChanged: (ThemePreference) -> Unit = {}, private val onKeepRunningInBackgroundChanged: (Boolean) -> Unit = {}, @@ -1013,7 +949,15 @@ class DesktopNextcloudServices( ?: resolvedSupportDiagnosticsRoot?.resolve("support-submissions") ?: Files.createTempDirectory("nextcloud-native-test-support-intake").toFile() private val secretStore = defaultDesktopSecretStore() + private val accountCredentials = DesktopAccountCredentialPersistence(preferences, secretStore, supportDiagnostics::record) + private val accountSessionPublication = DesktopAccountSessionPublication( + supportDiagnostics::registerPrivateValue, + ) { identity -> + supportDiagnostics.setActiveAccountIdentity(identity) + supportIntake.setActiveAccountIdentity(identity) + } private val sessionPublicationGuard = DesktopSessionPublicationGuard() + private val accountOperationGuard = DesktopAccountOperationGuard() private val appUpdater = DesktopAppUpdater( preferences = preferences.node("app-updates-v1"), onInstallerConfirmationOpened = { target -> onDesktopUpdateInstallerOpened(target.platform) }, @@ -1507,14 +1451,20 @@ class DesktopNextcloudServices( } } } - val accepted = synchronized(virtualFileProviderLock) { - synchronized(virtualFolderHydrationJobs) { - if ( - sessionClearing || - accountId in virtualFileCacheTierMutations || - virtualFolderHydrationJobs[jobKey].occupiesVirtualFolderHydrationSlot() - ) false - else true.also { virtualFolderHydrationJobs[jobKey] = job } + val accepted = accountOperationGuard.tryActivateResource { + if (!desktopResourceActivationMatchesActiveSession(loadSession(), session)) { + false + } else { + synchronized(virtualFileProviderLock) { + synchronized(virtualFolderHydrationJobs) { + if ( + sessionClearing || + accountId in virtualFileCacheTierMutations || + virtualFolderHydrationJobs[jobKey].occupiesVirtualFolderHydrationSlot() + ) false + else true.also { virtualFolderHydrationJobs[jobKey] = job } + } + } } } if (accepted) job.start() else job.cancel() @@ -1644,8 +1594,8 @@ class DesktopNextcloudServices( ) }, ) + private val accountSyncPairCleanupJournal = DesktopAccountSyncPairCleanupJournal(preferences) private val startOnLoginController = DesktopStartOnLoginController() - private val fileSyncRunLock = Mutex() private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var backgroundFileSyncJob: Job? = null private val mutableFileSyncTraySnapshot = MutableStateFlow( @@ -1679,6 +1629,9 @@ class DesktopNextcloudServices( backgroundFileSyncJob = serviceScope.launch { restoreConfirmedStartOnLoginRegistration() while (isActive) { + accountOperationGuard.serializeWhenSyncIdle { + retryPendingAccountSyncPairCleanups() + } if (!isFileSyncPaused()) { runCatching { syncAllFileSyncPairs(DesktopFileSyncRunSource.Background) } } @@ -1935,8 +1888,22 @@ class DesktopNextcloudServices( session: NextcloudSession, userId: String, ): VirtualFileStorageActionResult = withContext(Dispatchers.IO) { + accountOperationGuard.serializeResourceActivation { + activateVirtualFileProviderForCurrentAccount(session, userId) + } + } + + private fun activateVirtualFileProviderForCurrentAccount( + session: NextcloudSession, + userId: String, + ): VirtualFileStorageActionResult { + if (!desktopResourceActivationMatchesActiveSession(loadSession(), session)) { + return VirtualFileStorageActionResult.Rejected( + "The account changed before virtual file storage could be activated.", + ) + } if (!isLinuxDesktop() && !isWindowsDesktop()) { - return@withContext VirtualFileStorageActionResult.Unsupported( + return VirtualFileStorageActionResult.Unsupported( "This desktop build does not have a system virtual-file adapter for the current operating system.", ) } @@ -1955,7 +1922,7 @@ class DesktopNextcloudServices( windowsCloudFilesProvider != null && windowsCloudFilesIdentity == accountId && windowsCloudFilesFailure == null && windowsCloudFilesProvider?.runtimeRecoveryFailure() == null ) { - return@withContext VirtualFileStorageActionResult.Completed( + return VirtualFileStorageActionResult.Completed( "Windows Cloud Files are already connected at ${desktopWindowsCloudFilesRoot(accountId).absolutePath}.", ) } @@ -2081,13 +2048,13 @@ class DesktopNextcloudServices( ) throw failure } - return@withContext VirtualFileStorageActionResult.Completed( + return VirtualFileStorageActionResult.Completed( windowsCloudFilesRecoveryNotice ?: "Windows Cloud Files connected at ${desktopWindowsCloudFilesRoot(accountId).absolutePath}.", ) } if (linuxVirtualFileSystem != null && linuxVirtualFileMountIdentity == accountId) { - return@withContext VirtualFileStorageActionResult.Completed( + return VirtualFileStorageActionResult.Completed( "Virtual files are already mounted at ${desktopLinuxVirtualFileMountPoint(preferences, accountId).absolutePath}.", ) } @@ -2173,7 +2140,7 @@ class DesktopNextcloudServices( throw failure } } - VirtualFileStorageActionResult.Completed( + return VirtualFileStorageActionResult.Completed( "Virtual files mounted at ${desktopLinuxVirtualFileMountPoint(preferences, accountId).absolutePath}.", ) } @@ -2182,28 +2149,38 @@ class DesktopNextcloudServices( session: NextcloudSession, userId: String, ): VirtualFileStorageActionResult = withContext(Dispatchers.IO) { - synchronized(virtualFileProviderLock) { - linuxVirtualFileSystem?.unmount() - linuxVirtualFileSystem = null - linuxVirtualMetadataBackend = null - linuxVirtualFileMountIdentity = null - linuxVirtualFileFailure = null - windowsCloudFilesProvider?.close() - windowsCloudFilesProvider = null - windowsCloudFilesIdentity = null - windowsCloudFilesFailure = null - preferences.putBoolean( - virtualFileProviderPreferenceKey(desktopFileCacheAccountId(session)), - false, + accountOperationGuard.serializeResourceActivation { + val activeSession = loadSession() + if (!desktopResourceActivationMatchesActiveSession(activeSession, session)) { + return@serializeResourceActivation VirtualFileStorageActionResult.Rejected( + "The account changed before virtual file storage could be deactivated.", + ) + } + val accountId = desktopFileCacheAccountId(session) + synchronized(virtualFileProviderLock) { + if (desktopResourceDeactivationTargetsCurrentProvider(activeSession, session, linuxVirtualFileMountIdentity)) { + linuxVirtualFileSystem?.unmount() + linuxVirtualFileSystem = null + linuxVirtualMetadataBackend = null + linuxVirtualFileMountIdentity = null + linuxVirtualFileFailure = null + } + if (desktopResourceDeactivationTargetsCurrentProvider(activeSession, session, windowsCloudFilesIdentity)) { + windowsCloudFilesProvider?.close() + windowsCloudFilesProvider = null + windowsCloudFilesIdentity = null + windowsCloudFilesFailure = null + } + preferences.putBoolean(virtualFileProviderPreferenceKey(accountId), false) + } + VirtualFileStorageActionResult.Completed( + if (isWindowsDesktop()) { + "Windows Cloud Files disconnected. Placeholders, cached content, and remote files were kept." + } else { + "Virtual files unmounted. Cached content and remote files were kept." + }, ) } - VirtualFileStorageActionResult.Completed( - if (isWindowsDesktop()) { - "Windows Cloud Files disconnected. Placeholders, cached content, and remote files were kept." - } else { - "Virtual files unmounted. Cached content and remote files were kept." - }, - ) } override suspend fun acknowledgeVirtualFileProviderRecovery( @@ -2674,7 +2651,14 @@ class DesktopNextcloudServices( SupportDiagnosticFieldDraft("remote_root", remoteRootPath, SupportDiagnosticValuePrivacy.RemotePath), ) diagnoseDesktopSupportFailure(accountId, "sync.pair-add", diagnosticFields) { - fileSyncEngine.addPair(session, localRoot, remoteRootPath, configuration) + accountOperationGuard.serializeWhenSyncIdle addPair@{ + if (!desktopSyncRunMatchesActiveSession(loadSession(), session)) { + return@addPair FileSyncCenterActionResult.Rejected( + "The account changed before this desktop sync could be added.", + ) + } + fileSyncEngine.addPair(session, localRoot, remoteRootPath, configuration) + } }.also { result -> recordDesktopFileSyncResult(accountId, "sync.pair-add", diagnosticFields, result) runCatching { @@ -2696,9 +2680,14 @@ class DesktopNextcloudServices( SupportDiagnosticFieldDraft("pair", pairId, SupportDiagnosticValuePrivacy.Identifier), ) diagnoseDesktopSupportFailure(accountId, "sync.pair-run", diagnosticFields) { - fileSyncRunLock.withLock { + accountOperationGuard.withSyncRunLock syncRun@{ + if (!desktopSyncRunMatchesActiveSession(loadSession(), session)) { + return@syncRun FileSyncCenterActionResult.Rejected( + "The account changed before this desktop sync could start.", + ) + } if (isFileSyncPaused()) { - return@withLock FileSyncCenterActionResult.Rejected( + return@syncRun FileSyncCenterActionResult.Rejected( "Desktop syncing is paused. Resume it from the system tray first.", ) } @@ -2746,9 +2735,14 @@ class DesktopNextcloudServices( SupportDiagnosticFieldDraft("choice", choice.name.lowercase()), ) diagnoseDesktopSupportFailure(accountId, "sync.conflict-resolve", diagnosticFields) { - fileSyncRunLock.withLock { + accountOperationGuard.withSyncRunLock syncRun@{ + if (!desktopSyncRunMatchesActiveSession(loadSession(), session)) { + return@syncRun FileSyncCenterActionResult.Rejected( + "The account changed before this desktop sync could start.", + ) + } if (isFileSyncPaused()) { - return@withLock FileSyncCenterActionResult.Rejected( + return@syncRun FileSyncCenterActionResult.Rejected( "Desktop syncing is paused. Resume it from the system tray first.", ) } @@ -2793,9 +2787,14 @@ class DesktopNextcloudServices( SupportDiagnosticFieldDraft("conflict_count", resolutions.size.toString()), ) diagnoseDesktopSupportFailure(accountId, "sync.conflict-resolve-batch", diagnosticFields) { - fileSyncRunLock.withLock { + accountOperationGuard.withSyncRunLock syncRun@{ + if (!desktopSyncRunMatchesActiveSession(loadSession(), session)) { + return@syncRun FileSyncCenterActionResult.Rejected( + "The account changed before this desktop sync could start.", + ) + } if (isFileSyncPaused()) { - return@withLock FileSyncCenterActionResult.Rejected( + return@syncRun FileSyncCenterActionResult.Rejected( "Desktop syncing is paused. Resume it from the system tray first.", ) } @@ -2837,7 +2836,14 @@ class DesktopNextcloudServices( SupportDiagnosticFieldDraft("pair", pairId, SupportDiagnosticValuePrivacy.Identifier), ) diagnoseDesktopSupportFailure(accountId, "sync.pair-remove", diagnosticFields) { - fileSyncEngine.removePair(session, userId, pairId) + accountOperationGuard.withSyncRunLock syncRun@{ + if (!desktopSyncRunMatchesActiveSession(loadSession(), session)) { + return@syncRun FileSyncCenterActionResult.Rejected( + "The account changed before this desktop sync pair could be removed.", + ) + } + fileSyncEngine.removePair(session, userId, pairId) + } }.also { result -> recordDesktopFileSyncResult(accountId, "sync.pair-remove", diagnosticFields, result) runCatching { @@ -2918,23 +2924,23 @@ class DesktopNextcloudServices( ): FileSyncCenterActionResult = withContext(Dispatchers.IO) { var diagnosticAccountId: String? = null try { - fileSyncRunLock.withLock { + accountOperationGuard.withSyncRunLock syncRun@{ if (isFileSyncPaused()) { - return@withLock FileSyncCenterActionResult.Rejected("Desktop syncing is paused.") + return@syncRun FileSyncCenterActionResult.Rejected("Desktop syncing is paused.") } val session = loadSession() - ?: return@withLock FileSyncCenterActionResult.Rejected("Sign in before syncing folders.") + ?: return@syncRun FileSyncCenterActionResult.Rejected("Sign in before syncing folders.") val accountId = desktopFileCacheAccountId(session) diagnosticAccountId = accountId val userId = runCatching { loadServerInfo(session).userId }.getOrElse { failure -> - return@withLock FileSyncCenterActionResult.Rejected( + return@syncRun FileSyncCenterActionResult.Rejected( failure.message ?: "Could not load the signed-in account.", ) } val initial = loadDesktopFileSyncCenter(session) if (initial.pairs.isEmpty()) { publishFileSyncTraySnapshot(initial, emptyList()) - return@withLock FileSyncCenterActionResult.Completed("No desktop sync folders are configured.") + return@syncRun FileSyncCenterActionResult.Completed("No desktop sync folders are configured.") } mutableFileSyncTraySnapshot.value = mutableFileSyncTraySnapshot.value.copy( phase = DesktopFileSyncTrayPhase.Syncing, @@ -3664,72 +3670,125 @@ class DesktopNextcloudServices( "${desktopFileCacheAccountId(session)}-$appId-$digest.json", ) } - - override fun loadSession(): NextcloudSession? { - return sessionPublicationGuard.serialize { - val server = preferences.get(KEY_SERVER, null) - val login = preferences.get(KEY_LOGIN, null) - if (server == null || login == null) { - supportDiagnostics.setActiveAccountIdentity(null) - supportIntake.setActiveAccountIdentity(null) - return@serialize null - } - val password = secretStore.load(desktopSessionSecretReference(server, login)) - ?.decodeToString() - ?.takeIf(String::isNotBlank) - if (password == null) { - supportDiagnostics.setActiveAccountIdentity(null) - supportIntake.setActiveAccountIdentity(null) - return@serialize null - } - listOf(server, login, password).forEach(supportDiagnostics::registerPrivateValue) - NextcloudSession(server, login, password).also { session -> - restoreDesktopAccountRegistry(preferences, session, supportDiagnostics::record) - val accountIdentity = desktopFileCacheAccountId(session) - supportDiagnostics.setActiveAccountIdentity(accountIdentity) - supportIntake.setActiveAccountIdentity(accountIdentity) - } + override fun loadSession(): NextcloudSession? = sessionPublicationGuard.serialize { + val session = accountCredentials.loadActiveSession() + if (session == null) { + supportDiagnostics.setActiveAccountIdentity(null) + supportIntake.setActiveAccountIdentity(null) + } else { + accountSessionPublication.publish(session) } + session } override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { - sessionPublicationGuard.serialize { - val encodedRegistry = prepareDesktopAccountRegistry(session) - listOf(session.serverUrl, session.loginName, session.appPassword) - .forEach(supportDiagnostics::registerPrivateValue) - try { - secretStore.save( - reference = desktopSessionSecretReference(session.serverUrl, session.loginName), - username = session.loginName, - secret = session.appPassword.encodeToByteArray(), - ) - } catch (failure: Throwable) { - recordSupportDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Authentication, - operation = "credentials.save", - outcome = "failed", - code = if (failure is DesktopSecretStoreUnavailableException) { - "DESKTOP_SECRET_STORE_UNAVAILABLE" - } else { - "DESKTOP_SECRET_STORE_FAILED" - }, - exception = failure.toSupportDiagnosticExceptionDraft(), - ), + val persistedSession = accountOperationGuard.serializeWhenSyncIdle { + retryPendingAccountSyncPairCleanup(desktopFileCacheAccountId(session)) + sessionPublicationGuard.serialize { + val activeAccountId = accountCredentials.activeAccountId() + val activeSession = activeAccountId?.let(accountCredentials::loadSession) + val invalidatesLiveResources = desktopSessionSaveSwitchesAccount(activeAccountId, session.accountId) || + desktopSessionSaveReplacesActiveCredential(activeSession, session) + requireDesktopSessionSaveAllowed( + !invalidatesLiveResources || !hasLiveAccountResources(), + ::recordSupportDiagnostic, ) - throw failure + accountCredentials.saveSession(session).also(accountSessionPublication::publish) } - persistDesktopAccountRegistry(preferences, encodedRegistry) - preferences.put(KEY_SERVER, session.serverUrl) - preferences.put(KEY_LOGIN, session.loginName) - val accountIdentity = desktopFileCacheAccountId(session) - supportDiagnostics.setActiveAccountIdentity(accountIdentity) - supportIntake.setActiveAccountIdentity(accountIdentity) } synchronized(fileRangeSessionLock) { sessionClearing = false } startDesktopSyncLifecycle() + persistedSession + } + override fun listAccounts() = sessionPublicationGuard.serialize(accountCredentials::listAccounts) + override fun activeAccountId() = sessionPublicationGuard.serialize(accountCredentials::activeAccountId) + override fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = + sessionPublicationGuard.serialize { + accountCredentials.loadSession(accountId)?.also { session -> + accountSessionPublication.register(session) + } + } + override suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = + withContext(Dispatchers.IO) { + accountOperationGuard.serialize operation@{ + if (activeAccountId() == accountId) return@operation loadSession(accountId) + if (hasLiveAccountResources()) { + recordSupportDiagnostic(desktopAccountSelectionBlockedDiagnostic()) + return@operation null + } + val syncJob = synchronized(this@DesktopNextcloudServices) { + backgroundFileSyncJob.also { backgroundFileSyncJob = null } + } + restartDesktopSyncAfterSelection( + select = { + syncJob?.cancel() + syncJob?.join() + reopenDesktopSessionAfterSelection( + selected = accountOperationGuard.withSyncRunLock { + val selectedRecord = sessionPublicationGuard.serialize { + accountCredentials.listAccounts().firstOrNull { account -> account.id == accountId } + } + selectedRecord?.let { record -> + retryPendingAccountSyncPairCleanup(desktopFileCacheAccountId(record)) + } + sessionPublicationGuard.serialize { + accountCredentials.selectAccount(accountId)?.also { session -> + accountSessionPublication.publish(session) + } + } + }, + reopen = { synchronized(fileRangeSessionLock) { sessionClearing = false } }, + ) + }, + restart = ::startDesktopSyncLifecycle, + ) + } + } + private fun hasLiveAccountResources(): Boolean = + synchronized(fileRangeSessionLock) { activeFileRangeSessions.isNotEmpty() } || + synchronized(virtualFolderHydrationJobs) { virtualFolderHydrationJobs.values.any { it.isActive } } || + synchronized(virtualFileProviderLock) { + linuxVirtualFileSystem != null || windowsCloudFilesProvider != null || + virtualFileCacheTierMutations.isNotEmpty() + } + override suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = withContext(Dispatchers.IO) { + accountOperationGuard.serialize { + if (activeAccountId() == accountId) { + clearSessionForAccountOperation() + true + } else { + val account = listAccounts().firstOrNull { record -> record.id == accountId } + ?: return@serialize false + val providerAccountId = desktopFileCacheAccountId(account) + requireDesktopAccountRemovalReady(providerAccountId, isLinuxDesktop()) + accountOperationGuard.withSyncRunLock { + fileSyncEngine.requireAccountRemovalReady(providerAccountId) + removeDesktopAccountBeforeSyncPairCleanup( + accountId = providerAccountId, + prepareCleanup = accountSyncPairCleanupJournal::prepare, + commitCleanup = accountSyncPairCleanupJournal::commit, + clearCleanup = accountSyncPairCleanupJournal::clear, + accountStillExists = ::desktopAccountExists, + removeCredential = { sessionPublicationGuard.serialize { + removeDesktopAccountCredential(preferences, providerAccountId, { + accountCredentials.listAccounts().any { account -> account.id == accountId } + }) { + accountCredentials.removeAccount(accountId) + } + } }, + removeSyncPairs = { fileSyncEngine.removeAccountPairs(providerAccountId) }, + ) { + recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(providerAccountId, it)) + } + } + } + } } override suspend fun clearSession() = withContext(Dispatchers.IO) { + accountOperationGuard.serialize { clearSessionForAccountOperation() } + } + private suspend fun clearSessionForAccountOperation( + expectedSession: NextcloudSession? = null, revokeRemoteSession: suspend (NextcloudSession) -> Unit = {}, + ) { val userHome = File(System.getProperty("user.home")) val rangeSessions = synchronized(fileRangeSessionLock) { sessionClearing = true @@ -3737,135 +3796,130 @@ class DesktopNextcloudServices( } var cleared = false try { - val accountId = desktopStoredSessionAccountId(preferences) + val activeAccountId = activeAccountId() + val activeSession = activeAccountId?.let(::loadSession) + check(expectedSession == null || activeSession == expectedSession) { + "The account changed before its remote session could be revoked." + } + val activeRecord = activeAccountId?.let { id -> + listAccounts().firstOrNull { account -> account.id == id } + } + val accountId = activeSession?.let(::desktopFileCacheAccountId) + ?: activeRecord?.let(::desktopFileCacheAccountId) val syncJob = synchronized(this) { val active = backgroundFileSyncJob backgroundFileSyncJob = null active } syncJob?.cancel() - val hydrationJobs = accountId?.let(::cancelAllVirtualFolderHydration).orEmpty() - rangeSessions.forEach { source -> runCatching(source::close) } - hydrationJobs.forEach { job -> job.join() } - accountId?.let { clearedAccountId -> - val prefix = "$clearedAccountId\u0000" - synchronized(virtualFolderMutationLock) { - virtualFolderMutationGenerationsByJob.keys.removeIf { key -> key.startsWith(prefix) } - virtualFolderCompletedGenerations.keys.removeIf { key -> key.startsWith(prefix) } - virtualFolderRetryAtEpochMillis.keys.removeIf { key -> key.startsWith(prefix) } - } - } syncJob?.join() - synchronized(virtualFileProviderLock) { - linuxVirtualFileSystem?.unmount() - linuxVirtualFileSystem = null - linuxVirtualMetadataBackend = null - linuxVirtualFileMountIdentity = null - linuxVirtualFileFailure = null - val windowsCloudFilesFailureMessage = "Could not remove the Windows Cloud Files root." - val provider = windowsCloudFilesProvider - try { - if (provider != null) { - provider.removeSyncRoot() - } else if (isWindowsDesktop()) { - unregisterWindowsCloudFilesRootForUninstall(preferences) + accountOperationGuard.withSyncRunLock { + accountId + ?.also { requireDesktopAccountRemovalReady(it, isLinuxDesktop()) } + ?.let { fileSyncEngine.requireAccountRemovalReady(it) } + expectedSession?.let { session -> revokeRemoteSession(session) } + val hydrationJobs = accountId?.let(::cancelAllVirtualFolderHydration).orEmpty() + rangeSessions.forEach { source -> runCatching(source::close) } + hydrationJobs.forEach { job -> job.join() } + accountId?.let { clearedAccountId -> + val prefix = "$clearedAccountId\u0000" + synchronized(virtualFolderMutationLock) { + virtualFolderMutationGenerationsByJob.keys.removeIf { key -> key.startsWith(prefix) } + virtualFolderCompletedGenerations.keys.removeIf { key -> key.startsWith(prefix) } + virtualFolderRetryAtEpochMillis.keys.removeIf { key -> key.startsWith(prefix) } } - windowsCloudFilesFailure = null - } catch (failure: Throwable) { - windowsCloudFilesFailure = failure.message ?: windowsCloudFilesFailureMessage - supportDiagnostics.record( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.VirtualFiles, - operation = "cloud-files.signout-cleanup", - outcome = "failed", - fields = accountId?.let { - listOf( - SupportDiagnosticFieldDraft( - "account", - it, - SupportDiagnosticValuePrivacy.Identifier, - ), - ) - }.orEmpty(), - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - } finally { - runCatching { provider?.close() } - windowsCloudFilesProvider = null - windowsCloudFilesIdentity = null - preferences.remove(KEY_WINDOWS_CLOUD_FILES_ROOT) - accountId?.let { - clearWindowsCloudFilesRootPreferences( - preferences, - it, - desktopWindowsCloudFilesRoot(it, userHome).toPath(), - ) - clearWindowsCloudFilesRootPreferences( - preferences, - it, - desktopLegacyWindowsCloudFilesRoot(it, userHome).toPath(), + } + synchronized(virtualFileProviderLock) { + linuxVirtualFileSystem?.unmount() + linuxVirtualFileSystem = null + linuxVirtualMetadataBackend = null + linuxVirtualFileMountIdentity = null + linuxVirtualFileFailure = null + val windowsCloudFilesFailureMessage = "Could not remove the Windows Cloud Files root." + val provider = windowsCloudFilesProvider + try { + if (provider != null) { + provider.removeSyncRoot() + } else if (isWindowsDesktop()) { + unregisterWindowsCloudFilesRootForUninstall(preferences) + } + windowsCloudFilesFailure = null + } catch (failure: Throwable) { + windowsCloudFilesFailure = failure.message ?: windowsCloudFilesFailureMessage + supportDiagnostics.record( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.VirtualFiles, + operation = "cloud-files.signout-cleanup", + outcome = "failed", + fields = desktopAccountDiagnosticFields(accountId), + exception = failure.toSupportDiagnosticExceptionDraft(), + ), ) - } - if (isWindowsDesktop()) { - val uninstallFailure = runCatching { - unregisterWindowsCloudFilesRootForUninstall(preferences, userHome = userHome) - }.exceptionOrNull() - if (uninstallFailure != null) { - windowsCloudFilesFailure = windowsCloudFilesFailure ?: ( - uninstallFailure.message ?: windowsCloudFilesFailureMessage + } finally { + runCatching { provider?.close() } + windowsCloudFilesProvider = null + windowsCloudFilesIdentity = null + preferences.remove(KEY_WINDOWS_CLOUD_FILES_ROOT) + accountId?.let { + clearWindowsCloudFilesRootPreferences( + preferences, + it, + desktopWindowsCloudFilesRoot(it, userHome).toPath(), ) - supportDiagnostics.record( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.VirtualFiles, - operation = "cloud-files.signout-cleanup-retry", - outcome = "failed", - fields = accountId?.let { - listOf( - SupportDiagnosticFieldDraft( - "account", - it, - SupportDiagnosticValuePrivacy.Identifier, - ), - ) - }.orEmpty(), - exception = uninstallFailure.toSupportDiagnosticExceptionDraft(), - ), + clearWindowsCloudFilesRootPreferences( + preferences, + it, + desktopLegacyWindowsCloudFilesRoot(it, userHome).toPath(), ) } + if (isWindowsDesktop()) { + val uninstallFailure = runCatching { + unregisterWindowsCloudFilesRootForUninstall(preferences, userHome = userHome) + }.exceptionOrNull() + if (uninstallFailure != null) { + windowsCloudFilesFailure = windowsCloudFilesFailure ?: ( + uninstallFailure.message ?: windowsCloudFilesFailureMessage + ) + supportDiagnostics.record( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.VirtualFiles, + operation = "cloud-files.signout-cleanup-retry", + outcome = "failed", + fields = desktopAccountDiagnosticFields(accountId), + exception = uninstallFailure.toSupportDiagnosticExceptionDraft(), + ), + ) + } + } } } - } - mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot( - phase = DesktopFileSyncTrayPhase.Idle, - ) - val server = preferences.get(KEY_SERVER, null) - val login = preferences.get(KEY_LOGIN, null) - runCatching { - if (server != null && login != null) secretStore.clear(desktopSessionSecretReference(server, login)) - }.onFailure { failure -> - supportDiagnostics.record( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Authentication, - operation = "credentials.clear", - outcome = "failed", - exception = failure.toSupportDiagnosticExceptionDraft(), - ), + mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot(phase = DesktopFileSyncTrayPhase.Idle) + clearDesktopActiveAccountBeforeSyncPairCleanup( + accountId, + accountSyncPairCleanupJournal, + ::desktopAccountExists, + { + sessionPublicationGuard.serialize { + check( + activeAccountId == null || removeDesktopAccountCredential( + preferences, + accountId, + credentialStillExists = { + accountCredentials.listAccounts().any { account -> account.id == activeAccountId } + }, + ) { accountCredentials.removeAccount(activeAccountId) }, + ) + supportDiagnostics.setActiveAccountIdentity(null) + supportIntake.setActiveAccountIdentity(null) + } + }, + fileSyncEngine::removeAccountPairs, + ::recordSupportDiagnostic, ) - if (failure is DesktopSecretDeletionRecoveryUnavailableException || - failure is DesktopSecretLegacyCleanupUnavailableException) throw failure + cleared = true } - sessionPublicationGuard.serialize { - preferences.remove(KEY_SERVER) - preferences.remove(KEY_LOGIN) - clearDesktopAccountRegistry(preferences) - supportDiagnostics.setActiveAccountIdentity(null) - supportIntake.setActiveAccountIdentity(null) - } - cleared = true } finally { if (!cleared) { synchronized(fileRangeSessionLock) { sessionClearing = false } @@ -3873,20 +3927,45 @@ class DesktopNextcloudServices( } } } + + private suspend fun retryPendingAccountSyncPairCleanup(accountId: String) { + val cleanup = accountSyncPairCleanupJournal.pending() + .singleOrNull { pending -> pending.accountId == accountId } + ?: return + retryDesktopAccountSyncPairCleanup( + cleanup = cleanup, + accountStillExists = ::desktopAccountExists, + removeSyncPairs = fileSyncEngine::removeAccountPairs, + clearCleanup = accountSyncPairCleanupJournal::clear, + ) + } + + private suspend fun retryPendingAccountSyncPairCleanups() { + retryPendingDesktopAccountSyncPairCleanups( + cleanupJournal = accountSyncPairCleanupJournal, + accountStillExists = ::desktopAccountExists, + removeSyncPairs = fileSyncEngine::removeAccountPairs, + recordCleanupFailure = { accountId, failure -> + recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(accountId, failure)) + }, + ) + } + + private fun desktopAccountExists(accountId: String): Boolean = sessionPublicationGuard.serialize { + accountCredentials.listAccounts().any { account -> desktopFileCacheAccountId(account) == accountId } + } override suspend fun loadDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, ): PersistedDeckCardDraft? = withContext(Dispatchers.IO) { deckCardDrafts.load(session, key) } - override suspend fun saveDeckCardDraft( session: NextcloudSession, draft: PersistedDeckCardDraft, ) = withContext(Dispatchers.IO) { deckCardDrafts.save(session, draft) } - override suspend fun clearDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, @@ -3908,11 +3987,9 @@ class DesktopNextcloudServices( runCatching { openExternalUrlNow(url) } } } - override suspend fun openLoginUrl(url: String) = withContext(Dispatchers.IO) { openExternalUrlNow(url) } - private fun openExternalUrlNow(url: String) { try { externalUrlLauncher.open(url) @@ -4521,8 +4598,14 @@ class DesktopNextcloudServices( } }, ) - val registered = synchronized(fileRangeSessionLock) { - if (sessionClearing) false else activeFileRangeSessions.add(rangeSession) + val registered = accountOperationGuard.tryActivateResource { + if (!desktopResourceActivationMatchesActiveSession(loadSession(), session)) { + false + } else { + synchronized(fileRangeSessionLock) { + if (sessionClearing) false else activeFileRangeSessions.add(rangeSession) + } + } } if (!registered) { rangeSession.close() @@ -5537,7 +5620,6 @@ class DesktopNextcloudServices( hasMoreHistory = response.status != 304 && nextCursor != null, ) } - override suspend fun sendTalkMessage(session: NextcloudSession, token: String, message: String) = withContext(Dispatchers.IO) { val response = request( @@ -5551,20 +5633,23 @@ class DesktopNextcloudServices( check(response.status in 200..299) { "Sending the Talk message failed (HTTP ${response.status})." } Unit } - - override suspend fun revokeSession(session: NextcloudSession) = withContext(Dispatchers.IO) { - request("DELETE", session.serverUrl + "/ocs/v2.php/core/apppassword", session, ocsRequest = true) - Unit + override suspend fun revokeSession(session: NextcloudSession): Unit = withContext(Dispatchers.IO) { + accountOperationGuard.serialize { + clearSessionForAccountOperation(expectedSession = session) { current -> + request("DELETE", current.serverUrl + "/ocs/v2.php/core/apppassword", current, + ocsRequest = true, accountMutationSerialized = true, + ) + } + } } - - private fun ocsGet(session: NextcloudSession, path: String): JSONObject { + private suspend fun ocsGet(session: NextcloudSession, path: String): JSONObject { val separator = if ('?' in path) '&' else '?' val response = request("GET", session.serverUrl + path + separator + "format=json", session, ocsRequest = true) check(response.status in 200..299) { "Nextcloud API request failed (HTTP ${response.status})." } return JSONObject(response.text) } - private fun loadFileEtag(session: NextcloudSession, userId: String, path: String): String? { + private suspend fun loadFileEtag(session: NextcloudSession, userId: String, path: String): String? { val response = request( "PROPFIND", buildNextcloudFileUrl(session.serverUrl, userId, path), @@ -5584,7 +5669,7 @@ class DesktopNextcloudServices( .documentElement.firstText(DAV, "getetag") } - private fun request( + private suspend fun request( method: String, url: String, session: NextcloudSession? = null, @@ -5603,7 +5688,13 @@ class DesktopNextcloudServices( onNetworkFailure: (JvmNetworkFailureDiagnostic) -> Unit = {}, onFailurePhase: (JvmNetworkFailurePhase) -> Unit = {}, diagnosticIgnoredHttpStatuses: Set = emptySet(), + accountMutationSerialized: Boolean = false, ): HttpResponse { + if (session != null && !method.isReadOnlyJvmNetworkMethod() && !accountMutationSerialized) { + return accountOperationGuard.withAuthenticatedMutationSession(session, ::loadSession) { current -> request( + method, url, current, body, contentType, ocsRequest, headers, rawBody, maxResponseBytes, expectedSuccessResponseBytes, expectedSuccessResponseStatus, client, streamingBody, mutationExecutor, + onAmbiguousMutationResult, onNetworkFailure, onFailurePhase, diagnosticIgnoredHttpStatuses, true) } + } val started = System.nanoTime() require((expectedSuccessResponseBytes == null) == (expectedSuccessResponseStatus == null)) val requestBody = when { @@ -5942,8 +6033,6 @@ class DesktopNextcloudServices( const val APP_ID = "dev.obiente.nextcloudnative" const val KEY_THEME = "theme" const val KEY_LAST_OPENED_APP = "last_opened_app" - const val KEY_SERVER = "server" - const val KEY_LOGIN = "login" const val KEY_FILE_SYNC_PAUSED = "file_sync_paused" const val KEY_START_ON_LOGIN = "start_on_login" const val KEY_KEEP_RUNNING_IN_BACKGROUND = "keep_running_in_background" @@ -6116,16 +6205,6 @@ internal fun advanceAffectedVirtualFolderGenerations( } } -internal fun handleDesktopFileVersionRestoreStatus(status: Int, onRestored: () -> Unit) { - when (status) { - in 200..299 -> onRestored() - 403 -> error("You do not have permission to restore this file version.") - 404 -> error("This historical version no longer exists.") - 409 -> error("The server could not restore this version to the current file.") - else -> error("Restoring the file version failed (HTTP $status).") - } -} - private data class VirtualFolderListingGeneration( val path: String, val directory: Boolean, @@ -6168,73 +6247,6 @@ internal fun publishDesktopLinuxFallbackMetadataBestEffort( } } -internal fun parseDesktopFileVersionDavRecords(xml: ByteArray): List { - val factory = DocumentBuilderFactory.newInstance().apply { - isNamespaceAware = true - setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) - setFeature("http://xml.org/sax/features/external-general-entities", false) - setFeature("http://xml.org/sax/features/external-parameter-entities", false) - } - val responses = factory.newDocumentBuilder().parse(ByteArrayInputStream(xml)) - .getElementsByTagNameNS(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "response") - return buildList { - for (index in 0 until responses.length) { - val response = responses.item(index) - val properties = response.successfulFileVersionPropertyRoot() ?: continue - add( - FileVersionDavRecord( - href = response.fileVersionFirstText(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "href").orEmpty(), - contentLength = properties.fileVersionFirstText( - FILE_VERSION_DESKTOP_DAV_NAMESPACE, - "getcontentlength", - ), - lastModified = properties.fileVersionFirstText( - FILE_VERSION_DESKTOP_DAV_NAMESPACE, - "getlastmodified", - ), - etag = properties.fileVersionFirstText(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "getetag"), - author = properties.fileVersionFirstText(FILE_VERSION_DESKTOP_NC_NAMESPACE, "version-author"), - label = properties.fileVersionFirstText(FILE_VERSION_DESKTOP_NC_NAMESPACE, "version-label"), - ), - ) - } - } -} - -private fun org.w3c.dom.Node.successfulFileVersionPropertyRoot(): org.w3c.dom.Node? { - val element = this as? org.w3c.dom.Element ?: return null - val propstats = element.getElementsByTagNameNS(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "propstat") - if (propstats.length > 0) { - for (index in 0 until propstats.length) { - val propstat = propstats.item(index) - val status = propstat.fileVersionFirstText(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "status").orEmpty() - if (status.isFileVersionDavSuccessStatus()) return propstat - } - return null - } - return if ( - element.getElementsByTagNameNS(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "status") - .item(0)?.textContent.orEmpty().isFileVersionDavSuccessStatus() - ) { - element - } else { - null - } -} - -private fun String.isFileVersionDavSuccessStatus(): Boolean = - trim().split(' ').any { token -> token.toIntOrNull()?.let { it in 200..299 } == true } - -private fun org.w3c.dom.Node.fileVersionFirstText(namespace: String, localName: String): String? = - (this as? org.w3c.dom.Element) - ?.getElementsByTagNameNS(namespace, localName) - ?.item(0) - ?.textContent - ?.takeIf(String::isNotBlank) - -private const val FILE_VERSION_DESKTOP_DAV_NAMESPACE = "DAV:" -private const val FILE_VERSION_DESKTOP_NC_NAMESPACE = "http://nextcloud.org/ns" - internal fun parseDesktopSystemTagsDavResponse(xml: ByteArray): List { val factory = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt new file mode 100644 index 000000000..dde385832 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -0,0 +1,633 @@ +package dev.obiente.nextcloudnative.app + +import java.util.UUID +import java.util.prefs.Preferences +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopAccountCredentialPersistenceTest { + @Test + fun legacyCredentialMigratesAndRestartsWithTheExactActiveAccount() = withStore { preferences, secrets -> + val session = firstSession() + putLegacySession(preferences, secrets, session) + val persistence = persistence(preferences, secrets) + + assertEquals(session, persistence.loadActiveSession()) + assertNull(secrets.load(desktopSessionSecretReference(session.serverUrl, session.loginName))) + assertEquals(session.appPassword, secrets.load(desktopAccountSecretReference(session.accountId))?.decodeToString()) + + val restarted = persistence(preferences, secrets) + assertEquals(session, restarted.loadActiveSession()) + assertEquals(session.accountId, restarted.activeAccountId()) + } + + @Test + fun twoCredentialSlotsRestartAndSelectTheRequestedAccount() = withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(first) + persistence.saveSession(second) + + val restarted = persistence(preferences, secrets) + + assertEquals(setOf(first.accountRecord(), second.accountRecord()), restarted.listAccounts().toSet()) + assertEquals(second.accountId, restarted.activeAccountId()) + assertEquals(first, restarted.selectAccount(first.accountId)) + assertEquals(first, persistence(preferences, secrets).loadActiveSession()) + } + + @Test + fun credentialFreeAccountReadsDoNotRetrySecretCleanup() = withStore { preferences, secrets -> + val session = firstSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(session) + preferences.put("accountLegacyCleanupServer", session.serverUrl) + preferences.put("accountLegacyCleanupLogin", session.loginName) + preferences.put("accountCredentialSaveServer", session.serverUrl) + preferences.put("accountCredentialSaveLogin", session.loginName) + secrets.resetOperationCounts() + + assertEquals(listOf(session.accountRecord()), persistence.listAccounts()) + assertEquals(session.accountId, persistence.activeAccountId()) + assertEquals(0, secrets.loadCount) + assertEquals(0, secrets.clearCount) + assertEquals(session.serverUrl, preferences.get("accountLegacyCleanupServer", null)) + assertEquals(session.serverUrl, preferences.get("accountCredentialSaveServer", null)) + } + + @Test + fun accountRemovalJournalsBothCurrentAndLegacyCredentialCleanup() = withStore { preferences, secrets -> + val first = firstSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(first) + persistence.saveSession(secondSession()) + secrets.save( + desktopSessionSecretReference(first.serverUrl, first.loginName), + first.loginName, + first.appPassword.encodeToByteArray(), + ) + preferences.put("accountLegacyCleanupServer", first.serverUrl) + preferences.put("accountLegacyCleanupLogin", first.loginName) + secrets.failClears = true + + assertTrue(persistence.removeAccount(first.accountId)) + assertFalse(persistence.listAccounts().any { account -> account.id == first.accountId }) + assertNotNull(secrets.load(desktopAccountSecretReference(first.accountId))) + assertNotNull(secrets.load(desktopSessionSecretReference(first.serverUrl, first.loginName))) + assertEquals(first.accountId.storageKey, preferences.get("accountCredentialRemovals", null)) + + secrets.failClears = false + persistence.loadActiveSession() + assertNull(secrets.load(desktopAccountSecretReference(first.accountId))) + assertNull(secrets.load(desktopSessionSecretReference(first.serverUrl, first.loginName))) + assertNull(preferences.get("accountLegacyCleanupServer", null)) + assertNull(preferences.get("accountLegacyCleanupLogin", null)) + } + + @Test + fun selectionFlushesRegistryAndLegacyMetadataBeforeReturning() = withStore { preferences, secrets -> + var flushCount = 0 + val persistence = persistence(preferences, secrets) { flushCount += 1 } + persistence.saveSession(firstSession()) + persistence.saveSession(secondSession()) + + assertEquals(firstSession(), persistence.selectAccount(firstSession().accountId)) + assertEquals(10, flushCount) + assertEquals(firstSession().serverUrl, preferences.get("server", null)) + assertEquals(firstSession().loginName, preferences.get("login", null)) + } + + @Test + fun failedRegistryFlushRemovesANewlyCreatedCredentialSlot() = withStore { preferences, secrets -> + val session = firstSession() + val persistence = persistence(preferences, secrets) { + error("synthetic registry flush failure") + } + + assertFailsWith { persistence.saveSession(session) } + + assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + } + + @Test + fun failedNewCredentialRollbackRetainsTheRecoveryJournal() = withStore { preferences, secrets -> + val session = firstSession() + var flushCount = 0 + val persistence = persistence(preferences, secrets) { + flushCount += 1 + if (flushCount == 2) error("synthetic registry flush failure") + preferences.flush() + } + secrets.failClears = true + + assertFailsWith { persistence.saveSession(session) } + + assertNotNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertEquals(session.serverUrl, preferences.get("accountCredentialSaveServer", null)) + assertEquals(session.loginName, preferences.get("accountCredentialSaveLogin", null)) + + secrets.failClears = false + assertNull(persistence(preferences, secrets).loadActiveSession()) + assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertNull(preferences.get("accountCredentialSaveServer", null)) + assertNull(preferences.get("accountCredentialSaveLogin", null)) + } + + @Test + fun startupRecoveryRemovesANewCredentialWhoseRegistryCommitNeverCompleted() = + withStore { preferences, secrets -> + val session = firstSession() + preferences.put("accountCredentialSaveServer", session.serverUrl) + preferences.put("accountCredentialSaveLogin", session.loginName) + secrets.save( + desktopAccountSecretReference(session.accountId), + session.loginName, + session.appPassword.encodeToByteArray(), + ) + + assertNull(persistence(preferences, secrets).loadActiveSession()) + + assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertNull(preferences.get("accountCredentialSaveServer", null)) + assertNull(preferences.get("accountCredentialSaveLogin", null)) + } + + @Test + fun startupRecoveryKeepsANewCredentialAfterItsRegistryCommitCompleted() = + withStore { preferences, secrets -> + val session = firstSession() + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodeNextcloudAccountRegistry( + NextcloudAccountRegistry.Empty.upsertAndSelect(session.accountRecord()), + )) + preferences.put("accountCredentialSaveServer", session.serverUrl) + preferences.put("accountCredentialSaveLogin", session.loginName) + secrets.save( + desktopAccountSecretReference(session.accountId), + session.loginName, + session.appPassword.encodeToByteArray(), + ) + + assertEquals(session, persistence(preferences, secrets).loadActiveSession()) + + assertNotNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertNull(preferences.get("accountCredentialSaveServer", null)) + assertNull(preferences.get("accountCredentialSaveLogin", null)) + } + + @Test + fun startupRecoveryPreservesPendingCredentialWhenRegistryVersionIsUnreadable() = + withStore { preferences, secrets -> + val session = firstSession() + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, """{"version":2,"accounts":[]}""") + preferences.put("accountCredentialSaveServer", session.serverUrl) + preferences.put("accountCredentialSaveLogin", session.loginName) + secrets.save( + desktopAccountSecretReference(session.accountId), + session.loginName, + session.appPassword.encodeToByteArray(), + ) + + assertNull(persistence(preferences, secrets).loadActiveSession()) + + assertNotNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertEquals(session.serverUrl, preferences.get("accountCredentialSaveServer", null)) + } + + @Test + fun failedRegistryFlushRestoresThePreviousCredentialDuringReauthentication() = + withStore { preferences, secrets -> + val original = firstSession() + var failFlush = false + val persistence = persistence(preferences, secrets) { + if (failFlush) error("synthetic registry flush failure") + preferences.flush() + } + persistence.saveSession(original) + failFlush = true + + assertFailsWith { + persistence.saveSession(original.copy(appPassword = "replacement-password")) + } + + assertEquals( + original.appPassword, + secrets.load(desktopAccountSecretReference(original.accountId))?.decodeToString(), + ) + assertEquals(original, persistence(preferences, secrets).loadActiveSession()) + } + + @Test + fun canonicalEquivalentReauthenticationPreservesDesktopStorageIdentity() = + withStore { preferences, secrets -> + val original = NextcloudSession( + serverUrl = "https://CLOUD.example.test:443/nextcloud", + loginName = "alice", + appPassword = "original-password", + ) + val replacement = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud/", + loginName = "alice", + appPassword = "replacement-password", + ) + assertEquals(original.accountId, replacement.accountId) + val persistence = persistence(preferences, secrets) + persistence.saveSession(original) + + val persisted = persistence.saveSession(replacement) + + val restored = persistence(preferences, secrets).loadActiveSession() + assertEquals(original.serverUrl, persisted.serverUrl) + assertEquals(replacement.appPassword, persisted.appPassword) + assertEquals(original.serverUrl, restored?.serverUrl) + assertEquals(replacement.appPassword, restored?.appPassword) + assertEquals(desktopFileCacheAccountId(original), restored?.let(::desktopFileCacheAccountId)) + assertEquals(original.serverUrl, decodeRegistry(preferences).activeAccount?.serverUrl) + } + + @Test + fun unsupportedFutureRegistryPreservesLegacyCredentialWithoutExposingIt() = withStore { preferences, secrets -> + val session = firstSession() + val futureRegistry = """{"version":2,"futureAccounts":[{"id":"future"}]}""" + putLegacySession(preferences, secrets, session) + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, futureRegistry) + val diagnostics = mutableListOf() + + val persistence = persistence(preferences, secrets, diagnostics) + val restored = persistence.loadActiveSession() + + assertNull(restored) + assertTrue(persistence.listAccounts().isEmpty()) + assertNull(persistence.activeAccountId()) + assertEquals(futureRegistry, preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertNotNull(secrets.load(desktopSessionSecretReference(session.serverUrl, session.loginName))) + assertEquals( + listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), + diagnostics.mapNotNull { it.code }.distinct(), + ) + } + + @Test + fun malformedRegistryFallsBackWithoutDiscardingTheLegacyCredential() = withStore { preferences, secrets -> + val session = firstSession() + putLegacySession(preferences, secrets, session) + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, "{not-json") + val diagnostics = mutableListOf() + + val restored = persistence(preferences, secrets, diagnostics).loadActiveSession() + + assertEquals(session, restored) + assertEquals(session.accountId, decodeRegistry(preferences).activeAccountId) + assertEquals(listOf("ACCOUNT_REGISTRY_MALFORMED"), diagnostics.mapNotNull { it.code }) + assertDiagnosticsExcludePrivateValues(diagnostics) + } + + @Test + fun legacyMigrationFlushesBeforeDeletingTheOnlyLegacyCredential() = withStore { preferences, secrets -> + val session = firstSession() + val legacyReference = desktopSessionSecretReference(session.serverUrl, session.loginName) + putLegacySession(preferences, secrets, session) + var legacyPresentAtFlush = false + + val restored = persistence(preferences, secrets) { + legacyPresentAtFlush = legacyPresentAtFlush || secrets.load(legacyReference) != null + }.loadActiveSession() + + assertEquals(session, restored) + assertTrue(legacyPresentAtFlush) + assertNull(secrets.load(legacyReference)) + } + + @Test + fun failedLegacyCleanupIsRetriedAfterMigration() = withStore { preferences, secrets -> + val session = firstSession() + val legacyReference = desktopSessionSecretReference(session.serverUrl, session.loginName) + putLegacySession(preferences, secrets, session) + val persistence = persistence(preferences, secrets) + secrets.failClears = true + + assertEquals(session, persistence.loadActiveSession()) + assertNotNull(secrets.load(legacyReference)) + + secrets.failClears = false + assertEquals(session, persistence.loadActiveSession()) + assertNull(secrets.load(legacyReference)) + } + + @Test + fun pendingCleanupNeverDeletesTheOnlyReadableLegacyCredential() = withStore { preferences, secrets -> + val session = firstSession() + val legacyReference = desktopSessionSecretReference(session.serverUrl, session.loginName) + putLegacySession(preferences, secrets, session) + secrets.failSaves = true + + assertEquals(session, persistence(preferences, secrets).loadActiveSession()) + assertNotNull(secrets.load(legacyReference)) + + secrets.failSaves = false + assertEquals(session, persistence(preferences, secrets).loadActiveSession()) + assertNull(secrets.load(legacyReference)) + } + + @Test + fun accountRemovalRetriesPendingLegacyCleanupAfterSelectionChanged() = + withStore { preferences, secrets -> + val migrated = firstSession() + val other = secondSession() + val legacyReference = desktopSessionSecretReference(migrated.serverUrl, migrated.loginName) + putLegacySession(preferences, secrets, migrated) + val persistence = persistence(preferences, secrets) + secrets.failClears = true + + assertEquals(migrated, persistence.loadActiveSession()) + persistence.saveSession(other) + assertNotNull(secrets.load(legacyReference)) + + secrets.failClears = false + assertTrue(persistence.removeAccount(migrated.accountId)) + assertNull(secrets.load(legacyReference)) + } + + @Test + fun secureStoreReadFailureIsNotReportedAsMissingCredentials() = withStore { preferences, secrets -> + val persistence = persistence(preferences, secrets) + persistence.saveSession(firstSession()) + secrets.loadFailure = DesktopSecretStoreUnavailableException("synthetic locked keychain") + + assertEquals( + NextcloudSessionLoadState.SecureStorageUnavailable, + loadNextcloudSessionSafely(persistence::loadActiveSession), + ) + } + + @Test + fun failedMigrationFlushKeepsLegacyCredentialAndRollsBackCachedMetadata() = + withStore { preferences, secrets -> + val session = firstSession() + val legacyReference = desktopSessionSecretReference(session.serverUrl, session.loginName) + putLegacySession(preferences, secrets, session) + val diagnostics = mutableListOf() + var flushAttempts = 0 + + val restored = persistence(preferences, secrets, diagnostics) { + flushAttempts += 1 + if (flushAttempts == 1) error("synthetic flush failure") + }.loadActiveSession() + + assertEquals(session, restored) + assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertNotNull(secrets.load(legacyReference)) + assertEquals( + listOf("ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", "ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED"), + diagnostics.mapNotNull { it.code }, + ) + } + + @Test + fun activeRegistryMismatchNeverBindsTheLegacyPasswordToAnotherAccount() = withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + putLegacySession(preferences, secrets, first) + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodeNextcloudAccountRegistry(registry)) + val diagnostics = mutableListOf() + + val restored = persistence(preferences, secrets, diagnostics).loadActiveSession() + + assertNull(restored) + assertEquals(registry, decodeRegistry(preferences)) + assertNull(secrets.load(desktopAccountSecretReference(second.accountId))) + assertEquals(listOf("ACCOUNT_CREDENTIAL_ACTIVE_MISMATCH"), diagnostics.mapNotNull { it.code }) + assertDiagnosticsExcludePrivateValues(diagnostics) + } + + @Test + fun removingTheActiveAccountRetainsOtherCredentialsWithoutSelectingOne() = withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(first) + persistence.saveSession(second) + + assertTrue(persistence.removeAccount(second.accountId)) + + val restarted = persistence(preferences, secrets) + assertNull(restarted.loadActiveSession()) + assertNull(restarted.activeAccountId()) + assertEquals(listOf(first.accountRecord()), restarted.listAccounts()) + assertNotNull(secrets.load(desktopAccountSecretReference(first.accountId))) + assertNull(secrets.load(desktopAccountSecretReference(second.accountId))) + } + + @Test + fun activeAccountWithMissingCredentialCanStillBeRemoved() = withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(first) + persistence.saveSession(second) + secrets.clear(desktopAccountSecretReference(second.accountId)) + + assertNull(persistence.loadActiveSession()) + assertTrue(persistence.removeAccount(second.accountId)) + + val restarted = persistence(preferences, secrets) + assertNull(restarted.activeAccountId()) + assertEquals(listOf(first.accountRecord()), restarted.listAccounts()) + } + + @Test + fun failedCredentialDeletionKeepsAPostCommitRetryJournal() = withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(first) + persistence.saveSession(second) + secrets.failClears = true + + assertTrue(persistence.removeAccount(second.accountId)) + + assertNull(persistence.activeAccountId()) + assertEquals(listOf(first.accountRecord()), persistence.listAccounts()) + assertNotNull(secrets.load(desktopAccountSecretReference(second.accountId))) + assertEquals(second.accountId.storageKey, preferences.get("accountCredentialRemovals", null)) + + secrets.failClears = false + assertNull(persistence(preferences, secrets).loadActiveSession()) + assertNull(secrets.load(desktopAccountSecretReference(second.accountId))) + assertNull(preferences.get("accountCredentialRemovals", null)) + } + + @Test + fun removalJournalNeverDeletesAStillRegisteredCredential() = withStore { preferences, secrets -> + val session = firstSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(session) + preferences.put("accountCredentialRemovals", session.accountId.storageKey) + preferences.flush() + + assertEquals(session, persistence.loadActiveSession()) + assertNotNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertNull(preferences.get("accountCredentialRemovals", null)) + } + + @Test + fun failedRegistryFlushLeavesTheCredentialAndAccountIntact() = + withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + var flushAttempts = 0 + val persistence = persistence(preferences, secrets) { + flushAttempts += 1 + if (flushAttempts == 10) error("synthetic removal flush failure") + preferences.flush() + } + persistence.saveSession(first) + persistence.saveSession(second) + + assertFailsWith { + persistence.removeAccount(second.accountId) + } + + assertEquals(second.accountId, persistence.activeAccountId()) + assertEquals(setOf(first.accountRecord(), second.accountRecord()), persistence.listAccounts().toSet()) + assertNotNull(secrets.load(desktopAccountSecretReference(second.accountId))) + assertNull(preferences.get("accountCredentialRemovals", null)) + assertTrue(persistence.removeAccount(second.accountId)) + assertNull(persistence(preferences, secrets).activeAccountId()) + } + + @Test + fun largeRegistryPersistsCredentialAndMetadataThroughPreferenceChunks() = withStore { preferences, secrets -> + val session = NextcloudSession( + serverUrl = "https://cloud.example.test/" + "a".repeat(8_050), + loginName = "alice", + appPassword = "private-app-password", + ) + + assertEquals(session, persistence(preferences, secrets).saveSession(session)) + + assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertEquals(session.serverUrl, preferences.get("server", null)) + assertEquals(session.loginName, preferences.get("login", null)) + assertNotNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertEquals(session.accountId, decodeRegistry(preferences).activeAccountId) + } + + @Test + fun migrationFailureAttachesABoundedCauseWithoutPrivateValues() = withStore { preferences, secrets -> + val session = firstSession() + putLegacySession(preferences, secrets, session) + secrets.failSaves = true + val diagnostics = mutableListOf() + + val restored = persistence(preferences, secrets, diagnostics).loadActiveSession() + + assertEquals(session, restored) + assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + val diagnostic = diagnostics.single { it.code == "ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED" } + assertNotNull(diagnostic.exception) + assertNull(diagnostic.exception.message) + assertDiagnosticsExcludePrivateValues(diagnostics) + } + + private fun persistence( + preferences: Preferences, + secrets: MemorySecretStore, + diagnostics: MutableList = mutableListOf(), + flushPreferences: () -> Unit = preferences::flush, + ) = DesktopAccountCredentialPersistence(preferences, secrets, diagnostics::add, flushPreferences) + + private fun putLegacySession( + preferences: Preferences, + secrets: MemorySecretStore, + session: NextcloudSession, + ) { + preferences.put("server", session.serverUrl) + preferences.put("login", session.loginName) + secrets.save( + desktopSessionSecretReference(session.serverUrl, session.loginName), + session.loginName, + session.appPassword.encodeToByteArray(), + ) + } + + private fun decodeRegistry(preferences: Preferences): NextcloudAccountRegistry = requireNotNull( + decodeNextcloudAccountRegistry( + requireNotNull(DesktopAccountRegistryPreferenceStore(preferences).read()), + ), + ) + + private fun assertDiagnosticsExcludePrivateValues(diagnostics: List) { + val rendered = diagnostics.joinToString() + assertFalse(rendered.contains("private-app-password")) + assertFalse(rendered.contains("second-private-password")) + assertFalse(rendered.contains("alice")) + assertFalse(rendered.contains("cloud.example.test")) + } + + private fun firstSession() = NextcloudSession( + serverUrl = "https://cloud.example.test", + loginName = "alice", + appPassword = "private-app-password", + ) + + private fun secondSession() = NextcloudSession( + serverUrl = "https://second.example.test/nextcloud", + loginName = "bob", + appPassword = "second-private-password", + ) + + private fun withStore(block: (Preferences, MemorySecretStore) -> Unit) { + val preferences = Preferences.userRoot().node( + "dev/obiente/nextcloudnative/tests/account-credentials/${UUID.randomUUID()}", + ) + try { + block(preferences, MemorySecretStore()) + } finally { + preferences.removeNode() + } + } + + private class MemorySecretStore : DesktopSecretStore { + private val values = mutableMapOf() + var failSaves = false + var failClears = false + var loadFailure: RuntimeException? = null + var loadCount = 0 + private set + var clearCount = 0 + private set + + override fun load(reference: DesktopSecretReference): ByteArray? { + loadCount += 1 + loadFailure?.let { throw it } + return values[reference.targetName]?.copyOf() + } + + override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { + if (failSaves) error("private-app-password at cloud.example.test for alice") + values[reference.targetName] = secret.copyOf() + } + + override fun clear(reference: DesktopSecretReference) { + clearCount += 1 + if (failClears) error("synthetic secret deletion failure") + values.remove(reference.targetName) + } + + fun resetOperationCounts() { + loadCount = 0 + clearCount = 0 + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt new file mode 100644 index 000000000..4455b991e --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -0,0 +1,663 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +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.yield +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.UUID +import java.util.prefs.Preferences +import kotlin.concurrent.thread + +class DesktopAccountOperationGuardTest { + @Test + fun abortedAccountSelectionAlwaysRestartsDesktopSync() = runBlocking { + var restartCount = 0 + + assertFailsWith { + restartDesktopSyncAfterSelection( + select = { throw CancellationException("selection cancelled") }, + restart = { restartCount += 1 }, + ) + } + assertFailsWith { + restartDesktopSyncAfterSelection( + select = { error("credential persistence failed") }, + restart = { restartCount += 1 }, + ) + } + + assertEquals(2, restartCount) + } + + @Test + fun resourceActivationCannotPassAConcurrentAccountMutation() = runBlocking { + val guard = DesktopAccountOperationGuard() + val mutationEntered = CompletableDeferred() + val releaseMutation = CompletableDeferred() + var resourceActivated = false + + val mutation = async { + guard.serialize { + mutationEntered.complete(Unit) + releaseMutation.await() + } + } + mutationEntered.await() + val activation = async { + guard.serializeResourceActivation { resourceActivated = true } + } + yield() + + assertFalse(resourceActivated) + releaseMutation.complete(Unit) + mutation.await() + activation.await() + assertTrue(resourceActivated) + } + + @Test + fun synchronousRangeRegistrationCannotEnterDuringAccountMutation() = runBlocking { + val guard = DesktopAccountOperationGuard() + val mutationEntered = CompletableDeferred() + val releaseMutation = CompletableDeferred() + val mutation = async { + guard.serialize { + mutationEntered.complete(Unit) + releaseMutation.await() + } + } + mutationEntered.await() + + assertFalse(guard.tryActivateResource { true }) + + releaseMutation.complete(Unit) + mutation.await() + assertTrue(guard.tryActivateResource { true }) + } + + @Test + fun accountMutationObservesAResourceRegisteredJustBeforeItStarts() = runBlocking { + val guard = DesktopAccountOperationGuard() + val registrationEntered = CountDownLatch(1) + val releaseRegistration = CountDownLatch(1) + val mutationEntered = CompletableDeferred() + val registration = thread { + assertTrue( + guard.tryActivateResource { + registrationEntered.countDown() + check(releaseRegistration.await(5, TimeUnit.SECONDS)) + true + }, + ) + } + check(registrationEntered.await(5, TimeUnit.SECONDS)) + + val mutation = async(Dispatchers.Default) { + guard.serialize { mutationEntered.complete(Unit) } + } + yield() + assertFalse(mutationEntered.isCompleted) + + releaseRegistration.countDown() + registration.join() + mutation.await() + assertTrue(mutationEntered.isCompleted) + } + + @Test + fun resourceActivationRejectsAStaleAccountAfterWaitingForTheGuard() { + val first = NextcloudSession("https://first.example.test", "alice", "one") + val second = NextcloudSession("https://second.example.test", "bob", "two") + val guard = DesktopAccountOperationGuard() + var hydrationRegistered = false + + assertTrue(desktopResourceActivationMatchesActiveSession(first, first.copy())) + assertFalse(desktopResourceActivationMatchesActiveSession(second, first)) + assertFalse(desktopResourceActivationMatchesActiveSession(null, first)) + assertFalse( + desktopResourceActivationMatchesActiveSession( + first.copy(appPassword = "rotated"), + first, + ), + ) + assertFalse( + guard.tryActivateResource { + desktopResourceActivationMatchesActiveSession(second, first) && + true.also { hydrationRegistered = true } + }, + ) + assertFalse(hydrationRegistered) + } + + @Test + fun resourceDeactivationRejectsAStaleAccountAndAnotherAccountsProvider() { + val first = NextcloudSession("https://first.example.test", "alice", "one") + val second = NextcloudSession("https://second.example.test", "bob", "two") + val firstIdentity = desktopFileCacheAccountId(first) + val secondIdentity = desktopFileCacheAccountId(second) + + assertTrue(desktopResourceDeactivationTargetsCurrentProvider(first, first.copy(), firstIdentity)) + assertFalse(desktopResourceDeactivationTargetsCurrentProvider(second, first, secondIdentity)) + assertFalse(desktopResourceDeactivationTargetsCurrentProvider(first, first, secondIdentity)) + assertFalse(desktopResourceDeactivationTargetsCurrentProvider(null, first, firstIdentity)) + } + + @Test + fun syncRunRejectsAStaleAccountAfterWaitingForSelection() { + val first = NextcloudSession("https://first.example.test", "alice", "one") + val second = NextcloudSession("https://second.example.test", "bob", "two") + + assertTrue(desktopSyncRunMatchesActiveSession(first, first.copy())) + assertFalse(desktopSyncRunMatchesActiveSession(second, first)) + assertFalse(desktopSyncRunMatchesActiveSession(activeSession = null, first)) + assertFalse( + desktopSyncRunMatchesActiveSession( + first.copy(appPassword = "rotated"), + first, + ), + ) + } + + @Test + fun fileSyncPairCreationWaitsForRemovalAndRejectsTheStaleSession() = runBlocking { + val first = NextcloudSession("https://first.example.test", "alice", "one") + val second = NextcloudSession("https://second.example.test", "bob", "two") + val guard = DesktopAccountOperationGuard() + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var current = first + var pairCreated = false + val removal = async { + guard.serializeWhenSyncIdle { + current = second + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + removalEntered.await() + + val result = async { + guard.serializeWhenSyncIdle { + if (!desktopSyncRunMatchesActiveSession(current, first)) { + "rejected" + } else { + pairCreated = true + "created" + } + } + } + yield() + + assertFalse(result.isCompleted) + releaseRemoval.complete(Unit) + removal.await() + assertEquals("rejected", result.await()) + assertFalse(pairCreated) + } + + @Test + fun sessionRevocationWaitsForSyncAndBlocksMutationsUntilLocalRemoval() = runBlocking { + val guard = DesktopAccountOperationGuard() + val syncEntered = CompletableDeferred() + val releaseSync = CompletableDeferred() + val events = mutableListOf() + var localRemovalCommitted = false + + val sync = async { + guard.withSyncRunLock { + syncEntered.complete(Unit) + releaseSync.await() + } + } + syncEntered.await() + val revocation = async { + guard.serializeWhenSyncIdle { + events += "preflight" + events += "revoke" + localRemovalCommitted = true + events += "remove-local" + } + } + yield() + val laterMutation = async { + guard.serialize { localRemovalCommitted } + } + yield() + + assertFalse(revocation.isCompleted) + assertFalse(laterMutation.isCompleted) + releaseSync.complete(Unit) + sync.await() + revocation.await() + assertTrue(laterMutation.await()) + assertEquals(listOf("preflight", "revoke", "remove-local"), events) + } + + @Test + fun differentAccountSaveRequiresTheSelectionTransition() { + val first = NextcloudSession("https://first.example.test", "alice", "one") + val second = NextcloudSession("https://second.example.test", "bob", "two") + + assertFalse(desktopSessionSaveSwitchesAccount(null, first.accountId)) + assertFalse(desktopSessionSaveSwitchesAccount(first.accountId, first.accountId)) + assertTrue(desktopSessionSaveSwitchesAccount(first.accountId, second.accountId)) + } + + @Test + fun activeCredentialReplacementRequiresLiveResourcesToClose() { + val original = NextcloudSession("https://first.example.test", "alice", "one") + + assertFalse(desktopSessionSaveReplacesActiveCredential(activeSession = null, savedSession = original)) + assertFalse(desktopSessionSaveReplacesActiveCredential(original, original.copy())) + assertTrue( + desktopSessionSaveReplacesActiveCredential( + original, + original.copy(appPassword = "replacement-password"), + ), + ) + assertFalse( + desktopSessionSaveReplacesActiveCredential( + original, + NextcloudSession("https://second.example.test", "alice", "replacement-password"), + ), + ) + } + + @Test + fun blockedAccountSaveRecordsTheSelectionDiagnosticBeforeFailing() { + val diagnostics = mutableListOf() + + assertFailsWith { + requireDesktopSessionSaveAllowed(allowed = false, recordBlocked = diagnostics::add) + } + + assertEquals(listOf("ACCOUNT_SELECTION_ACTIVE_RESOURCES"), diagnostics.map { it.code }) + } + + @Test + fun retainedSelectionReopensTheDesktopSessionOnlyAfterSuccess() { + var reopenCount = 0 + val session = NextcloudSession("https://first.example.test", "alice", "one") + + assertNull(reopenDesktopSessionAfterSelection(null) { reopenCount += 1 }) + assertEquals(session, reopenDesktopSessionAfterSelection(session) { reopenCount += 1 }) + assertEquals(1, reopenCount) + } + @Test + fun removalCannotPassAConcurrentSelection() = runBlocking { + val guard = DesktopAccountOperationGuard() + val selectionStarted = CompletableDeferred() + val releaseSelection = CompletableDeferred() + val events = mutableListOf() + val selection = async { + guard.serialize { + events += "selection-started" + selectionStarted.complete(Unit) + releaseSelection.await() + events += "selection-finished" + } + } + selectionStarted.await() + + val removal = async { + guard.serialize { events += "removal" } + } + yield() + + assertFalse(removal.isCompleted) + releaseSelection.complete(Unit) + selection.await() + removal.await() + assertEquals(listOf("selection-started", "selection-finished", "removal"), events) + } + + @Test + fun accountMutationWaitsForAnIndependentSyncRun() = runBlocking { + val guard = DesktopAccountOperationGuard() + val releaseSync = CompletableDeferred() + val syncStarted = CompletableDeferred() + val events = mutableListOf() + val sync = async { + guard.withSyncRunLock { + syncStarted.complete(Unit) + releaseSync.await() + } + } + syncStarted.await() + val mutation = async { + guard.serializeWhenSyncIdle { + events += "account-mutated" + } + } + yield() + + assertFalse(mutation.isCompleted) + assertEquals(emptyList(), events) + releaseSync.complete(Unit) + sync.await() + mutation.await() + assertEquals(listOf("account-mutated"), events) + } + + @Test + fun pairRemovalWaitsForTheSelectionSyncBoundary() = runBlocking { + val guard = DesktopAccountOperationGuard() + val selectionEntered = CompletableDeferred() + val releaseSelection = CompletableDeferred() + var removalEntered = false + val selection = async { + guard.serialize { + guard.withSyncRunLock { + selectionEntered.complete(Unit) + releaseSelection.await() + } + } + } + selectionEntered.await() + val removal = async { + guard.withSyncRunLock { removalEntered = true } + } + yield() + + assertFalse(removalEntered) + releaseSelection.complete(Unit) + selection.await() + removal.await() + assertTrue(removalEntered) + } + + @Test + fun authenticatedMutationWaitsForSelectionAndRejectsTheStaleSession() = runBlocking { + val first = NextcloudSession("https://first.example.test", "alice", "one") + val second = NextcloudSession("https://second.example.test", "bob", "two") + val guard = DesktopAccountOperationGuard() + val selectionEntered = CompletableDeferred() + val releaseSelection = CompletableDeferred() + var current = first + var requestSent = false + val selection = async { + guard.serialize { + current = second + selectionEntered.complete(Unit) + releaseSelection.await() + } + } + selectionEntered.await() + + val mutation = async { + runCatching { + guard.withAuthenticatedMutationSession(first, { current }) { + requestSent = true + } + } + } + yield() + + assertFalse(mutation.isCompleted) + releaseSelection.complete(Unit) + selection.await() + assertTrue(mutation.await().isFailure) + assertFalse(requestSent) + } + + @Test + fun pendingLinuxWritebackBlocksAccountRemoval() { + requireDesktopAccountRemovalWritebacksResolved(0) + assertFailsWith { requireDesktopAccountRemovalWritebacksResolved(1) } + } + + @Test + fun failedCredentialRemovalRestoresProviderActivationPreference() = runBlocking { + val events = mutableListOf() + + val failure = runCatching { + removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = true, + clearProviderPreference = { events += "cleared" }, + restoreProviderPreference = { enabled -> events += "restored:$enabled" }, + removeCredential = { + events += "remove" + error("credential removal failed") + }, + ) + } + + assertTrue(failure.isFailure) + assertEquals(listOf("cleared", "remove", "restored:true"), events) + } + + @Test + fun successfulCredentialRemovalLeavesProviderPreferenceDisabled() = runBlocking { + val events = mutableListOf() + + assertTrue( + removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = true, + clearProviderPreference = { events += "cleared" }, + restoreProviderPreference = { enabled -> events += "restored:$enabled" }, + removeCredential = { + events += "remove" + true + }, + ), + ) + + assertEquals(listOf("cleared", "remove"), events) + } + + @Test + fun committedCredentialRemovalFailureDoesNotReactivateTheProvider() { + val events = mutableListOf() + + assertFailsWith { + removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = true, + clearProviderPreference = { events += "cleared" }, + restoreProviderPreference = { enabled -> events += "restored:$enabled" }, + removalCommitted = { true }, + removeCredential = { + events += "remove" + error("synthetic post-commit credential cleanup failure") + }, + ) + } + + assertEquals(listOf("cleared", "remove"), events) + } + + @Test + fun committedInactiveRemovalSurvivesSyncPairCleanupFailure() = runBlocking { + val events = mutableListOf() + + val removed = removeDesktopAccountBeforeSyncPairCleanup( + accountId = CLEANUP_ACCOUNT_ID, + prepareCleanup = { events += "prepare-cleanup" }, + commitCleanup = { events += "commit-cleanup" }, + clearCleanup = { events += "clear-cleanup" }, + accountStillExists = { false }, + removeCredential = { + events += "remove-credential" + true + }, + removeSyncPairs = { + events += "remove-pairs" + error("synthetic pair cleanup failure") + }, + recordCleanupFailure = { events += "diagnose-cleanup" }, + ) + + assertTrue(removed) + assertEquals( + listOf( + "prepare-cleanup", + "remove-credential", + "commit-cleanup", + "remove-pairs", + "diagnose-cleanup", + ), + events, + ) + } + + @Test + fun committedRemovalPreservesPairCleanupCancellation() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + removeDesktopAccountBeforeSyncPairCleanup( + accountId = CLEANUP_ACCOUNT_ID, + prepareCleanup = { events += "prepare-cleanup" }, + commitCleanup = { events += "commit-cleanup" }, + clearCleanup = { events += "clear-cleanup" }, + accountStillExists = { false }, + removeCredential = { + events += "remove-credential" + true + }, + removeSyncPairs = { + events += "remove-pairs" + throw CancellationException("pair cleanup owner stopped") + }, + recordCleanupFailure = { events += "diagnose-cleanup" }, + ) + } + + assertEquals( + listOf("prepare-cleanup", "remove-credential", "commit-cleanup", "remove-pairs"), + events, + ) + } + + @Test + fun postCommitCredentialFailureRetainsCommittedPairCleanupRecovery() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + removeDesktopAccountBeforeSyncPairCleanup( + accountId = CLEANUP_ACCOUNT_ID, + prepareCleanup = { events += "prepare-cleanup" }, + commitCleanup = { events += "commit-cleanup" }, + clearCleanup = { events += "clear-cleanup" }, + accountStillExists = { false }, + removeCredential = { + events += "remove-credential" + error("synthetic post-commit credential cleanup failure") + }, + removeSyncPairs = { events += "remove-pairs" }, + recordCleanupFailure = { events += "diagnose-cleanup" }, + ) + } + + assertEquals(listOf("prepare-cleanup", "remove-credential", "commit-cleanup"), events) + } + + @Test + fun failedActiveCredentialCommitPreservesSyncPairs() = runBlocking { + val events = mutableListOf() + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + + try { + assertFailsWith { + clearDesktopActiveAccountBeforeSyncPairCleanup( + accountId = CLEANUP_ACCOUNT_ID, + cleanupJournal = DesktopAccountSyncPairCleanupJournal(preferences), + accountStillExists = { true }, + commitRemoval = { + events += "remove-credential" + error("synthetic credential commit failure") + }, + removeSyncPairs = { events += "remove-pairs-$it" }, + recordDiagnostic = { events += "diagnose-cleanup" }, + ) + } + + assertEquals(listOf("remove-credential"), events) + assertTrue(DesktopAccountSyncPairCleanupJournal(preferences).pending().isEmpty()) + } finally { + preferences.removeNode() + } + } + + @Test + fun committedPairCleanupFailureSurvivesRestartAndBlocksReactivationUntilRetry() = runBlocking { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val firstJournal = DesktopAccountSyncPairCleanupJournal(preferences) + try { + val removalEvents = mutableListOf() + assertTrue( + removeDesktopAccountBeforeSyncPairCleanup( + accountId = CLEANUP_ACCOUNT_ID, + prepareCleanup = firstJournal::prepare, + commitCleanup = firstJournal::commit, + clearCleanup = firstJournal::clear, + accountStillExists = { false }, + removeCredential = { true }, + removeSyncPairs = { error("synthetic pair cleanup failure") }, + recordCleanupFailure = { removalEvents += "diagnose" }, + ), + ) + + assertEquals(listOf("diagnose"), removalEvents) + val restored = DesktopAccountSyncPairCleanupJournal(preferences) + assertEquals( + listOf( + DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Committed, + ), + ), + restored.pending(), + ) + + val retryEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = restored.pending().single(), + accountStillExists = { true }, + removeSyncPairs = { retryEvents += "remove-pairs-$it" }, + clearCleanup = { + retryEvents += "clear-cleanup-$it" + restored.clear(it) + }, + ) + + assertEquals( + listOf("remove-pairs-$CLEANUP_ACCOUNT_ID", "clear-cleanup-$CLEANUP_ACCOUNT_ID"), + retryEvents, + ) + assertTrue(restored.pending().isEmpty()) + } finally { + preferences.removeNode() + } + } + + @Test + fun preparedCleanupFromAnAbortedRemovalPreservesExistingPairs() = runBlocking { + val events = mutableListOf() + + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Prepared, + ), + accountStillExists = { true }, + removeSyncPairs = { events += "remove-pairs" }, + clearCleanup = { events += "clear-cleanup" }, + ) + + assertEquals(listOf("clear-cleanup"), events) + } + + private companion object { + const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt index ff547f349..1eed712e7 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt @@ -61,7 +61,7 @@ class DesktopAccountRegistryPersistenceTest { } @Test - fun oversizedMigrationReportsABoundedCauseWithoutChangingPreferences() = withPreferences { preferences -> + fun largeLegacyAccountMigratesThroughChunkedPreferences() = withPreferences { preferences -> val session = NextcloudSession( serverUrl = "https://cloud.example.test/" + "a".repeat(8_050), loginName = "alice", @@ -71,17 +71,15 @@ class DesktopAccountRegistryPersistenceTest { restoreDesktopAccountRegistry(preferences, session, diagnostics::add) + val encoded = requireNotNull(DesktopAccountRegistryPreferenceStore(preferences).read()) + assertEquals(session.accountId, requireNotNull(decodeNextcloudAccountRegistry(encoded)).activeAccountId) + assertTrue(encoded.length > Preferences.MAX_VALUE_LENGTH) assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) - val diagnostic = diagnostics.single() - assertEquals("ACCOUNT_REGISTRY_MIGRATION_FAILED", diagnostic.code) - assertNotNull(diagnostic.exception) - assertNull(diagnostic.exception.message) - assertFalse(diagnostic.toString().contains(session.appPassword)) - assertFalse(diagnostic.toString().contains(session.serverUrl)) + assertTrue(diagnostics.isEmpty()) } @Test - fun desktopValueLimitIsValidatedBeforeAnyMetadataWrite() = withPreferences { preferences -> + fun preparingALargeRegistryDoesNotWriteMetadata() = withPreferences { preferences -> val session = NextcloudSession( serverUrl = "https://cloud.example.test/" + "a".repeat(8_050), loginName = "alice", @@ -90,13 +88,58 @@ class DesktopAccountRegistryPersistenceTest { preferences.put("server", "existing-server") preferences.put("login", "existing-login") - assertFailsWith { prepareDesktopAccountRegistry(session) } + val encoded = prepareDesktopAccountRegistry(session) + assertTrue(encoded.length > Preferences.MAX_VALUE_LENGTH) assertEquals("existing-server", preferences.get("server", null)) assertEquals("existing-login", preferences.get("login", null)) assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) } + @Test + fun maximumAccountCountRoundTripsAcrossBoundedPreferenceChunks() = withPreferences { preferences -> + val accounts = (0 until MAX_LOCAL_ACCOUNTS).map { index -> + NextcloudSession( + serverUrl = "https://cloud-$index.example.test/nextcloud", + loginName = "person-$index-${"x".repeat(120)}", + appPassword = "not-persisted", + ).accountRecord() + } + val registry = NextcloudAccountRegistry(accounts, accounts.last().id) + val encoded = encodeNextcloudAccountRegistry(registry) + val store = DesktopAccountRegistryPreferenceStore(preferences) + + assertTrue(encoded.length > Preferences.MAX_VALUE_LENGTH) + store.write(encoded) + + assertEquals(encoded, DesktopAccountRegistryPreferenceStore(preferences).read()) + assertTrue( + preferences.keys() + .filter { key -> key.startsWith("account_registry_v2.") } + .map { key -> requireNotNull(preferences.get(key, null)) } + .all { value -> value.length <= Preferences.MAX_VALUE_LENGTH }, + ) + } + + @Test + fun failedInactiveGenerationWriteKeepsThePreviouslyCommittedRegistry() = withPreferences { preferences -> + val session = NextcloudSession( + serverUrl = "https://cloud.example.test/${"a".repeat(8_050)}", + loginName = "alice", + appPassword = "not-persisted", + ) + val first = prepareDesktopAccountRegistry(session) + val second = prepareDesktopAccountRegistry(session.copy(loginName = "bob")) + DesktopAccountRegistryPreferenceStore(preferences).write(first) + val failingStore = DesktopAccountRegistryPreferenceStore(preferences) { + error("synthetic inactive generation flush failure") + } + + assertFailsWith { failingStore.write(second) } + + assertEquals(first, DesktopAccountRegistryPreferenceStore(preferences).read()) + } + @Test fun explicitSaveAndRemovalOwnOnlyCredentialFreeMetadata() = withPreferences { preferences -> val session = session() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt index 7918f7ca4..932859581 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt @@ -20,6 +20,85 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put class DesktopFileSyncStoreTest { + @Test + fun `account removal deletes only that account's sync pairs and roots`() { + val directory = Files.createTempDirectory("desktop-sync-account-removal-").toFile() + try { + val first = FileSyncPair( + id = "first-pair", + accountId = "account-a", + localRootId = "first-root", + remoteRootPath = "Documents", + configuration = FileSyncConfiguration(deviceLabel = "Workstation"), + ) + val second = FileSyncPair( + id = "second-pair", + accountId = "account-b", + localRootId = "second-root", + remoteRootPath = "Photos", + configuration = FileSyncConfiguration(deviceLabel = "Workstation"), + ) + val roots = listOf( + DesktopFileSyncRootRecord(first.localRootId, directory.resolve("first").absolutePath, "First"), + DesktopFileSyncRootRecord(second.localRootId, directory.resolve("second").absolutePath, "Second"), + ) + val store = DesktopFileSyncStore(File(directory, "state.db"), legacyStateFile = null) + store.savePair(DesktopFileSyncPersistedState(FileSyncCoordinatorState(listOf(first)), roots.take(1)), first.id) + store.savePair(DesktopFileSyncPersistedState(FileSyncCoordinatorState(listOf(second)), roots.drop(1)), second.id) + + store.removeDesktopFileSyncAccountPairs("account-a") + + val retained = store.load() + assertEquals(listOf(second), retained.coordinator.pairs) + assertEquals(roots.drop(1), retained.roots) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `account removal retains every pair when one owns an unfinished remote upload`() { + val directory = Files.createTempDirectory("desktop-sync-account-upload-removal-").toFile() + try { + val owned = FileSyncPair( + id = "owned-pair", + accountId = "account-a", + localRootId = "owned-root", + remoteRootPath = "Documents", + configuration = FileSyncConfiguration(deviceLabel = "Workstation"), + pendingUploadCleanups = listOf( + FileSyncPendingUploadCleanup( + uploadId = "11111111-1111-4111-8111-111111111111", + relativePath = "draft.txt", + ), + ), + ) + val clear = FileSyncPair( + id = "clear-pair", + accountId = "account-a", + localRootId = "clear-root", + remoteRootPath = "Photos", + configuration = FileSyncConfiguration(deviceLabel = "Workstation"), + ) + val roots = listOf( + DesktopFileSyncRootRecord(owned.localRootId, directory.resolve("owned").absolutePath, "Owned"), + DesktopFileSyncRootRecord(clear.localRootId, directory.resolve("clear").absolutePath, "Clear"), + ) + val store = DesktopFileSyncStore(File(directory, "state.db"), legacyStateFile = null) + store.savePair(DesktopFileSyncPersistedState(FileSyncCoordinatorState(listOf(owned)), roots.take(1)), owned.id) + store.savePair(DesktopFileSyncPersistedState(FileSyncCoordinatorState(listOf(clear)), roots.drop(1)), clear.id) + + assertFails { store.requireDesktopFileSyncAccountRemovalReady("account-a") } + assertFails { store.removeDesktopFileSyncAccountPairs("account-a") } + + val retained = store.load() + assertEquals(setOf(owned, clear), retained.coordinator.pairs.toSet()) + assertEquals(roots.toSet(), retained.roots.toSet()) + } finally { + directory.deleteRecursively() + } + } + @Test fun `legacy json state imports once into the transactional database`() { val directory = Files.createTempDirectory("desktop-sync-legacy-import-").toFile() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt index a54ec313f..19f7ec35c 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt @@ -842,6 +842,23 @@ class DesktopSecretStoreTest { assertEquals("alice", first.attributes.getValue("login")) } + @Test + fun accountCredentialReferenceContainsOnlyTheOpaqueAccountIdentity() { + val session = NextcloudSession( + serverUrl = "https://cloud.invalid", + loginName = "alice", + appPassword = "private-app-password", + ) + + val reference = desktopAccountSecretReference(session.accountId) + val rendered = listOf(reference.targetName, reference.label, reference.attributes.toString()).joinToString() + + assertTrue(rendered.contains(session.accountId.storageKey)) + assertFalse(rendered.contains(session.serverUrl)) + assertFalse(rendered.contains(session.loginName)) + assertFalse(rendered.contains(session.appPassword)) + } + @Test fun windowsCredentialManagerRoundTripUsesCurrentUserCredentialSet() { if (desktopSecretStoreKind() != DesktopSecretStoreKind.WindowsCredentialManager) return diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransportTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransportTest.kt index bcc28a5f9..f942950d8 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransportTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransportTest.kt @@ -1,14 +1,17 @@ package dev.obiente.nextcloudnative.app import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull class JvmLoginFlowTransportTest { @Test - fun `not found advertised path accepts approval from entered base path compatibility endpoint`() { + fun `not found advertised path accepts approval from entered base path compatibility endpoint`() = runBlocking { val endpoints = mutableListOf() val execution = executeLoginPollHttp( challenge = challenge( @@ -41,7 +44,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `dual not found responses keep probing without abandoning advertised route`() { + fun `dual not found responses keep probing without abandoning advertised route`() = runBlocking { val endpoints = mutableListOf() val execution = executeLoginPollHttp( challenge = challenge( @@ -82,7 +85,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `incompatible compatibility response leaves advertised pending route selected`() { + fun `incompatible compatibility response leaves advertised pending route selected`() = runBlocking { val execution = executeLoginPollHttp( challenge = challenge( pollEndpoint = "https://cloud.example.test/login/v2/poll", @@ -105,7 +108,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `pre exchange DNS failure probes pending compatibility endpoint without pinning it`() { + fun `pre exchange DNS failure probes pending compatibility endpoint without pinning it`() = runBlocking { var diagnostic: JvmNetworkFailureDiagnostic? = null val endpoints = mutableListOf() val execution = executeLoginPollHttp( @@ -140,7 +143,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `pre exchange DNS failure selects compatibility endpoint after approval`() { + fun `pre exchange DNS failure selects compatibility endpoint after approval`() = runBlocking { var diagnostic: JvmNetworkFailureDiagnostic? = null val execution = executeLoginPollHttp( challenge = challenge( @@ -163,7 +166,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `incompatible fallback response preserves retryable advertised endpoint failure`() { + fun `incompatible fallback response preserves retryable advertised endpoint failure`() = runBlocking { listOf(405, 503).forEach { fallbackStatus -> var diagnostic: JvmNetworkFailureDiagnostic? = null val endpoints = mutableListOf() @@ -209,7 +212,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `failure after compatibility exchange is ambiguous`() { + fun `failure after compatibility exchange is ambiguous`() = runBlocking { var diagnostic: JvmNetworkFailureDiagnostic? = null val execution = executeLoginPollHttp( challenge = challenge( @@ -233,7 +236,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `malformed compatibility approval is never diagnosed as retry safe`() { + fun `malformed compatibility approval is never diagnosed as retry safe`() = runBlocking { val execution = executeLoginPollHttp( challenge = challenge( pollEndpoint = "https://cloud.example.test/login/v2/poll", @@ -259,6 +262,22 @@ class JvmLoginFlowTransportTest { assertEquals("false", fields["safe_to_retry"]) } + @Test + fun `poll cancellation is never detached or classified as a network failure`() = runBlocking { + assertFailsWith { + executeLoginPollHttp( + challenge = challenge( + pollEndpoint = "https://cloud.example.test/login/v2/poll", + fallbackEndpoint = "https://cloud.example.test/index.php/login/v2/poll", + ), + fallbackAlreadySelected = false, + poll = { throw CancellationException("screen left composition") }, + networkFailure = { null }, + ) + } + Unit + } + private fun challenge(pollEndpoint: String, fallbackEndpoint: String?) = LoginChallenge( enteredServerUrl = "https://cloud.example.test/nextcloud", pollEndpoint = pollEndpoint, diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransport.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransport.kt index 6f92593c2..a216d8ddc 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransport.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransport.kt @@ -21,10 +21,10 @@ data class LoginPollHttpExecution( val selectedFallbackReason: LoginPollFallbackReason? = null, ) -fun executeLoginPollHttp( +suspend fun executeLoginPollHttp( challenge: LoginChallenge, fallbackAlreadySelected: Boolean, - poll: (String) -> LoginPollHttpResponse, + poll: suspend (String) -> LoginPollHttpResponse, networkFailure: () -> JvmNetworkFailureDiagnostic?, ): LoginPollHttpExecution { val fallbackEndpoint = challenge.pollFallbackEndpoint @@ -39,7 +39,7 @@ fun executeLoginPollHttp( selectedFallbackReason = selectedFallbackReason, ) - fun attempt(endpoint: String): LoginPollHttpResponse = try { + suspend fun attempt(endpoint: String): LoginPollHttpResponse = try { poll(endpoint) } catch (failure: Throwable) { if (failure is CancellationException) throw failure diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 6e6b9967d..c7001d9dc 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -198,6 +198,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaCollections.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaTransferCenterHost.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeNotesHierarchy.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudChunkUploadPolicy.kt", @@ -607,8 +608,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaCollections.kt": "0d57ec1eaa6802513aafb88153f23e603a64fd7d1028e586227986ed155c6b14", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaTransferCenterHost.kt": "85b0eb13a4376d0f3d2c101063e6bfffe05a61a97d7109a5079e68ff11d05a64", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeNotesHierarchy.kt": "b753f1e3517a34f57d90f6a4c4067b8bfcc8085af080e99c33b3f4d29dffcbf4", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt": "0886c2513430f6940fd4eefe6f85091215122ef7129d9c218ab6a00823a59434", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt": "86f7d744325e498bc6b03ef7099558a31a14a4aaa8e904224fd546553c33eac1", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt": "b95f2863c026b25e545af677720d7f81cf57b1bd4a58bfbf2d99935b7499366b", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt": "569376bf76a4df6a5ca76efeb9bca5308d771f737272eea679fde32f7f5278bd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudChunkUploadPolicy.kt": "2635374193979991fa6b0e4d244a248b27d9ff4fcca148b47412530cb3031d87", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt": "570371d4b41907de1c2abd202a2c767dbe7d734d4c098266380b530c41aba75c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileListing.kt": "1f3de4f31d44d3ead139686d3fbcfbd27ccb520a9dab06ccc1d3e582ee25fb71", @@ -616,12 +618,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "5b29a90b69bb32aba118ef6c8b3f9d6eb26c03835823119b4a0f5bb1c1f4cb17", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "fb424bf8979ac292ef30daba64e905e6f5123066cda793aa0244907e11b465c9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "5d84838b505fcb68985a9372986334c1e35f65c41b9c4b5d57de64fe40062370", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "6f46197f9bd2e931b2f1ea89d3c23d57bb414a7f0ed7caf6f39d8cb01eaa9602", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "b53e2505663f0957dea9ad231b006d03d08f0405dc459974d85eb4768ef03484", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "ac2206703b224364c1a3ff4097026c9c81d856042c20d31e5c28358016c3062d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "873201e412de895571b4982ec1afe029afe348950afd7c1b2491904f015f2068", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "e91ef3e37306c8bbb78e1190142096ede1c2ef17951221a2a4a2de96bf8a2081", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "fead6cbf4f723ea46f99c9155b190967205d24f198687b7bfd5a213c474374b8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "c96941c7582218754243f780a0bb0954ba31484719d8045f1e609f5d8c04a7c5",