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..ba538af3c --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -0,0 +1,800 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudAccountId +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, String?, String?, String?) -> Unit, + private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String, String, String?, String?, String?) -> Unit, + private val activatePersistedAccount: suspend (NextcloudSession) -> Unit, +) { + private val appContext = context.applicationContext + private val handoffCleanup = AndroidExternalFileHandoffCleanup(appContext, preferences, ::commitPreferences) + private val accountRemovalCleanupJournal = AndroidAccountRemovalCleanupJournal( + preferences = preferences, + commit = ::commitPreferences, + recordMalformed = { + recordCredentialFailure( + code = "ACCOUNT_REMOVAL_CLEANUP_JOURNAL_MALFORMED", + operation = "account.remove-cleanup.restore", + component = SupportDiagnosticComponent.Sync, + ) + }, + ) + 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 accountRetentionSnapshot(): AndroidAccountRetentionSnapshot = readRegistryForCredentialLoad() + ?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts, registry.activeAccountId) } + ?: AndroidAccountRetentionSnapshot.Unavailable + + fun activeAccountId(): NextcloudAccountId? = readCredentialFreeRegistry()?.activeAccountId + fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = + ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + val registry = readRegistryForCredentialLoad() ?: return@serialize null + loadSession(accountId, registry) + } + + private fun loadSession( + accountId: NextcloudAccountId, + registry: NextcloudAccountRegistry, + ): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + if (registry.accounts.none { account -> account.id == accountId }) return@serialize null + restoreAndroidSessionAfterRemovalCleanup(accountId, accountRemovalCleanupJournal::snapshot) { + val aggregateRead = readStore() + if (!androidCredentialStoreAllowsSessionRestore(aggregateRead)) return@restoreAndroidSessionAfterRemovalCleanup null + val aggregate = (aggregateRead as? AndroidAccountCredentialStoreRead.Available)?.state + val slotRead = readCredentialSlot(accountId) + if (slotRead is AndroidAccountCredentialSlotRead.Unsupported) return@restoreAndroidSessionAfterRemovalCleanup 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@restoreAndroidSessionAfterRemovalCleanup 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, + read.state.sessions[session.accountId], + ) + } + is AndroidAccountCredentialStoreRead.Invalid -> { + val retained = readIndependentCredentialSlotState() + check(retained != null || !hasAndroidIndependentCredentialState(preferences)) { + "The aggregate account credential store is invalid; reset it before signing in again." + } + replaceActiveState( + replacement = (retained ?: AndroidAccountCredentialState.Empty).upsertAndSelect(session), + previousSession = retained?.activeSession, + replacedSession = retained?.sessions?.get(session.accountId), + suspectEncrypted = read.encrypted, + ) + } + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> { + val retained = readIndependentCredentialSlotState() + check(retained != null || !hasAndroidIndependentCredentialState(preferences)) { + "The independent account credential slots could not be recovered." + } + replaceActiveState( + replacement = (retained ?: AndroidAccountCredentialState.Empty).upsertAndSelect(session), + previousSession = retained?.activeSession, + replacedSession = retained?.sessions?.get(session.accountId), + ) + } + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) + } + requireNotNull(loadSession(session.accountId)) + } + + suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = + ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + val (current, suspectEncrypted) = recoverAndroidAccountCredentialStateForSelection( + readStore(), ::readIndependentCredentialSlotState, + ) + requireSupportedCredentialSlots(current.registry) + val selected = current.select(accountId) ?: return@withLock null + selectAndroidAccountAfterRemovalCleanup( + requireNotNull(selected.activeSession), ::retryPendingAccountRemovalCleanup, registerSessionPrivateValues, + ) { replaceActiveState(selected, current.activeSession, suspectEncrypted = suspectEncrypted) } + } + + suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = + ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + val current = requireValidStateForAccountRemoval(accountId) + val session = current.sessions[accountId] + ?: return@withLock removeUnavailableAccount(accountId, current) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) + withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session)) { + 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, + ) + accountRemovalCleanupJournal.clear(accountId.storageKey) + }, + persistInactiveRemoval = { persistState(current.remove(accountId), pendingCleanup) }, + rollbackInactiveRemoval = { + persistState(current) + accountRemovalCleanupJournal.clear(accountId.storageKey) + }, + completeCommittedCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, + ) + } + true + } + + private suspend fun removeUnavailableAccount( + accountId: NextcloudAccountId, + recovered: AndroidAccountCredentialState, + ): Boolean { + val target = resolveAndroidUnavailableAccountRemovalTarget(readCredentialFreeRegistry(), accountId) ?: return false + val unavailableSession = NextcloudSession(target.record.serverUrl, target.record.loginName, appPassword = "") + val accountIdentity = NextcloudDocumentIds.accountKey(unavailableSession) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(unavailableSession) + withAndroidAccountRemovalLease(accountIdentity) { + removeUnavailableAndroidAccountCredentialData( + accountIdentity = accountIdentity, + active = target.wasActive, + prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, + removeAccountOwnedWorkWithoutCredentials = { identity -> + retryQueuedUploadsCleanupWithoutCredentials( + pendingCleanup.accountStorageKey, + identity, + pendingCleanup.previewCacheIdentity, + pendingCleanup.durableMutationIdentity, + pendingCleanup.legacyAccountScopeDigest, + ) + }, + persistRemoval = { persistState(recovered.remove(accountId), pendingCleanup) }, + clearActiveAccount = { clearSession(recovered, pendingCleanup, unavailableSession) }, + rollbackRemoval = { + rollbackUnavailableAndroidAccountRemoval( + active = target.wasActive, recovered = recovered, persistRecovered = { state -> persistState(state) }, + clearCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, + ) + }, + completeCommittedCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, + ) + } + notifyDocumentRootsChanged() + 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) + accountRemovalCleanupJournal.clear(expectedSession.accountId.storageKey) + }, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + completeCommittedCleanup = { + accountRemovalCleanupJournal.clear(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) + withAndroidAccountRemovalLease(accountIdentity) { + removeAndroidAccountCredentialData( + active = true, + prepareAccountRemoval = { prepareAccountRemoval(session) }, + removeQueuedUploads = { removeQueuedUploads(session) }, + clearActiveAccount = { clearSession(read.state, pendingCleanup) }, + rollbackActiveRemoval = { + replaceActiveStateWhileOperationsIdle( + replacement = read.state, + previousSession = null, + suspectEncrypted = null, + ) + accountRemovalCleanupJournal.clear(session.accountId.storageKey) + }, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + completeCommittedCleanup = { + accountRemovalCleanupJournal.clear(session.accountId.storageKey) + }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, + ) + } + } + } + is AndroidAccountCredentialStoreRead.Invalid -> { + val retained = readIndependentCredentialSlotState() + when { + retained != null -> clearRecoveredInvalidStore(retained, read.encrypted) + hasAndroidIndependentCredentialState(preferences) -> + clearUnregisteredIndependentCredentialSlots(read.encrypted) + else -> clearInvalidStore(read.encrypted) + } + } + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> { + clearUnregisteredIndependentCredentialSlots(null) + } + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) + } + } + + private suspend fun clearSession( + current: AndroidAccountCredentialState, pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, + activeFallback: NextcloudSession? = null, + ) { + val removal = resolveAndroidActiveAccountRemovalTransition(current, activeFallback) ?: return + val replacement = removal.replacement + val encodedReplacement = replacement.takeUnless { state -> + state.registry.accounts.isEmpty() && state.sessions.isEmpty() + }?.let(::encryptState) + clearPersistedSession(encodedReplacement, replacement, pendingCleanup = pendingCleanup) + notifyDocumentRootsChanged() + } + + private suspend fun clearInvalidStore(suspectEncrypted: String?) { + clearPersistedSession( + encodedReplacement = null, + replacement = AndroidAccountCredentialState.Empty, + suspectEncrypted = suspectEncrypted, + ) + notifyDocumentRootsChanged() + } + private suspend fun clearUnregisteredIndependentCredentialSlots(suspectEncrypted: String?) = + clearUnregisteredAndroidAccountCredentialSlots( + preferences, sessionCipher, accountRemovalCleanupJournal, suspectEncrypted, + prepareAccountRemoval, removeQueuedUploads, ::commitPreferences, ::recordAccountRemovalCleanupFailure, + ::clearInvalidStore) + + private suspend fun clearRecoveredInvalidStore( + current: AndroidAccountCredentialState, + suspectEncrypted: String, + ) { + val activeSession = current.activeSession + if (activeSession != null) { + val accountIdentity = NextcloudDocumentIds.accountKey(activeSession) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(activeSession) + withAndroidAccountRemovalLease(accountIdentity) { + removeRecoveredAndroidAccountCredentialData( + prepareAccountRemoval = { prepareAccountRemoval(activeSession) }, + removeQueuedUploads = { removeQueuedUploads(activeSession) }, + clearRecoveredAccount = { + persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted, pendingCleanup) + }, + rollbackRecoveredAccount = { + replaceActiveStateWhileOperationsIdle( + replacement = current, + previousSession = null, + suspectEncrypted = suspectEncrypted, + ) + accountRemovalCleanupJournal.clear(activeSession.accountId.storageKey) + }, + completeCommittedCleanup = { + accountRemovalCleanupJournal.clear(activeSession.accountId.storageKey) + }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, + ) + } + } else { + persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted) + } + } + + private suspend fun persistRecoveredInvalidStoreAfterClear( + current: AndroidAccountCredentialState, + suspectEncrypted: String, + pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, + ) { + val replacement = removeActiveAndroidAccountCredentialState(current) + val encodedReplacement = replacement.takeUnless { state -> + state.registry.accounts.isEmpty() && state.sessions.isEmpty() + }?.let(::encryptState) + clearPersistedSession( + encodedReplacement, + replacement, + suspectEncrypted, + pendingCleanup, + ) + 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(handoffCleanup.prepare(accountRemovalCleanupJournal.prepareEdit(editor, pendingCleanup))) + }, + cancelAll = scheduler::cancelAll, + clearPublishedAccount = { publishAccountIdentity(null) }, + onScheduleMaintenanceFailure = ::recordAccountRemovalCleanupFailure, + ) + }, + clearHandoffs = handoffCleanup::complete, + recordFailure = ::recordAccountHandoffCleanupFailure, + ) + } + } + + private suspend fun replaceActiveState( + replacement: AndroidAccountCredentialState, + previousSession: NextcloudSession?, + replacedSession: NextcloudSession? = null, + suspectEncrypted: String? = null, + ) = replaceAndroidActiveStateWithAccountLeases( + replacement, previousSession, replacedSession, suspectEncrypted, + replace = ::replaceActiveStateWhileOperationsIdle) + private suspend fun replaceActiveStateWhileOperationsIdle( + replacement: AndroidAccountCredentialState, + previousSession: NextcloudSession?, + suspectEncrypted: String?, + replacedSession: NextcloudSession? = null, + ) { + val session = requireNotNull(replacement.activeSession) + val encrypted = encryptState(replacement) + val scheduler = AndroidFileSyncScheduler(appContext) + completeAndroidAccountSelectionTransition( + commitTransition = { markCommitted -> + 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(handoffCleanup.prepare(prepareCredentialSlotEdit(editor, replacement))) + markCommitted() + }, + 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 = handoffCleanup::complete, + recordFailure = ::recordAccountHandoffCleanupFailure, + ) + }, + finishMaintenance = { + activatePersistedAccount(session) + 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 = requireValidAndroidAccountCredentialState( + readStore(), ::requireSupportedCredentialSlots, + ) + + private fun requireValidStateForAccountRemoval(accountId: NextcloudAccountId): AndroidAccountCredentialState = + when (val read = readStore()) { + is AndroidAccountCredentialStoreRead.Available -> read.state.also { state -> + requireSupportedCredentialSlots(state.registry) + } + is AndroidAccountCredentialStoreRead.Invalid, + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable, + -> readIndependentCredentialSlotState(allowUnavailableActiveAccountId = accountId) + ?: 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) + hasAndroidIndependentCredentialState(preferences) -> + 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( + allowUnavailableActiveAccountId: NextcloudAccountId? = null, + ): 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 if (allowUnavailableActiveAccountId == null) { + reconstructAndroidAccountCredentialState(registry) { accountId -> + (slots[accountId] as? AndroidAccountCredentialSlotRead.Available)?.session + } + } else { + reconstructAndroidAccountCredentialStateForRemoval(registry, allowUnavailableActiveAccountId) { accountId -> + (slots[accountId] as? AndroidAccountCredentialSlotRead.Available)?.session + } + } + } + + 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( + accountRemovalCleanupJournal.prepareEdit( + 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 snapshot = accountRemovalCleanupJournal.snapshot() + val pending = pendingAndroidAccountRemovalCleanupForSession(session, snapshot.cleanups) + if (pending != null) { + try { + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = androidAccountRemovalCleanupOwnedByRegistry(pending, readCredentialFreeRegistry()?.accounts), + removeAccountOwnedWork = { + retryAndroidAccountOwnedStateCleanup(session, pending, retryQueuedUploadsCleanup) + }, + clearCleanup = { accountRemovalCleanupJournal.clear(pending.accountStorageKey) }, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + recordAccountRemovalCleanupFailure(failure) + throw androidAccountRemovalCleanupRetryFailure(failure) + } + } + requireAndroidAccountRemovalCleanupJournalAllowsActivation(snapshot) + } + 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 = retainedAndroidAccountCredentialSlotKeys(state) + 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..48c923432 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -0,0 +1,369 @@ +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.durableMutationAccountScope +import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry +import java.security.MessageDigest +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 AndroidAccountCredentialSelectionRecovery( + val state: AndroidAccountCredentialState, + val suspectEncrypted: String?, +) + +internal fun recoverAndroidAccountCredentialStateForSelection( + read: AndroidAccountCredentialStoreRead, + recoverIndependent: () -> AndroidAccountCredentialState?, +): AndroidAccountCredentialSelectionRecovery = when (read) { + is AndroidAccountCredentialStoreRead.Available -> AndroidAccountCredentialSelectionRecovery(read.state, null) + is AndroidAccountCredentialStoreRead.Invalid -> AndroidAccountCredentialSelectionRecovery( + recoverIndependent() ?: error("The independent account credential slots could not be recovered."), + read.encrypted, + ) + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> AndroidAccountCredentialSelectionRecovery( + recoverIndependent() ?: error("The independent account credential slots could not be recovered."), + null, + ) + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) +} + +internal fun requireValidAndroidAccountCredentialState( + read: AndroidAccountCredentialStoreRead, + requireSupportedSlots: (NextcloudAccountRegistry) -> Unit, +): AndroidAccountCredentialState = when (read) { + is AndroidAccountCredentialStoreRead.Available -> read.state.also { requireSupportedSlots(it.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) +} + +internal data class AndroidPendingAccountRemovalCleanup( + val accountStorageKey: String, + val workIdentity: String, + val previewCacheIdentity: String? = null, + val durableMutationIdentity: String? = null, + val legacyAccountScopeDigest: String? = null, +) { + init { + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(accountStorageKey)) + require(WORK_IDENTITY_PATTERN.matches(workIdentity)) + previewCacheIdentity?.let { identity -> + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(identity)) + require(identity.startsWith(workIdentity)) + } + durableMutationIdentity?.let { identity -> + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(identity)) + require(previewCacheIdentity != null) + } + legacyAccountScopeDigest?.let { identity -> + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(identity)) + require(durableMutationIdentity != null) + } + } +} + +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 data class AndroidIndependentCredentialSlotReset( + val preferenceKey: String, + val encrypted: String, + val session: NextcloudSession, +) + +internal fun recoverAndroidIndependentCredentialSlotsForReset( + preferenceKeys: Collection, + readEncrypted: (String) -> String?, + decrypt: (String) -> String, +): List { + val slotKeys = preferenceKeys + .filter { key -> key.startsWith(ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX) } + .sorted() + check(slotKeys.size <= MAX_ANDROID_ACCOUNT_CREDENTIALS) { + "The independent account credential slot set is too large to reset safely." + } + return slotKeys.map { key -> + val encrypted = checkNotNull(readEncrypted(key)) { + "An independent account credential slot disappeared during reset." + } + val restored = decodeAndroidAccountCredentialState(decrypt(encrypted)) + restored.unsupportedVersion?.let(::unsupportedCredentialStoreMutation) + val state = checkNotNull(restored.state) { + "An independent account credential slot is invalid and cannot be reset safely." + } + check(state.registry.accounts.size == 1 && state.sessions.size == 1) { + "An independent account credential slot has an invalid account count." + } + val session = checkNotNull(state.activeSession) { + "An independent account credential slot does not select its account." + } + check(key == androidAccountCredentialSlotKey(session.accountId)) { + "An independent account credential slot has a mismatched identity." + } + AndroidIndependentCredentialSlotReset(key, encrypted, session) + } +} + +internal fun retainedAndroidAccountCredentialSlotKeys( + state: AndroidAccountCredentialState, +): Set = state.registry.accounts.mapTo(hashSetOf()) { account -> + androidAccountCredentialSlotKey(account.id) +} + +internal fun hasAndroidIndependentCredentialState(preferences: SharedPreferences): Boolean = + preferences.contains(ANDROID_ACCOUNT_REGISTRY_KEY) || + preferences.all.keys.any { key -> key.startsWith(ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX) } + +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), + previewCacheIdentity = NextcloudDocumentIds.cacheAccountId(session), + durableMutationIdentity = durableMutationAccountScope(session), + legacyAccountScopeDigest = legacyAndroidAccountPersistenceScopeDigest(session), +) + +internal fun legacyAndroidAccountPersistenceScopeDigest(session: NextcloudSession): String? { + val identity = session.serverUrl.trimEnd('/') + "\u0000" + session.loginName + val digest = MessageDigest.getInstance("SHA-256").digest(identity.encodeToByteArray()) + .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } + return digest.takeUnless { it == session.accountId.storageKey } +} + +internal fun encodeAndroidPendingAccountRemovalCleanup( + cleanup: AndroidPendingAccountRemovalCleanup, +): String = listOfNotNull( + cleanup.accountStorageKey, + cleanup.workIdentity, + cleanup.previewCacheIdentity, + cleanup.durableMutationIdentity, + cleanup.legacyAccountScopeDigest, +).joinToString(":") + +internal fun decodeAndroidPendingAccountRemovalCleanup( + encoded: String, +): AndroidPendingAccountRemovalCleanup? { + val fields = encoded.split(':') + if (fields.size !in 2..5) return null + return runCatching { + AndroidPendingAccountRemovalCleanup( + accountStorageKey = fields[0], + workIdentity = fields[1], + previewCacheIdentity = fields.getOrNull(2), + durableMutationIdentity = fields.getOrNull(3), + legacyAccountScopeDigest = fields.getOrNull(4), + ) + }.getOrNull() +} + +internal data class RestoredAndroidPendingAccountRemovalCleanups( + val cleanups: Set, + val malformedEntryCount: Int, +) + +internal fun restoreAndroidPendingAccountRemovalCleanups( + encoded: Set, +): RestoredAndroidPendingAccountRemovalCleanups { + val cleanups = linkedSetOf() + var malformedEntryCount = 0 + encoded.forEach { entry -> + val cleanup = decodeAndroidPendingAccountRemovalCleanup(entry) + if (cleanup == null) malformedEntryCount += 1 else cleanups += cleanup + } + return RestoredAndroidPendingAccountRemovalCleanups(cleanups, malformedEntryCount) +} + +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 + return AndroidAccountCredentialState(registry, sessions) +} + +internal fun reconstructAndroidAccountCredentialStateForRemoval( + registry: NextcloudAccountRegistry, + accountId: NextcloudAccountId, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): AndroidAccountCredentialState? { + val recoverableRegistry = if (registry.activeAccountId == accountId) { + registry.copy(activeAccountId = null) + } else { + registry + } + return reconstructAndroidAccountCredentialState(recoverableRegistry, loadSession) +} + +internal data class AndroidUnavailableAccountRemovalTarget( + val record: NextcloudAccountRecord, + val wasActive: Boolean, +) + +internal fun resolveAndroidUnavailableAccountRemovalTarget( + registry: NextcloudAccountRegistry?, + accountId: NextcloudAccountId, +): AndroidUnavailableAccountRemovalTarget? { + val available = registry ?: return null + val record = available.accounts.firstOrNull { account -> account.id == accountId } ?: return null + return AndroidUnavailableAccountRemovalTarget(record, available.activeAccountId == accountId) +} + +internal data class AndroidActiveAccountRemovalTransition( + val identitySession: NextcloudSession, + val replacement: AndroidAccountCredentialState, +) + +internal fun resolveAndroidActiveAccountRemovalTransition( + current: AndroidAccountCredentialState, + fallback: NextcloudSession? = null, +): AndroidActiveAccountRemovalTransition? { + val session = current.activeSession ?: fallback ?: return null + val record = current.registry.accounts.firstOrNull { account -> account.id == session.accountId } ?: return null + check(session.accountRecord() == record) { "The fallback account identity changed." } + return AndroidActiveAccountRemovalTransition(session, current.remove(session.accountId)) +} + +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 fun androidIndependentCredentialStateCanBeExplicitlyReset( + registry: RestoredAndroidCredentialFreeRegistry?, +): Boolean = registry == null || registry.credentialRecoveryRequired + +internal fun requireAndroidIndependentCredentialStateCanBeExplicitlyReset(encodedRegistry: String?) { + check(androidIndependentCredentialStateCanBeExplicitlyReset(encodedRegistry?.let(::restoreAndroidCredentialFreeRegistry))) { + "The independent account credential slots could not be recovered." + } +} + +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..c5713fd0e --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -0,0 +1,193 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession +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 replaceAndroidActiveStateWithAccountLeases( + replacement: AndroidAccountCredentialState, + previousSession: NextcloudSession?, + replacedSession: NextcloudSession?, + suspectEncrypted: String?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + coordinator: AndroidFileRangeSessionCoordinator = ANDROID_FILE_RANGE_SESSION_COORDINATOR, + replace: suspend (AndroidAccountCredentialState, NextcloudSession?, String?, NextcloudSession?) -> Unit, +) { + val replacementSession = requireNotNull(replacement.activeSession) + val accountIdentities = listOfNotNull(previousSession, replacementSession, replacedSession) + .map(NextcloudDocumentIds::accountKey) + guard.withAccounts(accountIdentities) { + quiesceAndroidFileRangesBeforeCredentialReplacement(replacedSession, replacementSession, coordinator) + replace(replacement, previousSession, suspectEncrypted, replacedSession) + } +} + +internal suspend fun rollbackUnavailableAndroidAccountRemoval( + active: Boolean = false, + recovered: AndroidAccountCredentialState, + persistRecovered: suspend (AndroidAccountCredentialState) -> Unit, + clearCleanup: suspend () -> Unit, +) { + if (!active) persistRecovered(recovered) + clearCleanup() +} + +internal suspend fun retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry: Boolean?, + removeAccountOwnedWork: suspend () -> Unit, + clearCleanup: suspend () -> Unit, +) { + when (accountOwnedByRegistry) { + true -> clearCleanup() + false -> { + removeAccountOwnedWork() + clearCleanup() + } + null -> error("Account ownership is unavailable; pending cleanup cannot run safely.") + } +} + +internal fun androidAccountRemovalCleanupRetryFailure(failure: Exception) = IllegalStateException( + "Previous account cleanup must finish before this account can be added again.", + failure, +) + +internal suspend fun retryAndroidAccountOwnedStateCleanup( + session: NextcloudSession, + pending: AndroidPendingAccountRemovalCleanup, + retry: suspend (NextcloudSession, String, String?, String?, String?) -> Unit, +) { + retry( + session, + pending.workIdentity, + pending.previewCacheIdentity, + pending.durableMutationIdentity, + pending.legacyAccountScopeDigest, + ) +} + +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, + ) +} + +internal suspend fun removeUnavailableAndroidAccountCredentialData( + accountIdentity: String, + active: Boolean = false, + prepareAccountRemoval: suspend () -> Unit, + removeAccountOwnedWorkWithoutCredentials: suspend (String) -> Unit, + persistRemoval: suspend () -> Unit, + clearActiveAccount: suspend () -> Unit = persistRemoval, + rollbackRemoval: suspend () -> Unit, + completeCommittedCleanup: suspend () -> Unit = {}, + recordCommittedCleanupFailure: (Exception) -> Unit = {}, +) { + require(accountIdentity.isNotBlank()) + removeAndroidAccountCredentialData( + active = active, + prepareAccountRemoval = prepareAccountRemoval, + removeQueuedUploads = { removeAccountOwnedWorkWithoutCredentials(accountIdentity) }, + clearActiveAccount = clearActiveAccount, + rollbackActiveRemoval = rollbackRemoval, + persistInactiveRemoval = persistRemoval, + rollbackInactiveRemoval = rollbackRemoval, + completeCommittedCleanup = completeCommittedCleanup, + recordCommittedCleanupFailure = 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/AndroidAccountFileListing.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListing.kt new file mode 100644 index 000000000..604584105 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListing.kt @@ -0,0 +1,57 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudFile +import dev.obiente.nextcloudnative.app.NextcloudFileListing +import dev.obiente.nextcloudnative.app.NextcloudFileListingHttpException +import dev.obiente.nextcloudnative.app.NextcloudFileListingSource +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.io.IOException + +internal data class AndroidDavFileListingResponse(val status: Int, val files: List) + +/** The caller may reuse a lease only while its enclosing account operation still owns it. */ +internal suspend fun loadAndroidAccountFileListing( + session: NextcloudSession, + resolveSession: suspend () -> NextcloudSession?, + cache: AndroidFileReadCache, + path: String, + accountLeaseHeld: Boolean = false, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + request: suspend () -> AndroidDavFileListingResponse, +): NextcloudFileListing { + val read: suspend () -> NextcloudFileListing = { + readAndroidAccountFileListing(cache, NextcloudDocumentIds.accountKey(session), path, request) + } + return if (accountLeaseHeld) read() else withRetainedAndroidAccountFileRead(session, resolveSession, guard, read) +} + +private suspend fun readAndroidAccountFileListing( + cache: AndroidFileReadCache, + accountId: String, + path: String, + request: suspend () -> AndroidDavFileListingResponse, +): NextcloudFileListing = try { + val response = request() + if (response.status == 207) { + val files = response.files.drop(1) + .sortedWith(compareByDescending { it.isDirectory }.thenBy { it.name.lowercase() }) + runCatching { cache.storeListing(accountId, path, files) } + NextcloudFileListing(files, NextcloudFileListingSource.Network) + } else { + val cached = if (response.status >= 500) cache.cachedListing(accountId, path) else null + cached?.let { NextcloudFileListing(it.files, NextcloudFileListingSource.Cache) } + ?: throw NextcloudFileListingHttpException(response.status) + } +} catch (failure: IOException) { + cache.cachedListing(accountId, path)?.files + ?.let { NextcloudFileListing(it, NextcloudFileListingSource.Cache) } + ?: throw failure +} + +internal fun requireAndroidDocumentDirectory( + reference: NextcloudDocumentReference, + findDocument: (String) -> NextcloudFile, +) { + if (reference.isRoot) return + require(findDocument(reference.path).isDirectory) { "The selected parent is not a folder." } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt new file mode 100644 index 000000000..48efa09f2 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt @@ -0,0 +1,161 @@ +package dev.obiente.nextcloudnative + +import android.util.Base64 +import dev.obiente.nextcloudnative.app.NextcloudFileRangeSession +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.io.FileNotFoundException +import java.nio.charset.StandardCharsets +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal suspend fun withRetainedAndroidAccountFileRead( + expectedSession: NextcloudSession, + resolveSession: suspend () -> NextcloudSession?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + read: suspend () -> Result, +): Result = withContext(Dispatchers.IO) { + guard.withExactAccountSession( + expectedSession = expectedSession, + resolveSession = resolveSession, + unavailable = { error("The account changed before the file read could finish.") }, + ) { read() } +} + +internal class AndroidFileRangeSessionActivity { + private val monitor = Any() + private val active = linkedSetOf() + private val drained = CompletableDeferred() + private var closed = false + + fun start(cancel: () -> Unit = {}): (() -> Unit)? = synchronized(monitor) { + if (closed) return@synchronized null + val operation = Operation(cancel) + active += operation + { finish(operation) } + } + + private fun finish(operation: Operation) { + val complete = synchronized(monitor) { + active.remove(operation) + closed && active.isEmpty() + } + if (complete) drained.complete(Unit) + } + + fun close() { + val operations = synchronized(monitor) { + closed = true + active.toList().also { if (it.isEmpty()) drained.complete(Unit) } + } + operations.forEach { operation -> operation.cancel() } + } + + suspend fun awaitDrained() = drained.await() + + fun whenDrained(action: () -> Unit) { + drained.invokeOnCompletion { action() } + } + + private class Operation(val cancel: () -> Unit) +} + +internal class AndroidFileRangeSessionCoordinator { + private val monitor = Any() + private val registrations = mutableMapOf>() + + fun register( + accountIdentity: String, + activity: AndroidFileRangeSessionActivity, + closeSource: () -> Unit, + ): AutoCloseable { + lateinit var registration: Registration + registration = Registration( + closeSource = closeSource, + awaitDrained = activity::awaitDrained, + whenDrained = activity::whenDrained, + unregister = { unregister(accountIdentity, registration) }, + ) + synchronized(monitor) { registrations.getOrPut(accountIdentity, ::linkedSetOf) += registration } + return registration + } + + suspend fun quiesce(accountIdentity: String) { + val current = synchronized(monitor) { registrations[accountIdentity]?.toList().orEmpty() } + current.forEach(Registration::cancel) + current.forEach { registration -> registration.awaitDrained() } + synchronized(monitor) { registrations.remove(accountIdentity) } + } + + private fun unregister(accountIdentity: String, registration: Registration) = synchronized(monitor) { + registrations[accountIdentity]?.let { current -> + current -= registration + if (current.isEmpty()) registrations.remove(accountIdentity) + } + } + + private class Registration( + private val closeSource: () -> Unit, + val awaitDrained: suspend () -> Unit, + private val whenDrained: ((() -> Unit) -> Unit), + private val unregister: () -> Unit, + ) : AutoCloseable { + private val cancelled = AtomicBoolean(false) + + fun cancel() { + if (cancelled.compareAndSet(false, true)) closeSource() + } + + override fun close() { + cancel() + whenDrained(unregister) + } + } +} + +internal val ANDROID_FILE_RANGE_SESSION_COORDINATOR = AndroidFileRangeSessionCoordinator() + +internal suspend fun quiesceAndroidFileRangesBeforeCredentialReplacement( + previousSession: NextcloudSession?, + replacementSession: NextcloudSession, + coordinator: AndroidFileRangeSessionCoordinator = ANDROID_FILE_RANGE_SESSION_COORDINATOR, +) { + if ( + previousSession != null && previousSession.accountId == replacementSession.accountId && + previousSession != replacementSession + ) { + coordinator.quiesce(NextcloudDocumentIds.accountKey(previousSession)) + } +} + +internal fun openTrackedAndroidFileRangeSession( + expectedSession: NextcloudSession, + resolveSession: () -> NextcloudSession?, + activity: AndroidFileRangeSessionActivity, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + coordinator: AndroidFileRangeSessionCoordinator = ANDROID_FILE_RANGE_SESSION_COORDINATOR, + openSource: () -> NextcloudFileRangeSession, +): NextcloudFileRangeSession { + val lease = guard.acquireBlocking(NextcloudDocumentIds.accountKey(expectedSession)) + return try { + if (resolveSession() != expectedSession) { + throw FileNotFoundException("The account changed before the file range session could start.") + } + val source = openSource() + val registration = coordinator.register( + NextcloudDocumentIds.accountKey(expectedSession), activity, source::close, + ) + NextcloudFileRangeSession(source.size, source::read, registration::close, activity::start) + } catch (failure: Throwable) { + activity.close() + throw failure + } finally { + lease.close() + } +} + +internal fun androidFileRangeAuthorization(session: NextcloudSession): String = Base64.encodeToString( + "${session.loginName}:${session.appPassword}".toByteArray(StandardCharsets.UTF_8), + Base64.NO_WRAP, +) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt new file mode 100644 index 000000000..5bab6f672 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt @@ -0,0 +1,97 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.DurableMutationRecoveryKind +import dev.obiente.nextcloudnative.app.isSafePendingMutationId +import java.io.File +import java.nio.file.Files + +internal class AndroidAccountMutationRecoveryCleanup( + private val preferences: SharedPreferences, + pendingDynamicMutationDirectory: File, +) { + private val pendingDynamicMutationDirectory = pendingDynamicMutationDirectory.canonicalFile + + constructor(context: Context) : this( + preferences = context.applicationContext.getSharedPreferences("nextcloud_native", Context.MODE_PRIVATE), + pendingDynamicMutationDirectory = File(context.applicationContext.filesDir, "mutations/dynamic-v1"), + ) + + fun clearDurableRecoveries(accountScope: String) { + require(accountScope.isCanonicalAndroidMutationAccountScope()) { + "The durable mutation account identity is invalid." + } + synchronized(androidDurableMutationRecoveryLock) { + val keys = DurableMutationRecoveryKind.entries.map { kind -> + androidDurableMutationRecoveryKey(accountScope, kind) + } + val editor = preferences.edit() + keys.forEach(editor::remove) + editor.putBoolean( + ANDROID_DURABLE_MUTATION_CLEANUP_TOGGLE_KEY, + !preferences.getBoolean(ANDROID_DURABLE_MUTATION_CLEANUP_TOGGLE_KEY, false), + ) + check(editor.commit() && keys.none(preferences::contains)) { + "Could not clear this account's durable mutation recovery." + } + } + } + + fun clearPendingDynamicMutations(accountIdentity: String) { + require(accountIdentity.isCanonicalAndroidMutationAccountScope()) { + "The pending mutation account identity is invalid." + } + if (!pendingDynamicMutationDirectory.exists()) return + check(pendingDynamicMutationDirectory.isDirectory) { + "The pending mutation store is not a directory." + } + val candidates = pendingDynamicMutationDirectory.listFiles() + ?: error("The pending mutation store could not be read.") + val ownedPrefix = "$accountIdentity-" + candidates + .filter { candidate -> candidate.name.startsWith(ownedPrefix) } + .forEach { candidate -> + check(candidate.isOwnedPendingDynamicMutation(accountIdentity)) { + "The pending mutation store contains an unsupported account entry." + } + check(candidate.canonicalFile.parentFile == pendingDynamicMutationDirectory) { + "Unsafe pending mutation cleanup path." + } + check(candidate.isFile && !Files.isSymbolicLink(candidate.toPath())) { + "The pending mutation account entry is not a regular file." + } + check(!candidate.exists() || candidate.delete() && !candidate.exists()) { + "Could not clear this account's pending mutation." + } + } + } +} + +internal fun androidDurableMutationRecoveryKey( + accountScope: String, + kind: DurableMutationRecoveryKind, +): String = "durable-mutation-${kind.storageKey}-$accountScope" + +private fun File.isOwnedPendingDynamicMutation(accountIdentity: String): Boolean { + val suffix = when { + name.endsWith(".json.part") -> name.removeSuffix(".json.part") + name.endsWith(".json") -> name.removeSuffix(".json") + else -> return false + } + val ownedPrefix = "$accountIdentity-" + if (!suffix.startsWith(ownedPrefix)) return false + val identity = suffix.removePrefix(ownedPrefix) + val digestSeparator = identity.lastIndexOf('-') + if (digestSeparator <= 0) return false + val appId = identity.substring(0, digestSeparator) + val digest = identity.substring(digestSeparator + 1) + return appId.isSafePendingMutationId() && digest.isCanonicalAndroidMutationAccountScope() +} + +internal fun String.isCanonicalAndroidMutationAccountScope(): Boolean = + length == 64 && all { character -> character in '0'..'9' || character in 'a'..'f' } + +internal val androidDurableMutationRecoveryLock = Any() + +private const val ANDROID_DURABLE_MUTATION_CLEANUP_TOGGLE_KEY = "durable-mutation-cleanup-toggle-v1" 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..a6e0b70f6 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -0,0 +1,174 @@ +package dev.obiente.nextcloudnative + +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +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 tryWithAccount( + accountId: String, + unavailable: suspend () -> Result, + action: suspend () -> Result, + ): Result { + currentCoroutineContext().ensureActive() + val lease = tryAcquire(accountId) ?: return unavailable() + 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 tryAcquire(accountId: String): AndroidAccountOperationLease? { + require(accountId.isNotBlank()) + val lease = synchronized(monitor) { + accountLeases.getOrPut(accountId) { AccountLease() }.also { it.references += 1 } + } + if (!lease.mutex.tryLock()) { + releaseReference(accountId, lease) + return null + } + 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, +) + +internal suspend fun withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld: Boolean, + expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, + resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + action: suspend (dev.obiente.nextcloudnative.app.NextcloudSession, Boolean) -> Result, +): Result = if (accountMutationLeaseHeld) { + action(expectedSession, true) +} else { + guard.withAuthenticatedMutationSession(expectedSession, resolveSession) { currentSession -> + action(currentSession, true) + } +} + +internal suspend fun withAndroidAccountPrivateStatePublication( + expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, + credentialMutationMutex: Mutex, + guard: AndroidAccountOperationGuard, + resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, + unavailable: suspend () -> Result, + publish: suspend () -> Result, +): Result = credentialMutationMutex.withLock { + guard.withExactAccountSession(expectedSession, resolveSession, unavailable) { publish() } +} 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..f81ad074e --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -0,0 +1,184 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer +import dev.obiente.nextcloudnative.app.AccountPrivateMemoryCleanup +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.durableMutationAccountScope +import dev.obiente.nextcloudnative.app.removeAndroidHomeWorkspaceAccountPreferences +import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache +import java.io.File +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext + +internal class AndroidAccountOwnedStateCleanup( + context: Context, + private val fileReadCache: AndroidFileReadCache = AndroidFileReadCache( + File(context.applicationContext.cacheDir, "files-read-v1"), + ), + private val virtualFileCache: AndroidVirtualFileCache = AndroidVirtualFileCache(context.applicationContext), + private val clearPreviewAccount: (String) -> Unit = AndroidNativeMediaPreviewCache( + File(context.applicationContext.cacheDir, "native-media-previews-v1"), + )::clearAccount, + private val dynamicApiState: AndroidDynamicApiProcessState = androidDynamicApiProcessState( + File(context.applicationContext.cacheDir, "dynamic-api-v1"), + ), + private val dynamicDiscoveryCache: AndroidDynamicDiscoveryCache = AndroidDynamicDiscoveryCacheCoordinator.get( + File(context.applicationContext.filesDir, "contracts/discoveries-v1"), + ), + private val removeSupportAccount: suspend (String) -> Unit, +) { + private val appContext = context.applicationContext + private val fileOffline = AndroidFileOfflineAccountCleanup(appContext) + private val incomingShares = AndroidIncomingShareAccountCleanup(appContext) + private val durableUploads = AndroidDurableUploadAccountCleanup(appContext) + private val mediaBackupLedger = AndroidMediaBackupAccountCleanup(appContext) + private val mutationRecovery = AndroidAccountMutationRecoveryCleanup(appContext) + private val deckCardDrafts = AndroidDeckCardDraftStore(appContext) + + suspend fun remove(session: NextcloudSession) { + val accountIdentity = NextcloudDocumentIds.accountKey(session) + val cacheIdentity = NextcloudDocumentIds.cacheAccountId(session) + runAndroidAccountOwnedStateCleanups( + cacheIdentity, + clearPreviewAccount, + listOf( + { fenceAndroidDynamicApiStateForRemoval(cacheIdentity, dynamicApiState.coalescer, dynamicApiState.cache) }, + { dynamicDiscoveryCache.retireAccount(session.accountId.storageKey, cacheIdentity) }, + { removeSupportAccount(accountIdentity) }, + { + removeAndroidHomeWorkspaceAccountPreferences( + appContext, + session.accountId.storageKey, + legacyAndroidAccountPersistenceScopeDigest(session), + ) + }, + { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, + { fileOffline.removeForAccount(accountIdentity) }, + { incomingShares.removeForAccount(session) }, + { durableUploads.removeForAccount(accountIdentity) }, + { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { removeLegacyAndroidFileSyncStaging(File(appContext.cacheDir, "file-sync-staging")) }, + { removeAndroidFileSyncAccountStaging(File(appContext.cacheDir, "file-sync-staging"), accountIdentity) }, + { mediaBackupLedger.removeForAccount(accountIdentity) }, + { deckCardDrafts.removeAccount(session.accountId.storageKey, accountIdentity) }, + { fileReadCache.clearAccount(accountIdentity) }, + { virtualFileCache.clearAccount(accountIdentity) }, + { mutationRecovery.clearDurableRecoveries(durableMutationAccountScope(session)) }, + { mutationRecovery.clearPendingDynamicMutations(cacheIdentity) }, + { AccountPrivateMemoryCleanup.removeAccount(session.accountId.storageKey) }, + ), + ) + } + + suspend fun retry( + session: NextcloudSession, + accountIdentity: String, + previewCacheIdentity: String?, + durableMutationIdentity: String?, + legacyAccountScopeDigest: String?, + ) { + runAndroidAccountOwnedStateCleanups( + previewCacheIdentity, + clearPreviewAccount, + listOf( + { + previewCacheIdentity?.let { identity -> + fenceAndroidDynamicApiStateForRemoval(identity, dynamicApiState.coalescer, dynamicApiState.cache) + } + }, + { dynamicDiscoveryCache.retireAccount(session.accountId.storageKey, previewCacheIdentity) }, + { removeSupportAccount(accountIdentity) }, + { + removeAndroidHomeWorkspaceAccountPreferences( + appContext, + session.accountId.storageKey, + legacyAccountScopeDigest, + ) + }, + { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, + { fileOffline.removeForAccount(accountIdentity) }, + { incomingShares.removeForAccount(accountIdentity, session) }, + { durableUploads.removeForAccount(accountIdentity) }, + { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { removeLegacyAndroidFileSyncStaging(File(appContext.cacheDir, "file-sync-staging")) }, + { removeAndroidFileSyncAccountStaging(File(appContext.cacheDir, "file-sync-staging"), accountIdentity) }, + { mediaBackupLedger.removeForAccount(accountIdentity) }, + { deckCardDrafts.removeAccount(session.accountId.storageKey, accountIdentity) }, + { fileReadCache.clearAccount(accountIdentity) }, + { virtualFileCache.clearAccount(accountIdentity) }, + { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, + { previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) }, + { AccountPrivateMemoryCleanup.removeAccount(session.accountId.storageKey) }, + ), + ) + } + + suspend fun retryWithoutCredentials( + accountStorageKey: String, + accountIdentity: String, + previewCacheIdentity: String? = null, + durableMutationIdentity: String? = null, + legacyAccountScopeDigest: String? = null, + ) { + runAndroidAccountOwnedStateCleanups( + previewCacheIdentity, + clearPreviewAccount, + listOf( + { + previewCacheIdentity?.let { identity -> + fenceAndroidDynamicApiStateForRemoval(identity, dynamicApiState.coalescer, dynamicApiState.cache) + } + }, + { dynamicDiscoveryCache.retireAccount(accountStorageKey, previewCacheIdentity) }, + { removeSupportAccount(accountIdentity) }, + { + removeAndroidHomeWorkspaceAccountPreferences( + appContext, + accountStorageKey, + legacyAccountScopeDigest, + ) + }, + { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, + { fileOffline.removeForAccount(accountIdentity) }, + { incomingShares.removeForAccount(accountIdentity) }, + { durableUploads.removeForAccount(accountIdentity) }, + { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { removeLegacyAndroidFileSyncStaging(File(appContext.cacheDir, "file-sync-staging")) }, + { removeAndroidFileSyncAccountStaging(File(appContext.cacheDir, "file-sync-staging"), accountIdentity) }, + { mediaBackupLedger.removeForAccount(accountIdentity) }, + { deckCardDrafts.removeAccount(accountStorageKey, accountIdentity) }, + { fileReadCache.clearAccount(accountIdentity) }, + { virtualFileCache.clearAccount(accountIdentity) }, + { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, + { previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) }, + { AccountPrivateMemoryCleanup.removeAccount(accountStorageKey) }, + ), + ) + } +} + +internal suspend fun clearAndroidDynamicApiState( + accountIdentity: String, + coalescer: DynamicApiRequestCoalescer, + cache: DynamicApiResponseCache, +) = coalescer.fenceAccount(accountIdentity) { cache.invalidateAccount(accountIdentity) } + +internal suspend fun fenceAndroidDynamicApiStateForRemoval( + accountIdentity: String, + coalescer: DynamicApiRequestCoalescer, + cache: DynamicApiResponseCache, +) = withContext(NonCancellable) { + clearAndroidDynamicApiState(accountIdentity, coalescer, cache) +} + +internal suspend fun runAndroidAccountOwnedStateCleanups( + previewCacheIdentity: String?, + clearPreviewAccount: (String) -> Unit, + cleanups: List Unit>, +) { + val previewCleanup: suspend () -> Unit = { + previewCacheIdentity?.let(clearPreviewAccount) + } + runAndroidAccountRemovalCleanups(cleanups + previewCleanup) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountPrivateCacheDeletion.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountPrivateCacheDeletion.kt new file mode 100644 index 000000000..9d581a9b5 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountPrivateCacheDeletion.kt @@ -0,0 +1,15 @@ +package dev.obiente.nextcloudnative + +import java.io.File + +internal fun deleteAndroidAccountPrivateCache(root: File, accountId: String) { + require(accountId.length == 32 && accountId.all { character -> + character in '0'..'9' || character in 'a'..'f' + }) { "Private cache account identity is invalid." } + val canonicalRoot = root.canonicalFile + val accountDirectory = File(canonicalRoot, accountId).canonicalFile + check(accountDirectory.parentFile == canonicalRoot) { "Unsafe private account cache path." } + check(!accountDirectory.exists() || accountDirectory.deleteRecursively() && !accountDirectory.exists()) { + "Could not remove this account's private cache." + } +} 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..6cbb56f40 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -0,0 +1,117 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.content.Intent +import android.provider.DocumentsContract +import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext + +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) { + if (!resolved) rejectAndroidAccountRemovalForPendingDocumentChanges() +} + +internal fun rejectAndroidAccountRemovalForPendingDocumentChanges(): Nothing = + error("Finish or discard pending document changes before removing this account.") + +internal suspend fun withAndroidAccountRemovalLease( + accountIdentity: String, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + action: suspend () -> Result, +): Result = guard.tryWithAccount( + accountId = accountIdentity, + unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, + action = action, +) + +internal suspend fun revokeAndroidSessionAfterRemovalPreflight( + preflight: suspend () -> Unit, + revoke: suspend () -> Unit, + removeLocalAccount: suspend () -> Unit, +) { + preflight() + val revocationFailure: Exception? = try { + revoke() + null + } catch (cancelled: CancellationException) { + cancelled + } catch (failure: Exception) { + failure + } + val localRemovalFailure = try { + withContext(NonCancellable) { removeLocalAccount() } + null + } catch (failure: Exception) { + failure + } + if (revocationFailure is CancellationException) { + localRemovalFailure?.let(revocationFailure::addSuppressed) + throw revocationFailure + } + if (localRemovalFailure != null) { + revocationFailure?.let(localRemovalFailure::addSuppressed) + throw localRemovalFailure + } + revocationFailure?.let { throw it } +} + +internal suspend fun revokeAndroidSessionWithAccountLease( + accountIdentity: String, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + preflight: suspend () -> Unit, + revoke: suspend () -> Unit, + removeLocalAccount: suspend () -> Unit, +) = withAndroidAccountRemovalLease(accountIdentity, guard) { + 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) + ANDROID_FILE_RANGE_SESSION_COORDINATOR.quiesce(NextcloudDocumentIds.accountKey(session)) +} + +internal fun revokeAndroidAccountDocumentGrants(context: Context, accountIdentity: String) { + AndroidAccountDocumentGrantScope.entries.forEach { scope -> + context.revokeUriPermission( + scope.uri(nextcloudDocumentsAuthority(context.packageName), NextcloudDocumentIds.rootId(accountIdentity)), + NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS, + ) + } +} + +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/AndroidAccountRemovalCleanupJournal.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt new file mode 100644 index 000000000..2dc42bd83 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt @@ -0,0 +1,131 @@ +package dev.obiente.nextcloudnative + +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudAccountId +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal class AndroidAccountRemovalCleanupJournal( + private val preferences: SharedPreferences, + private val commit: (SharedPreferences.Editor) -> Unit, + private val recordMalformed: () -> Unit, +) { + fun pending(): Set = snapshot().cleanups + + fun snapshot(): RestoredAndroidPendingAccountRemovalCleanups { + val restored = restoreAndroidPendingAccountRemovalCleanups(readEncoded()) + if (restored.malformedEntryCount > 0) runCatching(recordMalformed) + return restored + } + + private fun readEncoded(): Set = try { + preferences.getStringSet(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, emptySet()).orEmpty() + } catch (failure: Exception) { + runCatching(recordMalformed) + throw AndroidAccountRemovalCleanupJournalException( + "The account-removal cleanup journal is unreadable.", + failure, + ) + } + + fun prepareEdit( + editor: SharedPreferences.Editor, + pendingCleanup: AndroidPendingAccountRemovalCleanup?, + ): SharedPreferences.Editor = if (pendingCleanup == null) { + editor + } else { + editor.putStringSet( + ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, + replaceAndroidAccountRemovalCleanup(readEncoded(), pendingCleanup, recordMalformed), + ) + } + + fun clear(accountStorageKey: String) { + val remaining = removeAndroidAccountRemovalCleanup(readEncoded(), accountStorageKey, recordMalformed) + 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) + commit(editor) + } +} + +internal class AndroidAccountRemovalCleanupJournalException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) + +internal fun requireValidAndroidAccountRemovalCleanupJournal( + encoded: Set, + recordMalformed: () -> Unit, +): Set { + val restored = restoreAndroidPendingAccountRemovalCleanups(encoded) + if (restored.malformedEntryCount > 0) { + runCatching(recordMalformed) + } + return restored.cleanups +} + +internal fun requireAndroidAccountRemovalCleanupJournalAllowsActivation( + snapshot: RestoredAndroidPendingAccountRemovalCleanups, +) { + check(snapshot.malformedEntryCount == 0) { + "Reset the malformed account-removal cleanup state before signing in again." + } +} + +internal inline fun restoreAndroidSessionAfterRemovalCleanup( + accountId: NextcloudAccountId, + loadSnapshot: () -> RestoredAndroidPendingAccountRemovalCleanups, + restoreSession: () -> Session?, +): Session? { + val snapshot = try { + loadSnapshot() + } catch (_: Exception) { + return null + } + if ( + snapshot.malformedEntryCount > 0 || + snapshot.cleanups.any { cleanup -> cleanup.accountStorageKey == accountId.storageKey } + ) return null + return restoreSession() +} + +internal suspend fun selectAndroidAccountAfterRemovalCleanup( + session: NextcloudSession, + retryPendingCleanup: suspend (NextcloudSession) -> Unit, + registerSessionPrivateValues: (NextcloudSession) -> Unit, + persistSelection: suspend () -> Unit, +): NextcloudSession { + retryPendingCleanup(session) + registerSessionPrivateValues(session) + persistSelection() + return session +} + +internal fun replaceAndroidAccountRemovalCleanup( + encoded: Set, + replacement: AndroidPendingAccountRemovalCleanup, + recordMalformed: () -> Unit, +): Set = removeAndroidAccountRemovalCleanup( + encoded, + replacement.accountStorageKey, + recordMalformed, +) + encodeAndroidPendingAccountRemovalCleanup(replacement) + +internal fun removeAndroidAccountRemovalCleanup( + encoded: Set, + accountStorageKey: String, + recordMalformed: () -> Unit, +): Set { + var malformedFound = false + val remaining = encoded.filterTo(linkedSetOf()) { entry -> + val cleanup = decodeAndroidPendingAccountRemovalCleanup(entry) + if (cleanup == null) { + malformedFound = true + true + } else { + cleanup.accountStorageKey != accountStorageKey + } + } + if (malformedFound) runCatching(recordMalformed) + return remaining +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt new file mode 100644 index 000000000..5948234fd --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt @@ -0,0 +1,229 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.durableMutationAccountScope +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.withLock + +internal fun installAndroidAccountRemovalCleanupRecovery( + context: Context, +): SharedPreferences.OnSharedPreferenceChangeListener { + val appContext = context.applicationContext + val preferences = appContext.getSharedPreferences(ANDROID_ACCOUNT_PREFERENCES, Context.MODE_PRIVATE) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if ( + key == ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY || + key == ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY + ) { + AndroidAccountRemovalCleanupRecoveryWork.schedule(appContext, preferences) + } + } + preferences.registerOnSharedPreferenceChangeListener(listener) + AndroidAccountRemovalCleanupRecoveryWork.schedule(appContext, preferences) + return listener +} + +internal object AndroidAccountRemovalCleanupRecoveryWork { + private const val UNIQUE_WORK = "nextcloud-native-account-removal-cleanup" + + fun schedule(context: Context, preferences: SharedPreferences) { + if ( + !preferences.contains(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY) && + !hasPendingAndroidExternalHandoffCleanup(preferences) + ) return + val request = OneTimeWorkRequestBuilder() + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context.applicationContext).enqueueUniqueWork( + UNIQUE_WORK, + ANDROID_ACCOUNT_REMOVAL_CLEANUP_WORK_POLICY, + request, + ) + } +} + +internal class AndroidAccountRemovalCleanupRecoveryWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + val preferences = applicationContext.getSharedPreferences( + ANDROID_ACCOUNT_PREFERENCES, + Context.MODE_PRIVATE, + ) + val journal = AndroidAccountRemovalCleanupJournal( + preferences = preferences, + commit = { editor -> ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + requireCommittedAndroidAccountCredentialEdit(editor) + } }, + recordMalformed = { Log.w(LOG_TAG, "Malformed account-removal cleanup journal retained") }, + ) + val handoffCleanup = AndroidExternalFileHandoffCleanup( + context = applicationContext, + preferences = preferences, + commit = { editor -> ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + requireCommittedAndroidAccountCredentialEdit(editor) + } }, + ) + val handoffCompleted = retryPendingAndroidExternalHandoffCleanup( + pending = handoffCleanup.pending(), + clearHandoffs = handoffCleanup::clearHandoffs, + clearJournal = handoffCleanup::clearJournal, + recordFailure = { + logAndroidAccountRemovalCleanupRecoveryDeferred { message -> Log.w(LOG_TAG, message) } + }, + ) + val registry = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) + ?.let(::restoreAndroidCredentialFreeRegistry) + ?.registry + val cleanup = AndroidAccountOwnedStateCleanup( + applicationContext, + removeSupportAccount = { accountIdentity -> + AndroidSupportIntakeCoordinator.removeAccount(applicationContext, accountIdentity) + }, + ) + val snapshot = try { + journal.snapshot() + } catch (failure: Exception) { + logAndroidAccountRemovalCleanupRecoveryDeferred { message -> Log.w(LOG_TAG, message, failure) } + return@withLock Result.retry() + } + val completed = recoverPendingAndroidAccountRemovalCleanups( + pending = snapshot.cleanups, + accountOwnedByRegistry = { pendingCleanup -> + androidAccountRemovalCleanupOwnedByRegistry(pendingCleanup, registry?.accounts) + }, + removeAccountOwnedWork = { pending -> + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(pending.workIdentity) { + cleanup.retryWithoutCredentials( + pending.accountStorageKey, + pending.workIdentity, + pending.previewCacheIdentity, + pending.durableMutationIdentity, + pending.legacyAccountScopeDigest, + ) + } + }, + clearCleanup = journal::clear, + recordFailure = { + logAndroidAccountRemovalCleanupRecoveryDeferred { message -> Log.w(LOG_TAG, message) } + }, + ) + if (androidAccountRemovalCleanupRecoveryCompleted(completed, snapshot, handoffCompleted)) { + Result.success() + } else { + Result.retry() + } + } +} + +internal fun androidAccountRemovalCleanupRecoveryCompleted( + validCleanupCompleted: Boolean, + snapshot: RestoredAndroidPendingAccountRemovalCleanups, + handoffCompleted: Boolean, +): Boolean = validCleanupCompleted && snapshot.malformedEntryCount == 0 && handoffCompleted + +internal suspend fun recoverPendingAndroidAccountRemovalCleanups( + pending: Collection, + accountOwnedByRegistry: (AndroidPendingAccountRemovalCleanup) -> Boolean?, + removeAccountOwnedWork: suspend (AndroidPendingAccountRemovalCleanup) -> Unit, + clearCleanup: suspend (String) -> Unit, + recordFailure: () -> Unit, +): Boolean { + var completed = true + pending.forEach { cleanup -> + try { + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = accountOwnedByRegistry(cleanup), + removeAccountOwnedWork = { removeAccountOwnedWork(cleanup) }, + clearCleanup = { clearCleanup(cleanup.accountStorageKey) }, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + completed = false + recordFailure() + } + } + return completed +} + +internal fun androidAccountRemovalCleanupOwnedByRegistry( + cleanup: AndroidPendingAccountRemovalCleanup, + retainedAccounts: List?, +): Boolean? { + retainedAccounts ?: return null + val storageOwner = retainedAccounts.firstOrNull { account -> + account.id.storageKey == cleanup.accountStorageKey + } + if (storageOwner != null) { + val storageOwnerWorkIdentity = NextcloudDocumentIds.accountKey( + storageOwner.serverUrl, + storageOwner.loginName, + ) + val storageOwnerPreviewIdentity = NextcloudDocumentIds.cacheAccountId( + NextcloudSession(storageOwner.serverUrl, storageOwner.loginName, appPassword = ""), + ) + check(storageOwnerWorkIdentity == cleanup.workIdentity) { + "The account-removal cleanup identities do not match." + } + check(cleanup.previewCacheIdentity == null || storageOwnerPreviewIdentity == cleanup.previewCacheIdentity) { + "The account-removal preview identity does not match." + } + check( + cleanup.durableMutationIdentity == null || + durableMutationAccountScope( + NextcloudSession(storageOwner.serverUrl, storageOwner.loginName, appPassword = ""), + ) == cleanup.durableMutationIdentity, + ) { + "The account-removal mutation identity does not match." + } + return true + } + check(retainedAccounts.none { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == cleanup.workIdentity + }) { + "The account-removal cleanup identity belongs to a retained account." + } + check(cleanup.durableMutationIdentity == null || retainedAccounts.none { account -> + durableMutationAccountScope( + NextcloudSession(account.serverUrl, account.loginName, appPassword = ""), + ) == cleanup.durableMutationIdentity + }) { + "The account-removal mutation identity belongs to a retained account." + } + return false +} + +internal fun readPendingAndroidAccountRemovalCleanups( + readPending: () -> Collection, + recordFailure: () -> Unit, +): Collection? = try { + readPending() +} catch (cancelled: CancellationException) { + throw cancelled +} catch (_: Exception) { + recordFailure() + null +} + +internal fun logAndroidAccountRemovalCleanupRecoveryDeferred( + logWarning: (String) -> Unit, +) { + logWarning("Account-removal cleanup recovery deferred") +} + +private const val ANDROID_ACCOUNT_PREFERENCES = "nextcloud_native" +private const val LOG_TAG = "AccountCleanupRecovery" +internal val ANDROID_ACCOUNT_REMOVAL_CLEANUP_WORK_POLICY = ExistingWorkPolicy.APPEND_OR_REPLACE diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt new file mode 100644 index 000000000..de9eb4a4a --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt @@ -0,0 +1,77 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudAccountId +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord + +internal sealed interface AndroidAccountRetentionSnapshot { + data class Available( + val accounts: List, + val activeAccountId: NextcloudAccountId? = null, + ) : AndroidAccountRetentionSnapshot + + data object Unavailable : AndroidAccountRetentionSnapshot +} + +internal fun AndroidAccountRetentionSnapshot.accountsOrEmpty(): List = + (this as? AndroidAccountRetentionSnapshot.Available)?.accounts.orEmpty() + +internal fun androidAccountIdentityIsRetained( + accountIdentity: String, + retainedAccounts: List, +): Boolean = retainedAccounts.any { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == accountIdentity +} + +internal fun shouldRetryIncomingShareForMissingSession( + accountIdentity: String, + snapshot: AndroidAccountRetentionSnapshot, +): Boolean = when (snapshot) { + is AndroidAccountRetentionSnapshot.Available -> + androidAccountIdentityIsRetained(accountIdentity, snapshot.accounts) + AndroidAccountRetentionSnapshot.Unavailable -> true +} + +internal fun AndroidAccountRetentionSnapshot.expectedAccountState( + accountIdentity: String, +): AndroidExpectedAccountState = when (this) { + is AndroidAccountRetentionSnapshot.Available -> { + val expected = accounts.firstOrNull { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == accountIdentity + } + when { + expected == null -> AndroidExpectedAccountState.Absent + expected.id == activeAccountId -> AndroidExpectedAccountState.Active + else -> AndroidExpectedAccountState.Inactive + } + } + AndroidAccountRetentionSnapshot.Unavailable -> AndroidExpectedAccountState.Unknown +} + +internal enum class AndroidExpectedAccountState { + Active, + Inactive, + Absent, + Unknown, +} + +internal enum class DurableUploadAccountMismatchOutcome { + RetryAccountRecovery, + DeferAccountActivation, + AccountUnavailable, +} + +internal fun durableUploadAccountMismatchOutcome( + expectedAccountId: String, + accountSnapshot: AndroidAccountRetentionSnapshot, +): DurableUploadAccountMismatchOutcome = when (accountSnapshot.expectedAccountState(expectedAccountId)) { + AndroidExpectedAccountState.Active, + AndroidExpectedAccountState.Unknown, + -> DurableUploadAccountMismatchOutcome.RetryAccountRecovery + AndroidExpectedAccountState.Inactive -> DurableUploadAccountMismatchOutcome.DeferAccountActivation + AndroidExpectedAccountState.Absent -> DurableUploadAccountMismatchOutcome.AccountUnavailable +} + +internal fun shouldRetryAndroidOfflineJobForMissingSession( + expectedAccountId: String, + snapshot: AndroidAccountRetentionSnapshot, +): Boolean = snapshot.expectedAccountState(expectedAccountId) != AndroidExpectedAccountState.Absent 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..2582379c9 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt @@ -0,0 +1,58 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext + +internal suspend fun completeAndroidAccountSelectionTransition( + transitionDispatcher: CoroutineDispatcher = Dispatchers.IO, + commitTransition: (() -> Unit) -> Unit, + finishMaintenance: suspend () -> Unit, +) { + val committed = AtomicBoolean() + var cancellation: CancellationException? = null + try { + withContext(transitionDispatcher) { + commitTransition { committed.set(true) } + } + } catch (cancelled: CancellationException) { + if (!committed.get()) throw cancelled + cancellation = cancelled + } + withContext(NonCancellable) { finishMaintenance() } + cancellation?.let { throw it } + currentCoroutineContext().ensureActive() +} + +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/AndroidDeckCardDraftAccountGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftAccountGuard.kt new file mode 100644 index 000000000..90a695e4b --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftAccountGuard.kt @@ -0,0 +1,16 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal suspend fun withAndroidDeckCardDraftSession( + expectedSession: NextcloudSession, + accountCredentials: AndroidAccountCredentialController, + action: suspend () -> Result, +): Result = withAndroidAccountPrivateStatePublication( + expectedSession = expectedSession, + credentialMutationMutex = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX, + guard = ANDROID_ACCOUNT_OPERATION_GUARD, + resolveSession = { accountCredentials.loadSession(expectedSession.accountId) }, + unavailable = { error("The account changed before the Deck draft operation could complete.") }, + publish = action, +) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt index f9e4931a1..9a346c8f0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt @@ -28,6 +28,7 @@ internal class AndroidDeckCardDraftStore( fun load(session: NextcloudSession, key: DeckCardDraftKey): PersistedDeckCardDraft? = synchronized(STORAGE_LOCK) { + migrateLegacyEntry(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session), key) val storedKey = storageKey(session, key) if (isQuarantined(storedKey)) { storage.remove(setOf(storedKey, quarantineKey(storedKey))) @@ -36,24 +37,30 @@ internal class AndroidDeckCardDraftStore( val encrypted = storage.getString(storedKey) ?: return@synchronized null val stored = decode(encrypted) requireStorageSlot(stored, storedKey) + requireStorageOwner(stored, session.accountId.storageKey) requireResource(stored, key) stored.draft } fun save(session: NextcloudSession, persisted: PersistedDeckCardDraft): Unit = synchronized(STORAGE_LOCK) { + check( + migrateLegacyEntry(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session), persisted.key), + ) { "The previous Deck card draft could not be retired before saving its replacement." } + migrateLegacyEntries(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session)) val storedKey = storageKey(session, persisted.key) clearQuarantineBeforeSave(storedKey) val existing = storage.getString(storedKey) existing?.let { val stored = decode(existing) requireStorageSlot(stored, storedKey) + requireStorageOwner(stored, session.accountId.storageKey) requireResource(stored, persisted.key) } if (existing == null) ensureCapacityForNewDraft(session) val updatedAtEpochMillis = nowEpochMillis() require(updatedAtEpochMillis >= 0L) { "The Deck draft timestamp is invalid." } - val encrypted = encode(storedKey, persisted, updatedAtEpochMillis) + val encrypted = encode(session.accountId.storageKey, storedKey, persisted, updatedAtEpochMillis) check(storage.putString(storedKey, encrypted)) { "The Deck card draft could not be saved." } prune(session) } @@ -65,41 +72,87 @@ internal class AndroidDeckCardDraftStore( * preference write must leave the original recovery record available for a later attempt. */ fun migrateLegacyEntries(session: NextcloudSession): Unit = synchronized(STORAGE_LOCK) { + migrateLegacyEntries(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session)) + } + + private fun migrateLegacyEntries(accountStorageKey: String, legacyAccountIdentity: String) { val entries = try { storage.entries() } catch (_: Exception) { - return@synchronized + return } entries.forEach { (storedKey, rawValue) -> - if (!storedKey.startsWith(KEY_PREFIX)) return@forEach + if (!storedKey.matches(LEGACY_DRAFT_KEY_PATTERN)) return@forEach val stored = try { (rawValue as? String)?.let(::decode) } catch (_: AndroidDeckDraftRecoveryException) { null } ?: return@forEach - if (stored.storageKey != null || storageKey(session, stored.draft.key) != storedKey) { - return@forEach - } - val migrated = try { - encode(storedKey, stored.draft, stored.updatedAtEpochMillis) - } catch (_: Exception) { + if ( + stored.accountStorageKey != null || + stored.storageKey != null && stored.storageKey != storedKey || + legacyStorageKey(legacyAccountIdentity, stored.draft.key) != storedKey + ) { return@forEach } try { - check(storage.putString(storedKey, migrated)) + migrateLegacyEntry(accountStorageKey, legacyAccountIdentity, stored.draft.key, stored) } catch (_: Exception) { - // Keep the legacy ciphertext available so a later session load can retry. + // Preserve the legacy record so the migration can be retried. } } } + private fun migrateLegacyEntry( + accountStorageKey: String, + legacyAccountIdentity: String, + key: DeckCardDraftKey, + decodedLegacy: StoredDeckCardDraft? = null, + ): Boolean { + val legacyKey = legacyStorageKey(legacyAccountIdentity, key) + val legacyMarker = quarantineKey(legacyKey, LEGACY_KEY_PREFIX, LEGACY_QUARANTINE_PREFIX) + val targetKey = storageKey(accountStorageKey, key) + val targetMarker = quarantineKey(targetKey) + val legacyEncrypted = storage.getString(legacyKey) + if (legacyEncrypted == null) { + val markerValue = storage.entries()[legacyMarker] as? String ?: return true + if (!storage.putString(targetMarker, markerValue)) return false + return storage.remove(setOf(legacyMarker)) + } + val legacy = decodedLegacy ?: decode(legacyEncrypted) + if ( + legacy.accountStorageKey != null || + legacy.storageKey != null && legacy.storageKey != legacyKey || + legacy.draft.key != key + ) { + throw AndroidDeckDraftRecoveryException( + IllegalArgumentException("The legacy Deck draft identity does not match."), + ) + } + val migrated = encode(accountStorageKey, targetKey, legacy.draft, legacy.updatedAtEpochMillis) + val markerValue = storage.entries()[legacyMarker] as? String + if (markerValue != null && !storage.putString(targetMarker, markerValue)) return false + val existingTarget = storage.getString(targetKey) + if (existingTarget == null) { + if (!storage.putString(targetKey, migrated)) return false + } else { + val existing = decode(existingTarget) + requireStorageSlot(existing, targetKey) + requireStorageOwner(existing, accountStorageKey) + requireResource(existing, key) + } + return storage.remove(setOf(legacyKey, legacyMarker)) + } + private fun encode( + accountStorageKey: String, storedKey: String, persisted: PersistedDeckCardDraft, updatedAtEpochMillis: Long, ): String { val value = JSONObject() .put("version", FORMAT_VERSION) + .put("accountStorageKey", accountStorageKey) .put("storageKey", storedKey) .put("updatedAtEpochMillis", updatedAtEpochMillis) .put("boardId", persisted.key.boardId) @@ -115,6 +168,7 @@ internal class AndroidDeckCardDraftStore( val encrypted = cipher.encrypt(value) val verified = decode(encrypted) requireStorageSlot(verified, storedKey) + requireStorageOwner(verified, accountStorageKey) requireResource(verified, persisted.key) check(verified.draft == persisted && verified.updatedAtEpochMillis == updatedAtEpochMillis) { "The Deck card draft could not be verified." @@ -127,11 +181,28 @@ internal class AndroidDeckCardDraftStore( key: DeckCardDraftKey, discardUnreadable: Boolean = false, ): Unit = synchronized(STORAGE_LOCK) { + if (discardUnreadable) { + val storedKey = storageKey(session, key) + val legacyKey = legacyStorageKey(NextcloudDocumentIds.accountKey(session), key) + check( + storage.remove( + setOf( + storedKey, + quarantineKey(storedKey), + legacyKey, + quarantineKey(legacyKey, LEGACY_KEY_PREFIX, LEGACY_QUARANTINE_PREFIX), + ), + ), + ) { "The Deck card draft could not be cleared." } + return@synchronized + } + migrateLegacyEntry(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session), key) val storedKey = storageKey(session, key) if (!discardUnreadable) { storage.getString(storedKey)?.let { existing -> val stored = decode(existing) requireStorageSlot(stored, storedKey) + requireStorageOwner(stored, session.accountId.storageKey) requireResource(stored, key) } } @@ -143,27 +214,72 @@ internal class AndroidDeckCardDraftStore( fun quarantineAfterSubmit(session: NextcloudSession, key: DeckCardDraftKey): Unit = synchronized(STORAGE_LOCK) { val storedKey = storageKey(session, key) + val legacyKey = legacyStorageKey(NextcloudDocumentIds.accountKey(session), key) + val legacyMarker = quarantineKey(legacyKey, LEGACY_KEY_PREFIX, LEGACY_QUARANTINE_PREFIX) + check(storage.putString(legacyMarker, QUARANTINE_MARKER)) { + "The submitted legacy Deck card draft could not be quarantined." + } check(storage.putString(quarantineKey(storedKey), QUARANTINE_MARKER)) { "The submitted Deck card draft could not be quarantined." } - if (!storage.remove(setOf(storedKey, quarantineKey(storedKey)))) return@synchronized + if (!storage.remove(setOf(legacyKey, legacyMarker, storedKey, quarantineKey(storedKey)))) { + return@synchronized + } } fun discardAll(): Unit = synchronized(STORAGE_LOCK) { val keys = storage.entries().keys.filterTo(linkedSetOf()) { key -> - key.startsWith(KEY_PREFIX) || key.startsWith(QUARANTINE_PREFIX) + key.startsWith(KEY_PREFIX) || key.startsWith(QUARANTINE_PREFIX) || + key.matches(LEGACY_DRAFT_KEY_PATTERN) || key.matches(LEGACY_QUARANTINE_KEY_PATTERN) } if (keys.isEmpty()) return@synchronized check(storage.remove(keys)) { "Saved Deck card drafts could not be discarded." } } + fun removeAccount(accountStorageKey: String, legacyAccountIdentity: String) = synchronized(STORAGE_LOCK) { + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(accountStorageKey)) + require(LEGACY_ACCOUNT_IDENTITY_PATTERN.matches(legacyAccountIdentity)) + val entries = storage.entries() + val keys = entries.keys.filterTo(linkedSetOf()) { key -> + key.startsWith(accountDraftPrefix(accountStorageKey)) || + key.startsWith(accountQuarantinePrefix(accountStorageKey)) + } + entries.forEach { (storedKey, rawValue) -> + if (!storedKey.matches(LEGACY_DRAFT_KEY_PATTERN)) return@forEach + val stored = try { + (rawValue as? String)?.let(::decode) + } catch (_: AndroidDeckDraftRecoveryException) { + null + } ?: return@forEach + if ( + stored.accountStorageKey == null && + (stored.storageKey == null || stored.storageKey == storedKey) && + legacyStorageKey(legacyAccountIdentity, stored.draft.key) == storedKey + ) { + keys += storedKey + keys += quarantineKey(storedKey, LEGACY_KEY_PREFIX, LEGACY_QUARANTINE_PREFIX) + } + } + if (keys.isNotEmpty()) { + check(storage.remove(keys)) { "Saved Deck card drafts for the account could not be removed." } + } + } + private fun decode(encrypted: String): StoredDeckCardDraft = try { val value = JSONObject(cipher.decrypt(encrypted)) - require(value.getInt("version") == FORMAT_VERSION) { + val version = value.getInt("version") + require(version == LEGACY_FORMAT_VERSION || version == FORMAT_VERSION) { "The Deck draft format is unsupported." } val updatedAtEpochMillis = value.getLong("updatedAtEpochMillis") require(updatedAtEpochMillis >= 0L) { "The Deck draft timestamp is invalid." } + val storageKey = value.optString("storageKey").takeIf(String::isNotBlank) + val accountStorageKey = value.optString("accountStorageKey").takeIf(String::isNotBlank) + require( + version == LEGACY_FORMAT_VERSION && accountStorageKey == null || + version == FORMAT_VERSION && storageKey != null && + accountStorageKey?.matches(ACCOUNT_STORAGE_KEY_PATTERN) == true, + ) { "The Deck draft account storage metadata is invalid." } StoredDeckCardDraft( draft = PersistedDeckCardDraft( key = DeckCardDraftKey( @@ -185,7 +301,8 @@ internal class AndroidDeckCardDraftStore( ), ), updatedAtEpochMillis = updatedAtEpochMillis, - storageKey = value.optString("storageKey").takeIf(String::isNotBlank), + storageKey = storageKey, + accountStorageKey = accountStorageKey, ) } catch (failure: Exception) { throw AndroidDeckDraftRecoveryException(failure) @@ -208,16 +325,25 @@ internal class AndroidDeckCardDraftStore( } } + private fun requireStorageOwner(stored: StoredDeckCardDraft, expected: String) { + if (stored.accountStorageKey != expected) { + throw AndroidDeckDraftRecoveryException( + IllegalArgumentException("The Deck draft account identity does not match."), + ) + } + } + private fun prune(session: NextcloudSession) { var unreadableEntries = 0 + val accountStorageKey = session.accountId.storageKey val metadata = storage.entries().mapNotNull { (key, rawValue) -> - if (!key.startsWith(KEY_PREFIX)) return@mapNotNull null + if (!key.startsWith(accountDraftPrefix(accountStorageKey))) return@mapNotNull null val stored = try { (rawValue as? String)?.let(::decode) } catch (_: AndroidDeckDraftRecoveryException) { null } - if (stored != null && isReadableRetentionEntry(session, key, stored)) { + if (stored != null && isReadableRetentionEntry(accountStorageKey, key, stored)) { DeckCardDraftRetention.Entry(key, stored.updatedAtEpochMillis) } else { // A Keystore or provider failure can make valid ciphertext temporarily unreadable. @@ -237,14 +363,15 @@ internal class AndroidDeckCardDraftStore( } private fun ensureCapacityForNewDraft(session: NextcloudSession) { - val draftEntries = storage.entries().filterKeys { it.startsWith(KEY_PREFIX) } + val accountStorageKey = session.accountId.storageKey + val draftEntries = storage.entries().filterKeys { it.startsWith(accountDraftPrefix(accountStorageKey)) } val overflow = draftEntries.size + 1 - DeckCardDraftRetention.MAX_ENTRIES if (overflow <= 0) return val readableEntries = draftEntries.count { (storedKey, rawValue) -> try { (rawValue as? String) ?.let(::decode) - ?.let { stored -> isReadableRetentionEntry(session, storedKey, stored) } == true + ?.let { stored -> isReadableRetentionEntry(accountStorageKey, storedKey, stored) } == true } catch (_: AndroidDeckDraftRecoveryException) { false } @@ -253,11 +380,10 @@ internal class AndroidDeckCardDraftStore( } private fun isReadableRetentionEntry( - session: NextcloudSession, + accountStorageKey: String, storedKey: String, stored: StoredDeckCardDraft, - ): Boolean = stored.storageKey?.let { recorded -> recorded == storedKey } - ?: (storageKey(session, stored.draft.key) == storedKey) + ): Boolean = stored.storageKey == storedKey && stored.accountStorageKey == accountStorageKey private fun isQuarantined(storedKey: String): Boolean = quarantineKey(storedKey) in storage.entries() @@ -271,36 +397,68 @@ internal class AndroidDeckCardDraftStore( } private fun quarantineKey(storedKey: String): String = - "$QUARANTINE_PREFIX${storedKey.removePrefix(KEY_PREFIX)}" + quarantineKey(storedKey, KEY_PREFIX, QUARANTINE_PREFIX) + + private fun quarantineKey(storedKey: String, draftPrefix: String, markerPrefix: String): String = + "$markerPrefix${storedKey.removePrefix(draftPrefix)}" internal fun storageKey(session: NextcloudSession, key: DeckCardDraftKey): String { + return storageKey(session.accountId.storageKey, key) + } + + private fun storageKey(accountStorageKey: String, key: DeckCardDraftKey): String { val scope = listOf( - NextcloudDocumentIds.accountKey(session), key.boardId.toString(), key.stackId.toString(), key.cardId?.toString() ?: "new", ).joinToString(separator = ":") - val digest = MessageDigest.getInstance("SHA-256") - .digest(scope.toByteArray(Charsets.UTF_8)) - .joinToString(separator = "") { byte -> - (byte.toInt() and 0xff).toString(16).padStart(2, '0') - } - return "$KEY_PREFIX$digest" + return "${accountDraftPrefix(accountStorageKey)}${sha256(scope)}" } + private fun legacyStorageKey(accountIdentity: String, key: DeckCardDraftKey): String { + val scope = listOf( + accountIdentity, + key.boardId.toString(), + key.stackId.toString(), + key.cardId?.toString() ?: "new", + ).joinToString(separator = ":") + return "$LEGACY_KEY_PREFIX${sha256(scope)}" + } + + internal fun legacyStorageKey(session: NextcloudSession, key: DeckCardDraftKey): String = + legacyStorageKey(NextcloudDocumentIds.accountKey(session), key) + + private fun accountDraftPrefix(accountStorageKey: String) = "$KEY_PREFIX${accountStorageKey}_" + + private fun accountQuarantinePrefix(accountStorageKey: String) = "$QUARANTINE_PREFIX${accountStorageKey}_" + + private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { byte -> + (byte.toInt() and 0xff).toString(16).padStart(2, '0') + } + private data class StoredDeckCardDraft( val draft: PersistedDeckCardDraft, val updatedAtEpochMillis: Long, val storageKey: String?, + val accountStorageKey: String?, ) internal companion object { private val STORAGE_LOCK = Any() const val PREFERENCES = "nextcloud_native_deck_drafts" - const val KEY_PREFIX = "draft_" - const val QUARANTINE_PREFIX = "submitted_" + const val KEY_PREFIX = "draft_v2_" + const val QUARANTINE_PREFIX = "submitted_v2_" + const val LEGACY_KEY_PREFIX = "draft_" + const val LEGACY_QUARANTINE_PREFIX = "submitted_" const val QUARANTINE_MARKER = "confirmed" - const val FORMAT_VERSION = 1 + const val LEGACY_FORMAT_VERSION = 1 + const val FORMAT_VERSION = 2 + private val ACCOUNT_STORAGE_KEY_PATTERN = Regex("[0-9a-f]{64}") + private val LEGACY_ACCOUNT_IDENTITY_PATTERN = Regex("[0-9a-f]{32}") + private val LEGACY_DRAFT_KEY_PATTERN = Regex("^draft_[0-9a-f]{64}$") + private val LEGACY_QUARANTINE_KEY_PATTERN = Regex("^submitted_[0-9a-f]{64}$") } } 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..98e5d3d7c 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,53 @@ 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) { + when (durableUploadAccountMismatchOutcome(initial.accountId, accountServices.accountRetentionSnapshot())) { + DurableUploadAccountMismatchOutcome.RetryAccountRecovery -> { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-retry", + accountId = initial.accountId, + jobId = jobId, + ) + return Result.retry() + } + DurableUploadAccountMismatchOutcome.DeferAccountActivation -> { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-deferred", + accountId = initial.accountId, + jobId = jobId, + ) + return Result.success() + } + DurableUploadAccountMismatchOutcome.AccountUnavailable -> Unit + } 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 +224,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 +244,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 +314,7 @@ internal class DeckAttachmentUploadWorker( ) picker.release(started.request.file) } - Result.success() + return Result.success() } private fun recordUploadDiagnostic( @@ -284,6 +346,13 @@ internal class DeckAttachmentUploadWorker( } } +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 +450,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/AndroidDynamicApiProcessState.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiProcessState.kt new file mode 100644 index 000000000..a0765fe98 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiProcessState.kt @@ -0,0 +1,28 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer +import dev.obiente.nextcloudnative.app.NextcloudApiResponse +import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Coordinates every Android owner of one dynamic response cache directory. + * + * AndroidManifest.xml does not assign an android:process to an app component, so activities, + * providers, and workers share this process registry. Disk deletion still fails closed in the + * cache implementation instead of relying on this process-only coordination for filesystem safety. + */ +internal class AndroidDynamicApiProcessState internal constructor(root: File) { + val cache = DynamicApiResponseCache(root) + val coalescer = DynamicApiRequestCoalescer() +} + +internal fun androidDynamicApiProcessState(root: File): AndroidDynamicApiProcessState { + val canonicalRoot = root.canonicalFile + return ANDROID_DYNAMIC_API_PROCESS_STATES.computeIfAbsent(canonicalRoot.path) { + AndroidDynamicApiProcessState(canonicalRoot) + } +} + +private val ANDROID_DYNAMIC_API_PROCESS_STATES = ConcurrentHashMap() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCache.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCache.kt new file mode 100644 index 000000000..7d23b6097 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCache.kt @@ -0,0 +1,94 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.isSafeDynamicDiscoveryCacheAppId +import dev.obiente.nextcloudnative.app.MAX_PERSISTED_DYNAMIC_DISCOVERY_BYTES +import dev.obiente.nextcloudnative.app.DynamicNativeMemoryCacheProducer +import java.io.File +import java.io.FileOutputStream + +/** Serializes persisted dynamic discovery publications with account retirement. */ +internal class AndroidDynamicDiscoveryCache(private val root: File) { + private val lock = Any() + private val retiredAccounts = mutableSetOf() + private val accountIncarnations = mutableMapOf() + + fun load(accountStorageKey: String, cacheAccountId: String, appId: String): String? = synchronized(lock) { + if (accountStorageKey in retiredAccounts) return@synchronized null + val target = cacheFile(cacheAccountId, appId) ?: return@synchronized null + if (!target.isFile || target.length() !in 1..MAX_PERSISTED_DYNAMIC_DISCOVERY_BYTES.toLong()) { + return@synchronized null + } + runCatching(target::readText).getOrNull() + } + + fun save( + accountStorageKey: String, + cacheAccountId: String, + appId: String, + encoded: String, + producer: DynamicNativeMemoryCacheProducer?, + ) = synchronized(lock) { + val current = producer ?: return@synchronized + require(current.accountStorageKey == accountStorageKey) + if ( + accountStorageKey in retiredAccounts || + current.incarnation != (accountIncarnations[accountStorageKey] ?: 0L) + ) { + return@synchronized + } + val target = cacheFile(cacheAccountId, appId) ?: return@synchronized + check(root.mkdirs() || root.isDirectory) { "Could not create the dynamic contract cache." } + val temporary = File(root, "${target.name}.part") + try { + FileOutputStream(temporary).use { output -> + output.write(encoded.encodeToByteArray()) + output.fd.sync() + } + check(temporary.renameTo(target) || runCatching { + temporary.copyTo(target, overwrite = true) + check(temporary.delete() || !temporary.exists()) + }.isSuccess) { "Could not publish the dynamic contract cache." } + } finally { + temporary.delete() + } + } + + fun retireAccount(accountStorageKey: String, cacheAccountId: String?) = synchronized(lock) { + if (retiredAccounts.add(accountStorageKey)) { + accountIncarnations[accountStorageKey] = (accountIncarnations[accountStorageKey] ?: 0L) + 1L + } + if (!root.exists()) return@synchronized + check(root.isDirectory) { "The dynamic contract cache is unavailable." } + val files = root.listFiles() ?: error("Could not inspect the dynamic contract cache.") + files.forEach { file -> check(file.isFile && file.name.matches(ACCOUNT_CACHE_FILE)) { + "The dynamic contract cache contains an unexpected entry." + } } + files.filter { cacheAccountId == null || it.name.startsWith("$cacheAccountId-") } + .forEach { file -> + check(file.delete() || !file.exists()) { "Could not clear the dynamic contract cache." } + } + } + + fun activateAccount(accountStorageKey: String) = synchronized(lock) { + retiredAccounts -= accountStorageKey + } + + private fun cacheFile(cacheAccountId: String, appId: String): File? { + if (!cacheAccountId.matches(ACCOUNT_CACHE_ID) || !appId.isSafeDynamicDiscoveryCacheAppId()) return null + return File(root, "$cacheAccountId-$appId.json") + } + + private companion object { + val ACCOUNT_CACHE_ID = Regex("[0-9a-f]{32}(?:[0-9a-f]{32})?") + val ACCOUNT_CACHE_FILE = Regex("${ACCOUNT_CACHE_ID.pattern}-[A-Za-z0-9._-]+\\.json(?:\\.part)?") + } +} + +internal object AndroidDynamicDiscoveryCacheCoordinator { + private val instances = mutableMapOf() + + fun get(root: File): AndroidDynamicDiscoveryCache = synchronized(this) { + val key = root.absoluteFile.normalize().path + instances.getOrPut(key) { AndroidDynamicDiscoveryCache(root) } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffCleanup.kt new file mode 100644 index 000000000..2006cc860 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffCleanup.kt @@ -0,0 +1,59 @@ +package dev.obiente.nextcloudnative + +import android.content.SharedPreferences + +internal const val ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY = "pending_external_handoff_cleanup_v1" +private const val ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP = "pending" + +internal fun prepareAndroidExternalHandoffCleanup( + editor: SharedPreferences.Editor, +): SharedPreferences.Editor = editor.putString( + ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY, + ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP, +) + +internal class AndroidExternalFileHandoffCleanup( + context: android.content.Context, + private val preferences: SharedPreferences, + private val commit: (SharedPreferences.Editor) -> Unit, +) { + private val appContext = context.applicationContext + + fun prepare(editor: SharedPreferences.Editor): SharedPreferences.Editor = + prepareAndroidExternalHandoffCleanup(editor) + + fun pending(): Boolean = preferences.contains(ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY) + + fun complete() { + clearHandoffs() + clearJournal() + } + + fun clearHandoffs() { + AndroidExternalFileHandoffRegistry.clearPersisted(AndroidExternalFileHandoffStore(appContext)) + } + + fun clearJournal() { + commit(preferences.edit().remove(ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY)) + } +} + +internal fun hasPendingAndroidExternalHandoffCleanup(preferences: SharedPreferences): Boolean = + preferences.contains(ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY) + +internal fun retryPendingAndroidExternalHandoffCleanup( + pending: Boolean, + clearHandoffs: () -> Unit, + clearJournal: () -> Unit, + recordFailure: (Exception) -> Unit, +): Boolean { + if (!pending) return true + return try { + clearHandoffs() + clearJournal() + true + } catch (failure: Exception) { + recordFailure(failure) + false + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffRegistry.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffRegistry.kt index d4fc7b89a..528d896d0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffRegistry.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffRegistry.kt @@ -248,16 +248,43 @@ internal object AndroidExternalFileHandoffRegistry { } fun clear() { + clearWithStore(null) + } + + fun clearPersisted(store: AndroidExternalFileHandoffStore) { + clearWithStore(store) + } + + private fun clearWithStore(store: AndroidExternalFileHandoffStore?) { + var persistenceFailure: Exception? = null + var cleanupStore: AndroidExternalFileHandoffStore? = null val removed = synchronized(lock) { - if (entries.isEmpty()) { - persistLocked() - return@synchronized emptyList() + if (store != null) { + val storeIdentity = store.stateFile.absolutePath + check(boundStoreIdentity == null || boundStoreIdentity == storeIdentity) { + "External handoff cleanup targeted a different persistent store." + } + if (boundStore == null) { + boundStore = store + boundStoreIdentity = storeIdentity + } + } + cleanupStore = boundStore ?: store + entries.values.toList().also { entries.clear() }.also { + try { + cleanupStore?.save(emptyList()) + } catch (failure: Exception) { + persistenceFailure = failure + } } - boundStore?.save(emptyList()) - entries.values.toList().also { entries.clear() } } removed.flatMap(Entry::readers).forEach(AndroidExternalFileHandoffLease::revoke) - removed.forEach { entry -> deleteManagedContentBestEffort(entry.record) } + try { + cleanupStore?.deleteAllManagedContent() + } catch (failure: Exception) { + persistenceFailure?.addSuppressed(failure) ?: run { persistenceFailure = failure } + } + persistenceFailure?.let { throw it } } internal fun resetProcessStateForTests() { @@ -291,7 +318,14 @@ internal object AndroidExternalFileHandoffRegistry { } private fun deleteManagedContentBestEffort(record: AndroidExternalFileHandoffRecord) { - runCatching { boundStore?.deleteManagedContent(record.documentId) } + boundStore?.let { store -> deleteManagedContentBestEffort(store, record) } + } + + private fun deleteManagedContentBestEffort( + store: AndroidExternalFileHandoffStore, + record: AndroidExternalFileHandoffRecord, + ) { + runCatching { store.deleteManagedContent(record.documentId) } .onFailure { failure -> Log.w(LOG_TAG, "Could not clear managed external handoff content", failure) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffStore.kt index e80400f83..57d70e388 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffStore.kt @@ -22,6 +22,7 @@ internal class AndroidExternalFileHandoffStoreException(message: String, cause: internal class AndroidExternalFileHandoffStore( internal val stateFile: File, internal val managedContentRoot: File? = null, + private val deleteStateFile: (File) -> Boolean = File::delete, ) { constructor(context: Context) : this( File(context.applicationContext.noBackupFilesDir, STATE_DIRECTORY).resolve(STATE_FILE_NAME), @@ -64,7 +65,7 @@ internal class AndroidExternalFileHandoffStore( val parent = stateFile.parentFile ?: throw AndroidExternalFileHandoffStoreException("External handoff state has no parent directory.") if (records.isEmpty()) { - if (stateFile.exists() && !stateFile.delete()) { + if (stateFile.exists() && (!deleteStateFile(stateFile) || stateFile.exists())) { throw AndroidExternalFileHandoffStoreException("Could not clear external handoff state.") } return @@ -105,6 +106,13 @@ internal class AndroidExternalFileHandoffStore( } } + fun deleteAllManagedContent() { + val root = managedContentRoot ?: return + if (root.exists() && (!root.deleteRecursively() || root.exists())) { + throw AndroidExternalFileHandoffStoreException("Could not clear managed external handoff content.") + } + } + private fun DataOutputStream.writeRecord(record: AndroidExternalFileHandoffRecord) { writeBoundedString(record.documentId, MAX_DOCUMENT_ID_BYTES) writeBoundedString(record.accountId, MAX_ACCOUNT_ID_BYTES) 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..29234a9ca 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt @@ -385,14 +385,30 @@ 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 accountSnapshot = services.accountRetentionSnapshot() + val session = resolveStoredAndroidAccountSession( + expectedAccountId, { accountSnapshot.accountsOrEmpty() }, services::loadSession, + ) + if (session == null) { + if (shouldRetryAndroidOfflineJobForMissingSession(expectedAccountId, accountSnapshot)) { + return AndroidOfflineExecutionOutcome.Retry + } finish( jobId, FileOfflineJobResult.PermanentFailure("Sign in to this account to finish the offline download."), @@ -693,7 +709,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/AndroidFileReadCache.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCache.kt index 393c43e18..fcd27ffd2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCache.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCache.kt @@ -104,6 +104,9 @@ internal class AndroidFileReadCache( ) } + @Synchronized + fun clearAccount(accountId: String) = deleteAndroidAccountPrivateCache(root, accountId) + private fun CacheState.bounded(): CacheState { var retainedEntries = 0 val retained = listings diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index b695249dc..06cb6de16 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -720,6 +720,7 @@ internal class AndroidFileSyncEngine(context: Context) { ): FileSyncExecutionSuccess { val pair = state.pairs.first { it.id == command.pairId } val work = pair.workItems.first { it.id == command.workId } + val accountStagingRoot = androidFileSyncAccountStagingRoot(stagingRoot, pair.accountId) require(isAndroidFileSyncExecutionAllowed(pair.localRootId, command.operation)) { "Detected media folders permit upload operations only." } @@ -733,10 +734,10 @@ internal class AndroidFileSyncEngine(context: Context) { } remote.createDirectory(operation.relativePath, operation.expectedRemoteEtag.takeUnless { replacingType }) } else { - withAndroidFileSyncStagingFile(stagingRoot, "upload") { staged -> + withAndroidFileSyncStagingFile(accountStagingRoot, "upload") { staged -> val exactLocal = local.stageForUpload( operation.relativePath, staged, - androidFileSyncStagingTransferLimit(stagingRoot, source.size), + androidFileSyncStagingTransferLimit(accountStagingRoot, source.size), remote::shouldContinueTransfer, ) val protectedDirectoryReplacement = @@ -779,6 +780,7 @@ internal class AndroidFileSyncEngine(context: Context) { local, remote, contentReadBudget, + accountStagingRoot, ) is FileSyncOperation.NeedsDecision, is FileSyncOperation.Skipped, @@ -792,8 +794,9 @@ internal class AndroidFileSyncEngine(context: Context) { local: AndroidFileSyncLocalTree, remote: AndroidFileSyncRemoteTree, contentReadBudget: AndroidFileSyncContentReadBudget, + accountStagingRoot: File, ): FileSyncExecutionSuccess { - executeAndroidFileSyncKeepBoth(operation, work, local, remote, stagingRoot) + executeAndroidFileSyncKeepBoth(operation, work, local, remote, accountStagingRoot) return FileSyncExecutionSuccess( synchronizedBaselines = listOf( verifiedBaseline( @@ -840,7 +843,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..ed2e2125b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import dev.obiente.nextcloudnative.app.FileSyncDirection import dev.obiente.nextcloudnative.app.FileSyncOperation +import dev.obiente.nextcloudnative.app.FileSyncPair import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -229,3 +230,70 @@ 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 (retiredPairs, retainedPairs) = current.coordinator.pairs.partition { pair -> + pair.accountId == accountId + } + if (retiredPairs.isEmpty()) return@withLock + val scheduler = AndroidFileSyncScheduler(context) + val notifications = AndroidNotificationCoordinator(context) + retireConfiguredFileSyncAccountPairs( + retiredPairs = retiredPairs, + retainedPairs = retainedPairs, + reconcileLocalDownloads = { pair -> + reconcileSafDownloadsBeforePairRemoval(context, pair.localRootId) + }, + cancelSchedule = { pair -> scheduler.cancel(pair.id) }, + cancelNotification = { pair -> + notifications.cancel(pair.accountId, androidFileSyncNotificationId(pair.id)) + }, + persistRetirement = { store.save(removeAndroidFileSyncAccountPairs(current, accountId)) }, + releaseLocalGrant = { localRootId -> + releaseSafGrantAfterPairRemoval(context, localRootId, releasesLocalGrant = true) + }, + ) + } +} + +internal suspend fun retireConfiguredFileSyncAccountPairs( + retiredPairs: List, + retainedPairs: List, + reconcileLocalDownloads: suspend (FileSyncPair) -> Boolean, + cancelSchedule: suspend (FileSyncPair) -> Unit, + cancelNotification: suspend (FileSyncPair) -> Unit, + persistRetirement: suspend () -> Unit, + releaseLocalGrant: suspend (String) -> Unit, +) { + retiredPairs.forEach { pair -> + check(reconcileLocalDownloads(pair)) { + "A local download still needs safe recovery. Run this folder sync before removing the account." + } + currentCoroutineContext().ensureActive() + } + retiredPairs.forEach { pair -> + cancelSchedule(pair) + cancelNotification(pair) + } + currentCoroutineContext().ensureActive() + + val retainedLocalRoots = retainedPairs.mapTo(hashSetOf()) { pair -> pair.localRootId } + val releasedLocalRoots = retiredPairs.asSequence() + .map { pair -> pair.localRootId } + .filter { localRootId -> localRootId.startsWith("content://") && localRootId !in retainedLocalRoots } + .distinct() + .toList() + withContext(NonCancellable) { + releasedLocalRoots.forEach { localRootId -> releaseLocalGrant(localRootId) } + 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..49e582441 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) } } } } @@ -72,16 +78,14 @@ internal class AndroidFileSyncSessionSchedulingGuard { persist: () -> Unit, cancelAll: () -> Unit, clearPublishedAccount: () -> Unit = {}, + onScheduleMaintenanceFailure: (Exception) -> Unit = {}, ) { synchronized(monitor) { + persist() generation += 1 accountId = null - try { - persist() - clearPublishedAccount() - } finally { - cancelAll() - } + runScheduleMaintenance(onScheduleMaintenanceFailure, clearPublishedAccount) + runScheduleMaintenance(onScheduleMaintenanceFailure, cancelAll) } } @@ -103,6 +107,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 +177,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/AndroidFileSyncStaging.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStaging.kt index 79d2b3ba6..48e7b5ac9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStaging.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStaging.kt @@ -3,6 +3,35 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.stagedFileTransferLimit import java.io.File +internal fun androidFileSyncAccountStagingRoot(stagingRoot: File, accountId: String): File { + require(accountId.matches(ANDROID_FILE_SYNC_STAGING_ACCOUNT_ID)) + return File(stagingRoot, accountId) +} + +internal fun removeAndroidFileSyncAccountStaging(stagingRoot: File, accountId: String) { + val accountRoot = androidFileSyncAccountStagingRoot(stagingRoot, accountId) + if (!accountRoot.exists()) return + check(accountRoot.isDirectory) { "The account sync staging storage is unavailable." } + accountRoot.listFiles()?.forEach { staged -> + check(staged.isFile && staged.name.matches(ANDROID_FILE_SYNC_STAGING_FILE)) { + "The account sync staging storage contains an unexpected entry." + } + check(staged.delete() || !staged.exists()) { "Could not clear account sync staging storage." } + } ?: error("Could not inspect account sync staging storage.") + check(accountRoot.delete() || !accountRoot.exists()) { "Could not clear account sync staging storage." } +} + +internal fun removeLegacyAndroidFileSyncStaging(stagingRoot: File) { + if (!stagingRoot.exists()) return + check(stagingRoot.isDirectory) { "The sync staging storage is unavailable." } + stagingRoot.listFiles()?.filter(File::isFile)?.forEach { staged -> + check(staged.name.matches(ANDROID_FILE_SYNC_STAGING_FILE)) { + "The sync staging storage contains an unexpected file." + } + check(staged.delete() || !staged.exists()) { "Could not clear legacy sync staging storage." } + } ?: error("Could not inspect sync staging storage.") +} + internal inline fun withAndroidFileSyncStagingFile( stagingRoot: File, prefix: String, @@ -17,6 +46,9 @@ internal inline fun withAndroidFileSyncStagingFile( } } +private val ANDROID_FILE_SYNC_STAGING_ACCOUNT_ID = Regex("[0-9a-f]{32}(?:[0-9a-f]{32})?") +private val ANDROID_FILE_SYNC_STAGING_FILE = Regex("(?:upload|keep-local|keep-remote)-[A-Za-z0-9._-]+\\.tmp") + internal fun androidFileSyncStagingTransferLimit(stagingRoot: File, declaredByteCount: Long?): Long { check(stagingRoot.isDirectory || stagingRoot.mkdirs()) { "Could not create sync staging storage." } return stagedFileTransferLimit( 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..eb371e61b --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt @@ -0,0 +1,153 @@ +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 = if (session == null || webDav == null) null else { request, uploadId -> + 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") + }, + recordChunkAbandonment = { _, _ -> + Log.w(LOG_TAG, "Remote staged-share chunk abandoned after credential 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 = {}, + recordChunkAbandonment: (AndroidIncomingShareRequest, String) -> Unit = { _, _ -> }, + removeRequest: (String) -> Unit, +) { + requests.forEach { request -> cancelWork(request.id) } + val retained = mutableSetOf() + var firstReleaseFailure: Exception? = null + requests.forEach { accountRequest -> + accountRequest.request?.chunkSession?.takeIf { releaseChunk == null }?.let { chunk -> + recordChunkAbandonment(accountRequest.request, chunk.uploadId) + } + accountRequest.request?.chunkSession?.takeIf { releaseChunk != null }?.let { chunk -> + try { + requireNotNull(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..f02be0886 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt @@ -80,23 +80,45 @@ 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.", + 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 accountSnapshot = services.accountRetentionSnapshot() + val session = (accountSnapshot as? AndroidAccountRetentionSnapshot.Available)?.let { available -> + resolveStoredAndroidAccountSession( + accountIdentity = accountIdentity, + listAccounts = { available.accounts }, + loadSession = { accountId -> services.loadSession(accountId) }, ) - failed?.let { - publishTerminalNotification(it) - scheduleIncomingShareCleanup(applicationContext, it.id) + } + if (session == null) { + if (shouldRetryIncomingShareForMissingSession(accountIdentity, accountSnapshot)) { + return Result.retry() } - return@withContext Result.failure() + 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 +135,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 +209,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 +244,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/AndroidMalformedCredentialReset.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt new file mode 100644 index 000000000..f7f44e284 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt @@ -0,0 +1,85 @@ +package dev.obiente.nextcloudnative + +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal suspend fun clearUnregisteredAndroidAccountCredentialSlots( + preferences: SharedPreferences, + sessionCipher: SessionCipher, + cleanupJournal: AndroidAccountRemovalCleanupJournal, + suspectEncrypted: String?, + prepareAccountRemoval: suspend (NextcloudSession) -> Unit, + removeAccountOwnedState: suspend (NextcloudSession) -> Unit, + commitPreferences: (SharedPreferences.Editor) -> Unit, + recordCleanupFailure: (Exception) -> Unit, + clearInvalidStore: suspend (String?) -> Unit, +) { + requireAndroidIndependentCredentialStateCanBeExplicitlyReset( + preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null), + ) + val slots = recoverAndroidIndependentCredentialSlotsForReset( + preferenceKeys = preferences.all.keys, + readEncrypted = { key -> preferences.getString(key, null) }, + decrypt = sessionCipher::decrypt, + ) + val cleanupSnapshot = cleanupJournal.snapshot() + requireAndroidAccountRemovalCleanupJournalAllowsActivation(cleanupSnapshot) + retireUnregisteredAndroidAccountCredentialSlots( + slots = slots, + preexistingCleanupAccountStorageKeys = cleanupSnapshot.cleanups.mapTo(hashSetOf()) { it.accountStorageKey }, + retryPreexistingCleanup = { slot -> + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = false, + removeAccountOwnedWork = { removeAccountOwnedState(slot.session) }, + clearCleanup = { cleanupJournal.clear(slot.session.accountId.storageKey) }, + ) + }, + prepareAccountRemoval = prepareAccountRemoval, + commitSlotRemoval = { slot, cleanup -> + commitPreferences( + cleanupJournal.prepareEdit(preferences.edit().remove(slot.preferenceKey), cleanup), + ) + }, + rollbackSlotRemoval = { slot -> + commitPreferences(preferences.edit().putString(slot.preferenceKey, slot.encrypted)) + }, + removeAccountOwnedState = removeAccountOwnedState, + clearCleanup = cleanupJournal::clear, + recordCleanupFailure = recordCleanupFailure, + ) + clearInvalidStore(suspectEncrypted) +} + +internal suspend fun retireUnregisteredAndroidAccountCredentialSlots( + slots: List, + preexistingCleanupAccountStorageKeys: Set = emptySet(), + retryPreexistingCleanup: suspend (AndroidIndependentCredentialSlotReset) -> Unit = {}, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + prepareAccountRemoval: suspend (NextcloudSession) -> Unit, + commitSlotRemoval: suspend (AndroidIndependentCredentialSlotReset, AndroidPendingAccountRemovalCleanup) -> Unit, + rollbackSlotRemoval: suspend (AndroidIndependentCredentialSlotReset) -> Unit, + removeAccountOwnedState: suspend (NextcloudSession) -> Unit, + clearCleanup: suspend (String) -> Unit, + recordCleanupFailure: (Exception) -> Unit, +) { + slots.forEach { slot -> + val session = slot.session + if (session.accountId.storageKey in preexistingCleanupAccountStorageKeys) { + retryPreexistingCleanup(slot) + } + val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) + withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session), guard) { + removeRecoveredAndroidAccountCredentialData( + prepareAccountRemoval = { prepareAccountRemoval(session) }, + removeQueuedUploads = { removeAccountOwnedState(session) }, + clearRecoveredAccount = { commitSlotRemoval(slot, pendingCleanup) }, + rollbackRecoveredAccount = { + rollbackSlotRemoval(slot) + clearCleanup(session.accountId.storageKey) + }, + completeCommittedCleanup = { clearCleanup(session.accountId.storageKey) }, + recordCommittedCleanupFailure = recordCleanupFailure, + ) + } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt new file mode 100644 index 000000000..5dbe5b564 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt @@ -0,0 +1,25 @@ +package dev.obiente.nextcloudnative + +import android.content.Context + +internal class AndroidMediaBackupAccountCleanup( + private val removeFromLedger: suspend (String) -> Unit, +) { + constructor(context: Context) : this( + removeFromLedger = { accountId -> + val store = createAndroidMediaBackupLedgerStore( + context = context.applicationContext, + recoverInterruptedTransfers = false, + ) + try { + store.deleteAccount(accountId) + } finally { + store.close() + } + }, + ) + + suspend fun removeForAccount(accountId: String) { + removeFromLedger(accountId) + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index a78d3032e..2d1344f27 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -14,6 +14,7 @@ import android.provider.Settings import android.util.Base64 import android.util.Log import dev.obiente.nextcloudnative.app.AcquiredOpenApiContract +import dev.obiente.nextcloudnative.app.AccountPrivateMemoryLifecycle import dev.obiente.nextcloudnative.app.AcquiredOpenApiContractSourceKind import dev.obiente.nextcloudnative.app.AcquiredContractKind import dev.obiente.nextcloudnative.app.DeckAttachment @@ -23,6 +24,7 @@ import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult import dev.obiente.nextcloudnative.app.DurableUploadScope import dev.obiente.nextcloudnative.app.DurableUploadStatus import dev.obiente.nextcloudnative.app.DurableMutationRecoveryKind +import dev.obiente.nextcloudnative.app.durableMutationAccountScope import dev.obiente.nextcloudnative.app.LoginChallenge import dev.obiente.nextcloudnative.app.LoginPollResult import dev.obiente.nextcloudnative.app.LoginTransportSecurity @@ -55,6 +57,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 @@ -73,7 +76,6 @@ import dev.obiente.nextcloudnative.app.FileVersionHistory import dev.obiente.nextcloudnative.app.FileVersionRestoreHttpResult import dev.obiente.nextcloudnative.app.NextcloudFileVersion import dev.obiente.nextcloudnative.app.classifyFileVersionRestoreHttpResponse -import dev.obiente.nextcloudnative.app.isSafeDynamicDiscoveryCacheAppId import dev.obiente.nextcloudnative.app.MAX_PERSISTED_DYNAMIC_MUTATION_BYTES import dev.obiente.nextcloudnative.app.decodePersistedDynamicMutation import dev.obiente.nextcloudnative.app.encodePersistedDynamicMutation @@ -88,6 +90,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 @@ -249,9 +252,6 @@ import java.io.IOException import java.io.OutputStream import java.net.URLEncoder import java.nio.charset.StandardCharsets -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption import java.util.Locale import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -371,45 +371,17 @@ internal suspend fun executeAndroidDynamicApiGet( ) } -/** Publishes a pre-synced mutation marker before its non-idempotent request may start. */ -internal fun publishAndroidPendingMutation(temporary: File, target: File) { - require(temporary.isFile) - require(temporary.parentFile == target.parentFile) - try { - Files.move( - temporary.toPath(), - target.toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING, - ) - } catch (_: AtomicMoveNotSupportedException) { - copyAndSyncAndroidPendingMutation(temporary, target) - } -} - -internal fun copyAndSyncAndroidPendingMutation(temporary: File, target: File) { - require(temporary.isFile) - require(temporary.parentFile == target.parentFile) - FileInputStream(temporary).use { input -> - FileOutputStream(target).use { output -> - input.copyTo(output) - output.fd.sync() - } - } - check(temporary.delete()) { "Could not clear the published pending mutation staging file." } -} - internal class AndroidNextcloudServices( context: Context, private val fileSyncRootPicker: AndroidFileSyncRootPicker? = null, 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() @@ -444,17 +416,26 @@ internal class AndroidNextcloudServices( catalogCache = FileAppStoreCatalogCache(File(appContext.filesDir, "contracts/catalogs")), verifiedContractCache = FileVerifiedContractCache(File(appContext.filesDir, "contracts/verified")), ) - private val dynamicDiscoveryCacheDirectory = File(appContext.filesDir, "contracts/discoveries-v1") + private val dynamicDiscoveryCache = AndroidDynamicDiscoveryCacheCoordinator.get( + File(appContext.filesDir, "contracts/discoveries-v1"), + ) private val pendingDynamicMutationDirectory = File(appContext.filesDir, "mutations/dynamic-v1") private val fileOfflineRepository = AndroidFileOfflineRepository(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")) private val nativeMediaPreviewCache = AndroidNativeMediaPreviewCache( File(appContext.cacheDir, "native-media-previews-v1"), ) + private val dynamicApiState = androidDynamicApiProcessState(File(appContext.cacheDir, "dynamic-api-v1")) + private val dynamicApiReadCache = dynamicApiState.cache + private val dynamicApiRequestCoalescer = dynamicApiState.coalescer + private val accountOwnedStateCleanup by lazy { + AndroidAccountOwnedStateCleanup( + appContext, fileReadCache, virtualFileCache, nativeMediaPreviewCache::clearAccount, + dynamicApiState, dynamicDiscoveryCache, supportIntake::removeAccount, + ) + } private val nativeMediaPreviewDecodeMutex = Mutex() - private val dynamicApiRequestCoalescer = DynamicApiRequestCoalescer() private val mediaTimelineCarryoverStore = MediaTimelineDavCarryoverStore() private val memoriesTimeline = MemoriesPreferredTimelineReadService { session, request -> executeNextcloudApi(session, request) @@ -481,6 +462,29 @@ 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, + retryQueuedUploadsCleanupWithoutCredentials = accountOwnedStateCleanup::retryWithoutCredentials, + activatePersistedAccount = { session -> + dynamicApiRequestCoalescer.activateAccount(NextcloudDocumentIds.cacheAccountId(session)) + AccountPrivateMemoryLifecycle.activateAccount(session.accountId.storageKey) + dynamicDiscoveryCache.activateAccount(session.accountId.storageKey) + }, + ) init { supportDiagnostics.registerPrivateValue(System.getProperty("user.home")) @@ -757,25 +761,35 @@ internal class AndroidNextcloudServices( kind: DurableMutationRecoveryKind, ): String? = withContext(Dispatchers.IO) { if (!accountScope.isCanonicalAndroidMutationAccountScope()) return@withContext null - preferences.getString(durableMutationRecoveryKey(accountScope, kind), null) + preferences.getString(androidDurableMutationRecoveryKey(accountScope, kind), null) ?.takeIf { encoded -> encoded.isNotEmpty() && encoded.encodeToByteArray().size <= MAX_ANDROID_MUTATION_RECOVERY_BYTES } } override suspend fun saveDurableMutationRecovery( + session: NextcloudSession, accountScope: String, kind: DurableMutationRecoveryKind, encoded: String, ): Boolean = withContext(Dispatchers.IO) { if (!accountScope.isCanonicalAndroidMutationAccountScope()) return@withContext false + if (durableMutationAccountScope(session) != accountScope) return@withContext false if (encoded.isEmpty() || encoded.encodeToByteArray().size > MAX_ANDROID_MUTATION_RECOVERY_BYTES) { return@withContext false } - synchronized(androidDurableMutationRecoveryLock) { - val key = durableMutationRecoveryKey(accountScope, kind) - if (preferences.contains(key)) return@synchronized false - preferences.edit().putString(key, encoded).commit() && preferences.getString(key, null) == encoded + withAndroidAccountPrivateStatePublication( + expectedSession = session, + credentialMutationMutex = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX, + guard = ANDROID_ACCOUNT_OPERATION_GUARD, + resolveSession = { accountCredentials.loadSession(session.accountId) }, + unavailable = { false }, + ) { + synchronized(androidDurableMutationRecoveryLock) { + val key = androidDurableMutationRecoveryKey(accountScope, kind) + if (preferences.contains(key)) return@synchronized false + preferences.edit().putString(key, encoded).commit() && preferences.getString(key, null) == encoded + } } } @@ -788,7 +802,7 @@ internal class AndroidNextcloudServices( if (expectedEncoded.isEmpty() || expectedEncoded.encodeToByteArray().size > MAX_ANDROID_MUTATION_RECOVERY_BYTES ) return@withContext false - val key = durableMutationRecoveryKey(accountScope, kind) + val key = androidDurableMutationRecoveryKey(accountScope, kind) synchronized(androidDurableMutationRecoveryLock) { val actual = preferences.getString(key, null) ?: return@synchronized true if (actual != expectedEncoded) return@synchronized false @@ -796,51 +810,30 @@ internal class AndroidNextcloudServices( } } - private fun durableMutationRecoveryKey( - accountScope: String, - kind: DurableMutationRecoveryKind, - ): String = "durable-mutation-${kind.storageKey}-$accountScope" - override suspend fun loadCachedDynamicAppDiscovery( session: NextcloudSession, appId: String, ): DynamicDescriptorDiscovery? = withContext(Dispatchers.IO) { - val target = dynamicDiscoveryCacheFile(session, appId) ?: return@withContext null - if (!target.isFile || target.length() !in 1..MAX_PERSISTED_DYNAMIC_DISCOVERY_BYTES.toLong()) { - return@withContext null - } - runCatching { target.readText() } - .getOrNull() + dynamicDiscoveryCache.load( + session.accountId.storageKey, + NextcloudDocumentIds.cacheAccountId(session), + appId, + ) ?.let { encoded -> decodePersistedDynamicDiscovery(encoded, appId, session.serverUrl) } } override suspend fun saveCachedDynamicAppDiscovery( session: NextcloudSession, discovery: DynamicDescriptorDiscovery, + producer: dev.obiente.nextcloudnative.app.DynamicNativeMemoryCacheProducer?, ) = withContext(Dispatchers.IO) { val encoded = encodePersistedDynamicDiscovery(discovery) ?: return@withContext - val target = dynamicDiscoveryCacheFile(session, discovery.descriptor.app.id) ?: return@withContext - check(dynamicDiscoveryCacheDirectory.mkdirs() || dynamicDiscoveryCacheDirectory.isDirectory) { - "Could not create the dynamic contract cache." - } - val temporary = File(dynamicDiscoveryCacheDirectory, "${target.name}.part") - FileOutputStream(temporary).use { output -> - output.write(encoded.encodeToByteArray()) - output.fd.sync() - } - check(temporary.renameTo(target) || runCatching { - temporary.copyTo(target, overwrite = true) - temporary.delete() - }.isSuccess) { - "Could not publish the dynamic contract cache." - } - } - - private fun dynamicDiscoveryCacheFile(session: NextcloudSession, appId: String): File? { - if (!appId.isSafeDynamicDiscoveryCacheAppId()) return null - return File( - dynamicDiscoveryCacheDirectory, - "${NextcloudDocumentIds.cacheAccountId(session)}-$appId.json", + dynamicDiscoveryCache.save( + session.accountId.storageKey, + NextcloudDocumentIds.cacheAccountId(session), + discovery.descriptor.app.id, + encoded, + producer, ) } @@ -871,21 +864,29 @@ internal class AndroidNextcloudServices( targetRecordId: String, values: Map, ) = withContext(Dispatchers.IO) { - val encoded = requireNotNull( - encodePersistedDynamicMutation(appId, actionId, targetRecordId, values), - ) { "The pending dynamic mutation is invalid." } - val target = requireNotNull(pendingDynamicMutationFile(session, appId, actionId, targetRecordId)) { - "The pending dynamic mutation identity is invalid." - } - check(pendingDynamicMutationDirectory.mkdirs() || pendingDynamicMutationDirectory.isDirectory) { - "Could not create the pending mutation store." - } - val temporary = File(pendingDynamicMutationDirectory, "${target.name}.part") - FileOutputStream(temporary).use { output -> - output.write(encoded.encodeToByteArray()) - output.fd.sync() + withAndroidAccountPrivateStatePublication( + expectedSession = session, + credentialMutationMutex = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX, + guard = ANDROID_ACCOUNT_OPERATION_GUARD, + resolveSession = { accountCredentials.loadSession(session.accountId) }, + unavailable = { error("The account changed before the pending mutation could be recorded.") }, + ) { + val encoded = requireNotNull( + encodePersistedDynamicMutation(appId, actionId, targetRecordId, values), + ) { "The pending dynamic mutation is invalid." } + val target = requireNotNull(pendingDynamicMutationFile(session, appId, actionId, targetRecordId)) { + "The pending dynamic mutation identity is invalid." + } + check(pendingDynamicMutationDirectory.mkdirs() || pendingDynamicMutationDirectory.isDirectory) { + "Could not create the pending mutation store." + } + val temporary = File(pendingDynamicMutationDirectory, "${target.name}.part") + FileOutputStream(temporary).use { output -> + output.write(encoded.encodeToByteArray()) + output.fd.sync() + } + publishAndroidPendingMutation(temporary, target) } - publishAndroidPendingMutation(temporary, target) } override suspend fun clearPendingDynamicMutation( @@ -894,8 +895,16 @@ internal class AndroidNextcloudServices( actionId: String, targetRecordId: String, ) = withContext(Dispatchers.IO) { - pendingDynamicMutationFile(session, appId, actionId, targetRecordId)?.let { target -> - check(!target.exists() || target.delete()) { "Could not clear the pending mutation." } + withAndroidAccountPrivateStatePublication( + expectedSession = session, + credentialMutationMutex = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX, + guard = ANDROID_ACCOUNT_OPERATION_GUARD, + resolveSession = { accountCredentials.loadSession(session.accountId) }, + unavailable = { error("The account changed before the pending mutation could be cleared.") }, + ) { + pendingDynamicMutationFile(session, appId, actionId, targetRecordId)?.let { target -> + check(!target.exists() || target.delete()) { "Could not clear the pending mutation." } + } } Unit } @@ -918,98 +927,39 @@ 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) + withAndroidDeckCardDraftSession(session, accountCredentials) { 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) + + internal fun accountRetentionSnapshot() = accountCredentials.accountRetentionSnapshot() + + override fun listAccounts() = accountRetentionSnapshot().accountsOrEmpty() + + 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, key: DeckCardDraftKey, ): PersistedDeckCardDraft? = withContext(Dispatchers.IO) { - deckCardDrafts.load(session, key) + withAndroidDeckCardDraftSession(session, accountCredentials) { deckCardDrafts.load(session, key) } } override suspend fun saveDeckCardDraft( session: NextcloudSession, draft: PersistedDeckCardDraft, ) = withContext(Dispatchers.IO) { - deckCardDrafts.save(session, draft) + withAndroidDeckCardDraftSession(session, accountCredentials) { deckCardDrafts.save(session, draft) } } override suspend fun clearDeckCardDraft( @@ -1017,55 +967,21 @@ internal class AndroidNextcloudServices( key: DeckCardDraftKey, discardUnreadable: Boolean, ) = withContext(Dispatchers.IO) { - deckCardDrafts.clear(session, key, discardUnreadable) + withAndroidDeckCardDraftSession(session, accountCredentials) { deckCardDrafts.clear(session, key, discardUnreadable) } } override suspend fun quarantineSubmittedDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, ) = withContext(Dispatchers.IO) { - deckCardDrafts.quarantineAfterSubmit(session, key) + withAndroidDeckCardDraftSession(session, accountCredentials) { deckCardDrafts.quarantineAfterSubmit(session, key) } } override suspend fun discardAllDeckCardDrafts() = withContext(Dispatchers.IO) { 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( @@ -1431,35 +1347,33 @@ internal class AndroidNextcloudServices( session: NextcloudSession, userId: String, path: String, - ): NextcloudFileListing = withContext(Dispatchers.IO) { - val accountId = NextcloudDocumentIds.accountKey(session) - try { - val response = request( - method = "PROPFIND", - url = buildNextcloudFileUrl(session.serverUrl, userId, path), - session = session, - body = DAV_PROPERTIES, - contentType = "application/xml; charset=utf-8", - headers = mapOf("Depth" to "1", "Accept" to "application/xml"), - ) - if (response.status == 207) { - val files = parseDavFiles(response.body, userId).drop(1) - .sortedWith(compareByDescending { it.isDirectory }.thenBy { it.name.lowercase() }) - runCatching { fileReadCache.storeListing(accountId, path, files) } - NextcloudFileListing(files, NextcloudFileListingSource.Network) - } else { - if (response.status >= 500) { - fileReadCache.cachedListing(accountId, path)?.files?.let { - return@withContext NextcloudFileListing(it, NextcloudFileListingSource.Cache) - } - } - throw NextcloudFileListingHttpException(response.status) - } - } catch (failure: IOException) { - fileReadCache.cachedListing(accountId, path)?.files - ?.let { NextcloudFileListing(it, NextcloudFileListingSource.Cache) } - ?: throw failure - } + ): NextcloudFileListing = listFilesWithSource(session, userId, path, accountLeaseHeld = false) + + internal suspend fun listFilesWhileAccountLeaseHeld( + session: NextcloudSession, + userId: String, + path: String, + ): List = listFilesWithSource(session, userId, path, accountLeaseHeld = true).files + + private suspend fun listFilesWithSource( + session: NextcloudSession, + userId: String, + path: String, + accountLeaseHeld: Boolean, + ): NextcloudFileListing = loadAndroidAccountFileListing( + session, { loadSession(session.accountId) }, fileReadCache, path, accountLeaseHeld, + ) { + val response = request( + method = "PROPFIND", + url = buildNextcloudFileUrl(session.serverUrl, userId, path), + session = session, + body = DAV_PROPERTIES, + contentType = "application/xml; charset=utf-8", + headers = mapOf("Depth" to "1", "Accept" to "application/xml"), + ) + AndroidDavFileListingResponse( + response.status, if (response.status == 207) parseDavFiles(response.body, userId) else emptyList(), + ) } override suspend fun listFilesCachedWithSource( @@ -1564,7 +1478,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( @@ -1579,7 +1499,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( @@ -1587,7 +1513,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( @@ -1598,64 +1530,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) } } } @@ -1781,7 +1718,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) } } @@ -1792,9 +1740,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( @@ -1814,9 +1772,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( @@ -1830,15 +1798,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) + } + } } } @@ -1849,9 +1827,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( @@ -2380,13 +2369,11 @@ internal class AndroidNextcloudServices( require(size > 0L) { "The file range session size must be positive." } val safeEtag = requireSafeFileRangeEtag(expectedEtag) val url = buildNextcloudFileUrl(session.serverUrl, userId, path) - val authorization = Base64.encodeToString( - "${session.loginName}:${session.appPassword}".toByteArray(StandardCharsets.UTF_8), - Base64.NO_WRAP, - ) + val authorization = androidFileRangeAuthorization(session) val closed = AtomicBoolean(false) - val activeCalls = ConcurrentHashMap.newKeySet() - return NextcloudFileRangeSession( + val activity = AndroidFileRangeSessionActivity() + return openTrackedAndroidFileRangeSession(session, { loadSession(session.accountId) }, activity) { + NextcloudFileRangeSession( size = size, readBlock = { offset, length -> withContext(Dispatchers.IO) { @@ -2408,8 +2395,8 @@ internal class AndroidNextcloudServices( .header("If-Match", safeEtag) .build() val call = noRedirectHttpClient.newCall(request) - activeCalls += call - if (closed.get()) { + val finishCall = activity.start(call::cancel) + if (finishCall == null) { call.cancel() } try { @@ -2456,17 +2443,17 @@ internal class AndroidNextcloudServices( ) throw failure } finally { - activeCalls -= call + finishCall?.invoke() } } }, closeBlock = { if (closed.compareAndSet(false, true)) { - activeCalls.forEach { call -> call.cancel() } - activeCalls.clear() + activity.close() } }, - ) + ) + } } override suspend fun downloadMemoriesFileRange( @@ -2672,112 +2659,120 @@ internal class AndroidNextcloudServices( } } - override suspend fun saveTextFile( - session: NextcloudSession, - userId: String, + override suspend fun saveTextFile(session: NextcloudSession, userId: String, path: String, text: String, expectedEtag: String, ): SavedTextFile = withContext(Dispatchers.IO) { - withNoBlockingAndroidDocumentWriteback(appContext, session, path) { - val specification = textFileDavSaveRequest(text, expectedEtag) - val response = request( - method = "PUT", - url = buildNextcloudFileUrl(session.serverUrl, userId, path), - session = session, - rawBody = specification.body, - contentType = specification.contentType, - headers = specification.headers, - ) - val confirmation = confirmTextFileDavSave(response.status) - val etag = response.etag ?: try { - loadFileEtag(session, userId, path) - } catch (failure: Exception) { - if (failure is CancellationException) throw failure - null + withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld, session, accountCredentials::loadSession, + ) { currentSession, accountMutationSerialized -> + withNoBlockingAndroidDocumentWritebackSuspending(appContext, currentSession, path) { + val specification = textFileDavSaveRequest(text, expectedEtag) + val response = request( + method = "PUT", + url = buildNextcloudFileUrl(currentSession.serverUrl, userId, path), + session = currentSession, + rawBody = specification.body, + contentType = specification.contentType, + headers = specification.headers, + accountMutationSerialized = accountMutationSerialized, + ) + val confirmation = confirmTextFileDavSave(response.status) + val etag = response.etag ?: try { + loadFileEtag(currentSession, userId, path) + } catch (failure: Exception) { + if (failure is CancellationException) throw failure + null + } + runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(currentSession), path) } + SavedTextFile(etag, confirmation.created) } - runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(session), path) } - SavedTextFile(etag, confirmation.created) } } - override suspend fun createTextFileIfAbsent( - session: NextcloudSession, - userId: String, - path: String, - text: String, - ): SavedTextFile = withContext(Dispatchers.IO) { + override suspend fun createTextFileIfAbsent(session: NextcloudSession, userId: String, path: String, text: String): + SavedTextFile = withContext(Dispatchers.IO) { val utf8 = text.toByteArray(StandardCharsets.UTF_8) require(utf8.size.toLong() <= MAX_EDITABLE_TEXT_BYTES) { "Text files larger than ${MAX_EDITABLE_TEXT_BYTES / (1024 * 1024)} MiB cannot be created in the app." } - val response = request( - method = "PUT", - url = buildNextcloudFileUrl(session.serverUrl, userId, path), - session = session, - rawBody = utf8, - contentType = "text/plain; charset=utf-8", - headers = mapOf("Accept" to "*/*", "If-None-Match" to "*"), - ) - if (response.status == 412) return@withContext SavedTextFile(etag = null, wasCreated = false) - check(response.status in 200..299) { "Creating the text file failed (HTTP ${response.status})." } - check(response.status == 201) { "The server did not confirm that a new text file was created." } - runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(session), path) } - SavedTextFile(response.etag, wasCreated = true) + withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld, session, accountCredentials::loadSession, + ) { currentSession, accountMutationSerialized -> + val response = request( + method = "PUT", + url = buildNextcloudFileUrl(currentSession.serverUrl, userId, path), + session = currentSession, + rawBody = utf8, + contentType = "text/plain; charset=utf-8", + headers = mapOf("Accept" to "*/*", "If-None-Match" to "*"), + accountMutationSerialized = accountMutationSerialized, + ) + if (response.status == 412) return@withAndroidAuthenticatedFileMutation SavedTextFile(null, false) + check(response.status in 200..299) { "Creating the text file failed (HTTP ${response.status})." } + check(response.status == 201) { "The server did not confirm that a new text file was created." } + runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(currentSession), path) } + SavedTextFile(response.etag, wasCreated = true) + } } - override suspend fun createDirectoryIfAbsent( - session: NextcloudSession, - userId: String, - path: String, - ): Boolean = withContext(Dispatchers.IO) { - val response = request( - method = "MKCOL", - url = buildNextcloudFileUrl(session.serverUrl, userId, path), - session = session, - headers = mapOf("Accept" to "*/*", "If-None-Match" to "*"), - maxResponseBytes = 64 * 1024, - ) - if (response.status in setOf(405, 412)) return@withContext false - if (response.status !in 200..299) throw fileOperationException(response.status) - check(response.status == 201) { "The server did not confirm that a new folder was created." } - runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(session), path) } - true - } + override suspend fun createDirectoryIfAbsent(session: NextcloudSession, userId: String, path: String): Boolean = + withContext(Dispatchers.IO) { + withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld, session, accountCredentials::loadSession, + ) { currentSession, accountMutationSerialized -> + val response = request( + method = "MKCOL", + url = buildNextcloudFileUrl(currentSession.serverUrl, userId, path), + session = currentSession, + headers = mapOf("Accept" to "*/*", "If-None-Match" to "*"), + maxResponseBytes = 64 * 1024, + accountMutationSerialized = accountMutationSerialized, + ) + if (response.status in setOf(405, 412)) return@withAndroidAuthenticatedFileMutation false + if (response.status !in 200..299) throw fileOperationException(response.status) + check(response.status == 201) { "The server did not confirm that a new folder was created." } + runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(currentSession), path) } + true + } + } - override suspend fun executeFileMutation( - session: NextcloudSession, - userId: String, - mutation: NextcloudFileMutation, - ): NextcloudFileMutationResult = withContext(Dispatchers.IO) { + override suspend fun executeFileMutation(session: NextcloudSession, userId: String, mutation: NextcloudFileMutation): + NextcloudFileMutationResult = withContext(Dispatchers.IO) { val spec = mutation.toWebDavMutationSpec() - withNoBlockingAndroidDocumentWriteback( - appContext, - session, - *listOfNotNull(spec.sourcePath, spec.destinationPath).toTypedArray(), - ) { - val headers = buildMap { - put("Accept", "*/*") - putAll(spec.conflictConditionHeaders()) - spec.destinationPath?.let { destinationPath -> - put("Destination", buildNextcloudFileUrl(session.serverUrl, userId, destinationPath)) - put("Overwrite", if (spec.overwrite) "T" else "F") + withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld, session, accountCredentials::loadSession, + ) { currentSession, accountMutationSerialized -> + withNoBlockingAndroidDocumentWritebackSuspending( + appContext, + currentSession, + *listOfNotNull(spec.sourcePath, spec.destinationPath).toTypedArray(), + ) { + val headers = buildMap { + put("Accept", "*/*") + putAll(spec.conflictConditionHeaders()) + spec.destinationPath?.let { destinationPath -> + put("Destination", buildNextcloudFileUrl(currentSession.serverUrl, userId, destinationPath)) + put("Overwrite", if (spec.overwrite) "T" else "F") + } } + val response = request( + method = spec.method, + url = buildNextcloudFileUrl(currentSession.serverUrl, userId, spec.sourcePath), + session = currentSession, + headers = headers, + maxResponseBytes = 64 * 1024, + accountMutationSerialized = accountMutationSerialized, + ) + if (response.status !in 200..299) throw fileOperationException(response.status) + val accountId = NextcloudDocumentIds.accountKey(currentSession) + runCatching { fileReadCache.invalidate(accountId, spec.sourcePath) } + spec.destinationPath?.let { destination -> + runCatching { fileReadCache.invalidate(accountId, destination) } + } + NextcloudFileMutationResult(spec.destinationPath, response.etag) } - val response = request( - method = spec.method, - url = buildNextcloudFileUrl(session.serverUrl, userId, spec.sourcePath), - session = session, - headers = headers, - maxResponseBytes = 64 * 1024, - ) - if (response.status !in 200..299) throw fileOperationException(response.status) - val accountId = NextcloudDocumentIds.accountKey(session) - runCatching { fileReadCache.invalidate(accountId, spec.sourcePath) } - spec.destinationPath?.let { destination -> - runCatching { fileReadCache.invalidate(accountId, destination) } - } - NextcloudFileMutationResult(spec.destinationPath, response.etag) } } @@ -2922,6 +2917,7 @@ internal class AndroidNextcloudServices( streamingBody = requestBody, maxResponseBytes = safeRequest.maximumResponseBytes, client = noRedirectHttpClient, + accountMutationSerialized = accountMutationLeaseHeld, ) NextcloudApiResponse( response.status, @@ -2942,7 +2938,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( @@ -3435,18 +3439,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", @@ -3458,7 +3463,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), @@ -3471,7 +3476,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, @@ -3488,7 +3493,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)) { @@ -3716,25 +3730,6 @@ internal class AndroidNextcloudServices( ) } - private fun elapsedMillis(startedNanos: Long): Long = - (System.nanoTime() - startedNanos).coerceAtLeast(0L) / 1_000_000L - - private fun java.io.InputStream.readBounded(maxBytes: Long, responseStatus: Int? = null): ByteArray { - val output = ByteArrayOutputStream(minOf(maxBytes, DEFAULT_BUFFER_CAPACITY.toLong()).toInt()) - val buffer = ByteArray(DEFAULT_BUFFER_CAPACITY) - var total = 0L - while (true) { - val read = read(buffer) - if (read == -1) break - total += read - if (total > maxBytes) { - throw NextcloudResponseTooLargeException(maxBytes, responseStatus) - } - output.write(buffer, 0, read) - } - return output.toByteArray() - } - private fun parseDavFiles(xml: ByteArray, userId: String): List { val document = SafeXmlParser.parse(xml) val responses = document.getElementsByTagNameNS(DAV_NAMESPACE, "response") @@ -3887,8 +3882,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" @@ -3948,9 +3941,6 @@ private fun NextcloudFile.isNativeTiffPreviewFormat(): Boolean { private const val MAX_ANDROID_MUTATION_RECOVERY_BYTES = 1024 * 1024 -private fun String.isCanonicalAndroidMutationAccountScope(): Boolean = - length == 64 && all { character -> character in '0'..'9' || character in 'a'..'f' } - internal sealed interface NativeTiffRangeReadPlan { val fileId: Long val sourceSize: Long @@ -4234,7 +4224,6 @@ private fun org.w3c.dom.Node.systemTagFirstText(namespace: String, localName: St private const val SYSTEM_TAG_DAV_NAMESPACE = "DAV:" private const val SYSTEM_TAG_OC_NAMESPACE = "http://owncloud.org/ns" private const val SYSTEM_TAG_NC_NAMESPACE = "http://nextcloud.org/ns" -private val androidDurableMutationRecoveryLock = Any() private const val MINIMUM_NATIVE_MEDIA_PREVIEW_DIMENSION = 64 private const val MAXIMUM_NATIVE_MEDIA_PREVIEW_DIMENSION = 4_096 private const val NATIVE_TIFF_DECODER_VERSION = "tiff-stream-v4" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNotifications.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNotifications.kt index 5350cacba..22b11fb20 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNotifications.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNotifications.kt @@ -268,6 +268,10 @@ internal class AndroidNotificationCoordinator(private val context: Context) { } } + fun cancel(accountKey: String, notificationId: Int) { + NotificationManagerCompat.from(context).cancel(accountKey, notificationId) + } + private fun openAppIntent( action: String, requestCode: Int, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPendingDynamicMutationPublication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPendingDynamicMutationPublication.kt new file mode 100644 index 000000000..62e38f4c0 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPendingDynamicMutationPublication.kt @@ -0,0 +1,36 @@ +package dev.obiente.nextcloudnative + +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** Publishes a pre-synced mutation marker before its non-idempotent request may start. */ +internal fun publishAndroidPendingMutation(temporary: File, target: File) { + require(temporary.isFile) + require(temporary.parentFile == target.parentFile) + try { + Files.move( + temporary.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + copyAndSyncAndroidPendingMutation(temporary, target) + } +} + +internal fun copyAndSyncAndroidPendingMutation(temporary: File, target: File) { + require(temporary.isFile) + require(temporary.parentFile == target.parentFile) + FileInputStream(temporary).use { input -> + FileOutputStream(target).use { output -> + input.copyTo(output) + output.fd.sync() + } + } + check(temporary.delete()) { "Could not clear the published pending mutation staging file." } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index e577ad0ca..c2de11a79 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -1,72 +1,311 @@ 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 = code, + outcome = restored.diagnosticOutcome(), + recordDiagnostic = recordDiagnostic, + ) + } + if (restored.needsPersistence && restored.state != null) { + runCatching { persistMigrated(encodeAndroidAccountCredentialState(restored.state)) } + .onFailure { failure -> + recordAccountCredentialDiagnostic( + code = "ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED", + outcome = "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.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 RestoredAndroidAccountCredentialState.diagnosticOutcome(): String = when { + unsupportedVersion != null || state?.mutationsAllowed == false -> "unsupported" + state == null -> "failed" + else -> "recovered" +} + +private fun recordAccountCredentialDiagnostic( + code: String, + outcome: String, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, + failure: Throwable? = null, +) { + recordDiagnostic( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Authentication, + operation = "account-credentials.restore", + outcome = outcome, + code = code, + exception = failure?.toNonSecretSupportDiagnosticExceptionDraft(), + ), + ) +} + +private class AndroidCredentialMismatchException : IllegalArgumentException() +private const val ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION = 2 +internal 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/AndroidResponseReading.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidResponseReading.kt new file mode 100644 index 000000000..ec6791522 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidResponseReading.kt @@ -0,0 +1,24 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudResponseTooLargeException +import java.io.ByteArrayOutputStream +import java.io.InputStream + +internal fun elapsedMillis(startedNanos: Long): Long = + (System.nanoTime() - startedNanos).coerceAtLeast(0L) / 1_000_000L + +internal fun InputStream.readBounded(maxBytes: Long, responseStatus: Int? = null): ByteArray { + val output = ByteArrayOutputStream(minOf(maxBytes, ANDROID_RESPONSE_BUFFER_BYTES.toLong()).toInt()) + val buffer = ByteArray(ANDROID_RESPONSE_BUFFER_BYTES) + var total = 0L + while (true) { + val read = read(buffer) + if (read == -1) break + total += read + if (total > maxBytes) throw NextcloudResponseTooLargeException(maxBytes, responseStatus) + output.write(buffer, 0, read) + } + return output.toByteArray() +} + +private const val ANDROID_RESPONSE_BUFFER_BYTES = 8 * 1024 diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt index f9a344cd2..4631255f7 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 nati.ve 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/AndroidSupportDiagnostics.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt index dba0c9a26..e2e7e4087 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt @@ -8,6 +8,7 @@ import android.content.Intent import androidx.core.content.FileProvider import dev.obiente.nextcloudnative.app.AsyncJvmSupportDiagnostics import dev.obiente.nextcloudnative.app.JvmSupportIntake +import dev.obiente.nextcloudnative.app.JvmSupportAccountStorageCleanup import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticFieldDraft @@ -18,8 +19,12 @@ import dev.obiente.nextcloudnative.app.boundedSupportDiagnosticsEnvironment import dev.obiente.nextcloudnative.app.toSupportDiagnosticExceptionDraft import dev.obiente.nextcloudnative.app.runWithCleanupBeforeHandoff import java.io.File +import java.nio.channels.FileChannel +import java.nio.file.StandardOpenOption import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -81,6 +86,7 @@ internal object AndroidSupportDiagnostics { * restoring or mutating that directory while an earlier facade is still packaging or uploading. */ internal object AndroidSupportIntakeCoordinator { + private val lock = ReentrantLock() @Volatile private var instance: JvmSupportIntake? = null @@ -88,7 +94,7 @@ internal object AndroidSupportIntakeCoordinator { context: Context, diagnostics: AsyncJvmSupportDiagnostics, client: OkHttpClient, - ): JvmSupportIntake = instance ?: synchronized(this) { + ): JvmSupportIntake = instance ?: lock.withLock { val appContext = context.applicationContext ?: context instance ?: JvmSupportIntake( diagnostics = diagnostics, @@ -98,6 +104,30 @@ internal object AndroidSupportIntakeCoordinator { supportMutationsAllowed = appContext.cloudMutationGate(), ).also { instance = it } } + + suspend fun removeAccount(context: Context, accountIdentity: String) { + instance?.let { intake -> + intake.removeAccount(accountIdentity) + return + } + val intakeCreatedWhileWaiting = withContext(Dispatchers.IO) { + lock.withLock { + instance ?: run { + val appContext = context.applicationContext ?: context + JvmSupportAccountStorageCleanup( + root = File(appContext.noBackupFilesDir, "support-submissions"), + directorySync = ::syncAndroidSupportDirectory, + ).removeAccount(accountIdentity, inMemoryArchive = null) + null + } + } + } + intakeCreatedWhileWaiting?.removeAccount(accountIdentity) + } +} + +private fun syncAndroidSupportDirectory(directory: File) { + FileChannel.open(directory.toPath(), StandardOpenOption.READ).use { channel -> channel.force(true) } } internal fun androidSupportDiagnosticsEnvironment(): SupportDiagnosticsEnvironment = diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCache.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCache.kt index f200a6073..3bda06b3e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCache.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCache.kt @@ -267,6 +267,10 @@ internal class AndroidVirtualFileCache(context: Context) { } } + fun clearAccount(accountId: String) = synchronized(STORE_LOCK) { + deleteAndroidAccountPrivateCache(root, accountId) + } + fun loadPolicy(): VirtualFileCachePolicy = VirtualFileCachePolicy( automaticCleanup = preferences.getBoolean(KEY_AUTOMATIC, true), maximumCacheBytes = preferences.getLong( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt index eaad3c181..93722fc4e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt @@ -60,6 +60,16 @@ internal class AndroidVirtualFileProxyCallback( @Synchronized override fun onRead(offset: Long, requestedSize: Int, data: ByteArray): Int { + val finishSourceUse = source.beginUse() + ?: throw OperationCanceledException("Virtual file read cancelled") + return try { + readWhileSourceIsRetained(offset, requestedSize, data) + } finally { + finishSourceUse() + } + } + + private fun readWhileSourceIsRetained(offset: Long, requestedSize: Int, data: ByteArray): Int { if (released || cancelled.get() || !accessAllowed()) { throw OperationCanceledException("Virtual file read cancelled") } 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 f0943d165..7cbb908a3 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) + requireAndroidDocumentDirectory(parent) { findDocument(session, account, it, accountLeaseHeld = true) } + 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, accountLeaseHeld = true) + 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, accountLeaseHeld = true) + 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) + requireAndroidDocumentDirectory(targetParent) { findDocument(session, account, it, accountLeaseHeld = true) } + val file = findDocument(session, account, source.path, accountLeaseHeld = true) + val destination = childPath(targetParent.path, file.name) + if (destination == source.path) return@withAndroidDocumentMutation sourceDocumentId + 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() } @@ -728,16 +727,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ) } - private fun requireDirectory( - session: NextcloudSession, - account: ResolvedAccount, - reference: NextcloudDocumentReference, - ) { - if (reference.isRoot) return - val parent = findDocument(session, account, reference.path) - require(parent.isDirectory) { "The selected parent is not a folder." } - } - private fun requireMutationEtag(file: NextcloudFile): String = file.etag?.takeIf(String::isNotBlank) ?: throw IllegalStateException("Nextcloud did not provide an ETag, so this document cannot be changed safely.") @@ -870,14 +859,20 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } } - private fun findDocument(session: NextcloudSession, account: ResolvedAccount, path: String): NextcloudFile = + private fun findDocument( + session: NextcloudSession, + account: ResolvedAccount, + path: String, + accountLeaseHeld: Boolean = false, + ): NextcloudFile = providerCall( message = "The requested Nextcloud document was not found.", accountIdentity = account.accountKey, ) { val parent = NextcloudDocumentIds.parentPath(path) runBlocking(Dispatchers.IO) { - services.listFiles(session, account.userId, parent) + if (accountLeaseHeld) services.listFilesWhileAccountLeaseHeld(session, account.userId, parent) + else services.listFiles(session, account.userId, parent) }.firstOrNull { it.path == path } ?: throw FileNotFoundException("The requested Nextcloud document was not found.") } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt index 0cb0acd5c..389d16f99 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,72 +48,75 @@ 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 -> - rethrowAndroidFileSyncCancellation(failure) - val disposition = backgroundSyncFailureDisposition(runAttemptCount) - services.recordSupportDiagnosticForAccountIdentity( - accountId, - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Sync, - operation = "sync.background-run", - outcome = "failed", - fields = listOf( - SupportDiagnosticFieldDraft( - "pair", - pairId, - SupportDiagnosticValuePrivacy.Identifier, - ), - SupportDiagnosticFieldDraft("failure_scope", "run"), - SupportDiagnosticFieldDraft("work_attempt", runAttemptCount.toString()), - SupportDiagnosticFieldDraft( - "retry_scheduled", - (disposition == BackgroundSyncWorkerDisposition.Retry).toString(), - ), + return@withContext try { + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountId) { + val current = services.loadSession() + if (current == null || !androidAccountOperationSessionIsCurrent(accountId, current)) { + return@withAccount Result.failure() + } + val result = engine.runPair(current, userId, pairId) + val pair = engine.loadCenter(current, userId).pairs.firstOrNull { it.id == pairId } + ?: return@withAccount Result.success() + pair.conflicts.firstOrNull()?.let { conflict -> + AndroidNotificationCoordinator(applicationContext).post( + NextcloudNotificationEvent.SyncConflict( + id = androidFileSyncNotificationId(pairId), + accountKey = accountId, + path = conflict.relativePath, + detail = syncConflictNotificationDetail(pair.conflictCount), ), - exception = failure.toSupportDiagnosticExceptionDraft(), - ), + ) + } + val completionDisposition = backgroundSyncCompletionDisposition( + failedCount = pair.failedCount, + resultRejected = result is FileSyncCenterActionResult.Rejected, ) - return@withContext disposition.toWorkerResult() + if (completionDisposition == BackgroundSyncWorkerDisposition.WaitForNextPeriod) { + services.recordSupportDiagnosticForAccountIdentity( + accountId, + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Sync, + operation = "sync.background-run", + outcome = "needs-attention", + fields = backgroundSyncCompletionDiagnosticFields( + pairId = pairId, + failedCount = pair.failedCount, + conflictCount = pair.conflictCount, + result = result, + ), + ), + ) + } + completionDisposition.toWorkerResult() } - val pair = engine.loadCenter(session, userId).pairs.firstOrNull { it.id == pairId } - ?: return@withContext Result.success() - pair.conflicts.firstOrNull()?.let { conflict -> - AndroidNotificationCoordinator(applicationContext).post( - NextcloudNotificationEvent.SyncConflict( - id = stableNotificationId(pairId), - accountKey = accountId, - path = conflict.relativePath, - detail = syncConflictNotificationDetail(pair.conflictCount), - ), - ) - } - val completionDisposition = backgroundSyncCompletionDisposition( - failedCount = pair.failedCount, - resultRejected = result is FileSyncCenterActionResult.Rejected, - ) - if (completionDisposition == BackgroundSyncWorkerDisposition.WaitForNextPeriod) { + } catch (failure: Throwable) { + rethrowAndroidFileSyncCancellation(failure) + val disposition = backgroundSyncFailureDisposition(runAttemptCount) services.recordSupportDiagnosticForAccountIdentity( accountId, SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Warning, + severity = SupportDiagnosticSeverity.Error, component = SupportDiagnosticComponent.Sync, operation = "sync.background-run", - outcome = "needs-attention", - fields = backgroundSyncCompletionDiagnosticFields( - pairId = pairId, - failedCount = pair.failedCount, - conflictCount = pair.conflictCount, - result = result, + outcome = "failed", + fields = listOf( + SupportDiagnosticFieldDraft( + "pair", + pairId, + SupportDiagnosticValuePrivacy.Identifier, + ), + SupportDiagnosticFieldDraft("failure_scope", "run"), + SupportDiagnosticFieldDraft("work_attempt", runAttemptCount.toString()), + SupportDiagnosticFieldDraft( + "retry_scheduled", + (disposition == BackgroundSyncWorkerDisposition.Retry).toString(), + ), ), + exception = failure.toSupportDiagnosticExceptionDraft(), ), ) - // Per-item failures and attempt counts are durable coordinator state. An immediate - // WorkManager retry bypasses the periodic cadence and re-executes known failed work. - completionDisposition.toWorkerResult() - } else { - completionDisposition.toWorkerResult() + disposition.toWorkerResult() } } @@ -143,7 +145,7 @@ internal class NextcloudFileSyncWorker( .setOnlyAlertOnce(true) .setProgress(0, 0, true) .build() - val id = stableNotificationId(pairId) + val id = androidFileSyncNotificationId(pairId) return if (Build.VERSION.SDK_INT >= 29) { ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } else { @@ -151,9 +153,6 @@ internal class NextcloudFileSyncWorker( } } - private fun stableNotificationId(pairId: String): Int = - pairId.hashCode().let { if (it == Int.MIN_VALUE) 1 else kotlin.math.abs(it) }.coerceAtLeast(1) - private fun isForegroundServiceStartNotAllowed(error: IllegalStateException): Boolean = Build.VERSION.SDK_INT >= 31 && isForegroundServiceStartNotAllowedApi31(error) @@ -168,6 +167,64 @@ internal class NextcloudFileSyncWorker( } } +internal fun androidFileSyncNotificationId(pairId: String): Int = + pairId.hashCode().let { if (it == Int.MIN_VALUE) 1 else kotlin.math.abs(it) }.coerceAtLeast(1) + +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 accountSnapshot = services.accountRetentionSnapshot() + val session = services.loadSession() + ?.takeIf { restored -> isAndroidFileSyncScheduleRestorationCurrent(expectedAccountId, restored) } + ?: return@withContext if (shouldRetryAndroidFileSyncScheduleRestoration(expectedAccountId, accountSnapshot)) { + Result.retry() + } else { + 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 shouldRetryAndroidFileSyncScheduleRestoration( + expectedAccountId: String, + snapshot: AndroidAccountRetentionSnapshot, +): Boolean = when (snapshot.expectedAccountState(expectedAccountId)) { + AndroidExpectedAccountState.Active, + AndroidExpectedAccountState.Unknown, + -> true + AndroidExpectedAccountState.Inactive, + AndroidExpectedAccountState.Absent, + -> false +} + +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/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index 6d6998e52..d5ca61bcc 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt @@ -2,10 +2,18 @@ package dev.obiente.nextcloudnative import android.app.Application import android.content.Context +import android.content.SharedPreferences class NextcloudNativeApplication : Application() { + private var accountCleanupListener: SharedPreferences.OnSharedPreferenceChangeListener? = null + override fun attachBaseContext(base: Context) { super.attachBaseContext(base) installAndroidUncaughtDiagnosticHandler(base) } + + override fun onCreate() { + super.onCreate() + accountCleanupListener = installAndroidAccountRemovalCleanupRecovery(this) + } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListingTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListingTest.kt new file mode 100644 index 000000000..af6bf991e --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListingTest.kt @@ -0,0 +1,149 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudFile +import dev.obiente.nextcloudnative.app.NextcloudFileListingHttpException +import dev.obiente.nextcloudnative.app.NextcloudFileListingSource +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.io.IOException +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +class AndroidAccountFileListingTest { + @Test + fun mutationMetadataReusesLeaseForRenameDeleteMoveAndNestedCreate() = withCache { cache -> + val guard = AndroidAccountOperationGuard() + val session = session() + val key = NextcloudDocumentIds.accountKey(session) + val lookups = listOf("rename", "delete", "move source", "move destination", "nested create") + lookups.forEach { operation -> + val lease = acquireAndroidDocumentMutationAccountLease(session, { session }, guard) + try { + fun findDocument(path: String): NextcloudFile = runBlocking { + withTimeout(1_000) { + loadAndroidAccountFileListing( + session, { error("The mutation already validated its session") }, cache, + NextcloudDocumentIds.parentPath(path), accountLeaseHeld = true, guard = guard, + ) { + assertFalse(guard.tryWithAccount(key, unavailable = { false }, action = { true }), operation) + AndroidDavFileListingResponse(207, listOf(file("", true), file(path, true))) + }.files.single() + } + } + if (operation in listOf("move destination", "nested create")) { + requireAndroidDocumentDirectory(NextcloudDocumentReference(key, "Notes/Child"), ::findDocument) + } else { + assertEquals("Notes/Child", findDocument("Notes/Child").path, operation) + } + runBlocking { + assertFalse(guard.tryWithAccount(key, unavailable = { false }, action = { true }), operation) + } + } finally { + lease.close() + } + runBlocking { assertTrue(guard.tryWithAccount(key, unavailable = { false }, action = { true }), operation) } + } + } + + @Test + fun ordinaryListingWaitsForLeaseAndRejectsRemovedSessionBeforeRequest() = withCache { cache -> + runBlocking { + val guard = AndroidAccountOperationGuard() + val session = session() + var current: NextcloudSession? = session + val lease = guard.acquireBlocking(NextcloudDocumentIds.accountKey(session)) + var requested = false + val listing = async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + loadAndroidAccountFileListing(session, { current }, cache, "", guard = guard) { + requested = true + AndroidDavFileListingResponse(207, emptyList()) + } + } + } + try { + assertFalse(listing.isCompleted) + assertFalse(requested) + current = null + } finally { + lease.close() + } + val result = withTimeout(1_000) { listing.await() } + assertTrue(result.exceptionOrNull() is IllegalStateException) + assertFalse(requested) + } + } + + @Test + fun listingExtractionPreservesNetworkSortingAndOfflineFallbackBoundaries() = withCache { cache -> + runBlocking { + val session = session() + val guard = AndroidAccountOperationGuard() + val root = file("", true) + val directory = file("Notes", true) + val alpha = file("a.txt") + val zulu = file("Z.txt") + val fresh = loadAndroidAccountFileListing(session, { session }, cache, "", guard = guard) { + assertFalse(guard.tryWithAccount( + NextcloudDocumentIds.accountKey(session), unavailable = { false }, action = { true }, + )) + AndroidDavFileListingResponse(207, listOf(root, zulu, alpha, directory)) + } + assertEquals(NextcloudFileListingSource.Network, fresh.source) + assertEquals(listOf(directory, alpha, zulu), fresh.files) + for (status in listOf(500, 503)) { + val cached = loadAndroidAccountFileListing(session, { session }, cache, "", guard = guard) { + AndroidDavFileListingResponse(status, emptyList()) + } + assertEquals(NextcloudFileListingSource.Cache, cached.source) + assertEquals(fresh.files, cached.files) + } + val offline = loadAndroidAccountFileListing(session, { session }, cache, "", guard = guard) { + throw IOException("offline") + } + assertEquals(fresh.files, offline.files) + assertEquals(NextcloudFileListingSource.Cache, offline.source) + for (status in listOf(401, 403, 404)) { + assertEquals(status, assertFailsWith { + loadAndroidAccountFileListing(session, { session }, cache, "", guard = guard) { + AndroidDavFileListingResponse(status, emptyList()) + } + }.status) + } + assertFailsWith { + loadAndroidAccountFileListing(session, { session }, cache, "", guard = guard) { + throw CancellationException("cancelled") + } + } + } + } + + @Test + fun rootCreateSkipsLookupAndNonDirectoryParentIsRejected() { + val key = NextcloudDocumentIds.accountKey(session()) + requireAndroidDocumentDirectory(NextcloudDocumentReference(key, "")) { error("Root has no parent listing") } + assertFailsWith { + requireAndroidDocumentDirectory(NextcloudDocumentReference(key, "Notes.txt")) { file(it) } + } + } + + private fun session() = NextcloudSession("https://cloud.example.test", "alice", "fixture-password") + + private fun file(path: String, directory: Boolean = false) = NextcloudFile( + path = path, name = path.substringAfterLast('/'), isDirectory = directory, + mimeType = null, size = null, lastModified = null, fileId = null, hasPreview = false, etag = "\"etag\"", + ) + + private fun withCache(block: (AndroidFileReadCache) -> Unit) { + val root = Files.createTempDirectory("ncn-account-listing-test-").toFile() + try { block(AndroidFileReadCache(root)) } finally { root.deleteRecursively() } + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt new file mode 100644 index 000000000..39052c7a5 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt @@ -0,0 +1,215 @@ +package dev.obiente.nextcloudnative + +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.DurableMutationRecoveryKind +import java.io.File +import java.lang.reflect.Proxy +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidAccountMutationRecoveryCleanupTest { + @Test + fun accountCleanupPurgesEveryDurableKindAndOwnedPendingFile() { + val root = Files.createTempDirectory("android-account-mutation-cleanup-").toFile() + val outside = Files.createTempFile( + requireNotNull(root.parentFile).toPath(), + "retained-mutation-", + ".json", + ).toFile() + try { + val removed = "a".repeat(64) + val retained = "b".repeat(64) + val values = linkedMapOf() + DurableMutationRecoveryKind.entries.forEach { kind -> + values[androidDurableMutationRecoveryKey(removed, kind)] = "removed-${kind.storageKey}" + values[androidDurableMutationRecoveryKey(retained, kind)] = "retained-${kind.storageKey}" + } + values["unrelated-preference"] = "retained" + val digest = "1".repeat(64) + val removedPublished = File(root, "$removed-notes-$digest.json").apply { writeText("removed") } + val removedStaging = File(root, "$removed-calendar-$digest.json.part").apply { writeText("removed") } + val retainedPublished = File(root, "$retained-notes-$digest.json").apply { writeText("retained") } + val cleanup = AndroidAccountMutationRecoveryCleanup(recordingPreferences(values), root) + + repeat(2) { + cleanup.clearDurableRecoveries(removed) + cleanup.clearPendingDynamicMutations(removed) + } + + DurableMutationRecoveryKind.entries.forEach { kind -> + assertFalse(androidDurableMutationRecoveryKey(removed, kind) in values) + assertEquals("retained-${kind.storageKey}", values[androidDurableMutationRecoveryKey(retained, kind)]) + } + assertEquals("retained", values["unrelated-preference"]) + assertFalse(removedPublished.exists()) + assertFalse(removedStaging.exists()) + assertTrue(retainedPublished.isFile) + assertTrue(outside.isFile) + } finally { + root.deleteRecursively() + outside.delete() + } + } + + @Test + fun malformedOrNewerAccountEntryDefersCleanupInsteadOfBeingIgnored() { + val root = Files.createTempDirectory("android-account-mutation-unknown-").toFile() + try { + val removed = "d".repeat(64) + val unknown = File(root, "$removed-notes-${"2".repeat(64)}.json.v2").apply { + writeText("future-format") + } + val cleanup = AndroidAccountMutationRecoveryCleanup(recordingPreferences(linkedMapOf()), root) + + assertFailsWith { + cleanup.clearPendingDynamicMutations(removed) + } + + assertTrue(unknown.isFile) + } finally { + root.deleteRecursively() + } + } + + @Test + fun invalidPendingIdentityCannotEscapeTheMutationDirectory() { + val root = Files.createTempDirectory("android-account-mutation-confinement-").toFile() + val outside = Files.createTempFile( + requireNotNull(root.parentFile).toPath(), + "outside-mutation-", + ".json", + ).toFile() + try { + val cleanup = AndroidAccountMutationRecoveryCleanup(recordingPreferences(linkedMapOf()), root) + + assertFailsWith { + cleanup.clearPendingDynamicMutations("../${outside.name}") + } + + assertTrue(outside.isFile) + } finally { + root.deleteRecursively() + outside.delete() + } + } + + @Test + fun retryPersistsCleanupAfterFailedCommitRemovedOnlyTheMemoryValues() { + val removed = "c".repeat(64) + val diskValues = DurableMutationRecoveryKind.entries.associateTo(linkedMapOf()) { kind -> + androidDurableMutationRecoveryKey(removed, kind) to "pending-${kind.storageKey}" + } + val preferences = failedThenSuccessfulPreferences(diskValues) + val root = Files.createTempDirectory("android-account-mutation-retry-").toFile() + try { + val cleanup = AndroidAccountMutationRecoveryCleanup(preferences.preferences, root) + + assertFailsWith { cleanup.clearDurableRecoveries(removed) } + assertTrue(DurableMutationRecoveryKind.entries.all { kind -> + androidDurableMutationRecoveryKey(removed, kind) in preferences.diskValues + }) + assertTrue(DurableMutationRecoveryKind.entries.all { kind -> + !preferences.preferences.contains(androidDurableMutationRecoveryKey(removed, kind)) + }) + + cleanup.clearDurableRecoveries(removed) + + assertEquals(2, preferences.commitCalls()) + assertTrue(DurableMutationRecoveryKind.entries.all { kind -> + androidDurableMutationRecoveryKey(removed, kind) !in preferences.diskValues + }) + } finally { + root.deleteRecursively() + } + } + + private fun recordingPreferences( + values: MutableMap, + ): SharedPreferences = Proxy.newProxyInstance( + SharedPreferences::class.java.classLoader, + arrayOf(SharedPreferences::class.java), + ) { _, method, arguments -> + when (method.name) { + "contains" -> values.containsKey(requireNotNull(arguments)[0] as String) + "getBoolean" -> arguments?.get(1) as Boolean + "edit" -> recordingEditor(values) + else -> error("Unexpected SharedPreferences call: ${method.name}") + } + } as SharedPreferences + + private fun recordingEditor( + values: MutableMap, + ): SharedPreferences.Editor { + val removals = linkedSetOf() + return Proxy.newProxyInstance( + SharedPreferences.Editor::class.java.classLoader, + arrayOf(SharedPreferences.Editor::class.java), + ) { proxy, method, arguments -> + when (method.name) { + "remove" -> proxy.also { removals += requireNotNull(arguments)[0] as String } + "putBoolean" -> proxy + "commit" -> true.also { removals.forEach(values::remove) } + else -> error("Unexpected SharedPreferences.Editor call: ${method.name}") + } + } as SharedPreferences.Editor + } + + private fun failedThenSuccessfulPreferences( + initialDiskValues: MutableMap, + ): RestartFaithfulPreferences { + val diskValues = initialDiskValues.toMutableMap() + val memoryValues = initialDiskValues.toMutableMap() + var commitCalls = 0 + lateinit var preferences: SharedPreferences + preferences = Proxy.newProxyInstance( + SharedPreferences::class.java.classLoader, + arrayOf(SharedPreferences::class.java), + ) { _, method, arguments -> + when (method.name) { + "contains" -> requireNotNull(arguments)[0] in memoryValues + "getBoolean" -> memoryValues[requireNotNull(arguments)[0] as String] as? Boolean ?: arguments[1] + "edit" -> { + val removals = linkedSetOf() + val booleans = linkedMapOf() + Proxy.newProxyInstance( + SharedPreferences.Editor::class.java.classLoader, + arrayOf(SharedPreferences.Editor::class.java), + ) { proxy, editorMethod, editorArguments -> + when (editorMethod.name) { + "remove" -> proxy.also { + removals += requireNotNull(editorArguments)[0] as String + } + "putBoolean" -> proxy.also { + booleans[requireNotNull(editorArguments)[0] as String] = editorArguments[1] as Boolean + } + "commit" -> { + commitCalls += 1 + removals.forEach(memoryValues::remove) + memoryValues.putAll(booleans) + (commitCalls > 1).also { persisted -> + if (persisted) { + removals.forEach(diskValues::remove) + diskValues.putAll(booleans) + } + } + } + else -> error("Unexpected SharedPreferences.Editor call: ${editorMethod.name}") + } + } as SharedPreferences.Editor + } + else -> error("Unexpected SharedPreferences call: ${method.name}") + } + } as SharedPreferences + return RestartFaithfulPreferences(preferences, diskValues) { commitCalls } + } + + private data class RestartFaithfulPreferences( + val preferences: SharedPreferences, + val diskValues: Map, + val commitCalls: () -> Int, + ) +} 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..3f2fc3801 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -0,0 +1,834 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudFileRangeSession +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.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield + +class AndroidAccountOperationGuardTest { + @Test + fun accountRemovalWaitsForCrossingDeckDraftSaveThenDeletesIt() = runBlocking { + val guard = AndroidAccountOperationGuard() + val credentialMutations = Mutex() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val saveEntered = CompletableDeferred() + val releaseSave = CompletableDeferred() + var current: NextcloudSession? = session + var draftExists = false + val save = async { + withAndroidAccountPrivateStatePublication( + expectedSession = session, + credentialMutationMutex = credentialMutations, + guard = guard, + resolveSession = { current }, + unavailable = { false }, + ) { + saveEntered.complete(Unit) + releaseSave.await() + draftExists = true + true + } + } + saveEntered.await() + val removal = async { + credentialMutations.withLock { + guard.withAccount(NextcloudDocumentIds.accountKey(session)) { + current = null + draftExists = false + } + } + } + yield() + + assertFalse(removal.isCompleted) + releaseSave.complete(Unit) + assertTrue(save.await()) + removal.await() + + assertFalse(draftExists) + } + + @Test + fun lateDurableWriterCannotPublishAfterRemovalAndCredentialReplacement() = runBlocking { + val guard = AndroidAccountOperationGuard() + val credentialMutations = Mutex() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val replacement = original.copy(appPassword = "replacement-password") + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var current: NextcloudSession? = original + var durablePublished = false + val removal = async { + credentialMutations.withLock { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { + current = replacement + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + } + removalEntered.await() + + val staleWriter = async { + withAndroidAccountPrivateStatePublication( + expectedSession = original, + credentialMutationMutex = credentialMutations, + guard = guard, + resolveSession = { current }, + unavailable = { false }, + ) { + durablePublished = true + true + } + } + yield() + assertFalse(staleWriter.isCompleted) + releaseRemoval.complete(Unit) + removal.await() + + assertFalse(staleWriter.await()) + assertFalse(durablePublished) + assertTrue( + withAndroidAccountPrivateStatePublication( + expectedSession = replacement, + credentialMutationMutex = credentialMutations, + guard = guard, + resolveSession = { current }, + unavailable = { false }, + publish = { true }, + ), + ) + } + + @Test + fun latePendingWriterCannotPublishAfterRemovalAndReadd() = runBlocking { + val guard = AndroidAccountOperationGuard() + val credentialMutations = Mutex() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val readded = original.copy(appPassword = "new-password") + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var current: NextcloudSession? = original + var pendingPublished = false + val removal = async { + credentialMutations.withLock { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { + current = readded + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + } + removalEntered.await() + + val staleWriter = async { + withAndroidAccountPrivateStatePublication( + expectedSession = original, + credentialMutationMutex = credentialMutations, + guard = guard, + resolveSession = { current }, + unavailable = { false }, + ) { + pendingPublished = true + true + } + } + yield() + releaseRemoval.complete(Unit) + removal.await() + + assertFalse(staleWriter.await()) + assertFalse(pendingPublished) + assertTrue( + withAndroidAccountPrivateStatePublication( + expectedSession = readded, + credentialMutationMutex = credentialMutations, + guard = guard, + resolveSession = { current }, + unavailable = { false }, + publish = { true }, + ), + ) + } + + @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 writableDescriptorLeaseRejectsAccountRemovalWithoutWaitingForClose() = runBlocking { + val guard = AndroidAccountOperationGuard() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val accountIdentity = NextcloudDocumentIds.accountKey(session) + val descriptorLease = acquireAndroidDocumentMutationAccountLease(session, { session }, guard) + var removalEntered = false + + val failure = try { + assertFailsWith { + withTimeout(1_000L) { + withAndroidAccountRemovalLease(accountIdentity, guard) { + removalEntered = true + } + } + } + } finally { + descriptorLease.close() + } + + assertEquals( + "Finish or discard pending document changes before removing this account.", + failure.message, + ) + assertFalse(removalEntered) + withTimeout(1_000L) { + withAndroidAccountRemovalLease(accountIdentity, guard) { removalEntered = true } + } + assertTrue(removalEntered) + } + + @Test + fun removalCancelsAndDrainsOpenRangeSessionBeforeCredentialCommit() = runBlocking { + val guard = AndroidAccountOperationGuard() + val coordinator = AndroidFileRangeSessionCoordinator() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val readStarted = CompletableDeferred() + val cancelObserved = CompletableDeferred() + val releaseRead = CompletableDeferred() + val activity = AndroidFileRangeSessionActivity() + val rangeSession = openTrackedAndroidFileRangeSession( + expectedSession = session, + resolveSession = { session }, + activity = activity, + guard = guard, + coordinator = coordinator, + openSource = { + NextcloudFileRangeSession( + size = 8L, + readBlock = { _, length -> + val finishCall = requireNotNull(activity.start { cancelObserved.complete(Unit) }) + try { + readStarted.complete(Unit) + releaseRead.await() + ByteArray(length) + } finally { + finishCall() + } + }, + closeBlock = activity::close, + ) + }, + ) + val read = async { + val finishUse = requireNotNull(rangeSession.beginUse()) + try { + rangeSession.read(0L, 1) + } finally { + finishUse() + } + } + readStarted.await() + var committed = false + val removal = async { + withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session), guard) { + coordinator.quiesce(NextcloudDocumentIds.accountKey(session)) + committed = true + } + } + + cancelObserved.await() + yield() + assertFalse(committed) + releaseRead.complete(Unit) + read.await() + removal.await() + assertTrue(committed) + rangeSession.close() + rangeSession.close() + } + + @Test + fun staleRangeSessionCannotStartAfterCredentialRetirement() = runBlocking { + val guard = AndroidAccountOperationGuard() + val session = NextcloudSession("https://cloud.example.test", "alice", "old-password") + + assertFailsWith { + openTrackedAndroidFileRangeSession( + expectedSession = session, + resolveSession = { session.copy(appPassword = "new-password") }, + activity = AndroidFileRangeSessionActivity(), + guard = guard, + openSource = { error("stale range source must not open") }, + ) + } + + withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session), guard) { } + + assertFailsWith { + openTrackedAndroidFileRangeSession( + expectedSession = session, + resolveSession = { session }, + activity = AndroidFileRangeSessionActivity(), + guard = guard, + openSource = { error("synthetic range construction failure") }, + ) + } + withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session), guard) { } + } + + @Test + fun sameAccountReauthenticationDrainsOldPasswordRangeBeforeCredentialCommit() = runBlocking { + val coordinator = AndroidFileRangeSessionCoordinator() + val old = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val replacement = old.copy(appPassword = "new-password") + val activity = AndroidFileRangeSessionActivity() + val cancelObserved = CompletableDeferred() + val finishRead = requireNotNull(activity.start { cancelObserved.complete(Unit) }) + coordinator.register(NextcloudDocumentIds.accountKey(old), activity, activity::close) + var committed = false + + val reauthenticate = async { + quiesceAndroidFileRangesBeforeCredentialReplacement(old, replacement, coordinator) + committed = true + } + + cancelObserved.await() + assertFalse(committed) + finishRead() + reauthenticate.await() + assertTrue(committed) + assertNull(activity.start()) + } + + @Test + fun selectingAnotherRetainedAccountLeavesPreviousAccountRangeOpen() = runBlocking { + val coordinator = AndroidFileRangeSessionCoordinator() + val previous = NextcloudSession("https://one.example.test", "alice", "first-password") + val selected = NextcloudSession("https://two.example.test", "bob", "second-password") + val activity = AndroidFileRangeSessionActivity() + var cancelled = false + coordinator.register(NextcloudDocumentIds.accountKey(previous), activity, activity::close) + val finishRead = requireNotNull(activity.start { cancelled = true }) + + quiesceAndroidFileRangesBeforeCredentialReplacement(previous, selected, coordinator) + + assertFalse(cancelled) + finishRead() + activity.close() + } + + @Test + fun inactiveReauthenticationLocksOldRangeIdentityAgainstLateRegistration() = runBlocking { + val guard = AndroidAccountOperationGuard() + val coordinator = AndroidFileRangeSessionCoordinator() + val old = NextcloudSession("https://CLOUD.example.test:443/", "alice", "old-password") + val replacement = NextcloudSession("https://cloud.example.test", "alice", "new-password") + val active = NextcloudSession("https://two.example.test", "bob", "second-password") + val replacementState = AndroidAccountCredentialState.Empty.upsertAndSelect(replacement) + val activity = AndroidFileRangeSessionActivity() + val cancelObserved = CompletableDeferred() + val finishOldRead = requireNotNull(activity.start { cancelObserved.complete(Unit) }) + coordinator.register(NextcloudDocumentIds.accountKey(old), activity, activity::close) + var current: NextcloudSession? = old + + val transition = async { + replaceAndroidActiveStateWithAccountLeases( + replacement = replacementState, + previousSession = active, + replacedSession = old, + suspectEncrypted = null, + guard = guard, + coordinator = coordinator, + ) { _, _, _, _ -> current = replacement } + } + cancelObserved.await() + val lateOpen = async { + runCatching { + openTrackedAndroidFileRangeSession( + old, { current }, AndroidFileRangeSessionActivity(), guard, coordinator, + ) { NextcloudFileRangeSession(8L, { _, length -> ByteArray(length) }) } + } + } + yield() + assertFalse(lateOpen.isCompleted) + + finishOldRead() + transition.await() + assertTrue(lateOpen.await().exceptionOrNull() is FileNotFoundException) + assertEquals(replacement, current) + } + + @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) + } + + @Test + fun textFileCreationWaitsForSelectionAndRejectsTheStaleSession() = runBlocking { + assertCreateMutationWaitsForAccountTransition( + transition = { NextcloudSession("https://other.example.test", "bob", "new-password") }, + method = "PUT", + ) + } + + @Test + fun directoryCreationWaitsForRemovalAndRejectsTheStaleSession() = runBlocking { + assertCreateMutationWaitsForAccountTransition(transition = { null }, method = "MKCOL") + } + + private suspend fun assertCreateMutationWaitsForAccountTransition( + transition: () -> NextcloudSession?, + method: String, + ) = coroutineScope { + val guard = AndroidAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val transitionEntered = CompletableDeferred() + val releaseTransition = CompletableDeferred() + var current: NextcloudSession? = original + var requestMethod: String? = null + val transitionJob = async { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { + current = transition() + transitionEntered.complete(Unit) + releaseTransition.await() + } + } + transitionEntered.await() + val mutation = async { + runCatching { + withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld = false, + expectedSession = original, + resolveSession = { current }, + guard = guard, + ) { _, _ -> + requestMethod = method + } + } + } + yield() + + assertFalse(mutation.isCompleted) + releaseTransition.complete(Unit) + transitionJob.await() + assertTrue(mutation.await().isFailure) + assertEquals(null, requestMethod) + } + + @Test + fun authenticatedFileMutationPropagatesItsHeldLeaseToTheRequestBoundary() = runBlocking { + val guard = AndroidAccountOperationGuard() + val session = NextcloudSession("https://cloud.example.test", "alice", "fixture-password") + var requestObservedSerializedLease = false + + withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld = false, + expectedSession = session, + resolveSession = { session }, + guard = guard, + ) { _, accountMutationSerialized -> + requestObservedSerializedLease = accountMutationSerialized + val nestedLeaseAvailable = guard.tryWithAccount( + NextcloudDocumentIds.accountKey(session), + unavailable = { false }, + action = { true }, + ) + assertFalse(nestedLeaseAvailable) + } + + assertTrue(requestObservedSerializedLease) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt new file mode 100644 index 000000000..2b006f805 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt @@ -0,0 +1,155 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class AndroidAccountPreviewCleanupRecoveryTest { + @Test + fun readdedCanonicalAccountRetriesTheRemovedPreviewIdentity() = runBlocking { + val removed = session().copy(serverUrl = "HTTPS://CLOUD.EXAMPLE.TEST:443/") + val readded = session().copy(serverUrl = "https://cloud.example.test") + val pending = pendingAndroidAccountRemovalCleanup(removed) + val retried = mutableListOf>() + + retryAndroidAccountOwnedStateCleanup(readded, pending) { _, workIdentity, previewIdentity, _, _ -> + retried += workIdentity to previewIdentity + } + + assertEquals(removed.accountId, readded.accountId) + assertFalse(NextcloudDocumentIds.cacheAccountId(removed) == NextcloudDocumentIds.cacheAccountId(readded)) + assertEquals(legacyAndroidAccountPersistenceScopeDigest(removed), pending.legacyAccountScopeDigest) + assertTrue(requireNotNull(pending.legacyAccountScopeDigest) != pending.accountStorageKey) + assertEquals( + NextcloudDocumentIds.accountKey(removed) to NextcloudDocumentIds.cacheAccountId(removed), + retried.single(), + ) + } + + @Test + fun legacyCleanupWithoutPreviewIdentityDoesNotTargetAReaddedAccount() = runBlocking { + val readded = session().copy(serverUrl = "https://cloud.example.test") + val legacy = requireNotNull( + decodeAndroidPendingAccountRemovalCleanup( + "${readded.accountId.storageKey}:${NextcloudDocumentIds.accountKey(readded)}", + ), + ) + val retriedPreviewIdentities = mutableListOf() + + retryAndroidAccountOwnedStateCleanup(readded, legacy) { _, _, previewIdentity, _, _ -> + retriedPreviewIdentities += previewIdentity + } + + assertEquals(listOf(null), retriedPreviewIdentities) + assertFalse(NextcloudDocumentIds.cacheAccountId(readded) in retriedPreviewIdentities) + } + + @Test + fun cleanupTombstoneCarriesThePathConfinedPreviewIdentityAndReadsLegacyEntries() { + val pending = pendingAndroidAccountRemovalCleanup(session()) + + assertEquals(64, requireNotNull(pending.previewCacheIdentity).length) + assertEquals(64, requireNotNull(pending.durableMutationIdentity).length) + assertNull(pending.legacyAccountScopeDigest) + assertTrue(pending.previewCacheIdentity.startsWith(pending.workIdentity)) + assertEquals(pending, decodeAndroidPendingAccountRemovalCleanup(encodeAndroidPendingAccountRemovalCleanup(pending))) + assertNull( + decodeAndroidPendingAccountRemovalCleanup( + "${pending.accountStorageKey}:${pending.workIdentity}", + )?.previewCacheIdentity, + ) + assertNull( + decodeAndroidPendingAccountRemovalCleanup( + "${pending.accountStorageKey}:${pending.workIdentity}", + )?.durableMutationIdentity, + ) + assertNull( + decodeAndroidPendingAccountRemovalCleanup( + "${pending.accountStorageKey}:${pending.workIdentity}", + )?.legacyAccountScopeDigest, + ) + val mismatchedIdentity = if (pending.workIdentity.first() == 'f') "e".repeat(64) else "f".repeat(64) + assertFailsWith { pending.copy(previewCacheIdentity = mismatchedIdentity) } + } + + @Test + fun previewDeletionFailureRetainsCommittedCleanupUntilARecoverySucceeds() = runBlocking { + val pending = pendingAndroidAccountRemovalCleanup(session()) + var previewAttempts = 0 + var otherCleanupAttempts = 0 + var cleanupMarkerClears = 0 + var diagnosed = 0 + var failPreviewDeletion = true + suspend fun removeAccountOwnedState() { + runAndroidAccountOwnedStateCleanups( + previewCacheIdentity = pending.previewCacheIdentity, + clearPreviewAccount = { + previewAttempts += 1 + if (failPreviewDeletion) error("synthetic preview deletion failure") + }, + cleanups = listOf({ otherCleanupAttempts += 1 }), + ) + } + + removeAndroidAccountCredentialData( + active = false, + removeQueuedUploads = { removeAccountOwnedState() }, + clearActiveAccount = {}, + rollbackActiveRemoval = {}, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + completeCommittedCleanup = { cleanupMarkerClears += 1 }, + recordCommittedCleanupFailure = { diagnosed += 1 }, + ) + + assertEquals(0, cleanupMarkerClears) + assertEquals(1, diagnosed) + failPreviewDeletion = false + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = false, + removeAccountOwnedWork = { removeAccountOwnedState() }, + clearCleanup = { cleanupMarkerClears += 1 }, + ) + assertEquals(1, cleanupMarkerClears) + assertEquals(2, previewAttempts) + assertEquals(2, otherCleanupAttempts) + } + + @Test + fun journaledPreviewDeletionIsPathConfinedAndIdempotent() = runBlocking { + val root = Files.createTempDirectory("android-preview-account-cleanup-").toFile() + try { + val pending = pendingAndroidAccountRemovalCleanup(session()) + val previewIdentity = requireNotNull(pending.previewCacheIdentity) + val cache = AndroidNativeMediaPreviewCache(root, maximumBytes = 1_024L) + val key = NativeMediaPreviewCacheKey(previewIdentity, 1L, "etag", 64, "decoder-v1") + assertTrue(cache.store(key, byteArrayOf(1), cache.accountGeneration(previewIdentity))) + + repeat(2) { + runAndroidAccountOwnedStateCleanups( + previewCacheIdentity = previewIdentity, + clearPreviewAccount = cache::clearAccount, + cleanups = emptyList(), + ) + } + + assertNull(cache.load(key)) + assertFailsWith { cache.clearAccount("../outside") } + } finally { + root.deleteRecursively() + } + Unit + } + + private fun session() = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "preview-user", + appPassword = "fixture-password", + ) +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt new file mode 100644 index 000000000..9187d2588 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt @@ -0,0 +1,121 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileSyncConfiguration +import dev.obiente.nextcloudnative.app.FileSyncPair +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class AndroidAccountRecoveryPriorityTest { + @Test + fun scheduleRestorationRetriesOnlyWhenTheExpectedAccountMayStillBeActive() { + val expected = NextcloudSession("https://cloud.example.test/nextcloud", "alice", "secret") + val other = NextcloudSession("https://cloud.example.test/nextcloud", "bob", "other-secret") + val expectedIdentity = NextcloudDocumentIds.accountKey(expected) + + assertTrue( + shouldRetryAndroidFileSyncScheduleRestoration( + expectedIdentity, + AndroidAccountRetentionSnapshot.Available( + accounts = listOf(expected.accountRecord(), other.accountRecord()), + activeAccountId = expected.accountId, + ), + ), + ) + assertFalse( + shouldRetryAndroidFileSyncScheduleRestoration( + expectedIdentity, + AndroidAccountRetentionSnapshot.Available( + accounts = listOf(expected.accountRecord(), other.accountRecord()), + activeAccountId = other.accountId, + ), + ), + ) + assertTrue( + shouldRetryAndroidFileSyncScheduleRestoration( + expectedIdentity, + AndroidAccountRetentionSnapshot.Unavailable, + ), + ) + } + + @Test + fun offlineJobRetriesUntilTheExpectedAccountIsConfirmedAbsent() { + val expected = NextcloudSession("https://cloud.example.test/nextcloud", "alice", "secret") + val other = NextcloudSession("https://cloud.example.test/nextcloud", "bob", "other-secret") + val expectedIdentity = NextcloudDocumentIds.accountKey(expected) + + assertTrue( + shouldRetryAndroidOfflineJobForMissingSession( + expectedIdentity, + AndroidAccountRetentionSnapshot.Available( + accounts = listOf(expected.accountRecord(), other.accountRecord()), + activeAccountId = expected.accountId, + ), + ), + ) + assertTrue( + shouldRetryAndroidOfflineJobForMissingSession( + expectedIdentity, + AndroidAccountRetentionSnapshot.Unavailable, + ), + ) + assertTrue( + shouldRetryAndroidOfflineJobForMissingSession( + expectedIdentity, + AndroidAccountRetentionSnapshot.Available( + accounts = listOf(expected.accountRecord(), other.accountRecord()), + activeAccountId = other.accountId, + ), + ), + ) + assertFalse( + shouldRetryAndroidOfflineJobForMissingSession( + expectedIdentity, + AndroidAccountRetentionSnapshot.Available( + accounts = listOf(other.accountRecord()), + activeAccountId = other.accountId, + ), + ), + ) + } + + @Test + fun accountRetirementRetainsPairMappingUntilEverySafGrantReleaseIsAttempted() = runBlocking { + val retiredPairs = listOf( + fileSyncPair("retired-a", "content://documents/first"), + fileSyncPair("retired-b", "content://documents/second"), + ) + val events = mutableListOf() + + assertFailsWith { + retireConfiguredFileSyncAccountPairs( + retiredPairs = retiredPairs, + retainedPairs = emptyList(), + reconcileLocalDownloads = { true }, + cancelSchedule = {}, + cancelNotification = {}, + persistRetirement = { events += "persist-retirement" }, + releaseLocalGrant = { localRootId -> + events += "release-$localRootId" + if (localRootId.endsWith("first")) error("synthetic grant release interruption") + }, + ) + } + + assertEquals(listOf("release-content://documents/first"), events) + } + + private fun fileSyncPair(id: String, localRootId: String) = FileSyncPair( + id = id, + accountId = "removed-account", + localRootId = localRootId, + remoteRootPath = "Documents", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt new file mode 100644 index 000000000..585998455 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt @@ -0,0 +1,274 @@ +package dev.obiente.nextcloudnative + +import androidx.work.ExistingWorkPolicy +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidAccountRemovalCleanupRecoveryWorkTest { + @Test + fun newCleanupMarkersAppendBehindAStillRunningRecovery() { + assertEquals( + ExistingWorkPolicy.APPEND_OR_REPLACE, + ANDROID_ACCOUNT_REMOVAL_CLEANUP_WORK_POLICY, + ) + } + + @Test + fun removedAccountsAreCleanedWithoutBeingSavedAgain() = runBlocking { + val removed = cleanup("a", "1") + val restored = cleanup("b", "2") + val events = mutableListOf() + + val completed = recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(removed, restored), + accountOwnedByRegistry = { cleanup -> cleanup.accountStorageKey == restored.accountStorageKey }, + removeAccountOwnedWork = { events += "remove:${it.workIdentity}" }, + clearCleanup = { events += "clear:$it" }, + recordFailure = { events += "failure" }, + ) + + assertTrue(completed) + assertEquals( + listOf( + "remove:${removed.workIdentity}", + "clear:${removed.accountStorageKey}", + "clear:${restored.accountStorageKey}", + ), + events, + ) + } + + @Test + fun unreadableRegistryDefersCleanupWithoutDeletingAccountOwnedState() = runBlocking { + val pending = cleanup("a", "1") + val events = mutableListOf() + + val completed = recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(pending), + accountOwnedByRegistry = { null }, + removeAccountOwnedWork = { events += "remove" }, + clearCleanup = { events += "clear" }, + recordFailure = { events += "failure" }, + ) + + assertFalse(completed) + assertEquals(listOf("failure"), events) + } + + @Test + fun cleanupCancellationIsNotReportedAsARecoverableFailure() = runBlocking { + val pending = cleanup("a", "1") + val events = mutableListOf() + + kotlin.test.assertFailsWith { + recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(pending), + accountOwnedByRegistry = { false }, + removeAccountOwnedWork = { + events += "remove" + throw CancellationException("synthetic cancellation") + }, + clearCleanup = { events += "clear" }, + recordFailure = { events += "failure" }, + ) + } + + assertEquals(listOf("remove"), events) + } + + @Test + fun recoveryFailureLogDoesNotExposeTheFailureMessage() = runBlocking { + val pending = cleanup("a", "1") + val messages = mutableListOf() + + val completed = recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(pending), + accountOwnedByRegistry = { false }, + removeAccountOwnedWork = { + error("private/path/account-secret") + }, + clearCleanup = {}, + recordFailure = { + logAndroidAccountRemovalCleanupRecoveryDeferred(messages::add) + }, + ) + + assertFalse(completed) + assertEquals(listOf("Account-removal cleanup recovery deferred"), messages) + assertFalse(messages.single().contains("private/path/account-secret")) + } + + @Test + fun supportCleanupFailureKeepsRestartRecoveryPendingUntilRetrySucceeds() = runBlocking { + val pending = cleanup("a", "1") + var supportCleanupFails = true + var supportCleanupAttempts = 0 + var clears = 0 + + suspend fun recover(): Boolean = recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(pending), + accountOwnedByRegistry = { false }, + removeAccountOwnedWork = { + supportCleanupAttempts += 1 + if (supportCleanupFails) error("synthetic support cleanup failure") + }, + clearCleanup = { clears += 1 }, + recordFailure = {}, + ) + + assertFalse(recover()) + assertEquals(0, clears) + supportCleanupFails = false + assertTrue(recover()) + assertEquals(2, supportCleanupAttempts) + assertEquals(1, clears) + } + + @Test + fun unreadableCleanupJournalDefersRecoveryWithABoundedMessage() { + val messages = mutableListOf() + + val pending = readPendingAndroidAccountRemovalCleanups( + readPending = { error("private/path/account-secret") }, + recordFailure = { + logAndroidAccountRemovalCleanupRecoveryDeferred(messages::add) + }, + ) + + assertEquals(null, pending) + assertEquals(listOf("Account-removal cleanup recovery deferred"), messages) + assertFalse(messages.single().contains("private/path/account-secret")) + } + + @Test + fun handoffCleanupJournalIsClearedOnlyAfterDurableCleanupSucceeds() { + val events = mutableListOf() + + val firstCompleted = retryPendingAndroidExternalHandoffCleanup( + pending = true, + clearHandoffs = { + events += "clear-handoffs" + error("synthetic persistence failure") + }, + clearJournal = { events += "clear-journal" }, + recordFailure = { events += "failure" }, + ) + val retryCompleted = retryPendingAndroidExternalHandoffCleanup( + pending = true, + clearHandoffs = { events += "retry-handoffs" }, + clearJournal = { events += "clear-journal" }, + recordFailure = { events += "failure" }, + ) + + assertFalse(firstCompleted) + assertTrue(retryCompleted) + assertEquals(listOf("clear-handoffs", "failure", "retry-handoffs", "clear-journal"), events) + } + + @Test + fun cleanupJournalReadCancellationIsPropagated() { + var recorded = false + + kotlin.test.assertFailsWith { + readPendingAndroidAccountRemovalCleanups( + readPending = { throw CancellationException("synthetic cancellation") }, + recordFailure = { recorded = true }, + ) + } + + assertFalse(recorded) + } + + @Test + fun crossedCleanupIdentityCannotDeleteARetainedAccountsState() = runBlocking { + val retained = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "retained-user", + appPassword = "fixture-password", + ) + val removed = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "removed-user", + appPassword = "fixture-password", + ) + val retainedIdentity = pendingAndroidAccountRemovalCleanup(retained) + val crossed = pendingAndroidAccountRemovalCleanup(removed).copy( + workIdentity = retainedIdentity.workIdentity, + previewCacheIdentity = retainedIdentity.previewCacheIdentity, + ) + val events = mutableListOf() + + val completed = recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(crossed), + accountOwnedByRegistry = { cleanup -> + androidAccountRemovalCleanupOwnedByRegistry(cleanup, listOf(retained.accountRecord())) + }, + removeAccountOwnedWork = { events += "remove" }, + clearCleanup = { events += "clear" }, + recordFailure = { events += "failure" }, + ) + + assertFalse(completed) + assertEquals(listOf("failure"), events) + } + + @Test + fun matchingCleanupIdentityRecognizesItsRetainedAccount() { + val retained = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "retained-user", + appPassword = "fixture-password", + ) + + assertEquals( + true, + androidAccountRemovalCleanupOwnedByRegistry( + pendingAndroidAccountRemovalCleanup(retained), + listOf(retained.accountRecord()), + ), + ) + } + + @Test + fun crossedDurableMutationIdentityCannotDeleteARetainedAccountsRecovery() = runBlocking { + val retained = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "retained-user", + appPassword = "fixture-password", + ) + val removed = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "removed-user", + appPassword = "fixture-password", + ) + val crossed = pendingAndroidAccountRemovalCleanup(removed).copy( + durableMutationIdentity = pendingAndroidAccountRemovalCleanup(retained).durableMutationIdentity, + ) + val events = mutableListOf() + + val completed = recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(crossed), + accountOwnedByRegistry = { cleanup -> + androidAccountRemovalCleanupOwnedByRegistry(cleanup, listOf(retained.accountRecord())) + }, + removeAccountOwnedWork = { events += "remove" }, + clearCleanup = { events += "clear" }, + recordFailure = { events += "failure" }, + ) + + assertFalse(completed) + assertEquals(listOf("failure"), events) + } + + private fun cleanup(accountCharacter: String, workCharacter: String) = + AndroidPendingAccountRemovalCleanup( + accountStorageKey = accountCharacter.repeat(64), + workIdentity = workCharacter.repeat(32), + ) +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt new file mode 100644 index 000000000..2fb112ccb --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt @@ -0,0 +1,354 @@ +package dev.obiente.nextcloudnative + +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord +import java.lang.reflect.Proxy +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidAccountRemovalRecoveryTest { + @Test + fun unavailableActiveCredentialRemovalUsesActiveTeardown() = runBlocking { + val events = mutableListOf() + + removeUnavailableAndroidAccountCredentialData( + accountIdentity = "account-identity", + active = true, + prepareAccountRemoval = { events += "prepare" }, + removeAccountOwnedWorkWithoutCredentials = { events += "remove:$it" }, + persistRemoval = { events += "persist-inactive" }, + clearActiveAccount = { events += "clear-active" }, + rollbackRemoval = { events += "rollback" }, + completeCommittedCleanup = { events += "clear-cleanup" }, + ) + + assertEquals( + listOf("prepare", "clear-active", "remove:account-identity", "clear-cleanup"), + events, + ) + } + + @Test + fun unavailableRemovalTargetPreservesCredentialFreeActiveOwnership() { + val session = NextcloudSession("https://cloud.example.test", "alice", "unused-secret") + val registry = NextcloudAccountRegistry.Empty.upsertAndSelect(session.accountRecord()) + + val target = requireNotNull(resolveAndroidUnavailableAccountRemovalTarget(registry, session.accountId)) + + assertEquals(session.accountRecord(), target.record) + assertTrue(target.wasActive) + } + + @Test + fun activeFallbackIsExcludedFromThePersistedReplacement() { + val fallback = NextcloudSession("https://cloud.example.test", "alice", "must-not-persist") + val recovered = AndroidAccountCredentialState( + registry = NextcloudAccountRegistry.Empty.upsertAndSelect(fallback.accountRecord()) + .copy(activeAccountId = null), + sessions = emptyMap(), + ) + + val removal = requireNotNull(resolveAndroidActiveAccountRemovalTransition(recovered, fallback)) + + assertTrue(removal.replacement.registry.accounts.isEmpty()) + assertTrue(removal.replacement.sessions.isEmpty()) + assertFalse(encodeAndroidAccountCredentialState(removal.replacement).contains("must-not-persist")) + } + + @Test + fun failedActiveRemovalPersistenceDoesNotOverwriteCredentialFreeOwnership() = runBlocking { + val events = mutableListOf() + + rollbackUnavailableAndroidAccountRemoval( + active = true, + recovered = AndroidAccountCredentialState.Empty, + persistRecovered = { events += "persist-reconstructed-state" }, + clearCleanup = { events += "clear-uncommitted-cleanup" }, + ) + + assertEquals(listOf("clear-uncommitted-cleanup"), events) + } + + @Test + fun unavailableCredentialRemovalCleansCommittedStateByIdentity() = runBlocking { + val events = mutableListOf() + + removeUnavailableAndroidAccountCredentialData( + accountIdentity = "account-identity", + prepareAccountRemoval = { events += "prepare" }, + removeAccountOwnedWorkWithoutCredentials = { identity -> events += "remove:$identity" }, + persistRemoval = { events += "persist" }, + rollbackRemoval = { events += "rollback" }, + completeCommittedCleanup = { events += "clear" }, + recordCommittedCleanupFailure = { events += "failure" }, + ) + + assertEquals( + listOf("prepare", "persist", "remove:account-identity", "clear"), + events, + ) + } + + @Test + fun unavailableCredentialRemovalPreservesCleanupCancellation() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + removeUnavailableAndroidAccountCredentialData( + accountIdentity = "account-identity", + prepareAccountRemoval = {}, + removeAccountOwnedWorkWithoutCredentials = { + events += "remove" + throw CancellationException("synthetic cancellation") + }, + persistRemoval = { events += "persist" }, + rollbackRemoval = { events += "rollback" }, + completeCommittedCleanup = { events += "clear" }, + recordCommittedCleanupFailure = { events += "failure" }, + ) + } + + assertEquals(listOf("persist", "remove"), events) + } + + @Test + fun restoredAccountRetriesMarkerClearWithoutDeletingOwnedWork() = runBlocking { + var failClear = true + val events = mutableListOf() + val retry = suspend { + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = true, + removeAccountOwnedWork = { events += "remove-owned-work" }, + clearCleanup = { + events += "clear-cleanup" + if (failClear) { + failClear = false + error("synthetic cleanup marker commit failure") + } + }, + ) + } + + assertFailsWith { retry() } + retry() + + assertEquals(listOf("clear-cleanup", "clear-cleanup"), events) + } + + @Test + fun unknownAccountOwnershipFailsClosedBeforeCleanup() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = null, + removeAccountOwnedWork = { events += "remove-owned-work" }, + clearCleanup = { events += "clear-cleanup" }, + ) + } + + assertTrue(events.isEmpty()) + } + + @Test + fun malformedCleanupTombstonesDoNotHideValidRecoveryState() { + val valid = AndroidPendingAccountRemovalCleanup( + accountStorageKey = "a".repeat(64), + workIdentity = "1".repeat(32), + ) + val encoded = linkedSetOf(encodeAndroidPendingAccountRemovalCleanup(valid), "truncated-row") + var malformedRecorded = false + + assertEquals( + setOf(valid), + requireValidAndroidAccountRemovalCleanupJournal(encoded) { + malformedRecorded = true + }, + ) + + assertTrue(malformedRecorded) + assertTrue("truncated-row" in encoded) + val snapshot = restoreAndroidPendingAccountRemovalCleanups(encoded) + assertEquals(setOf(valid), snapshot.cleanups) + assertEquals(1, snapshot.malformedEntryCount) + assertFailsWith { + requireAndroidAccountRemovalCleanupJournalAllowsActivation(snapshot) + } + assertFalse(androidAccountRemovalCleanupRecoveryCompleted(true, snapshot, true)) + } + + @Test + fun malformedOnlyCleanupJournalBlocksAccountReactivation() { + val snapshot = restoreAndroidPendingAccountRemovalCleanups(setOf("truncated-row")) + + assertTrue(snapshot.cleanups.isEmpty()) + assertEquals(1, snapshot.malformedEntryCount) + assertFailsWith { + requireAndroidAccountRemovalCleanupJournalAllowsActivation(snapshot) + } + } + + @Test + fun malformedCleanupBlocksSelectionBeforePersistencePublicationOrUploadResume() = runBlocking { + val events = mutableListOf() + val snapshot = restoreAndroidPendingAccountRemovalCleanups(setOf("truncated-row")) + + assertFailsWith { + selectAndroidAccountAfterRemovalCleanup( + session = NextcloudSession("https://cloud.example.test", "alice", "secret"), + retryPendingCleanup = { requireAndroidAccountRemovalCleanupJournalAllowsActivation(snapshot) }, + registerSessionPrivateValues = { events += "publish-private-state" }, + persistSelection = { events += listOf("persist-selection", "publish-account", "resume-uploads") }, + ) + } + + assertTrue(events.isEmpty()) + } + + @Test + fun malformedCleanupBlocksStartupAndExplicitCredentialLoadsBeforePrivatePublication() { + val session = NextcloudSession("https://cloud.example.test", "alice", "secret") + val snapshot = restoreAndroidPendingAccountRemovalCleanups(setOf("truncated-row")) + var privatePublications = 0 + var publicSessionPublications = 0 + val restore = { + restoreAndroidSessionAfterRemovalCleanup(session.accountId, { snapshot }) { + privatePublications += 1 + session + } + } + + val startup = AndroidFileSyncSessionSchedulingGuard().restorePersistedSession( + load = restore, + accountIdOf = NextcloudDocumentIds::accountKey, + publishAccount = { restored, _ -> if (restored != null) publicSessionPublications += 1 }, + ) + + assertEquals(null, startup) + assertEquals(null, restore()) + assertEquals(0, privatePublications) + assertEquals(0, publicSessionPublications) + } + + @Test + fun credentialLoadsStayBlockedWhileAsyncCleanupStillOwnsTheMatchingTombstone() = runBlocking { + val session = NextcloudSession("https://cloud.example.test", "alice", "secret") + val pending = AndroidPendingAccountRemovalCleanup( + accountStorageKey = session.accountId.storageKey, + workIdentity = NextcloudDocumentIds.accountKey(session), + ) + var encoded = setOf(encodeAndroidPendingAccountRemovalCleanup(pending)) + val cleanupEntered = CompletableDeferred() + val releaseCleanup = CompletableDeferred() + val worker = async { + recoverPendingAndroidAccountRemovalCleanups( + pending = setOf(pending), + accountOwnedByRegistry = { true }, + removeAccountOwnedWork = {}, + clearCleanup = { + cleanupEntered.complete(Unit) + releaseCleanup.await() + encoded = emptySet() + }, + recordFailure = {}, + ) + } + cleanupEntered.await() + var privatePublications = 0 + val restore = { + restoreAndroidSessionAfterRemovalCleanup( + session.accountId, + { restoreAndroidPendingAccountRemovalCleanups(encoded) }, + ) { + privatePublications += 1 + session + } + } + + assertEquals( + null, + AndroidFileSyncSessionSchedulingGuard().restorePersistedSession(restore, NextcloudDocumentIds::accountKey), + ) + assertEquals(null, restore()) + assertEquals(0, privatePublications) + releaseCleanup.complete(Unit) + assertTrue(worker.await()) + assertEquals(session, restore()) + assertEquals(1, privatePublications) + } + + @Test + fun malformedCleanupJournalReadDoesNotRewriteStoredTombstones() { + val valid = AndroidPendingAccountRemovalCleanup( + accountStorageKey = "a".repeat(64), + workIdentity = "1".repeat(32), + ) + val encoded = linkedSetOf(encodeAndroidPendingAccountRemovalCleanup(valid), "truncated-row") + var editCalls = 0 + var commitCalls = 0 + val preferences = Proxy.newProxyInstance( + SharedPreferences::class.java.classLoader, + arrayOf(SharedPreferences::class.java), + ) { _, method, _ -> + when (method.name) { + "getStringSet" -> encoded + "edit" -> { + editCalls += 1 + error("Malformed cleanup recovery must not edit preferences") + } + else -> error("Unexpected SharedPreferences call: ${method.name}") + } + } as SharedPreferences + val journal = AndroidAccountRemovalCleanupJournal( + preferences = preferences, + commit = { commitCalls += 1 }, + recordMalformed = {}, + ) + + assertEquals(setOf(valid), journal.pending()) + + assertEquals(0, editCalls) + assertEquals(0, commitCalls) + assertEquals(linkedSetOf(encodeAndroidPendingAccountRemovalCleanup(valid), "truncated-row"), encoded) + } + + @Test + fun cleanupJournalEditsPreserveMalformedPeersWhileReplacingValidTombstones() { + val original = AndroidPendingAccountRemovalCleanup( + accountStorageKey = "a".repeat(64), + workIdentity = "1".repeat(32), + ) + val replacement = original.copy(workIdentity = "2".repeat(32)) + val peer = AndroidPendingAccountRemovalCleanup( + accountStorageKey = "b".repeat(64), + workIdentity = "3".repeat(32), + ) + val malformed = "truncated-row" + var malformedCount = 0 + val encoded = linkedSetOf( + encodeAndroidPendingAccountRemovalCleanup(original), + encodeAndroidPendingAccountRemovalCleanup(peer), + malformed, + ) + + val replaced = replaceAndroidAccountRemovalCleanup(encoded, replacement) { malformedCount += 1 } + val cleared = removeAndroidAccountRemovalCleanup(replaced, replacement.accountStorageKey) { + malformedCount += 1 + } + + assertEquals( + linkedSetOf(encodeAndroidPendingAccountRemovalCleanup(peer), malformed), + cleared, + ) + assertEquals(2, malformedCount) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionCredentialRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionCredentialRecoveryTest.kt new file mode 100644 index 000000000..40ddfc9d6 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionCredentialRecoveryTest.kt @@ -0,0 +1,59 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class AndroidAccountSelectionCredentialRecoveryTest { + @Test + fun `valid credential slots recover account selection around a malformed aggregate`() { + val first = NextcloudSession("https://one.example.test", "alice", "first-secret") + val second = NextcloudSession("https://two.example.test", "bob", "second-secret") + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + val slots = mapOf(first.accountId to first, second.accountId to second) + + val recovery = recoverAndroidAccountCredentialStateForSelection( + AndroidAccountCredentialStoreRead.Invalid("malformed-encrypted-aggregate"), + ) { + reconstructAndroidAccountCredentialState(registry, slots::get) + } + val selected = assertNotNull(recovery.state.select(first.accountId)) + + assertEquals(first, selected.activeSession) + assertEquals(slots, selected.sessions) + assertEquals("malformed-encrypted-aggregate", recovery.suspectEncrypted) + } + + @Test + fun `recovered state preserves unreadable slots until their registry account is removed`() { + val available = NextcloudSession("https://one.example.test", "alice", "first-secret") + val unreadable = NextcloudSession("https://two.example.test", "bob", "second-secret") + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(unreadable.accountRecord()) + .upsertAndSelect(available.accountRecord()) + val recovered = assertNotNull( + reconstructAndroidAccountCredentialState(registry) { accountId -> + available.takeIf { accountId == available.accountId } + }, + ) + + assertNull(recovered.sessions[unreadable.accountId]) + assertEquals( + setOf( + androidAccountCredentialSlotKey(available.accountId), + androidAccountCredentialSlotKey(unreadable.accountId), + ), + retainedAndroidAccountCredentialSlotKeys(recovered), + ) + assertEquals( + setOf(androidAccountCredentialSlotKey(available.accountId)), + retainedAndroidAccountCredentialSlotKeys(recovered.remove(unreadable.accountId)), + ) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionPostCommitTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionPostCommitTest.kt new file mode 100644 index 000000000..388669181 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionPostCommitTest.kt @@ -0,0 +1,90 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AndroidAccountSelectionPostCommitTest { + @Test + fun cancellationAfterCommitFinishesMaintenanceBeforePropagating() = runBlocking { + val events = mutableListOf() + val cancellation = CancellationException("selection owner stopped after commit") + val selection = async { + val owner = currentCoroutineContext() + completeAndroidAccountSelectionTransition( + transitionDispatcher = Dispatchers.Default, + commitTransition = { markCommitted -> + events += "commit" + markCommitted() + owner.cancel(cancellation) + }, + finishMaintenance = { + yield() + assertTrue(currentCoroutineContext().isActive) + events += "maintain" + }, + ) + } + + assertFailsWith { selection.await() } + assertEquals(listOf("commit", "maintain"), events) + } + + @Test + fun cancellationAfterCredentialCommitActivatesThePersistedAccountBeforePropagating() = runBlocking { + val events = mutableListOf() + val cancellation = CancellationException("login owner stopped after credential commit") + val save = async { + val owner = currentCoroutineContext() + completeAndroidAccountSelectionTransition( + transitionDispatcher = Dispatchers.Default, + commitTransition = { markCommitted -> + events += "persist" + markCommitted() + owner.cancel(cancellation) + }, + finishMaintenance = { + assertTrue(currentCoroutineContext().isActive) + events += "activate-dynamic" + yield() + events += "activate-private" + events += "activate-discovery" + }, + ) + } + + assertFailsWith { save.await() } + assertEquals( + listOf("persist", "activate-dynamic", "activate-private", "activate-discovery"), + events, + ) + } + + @Test + fun cancellationBeforeCommitDoesNotRunTransitionOrMaintenance() = runBlocking { + val events = mutableListOf() + val selection = async { + currentCoroutineContext().cancel(CancellationException("selection stopped before commit")) + completeAndroidAccountSelectionTransition( + transitionDispatcher = Dispatchers.Default, + commitTransition = { + events += "commit" + it() + }, + finishMaintenance = { events += "maintain" }, + ) + } + + assertFailsWith { selection.await() } + assertTrue(events.isEmpty()) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt index 7f28e927d..cbe90ab94 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt @@ -118,7 +118,7 @@ class AndroidDeckCardDraftStoreTest { cipher = IdentityDeckDraftCipher, nowEpochMillis = { ++now }, ) - val unreadableKey = "${AndroidDeckCardDraftStore.KEY_PREFIX}unreadable" + val unreadableKey = store.storageKey(session, persisted(cardId = 999L).key) storage.values[unreadableKey] = "not-json" val saved = (1L..(DeckCardDraftRetention.MAX_ENTRIES + 3L)).map { cardId -> persisted(cardId = cardId, title = "Draft $cardId").also { @@ -148,7 +148,11 @@ class AndroidDeckCardDraftStoreTest { val mismatchedKey = store.storageKey(session, persisted(cardId = 99L).key) val ciphertext = storage.values.getValue(sourceKey) as String storage.values[mismatchedKey] = if (legacy) { - JSONObject(ciphertext).apply { remove("storageKey") }.toString() + JSONObject(ciphertext).apply { + put("version", AndroidDeckCardDraftStore.LEGACY_FORMAT_VERSION) + remove("accountStorageKey") + remove("storageKey") + }.toString() } else { ciphertext } @@ -182,9 +186,9 @@ class AndroidDeckCardDraftStoreTest { persisted(cardId = cardId, title = "Legacy $cardId").also { draft -> store.save(session, draft) val storedKey = store.storageKey(session, draft.key) - storage.values[storedKey] = JSONObject(storage.values.getValue(storedKey) as String) - .apply { remove("storageKey") } - .toString() + val legacyKey = store.legacyStorageKey(session, draft.key) + storage.values[legacyKey] = legacyCiphertext(storage.values.getValue(storedKey) as String) + storage.values.remove(storedKey) } } val newcomer = persisted(cardId = 1_000L, title = "New draft") @@ -200,7 +204,7 @@ class AndroidDeckCardDraftStoreTest { } @Test - fun `session migration makes legacy drafts readable to cross account retention`() { + fun `legacy drafts from another account do not consume retention`() { val storage = MemoryDeckDraftStorage() var now = 0L val store = AndroidDeckCardDraftStore( @@ -212,9 +216,9 @@ class AndroidDeckCardDraftStoreTest { persisted(cardId = cardId, title = "Legacy $cardId").also { draft -> store.save(session, draft) val storedKey = store.storageKey(session, draft.key) - storage.values[storedKey] = JSONObject(storage.values.getValue(storedKey) as String) - .apply { remove("storageKey") } - .toString() + val legacyKey = store.legacyStorageKey(session, draft.key) + storage.values[legacyKey] = legacyCiphertext(storage.values.getValue(storedKey) as String) + storage.values.remove(storedKey) } } val otherSession = NextcloudSession( @@ -227,21 +231,192 @@ class AndroidDeckCardDraftStoreTest { store.migrateLegacyEntries(otherSession) assertEquals(untouchedLegacyCiphertext, storage.values) - assertFailsWith { - store.save(otherSession, newcomer) - } + store.save(otherSession, newcomer) + assertEquals(newcomer, store.load(otherSession, newcomer.key)) store.migrateLegacyEntries(session) - store.save(otherSession, newcomer) - assertEquals(DeckCardDraftRetention.MAX_ENTRIES, storage.values.size) - assertEquals(newcomer, store.load(otherSession, newcomer.key)) + assertEquals(DeckCardDraftRetention.MAX_ENTRIES + 1, storage.values.size) assertEquals( - DeckCardDraftRetention.MAX_ENTRIES - 1, + DeckCardDraftRetention.MAX_ENTRIES, legacyDrafts.count { draft -> store.load(session, draft.key) != null }, ) } + @Test + fun `each account has its own retention budget`() { + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val otherSession = session.copy(loginName = "bob") + + repeat(DeckCardDraftRetention.MAX_ENTRIES) { index -> + store.save(session, persisted(cardId = 20_000L + index)) + store.save(otherSession, persisted(cardId = 30_000L + index)) + } + + assertEquals(DeckCardDraftRetention.MAX_ENTRIES * 2, storage.values.size) + assertEquals(persisted(cardId = 20_000L), store.load(session, persisted(cardId = 20_000L).key)) + assertEquals(persisted(cardId = 30_000L), store.load(otherSession, persisted(cardId = 30_000L).key)) + } + + @Test + fun `account removal is retryable and preserves another account`() { + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val otherSession = session.copy(loginName = "bob") + val removed = persisted(cardId = 51L) + val retained = persisted(cardId = 52L) + store.save(session, removed) + store.save(otherSession, retained) + storage.removeSucceeds = false + store.quarantineAfterSubmit(session, removed.key) + + assertFailsWith { + store.removeAccount(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session)) + } + assertTrue(storage.values.keys.any { it.contains(session.accountId.storageKey) }) + + storage.removeSucceeds = true + store.removeAccount(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session)) + + assertTrue(storage.values.keys.none { it.contains(session.accountId.storageKey) }) + assertEquals(retained, store.load(otherSession, retained.key)) + } + + @Test + fun `completed migration is not rolled back when legacy deletion must retry`() { + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val original = persisted(title = "Legacy") + store.save(session, original) + val targetKey = store.storageKey(session, original.key) + val legacyKey = store.legacyStorageKey(session, original.key) + storage.values[legacyKey] = legacyCiphertext(storage.values.getValue(targetKey) as String) + storage.values.remove(targetKey) + storage.removeSucceeds = false + + store.migrateLegacyEntries(session) + val updated = original.copy(draft = original.draft.copy(title = "Newer")) + assertFailsWith { store.save(session, updated) } + assertEquals(original, store.load(session, original.key)) + assertTrue(legacyKey in storage.values) + + storage.removeSucceeds = true + val restarted = store(storage, IdentityDeckDraftCipher) + restarted.save(session, updated) + + assertEquals(updated, restarted.load(session, original.key)) + assertTrue(legacyKey !in storage.values) + } + + @Test + fun `legacy submitted markers must retire before replacement survives restart`() { + listOf(false, true).forEach { hasLegacyDraft -> + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val original = persisted(title = "Submitted") + val replacement = persisted(title = "Fresh replacement") + val legacyKey = store.legacyStorageKey(session, original.key) + val marker = legacyKey.replaceFirst("draft_", "submitted_") + if (hasLegacyDraft) storage.values[legacyKey] = legacyCiphertextFor(original, legacyKey) + storage.values[marker] = AndroidDeckCardDraftStore.QUARANTINE_MARKER + storage.removeSucceeds = false + + assertNull(store.load(session, original.key)) + assertFailsWith { store.save(session, replacement) } + assertTrue(marker in storage.values) + + storage.removeSucceeds = true + val restarted = store(storage, IdentityDeckDraftCipher) + restarted.save(session, replacement) + + assertTrue(legacyKey !in storage.values) + assertTrue(marker !in storage.values) + repeat(2) { + assertEquals(replacement, store(storage, IdentityDeckDraftCipher).load(session, original.key)) + } + } + } + + @Test + fun `submitted legacy draft cannot return after migration deletion fails`() { + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val original = persisted(title = "Submitted legacy draft") + val legacyKey = store.legacyStorageKey(session, original.key) + val legacyCiphertext = legacyCiphertextFor(original, legacyKey) + storage.values[legacyKey] = legacyCiphertext + storage.removeSucceeds = false + + assertEquals(original, store.load(session, original.key)) + store.quarantineAfterSubmit(session, original.key) + + assertEquals(legacyCiphertext, storage.values[legacyKey]) + assertEquals( + AndroidDeckCardDraftStore.QUARANTINE_MARKER, + storage.values[legacyKey.replaceFirst("draft_", "submitted_")], + ) + assertNull(store(storage, IdentityDeckDraftCipher).load(session, original.key)) + storage.removeSucceeds = true + assertNull(store(storage, IdentityDeckDraftCipher).load(session, original.key)) + assertTrue(storage.values.isEmpty()) + } + + @Test + fun `explicit legacy discard bypasses decryption and preserves unrelated recovery`() { + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val key = persisted().key + val legacyKey = store.legacyStorageKey(session, key) + val targetKey = store.storageKey(session, key) + val marker = legacyKey.replaceFirst("draft_", "submitted_") + val targetMarker = targetKey.replaceFirst("draft_v2_", "submitted_v2_") + val unrelated = setOf( + store.legacyStorageKey(session.copy(loginName = "bob"), key), + store.legacyStorageKey(session, persisted(cardId = 91L).key), + store.storageKey(session.copy(loginName = "bob"), key), + ).associateWith { "unrelated-unreadable" } + storage.values.putAll(unrelated) + setOf(legacyKey, targetKey, marker, targetMarker).forEach { storage.values[it] = "unreadable" } + val unavailable = store(storage, object : AndroidDeckDraftCipher { + override fun encrypt(value: String): String = error("No cipher access during explicit discard") + override fun decrypt(value: String): String = error("No cipher access during explicit discard") + }) + + assertFailsWith { store.load(session, key) } + assertFailsWith { store.clear(session, key) } + storage.removeSucceeds = false + assertFailsWith { unavailable.clear(session, key, discardUnreadable = true) } + assertEquals("unreadable", storage.values[legacyKey]) + storage.removeSucceeds = true + + unavailable.clear(session, key, discardUnreadable = true) + + assertEquals(unrelated.keys, storage.values.keys) + unrelated.forEach { (storedKey, value) -> assertEquals(value, storage.getString(storedKey)) } + val replacement = persisted(title = "Replacement") + store.save(session, replacement) + assertEquals(replacement, store(storage, IdentityDeckDraftCipher).load(session, key)) + } + + @Test + fun `account removal preserves unreadable and unattributable legacy drafts`() { + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val legacyKey = store.legacyStorageKey(session, persisted().key) + val unrelatedKey = store.legacyStorageKey(session.copy(loginName = "bob"), persisted(cardId = 91L).key) + val attributableKey = store.legacyStorageKey(session, persisted(cardId = 92L).key) + storage.values[legacyKey] = "unreadable" + storage.values[unrelatedKey] = legacyCiphertextFor(persisted(cardId = 91L), unrelatedKey) + storage.values[attributableKey] = legacyCiphertextFor(persisted(cardId = 92L), attributableKey) + + store.removeAccount(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session)) + + assertEquals("unreadable", storage.values[legacyKey]) + assertTrue(unrelatedKey in storage.values) + assertTrue(attributableKey !in storage.values) + } + @Test fun `failed legacy migration leaves recovery ciphertext intact`() { val storage = MemoryDeckDraftStorage() @@ -249,24 +424,24 @@ class AndroidDeckCardDraftStoreTest { val legacy = persisted() store.save(session, legacy) val storedKey = store.storageKey(session, legacy.key) - val legacyCiphertext = JSONObject(storage.values.getValue(storedKey) as String) - .apply { remove("storageKey") } - .toString() - storage.values[storedKey] = legacyCiphertext + val legacyKey = store.legacyStorageKey(session, legacy.key) + val legacyCiphertext = legacyCiphertext(storage.values.getValue(storedKey) as String) + storage.values[legacyKey] = legacyCiphertext + storage.values.remove(storedKey) storage.putSucceeds = false store.migrateLegacyEntries(session) - assertEquals(legacyCiphertext, storage.values[storedKey]) + assertEquals(legacyCiphertext, storage.values[legacyKey]) } @Test fun `unreadable drafts can fill but cannot exceed the retention ceiling`() { val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) repeat(DeckCardDraftRetention.MAX_ENTRIES) { index -> - storage.values["${AndroidDeckCardDraftStore.KEY_PREFIX}unreadable-$index"] = "not-json" + storage.values[store.storageKey(session, persisted(cardId = 10_000L + index).key)] = "not-json" } - val store = store(storage, IdentityDeckDraftCipher) assertFailsWith { store.save(session, persisted()) @@ -306,10 +481,10 @@ class AndroidDeckCardDraftStoreTest { @Test fun `explicit reset restores capacity after unreadable drafts fill the store`() { val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) repeat(DeckCardDraftRetention.MAX_ENTRIES) { index -> - storage.values["${AndroidDeckCardDraftStore.KEY_PREFIX}unreadable-$index"] = "not-json" + storage.values[store.storageKey(session, persisted(cardId = 10_000L + index).key)] = "not-json" } - val store = store(storage, IdentityDeckDraftCipher) assertFailsWith { store.save(session, persisted()) } store.discardAll() @@ -428,6 +603,27 @@ class AndroidDeckCardDraftStoreTest { ), ) + private fun legacyCiphertext(current: String): String = JSONObject(current).apply { + put("version", AndroidDeckCardDraftStore.LEGACY_FORMAT_VERSION) + remove("accountStorageKey") + remove("storageKey") + }.toString() + + private fun legacyCiphertextFor(persisted: PersistedDeckCardDraft, storageKey: String): String = JSONObject() + .put("version", AndroidDeckCardDraftStore.LEGACY_FORMAT_VERSION) + .put("storageKey", storageKey) + .put("updatedAtEpochMillis", 100L) + .put("boardId", persisted.key.boardId) + .put("stackId", persisted.key.stackId) + .put("cardId", persisted.key.cardId) + .put("title", persisted.draft.title) + .put("descriptionMarkdown", persisted.draft.descriptionMarkdown) + .put("dueDate", persisted.draft.dueDate) + .put("dueTime", persisted.draft.dueTime) + .put("dueAtBeforeEditing", persisted.draft.dueAtBeforeEditing) + .put("dueFieldsEdited", persisted.draft.dueFieldsEdited) + .toString() + private class MemoryDeckDraftStorage : AndroidDeckDraftStorage { val values = linkedMapOf() var putSucceeds = true diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 8960cc28a..b51979285 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -4,9 +4,12 @@ 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.accountRecord 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 +18,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 +314,97 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(DurableUploadState.OutcomeUnknown, durableUploadStateForHttpResponse(500)) } + @Test + fun `retained background account is deferred without reading its credential`() { + val retainedSession = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "alice", + appPassword = "fixture-password", + ) + val accountId = NextcloudDocumentIds.accountKey(retainedSession) + + assertEquals( + DurableUploadAccountMismatchOutcome.DeferAccountActivation, + durableUploadAccountMismatchOutcome( + accountId, + AndroidAccountRetentionSnapshot.Available(listOf(retainedSession.accountRecord())), + ), + ) + } + + @Test + fun `unreadable account registry defers queued upload recovery`() { + assertEquals( + DurableUploadAccountMismatchOutcome.RetryAccountRecovery, + durableUploadAccountMismatchOutcome(ACCOUNT_A, AndroidAccountRetentionSnapshot.Unavailable), + ) + } + + @Test + fun `active account with unreadable credential keeps its upload scheduled`() { + val retainedSession = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "alice", + appPassword = "fixture-password", + ) + val accountId = NextcloudDocumentIds.accountKey(retainedSession) + + assertEquals( + DurableUploadAccountMismatchOutcome.RetryAccountRecovery, + durableUploadAccountMismatchOutcome( + accountId, + AndroidAccountRetentionSnapshot.Available( + accounts = listOf(retainedSession.accountRecord()), + activeAccountId = retainedSession.accountId, + ), + ), + ) + } + + @Test + fun `valid account registry without expected account makes upload unavailable`() { + val retainedSession = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "alice", + appPassword = "fixture-password", + ) + val accountId = NextcloudDocumentIds.accountKey(retainedSession) + + assertEquals( + DurableUploadAccountMismatchOutcome.AccountUnavailable, + durableUploadAccountMismatchOutcome( + accountId, + AndroidAccountRetentionSnapshot.Available(emptyList()), + ), + ) + assertEquals( + DurableUploadAccountMismatchOutcome.AccountUnavailable, + durableUploadAccountMismatchOutcome( + accountId, + AndroidAccountRetentionSnapshot.Available( + listOf(retainedSession.copy(loginName = "another-account").accountRecord()), + ), + ), + ) + } + + @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/AndroidDynamicApiCachePolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt index fa1059940..341f76c41 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt @@ -3,11 +3,38 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer import dev.obiente.nextcloudnative.app.NextcloudApiCachePolicy import dev.obiente.nextcloudnative.app.NextcloudApiResponse +import dev.obiente.nextcloudnative.contracts.CachedDynamicApiResponse +import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache +import java.nio.file.Files +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertNull +import kotlin.test.assertSame class AndroidDynamicApiCachePolicyTest { + @Test + fun `Android services sharing a canonical cache root share removal fences`() { + val parent = Files.createTempDirectory("android-dynamic-process-state-").toFile() + try { + val first = androidDynamicApiProcessState(parent.resolve("cache")) + val second = androidDynamicApiProcessState(parent.resolve("nested/../cache")) + + assertSame(first, second) + assertSame(first.cache, second.cache) + assertSame(first.coalescer, second.coalescer) + } finally { + parent.deleteRecursively() + } + } + @Test fun `force network bypasses both Android dynamic cache reads`() = runBlocking { val coalescer = DynamicApiRequestCoalescer() @@ -66,4 +93,104 @@ class AndroidDynamicApiCachePolicyTest { assertEquals(0, invalidations) assertEquals(1, networkLoads) } + + @Test + fun `account cleanup fences a late Android GET before deleting its cache`() = runBlocking { + supervisorScope { + val root = Files.createTempDirectory("android-dynamic-cache-cleanup-").toFile() + try { + val accountId = "a".repeat(64) + val requestIdentity = "GET /dashboard/widgets" + val cache = DynamicApiResponseCache(root) + val coalescer = DynamicApiRequestCoalescer() + val started = CompletableDeferred() + val release = CompletableDeferred() + val response = CachedDynamicApiResponse(200, "private".encodeToByteArray(), null, null) + cache.store(accountId, requestIdentity, response) + val read = async { + coalescer.execute(accountId, requestIdentity, load = { + started.complete(Unit) + release.await() + response + }, commit = { cache.store(accountId, requestIdentity, it) }) + } + started.await() + + clearAndroidDynamicApiState(accountId, coalescer, cache) + release.complete(Unit) + + assertFails { read.await() } + assertNull(cache.load(accountId, requestIdentity, 1_024)) + } finally { + root.deleteRecursively() + } + } + } + + @Test + fun `committed removal fences dynamic reads even when cleanup is cancelled`() = runBlocking { + val root = Files.createTempDirectory("android-dynamic-cancelled-cleanup-").toFile() + try { + val accountId = "c".repeat(64) + val requestIdentity = "GET /dashboard/widgets" + val cache = DynamicApiResponseCache(root) + val coalescer = DynamicApiRequestCoalescer() + cache.store( + accountId, + requestIdentity, + CachedDynamicApiResponse(200, "private".encodeToByteArray(), null, null), + ) + + val removal = launch { + currentCoroutineContext().cancel() + fenceAndroidDynamicApiStateForRemoval(accountId, coalescer, cache) + } + removal.join() + + assertFails { + coalescer.execute(accountId, requestIdentity, load = { error("must remain fenced") }) + } + assertNull(cache.load(accountId, requestIdentity, 1_024)) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `second Android service cannot commit a GET that crossed account removal`() = runBlocking { + supervisorScope { + val root = Files.createTempDirectory("android-dynamic-cross-service-").toFile() + try { + val accountId = "b".repeat(64) + val requestIdentity = "GET /dashboard/widgets" + val firstService = androidDynamicApiProcessState(root) + val secondService = androidDynamicApiProcessState(root.resolve(".")) + val started = CompletableDeferred() + val release = CompletableDeferred() + val response = CachedDynamicApiResponse(200, "private".encodeToByteArray(), null, null) + val read = async { + secondService.coalescer.execute(accountId, requestIdentity, load = { + started.complete(Unit) + release.await() + NextcloudApiResponse(200, response.body, response.contentType, response.etag) + }, commit = { loaded -> + secondService.cache.store( + accountId, + requestIdentity, + CachedDynamicApiResponse(loaded.status, loaded.body, loaded.contentType, loaded.etag), + ) + }) + } + started.await() + + clearAndroidDynamicApiState(accountId, firstService.coalescer, firstService.cache) + release.complete(Unit) + + assertFails { read.await() } + assertNull(firstService.cache.load(accountId, requestIdentity, 1_024)) + } finally { + root.deleteRecursively() + } + } + } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt new file mode 100644 index 000000000..8282725f1 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt @@ -0,0 +1,66 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.DynamicNativeMemoryCacheProducer +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AndroidDynamicDiscoveryCacheRetirementTest { + @Test + fun `retirement deletes one account prefix and rejects stale publication until activation`() { + val root = Files.createTempDirectory("dynamic-discovery").toFile() + val cache = AndroidDynamicDiscoveryCache(root) + val removedStorageKey = "a".repeat(64) + val removedCacheId = "1".repeat(64) + val retainedStorageKey = "b".repeat(64) + val retainedCacheId = "2".repeat(64) + val removedProducer = DynamicNativeMemoryCacheProducer(removedStorageKey, 0L) + val retainedProducer = DynamicNativeMemoryCacheProducer(retainedStorageKey, 0L) + try { + cache.save(removedStorageKey, removedCacheId, "deck", "removed", removedProducer) + cache.save(retainedStorageKey, retainedCacheId, "deck", "retained", retainedProducer) + + cache.retireAccount(removedStorageKey, removedCacheId) + cache.activateAccount(removedStorageKey) + cache.save(removedStorageKey, removedCacheId, "deck", "stale", removedProducer) + + assertNull(cache.load(removedStorageKey, removedCacheId, "deck")) + assertEquals("retained", cache.load(retainedStorageKey, retainedCacheId, "deck")) + assertFalse(root.resolve("$removedCacheId-deck.json").exists()) + assertTrue(root.resolve("$retainedCacheId-deck.json").isFile) + + cache.save( + removedStorageKey, removedCacheId, "deck", "current", + DynamicNativeMemoryCacheProducer(removedStorageKey, 1L), + ) + assertEquals("current", cache.load(removedStorageKey, removedCacheId, "deck")) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `legacy cleanup without a persisted cache identity removes all discovery metadata`() { + val root = Files.createTempDirectory("dynamic-discovery-legacy").toFile() + val cache = AndroidDynamicDiscoveryCache(root) + try { + cache.save( + "a".repeat(64), "1".repeat(64), "deck", "first", + DynamicNativeMemoryCacheProducer("a".repeat(64), 0L), + ) + cache.save( + "b".repeat(64), "2".repeat(64), "talk", "second", + DynamicNativeMemoryCacheProducer("b".repeat(64), 0L), + ) + + cache.retireAccount("a".repeat(64), null) + + assertTrue(root.listFiles().orEmpty().isEmpty()) + } finally { + root.deleteRecursively() + } + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffTest.kt index 9a8aed2ff..ebcc8ec40 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffTest.kt @@ -322,10 +322,12 @@ class AndroidExternalFileHandoffTest { } @Test - fun `durable clear failure preserves live handoff authority and reports failure`() { + fun `durable clear failure revokes readers and retry prevents restart restoration`() { val root = Files.createTempDirectory("nextcloud-handoff-clear-test-").toFile() - val store = AndroidExternalFileHandoffStore(root.resolve("records.bin")) + val stateFile = root.resolve("records.bin") + val store = AndroidExternalFileHandoffStore(stateFile, deleteStateFile = { false }) val session = NextcloudSession("https://cloud.example.test", "person", "secret") + var cleanupPending = true AndroidExternalFileHandoffRegistry.resetProcessStateForTests() try { AndroidExternalFileHandoffRegistry.bind(store, nowEpochMillis = 10L) @@ -334,17 +336,37 @@ class AndroidExternalFileHandoffTest { handoffFile(size = 4L), nowEpochMillis = 10L, ) - assertTrue(store.stateFile.delete()) - assertTrue(store.stateFile.mkdir()) - store.stateFile.resolve("blocker").writeText("keep directory non-empty") + val lease = requireNotNull(AndroidExternalFileHandoffRegistry.acquire(record.documentId, session, 11L)) + var revoked = false + lease.onRevoked { revoked = true } - assertFailsWith { - AndroidExternalFileHandoffRegistry.clear() - } - assertEquals( - record, - AndroidExternalFileHandoffRegistry.peek(record.documentId, session, nowEpochMillis = 11L), + assertFalse( + retryPendingAndroidExternalHandoffCleanup( + pending = cleanupPending, + clearHandoffs = AndroidExternalFileHandoffRegistry::clear, + clearJournal = { cleanupPending = false }, + recordFailure = {}, + ), + ) + assertTrue(revoked) + assertFalse(lease.isValid()) + assertEquals(null, AndroidExternalFileHandoffRegistry.peek(record.documentId, session, 11L)) + assertTrue(stateFile.isFile) + + AndroidExternalFileHandoffRegistry.resetProcessStateForTests() + val restartedStore = AndroidExternalFileHandoffStore(stateFile) + assertTrue( + retryPendingAndroidExternalHandoffCleanup( + pending = cleanupPending, + clearHandoffs = { AndroidExternalFileHandoffRegistry.clearPersisted(restartedStore) }, + clearJournal = { cleanupPending = false }, + recordFailure = {}, + ), ) + AndroidExternalFileHandoffRegistry.bind(restartedStore, nowEpochMillis = 11L) + assertFalse(cleanupPending) + assertEquals(null, AndroidExternalFileHandoffRegistry.peek(record.documentId, session, 11L)) + assertFalse(stateFile.exists()) } finally { AndroidExternalFileHandoffRegistry.resetProcessStateForTests() root.deleteRecursively() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt index d75e81d9b..56d138995 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt @@ -1,12 +1,17 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.NextcloudFile +import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.File import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking class AndroidFileReadCacheTest { @Test @@ -47,6 +52,69 @@ class AndroidFileReadCacheTest { assertEquals(listOf(bob), cache.cachedListing(ACCOUNT_B, "Notes")?.files) } + @Test + fun accountRemovalDeletesOnlyItsPrivateListingCache() = withCache { root, cache -> + val alice = file("Notes/alice.md", "\"a\"") + val bob = file("Notes/bob.md", "\"b\"") + cache.storeListing(ACCOUNT_A, "Notes", listOf(alice), 10) + cache.storeListing(ACCOUNT_B, "Notes", listOf(bob), 20) + + cache.clearAccount(ACCOUNT_A) + + assertFalse(File(root, ACCOUNT_A).exists()) + assertEquals(listOf(bob), cache.cachedListing(ACCOUNT_B, "Notes")?.files) + } + + @Test + fun accountRemovalQuiescesListingCacheWritesBeforeCleanupCompletes() = runBlocking { + val root = Files.createTempDirectory("ncn-android-file-cache-removal-").toFile() + try { + val cache = AndroidFileReadCache(root) + val guard = AndroidAccountOperationGuard() + val session = NextcloudSession( + "https://cloud.example.test", + "alice", + "fixture-password", + ) + val accountId = NextcloudDocumentIds.accountKey(session) + val readStarted = CompletableDeferred() + val releaseRead = CompletableDeferred() + var currentSession: NextcloudSession? = session + var cleanupCompleted = false + val read = async { + withRetainedAndroidAccountFileRead(session, { currentSession }, guard) { + readStarted.complete(Unit) + releaseRead.await() + cache.storeListing(accountId, "Notes", listOf(file("Notes/a.md", "\"a\"")), 10) + } + } + readStarted.await() + + val prematureRemoval = runCatching { + withAndroidAccountRemovalLease(accountId, guard) { + currentSession = null + cache.clearAccount(accountId) + cleanupCompleted = true + } + } + assertTrue(prematureRemoval.isFailure) + assertFalse(cleanupCompleted) + + releaseRead.complete(Unit) + read.await() + withAndroidAccountRemovalLease(accountId, guard) { + currentSession = null + cache.clearAccount(accountId) + cleanupCompleted = true + } + + assertTrue(cleanupCompleted) + assertFalse(File(root, accountId).exists()) + } finally { + root.deleteRecursively() + } + } + @Test fun newestListingsWinBoundedMetadataQuota() = withCache( maximumListings = 3, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index ccf2c2a5c..dcaccc5d1 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,118 @@ class AndroidFileSyncEngineInvariantTest { assertTrue(reconciled) } + @Test + fun accountRetirementReconcilesBeforePersistingAndReleasesOnlyUnsharedSafGrants() = runBlocking { + val sharedRoot = "content://documents/shared" + val retiredRoot = "content://documents/retired" + val retiredPairs = listOf( + fileSyncPair("retired-a", "removed-account", sharedRoot), + fileSyncPair("retired-b", "removed-account", retiredRoot), + fileSyncPair("retired-c", "removed-account", retiredRoot), + ) + val retainedPairs = listOf(fileSyncPair("retained", "retained-account", sharedRoot)) + val events = mutableListOf() + + retireConfiguredFileSyncAccountPairs( + retiredPairs = retiredPairs, + retainedPairs = retainedPairs, + reconcileLocalDownloads = { pair -> events += "reconcile-${pair.id}"; true }, + cancelSchedule = { pair -> events += "cancel-${pair.id}" }, + cancelNotification = { pair -> events += "cancel-notification-${pair.id}" }, + persistRetirement = { events += "persist-retirement" }, + releaseLocalGrant = { localRootId -> events += "release-$localRootId" }, + ) + + assertEquals( + listOf( + "reconcile-retired-a", + "reconcile-retired-b", + "reconcile-retired-c", + "cancel-retired-a", + "cancel-notification-retired-a", + "cancel-retired-b", + "cancel-notification-retired-b", + "cancel-retired-c", + "cancel-notification-retired-c", + "release-$retiredRoot", + "persist-retirement", + ), + events, + ) + } + + @Test + fun accountRetirementKeepsPairsAndGrantsWhenLocalRecoveryIsUnavailable() = runBlocking { + val retiredPairs = listOf( + fileSyncPair("retired-a", "removed-account", "content://documents/first"), + fileSyncPair("retired-b", "removed-account", "content://documents/second"), + ) + val events = mutableListOf() + + assertFailsWith { + retireConfiguredFileSyncAccountPairs( + retiredPairs = retiredPairs, + retainedPairs = emptyList(), + reconcileLocalDownloads = { pair -> events += "reconcile-${pair.id}"; pair.id == "retired-a" }, + cancelSchedule = { pair -> events += "cancel-${pair.id}" }, + cancelNotification = { pair -> events += "cancel-notification-${pair.id}" }, + persistRetirement = { events += "persist-retirement" }, + releaseLocalGrant = { localRootId -> events += "release-$localRootId" }, + ) + } + + assertEquals(listOf("reconcile-retired-a", "reconcile-retired-b"), events) + } + + @Test + fun accountRetirementKeepsPairIdsUntilEveryScheduleCancellationCompletes() = runBlocking { + val retiredPairs = listOf( + fileSyncPair("pair-a", "removed-account", "first-root"), + fileSyncPair("pair-b", "removed-account", "second-root"), + ) + val events = mutableListOf() + + assertFailsWith { + retireConfiguredFileSyncAccountPairs( + retiredPairs = retiredPairs, + retainedPairs = emptyList(), + reconcileLocalDownloads = { true }, + cancelSchedule = { pair -> + events += "cancel-${pair.id}" + if (pair.id == "pair-b") error("synthetic WorkManager cancellation failure") + }, + cancelNotification = { pair -> events += "cancel-notification-${pair.id}" }, + persistRetirement = { events += "persist-retirement" }, + releaseLocalGrant = { localRootId -> events += "release-$localRootId" }, + ) + } + + assertEquals(listOf("cancel-pair-a", "cancel-notification-pair-a", "cancel-pair-b"), events) + } + + @Test + fun accountRetirementKeepsPairStateWhenConflictNotificationCannotBeCanceled() = runBlocking { + val pair = fileSyncPair("pair", "removed-account", "root") + val events = mutableListOf() + + assertFailsWith { + retireConfiguredFileSyncAccountPairs( + retiredPairs = listOf(pair), + retainedPairs = emptyList(), + reconcileLocalDownloads = { true }, + cancelSchedule = { events += "cancel-schedule" }, + cancelNotification = { + events += "cancel-notification" + error("synthetic notification cancellation failure") + }, + persistRetirement = { events += "persist-retirement" }, + releaseLocalGrant = { events += "release-grant" }, + ) + } + + assertEquals(listOf("cancel-schedule", "cancel-notification"), events) + } + @Test fun pairRemovalRecoveryPropagatesCancellationBeforeAnyMutation() = runBlocking { val events = mutableListOf() @@ -720,15 +915,126 @@ 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 committedSessionClearContainsMaintenanceFailures() { + val guard = AndroidFileSyncSessionSchedulingGuard() + guard.restorePersistedSession(load = { "account-old" }, accountIdOf = { it }) + val events = mutableListOf() + + guard.clearSession( + persist = { events += "clear-session" }, + clearPublishedAccount = { + events += "publish-none" + error("synthetic publication failure") + }, + cancelAll = { + events += "cancel-all" + error("synthetic cancellation failure") + }, + onScheduleMaintenanceFailure = { events += "diagnose" }, + ) + + assertEquals(listOf("clear-session", "publish-none", "diagnose", "cancel-all", "diagnose"), events) + assertEquals(null, guard.capture("account-old")) } @Test @@ -862,6 +1168,18 @@ class AndroidFileSyncEngineInvariantTest { assertTrue(guard.capture("account-new") != null) } + private fun fileSyncPair( + id: String, + accountId: String, + localRootId: String, + ) = FileSyncPair( + id = id, + accountId = accountId, + localRootId = localRootId, + remoteRootPath = "Documents", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) + private fun assertThreadBlocked(thread: Thread) { val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) while ( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStagingRetirementTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStagingRetirementTest.kt new file mode 100644 index 000000000..7cb137398 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStagingRetirementTest.kt @@ -0,0 +1,46 @@ +package dev.obiente.nextcloudnative + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidFileSyncStagingRetirementTest { + @Test + fun `account retirement removes only its crashed staging files`() { + val root = Files.createTempDirectory("sync-staging").toFile() + val removed = "a".repeat(64) + val retained = "b".repeat(64) + try { + val removedRoot = androidFileSyncAccountStagingRoot(root, removed).apply { mkdirs() } + val retainedRoot = androidFileSyncAccountStagingRoot(root, retained).apply { mkdirs() } + val removedStage = java.io.File(removedRoot, "upload-crashed.tmp").apply { writeText("private") } + val retainedStage = java.io.File(retainedRoot, "keep-local-running.tmp").apply { writeText("other") } + + removeAndroidFileSyncAccountStaging(root, removed) + + assertFalse(removedStage.exists()) + assertFalse(removedRoot.exists()) + assertTrue(retainedStage.isFile) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `legacy crash files are reclaimed without entering account directories`() { + val root = Files.createTempDirectory("sync-staging-legacy").toFile() + try { + val legacy = java.io.File(root, "keep-remote-crashed.tmp").apply { writeText("private") } + val retainedRoot = androidFileSyncAccountStagingRoot(root, "c".repeat(64)).apply { mkdirs() } + val retained = java.io.File(retainedRoot, "upload-running.tmp").apply { writeText("other") } + + removeLegacyAndroidFileSyncStaging(root) + + assertFalse(legacy.exists()) + assertTrue(retained.isFile) + } finally { + root.deleteRecursively() + } + } +} 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..8e5b5d1f6 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt @@ -1,8 +1,10 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.MAX_NEXTCLOUD_UPLOAD_CHUNKS +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.NextcloudUploadTransferPlan import dev.obiente.nextcloudnative.app.RemoteFolderSelectionAccess +import dev.obiente.nextcloudnative.app.accountRecord import dev.obiente.nextcloudnative.app.nextcloudUploadTransferPlan import java.nio.file.Files import java.security.MessageDigest @@ -13,8 +15,47 @@ 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 + fun retainedOrUnknownAccountWithUnreadableCredentialsRetriesIncomingShareUpload() { + val retainedSession = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "alice", + appPassword = "fixture-password", + ) + val accountIdentity = NextcloudDocumentIds.accountKey(retainedSession) + + assertTrue( + shouldRetryIncomingShareForMissingSession( + accountIdentity, + AndroidAccountRetentionSnapshot.Available(listOf(retainedSession.accountRecord())), + ), + ) + assertTrue( + shouldRetryIncomingShareForMissingSession( + accountIdentity, + AndroidAccountRetentionSnapshot.Unavailable, + ), + ) + assertFalse( + shouldRetryIncomingShareForMissingSession( + accountIdentity, + AndroidAccountRetentionSnapshot.Available(emptyList()), + ), + ) + assertFalse( + shouldRetryIncomingShareForMissingSession( + accountIdentity, + AndroidAccountRetentionSnapshot.Available( + listOf(retainedSession.copy(loginName = "another-account").accountRecord()), + ), + ), + ) + } + @Test fun staleWorkerTransitionCannotOverwriteCancellation() { val canceled = request(AndroidIncomingShareState.Canceled) @@ -708,6 +749,122 @@ 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 credentiallessRecoveryAbandonsRemoteChunkAndRemovesPrivateStagingOnce() = runBlocking { + val staged = request(AndroidIncomingShareState.Uploading).copy( + chunkSession = AndroidIncomingShareChunkSession( + fileIndex = 0, + targetName = "first.txt", + uploadId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + ) + val events = mutableListOf() + + removeAndroidIncomingShareRequests( + requests = listOf(AndroidIncomingShareAccountRequest(staged.id, staged)), + cancelWork = { events += "cancel" }, + releaseChunk = null, + recordChunkAbandonment = { _, _ -> events += "abandon-remote" }, + removeRequest = { events += "remove" }, + ) + + assertEquals(listOf("cancel", "abandon-remote", "remove"), 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/AndroidIndependentCredentialSlotResetTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt new file mode 100644 index 000000000..40ee694de --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt @@ -0,0 +1,213 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking + +class AndroidIndependentCredentialSlotResetTest { + @Test + fun malformedRegistryResetRecoversEveryBoundedSlotIdentityBeforeDeletion() { + val first = NextcloudSession("https://one.example.test", "alice", "first-secret") + val second = NextcloudSession("https://two.example.test", "bob", "second-secret") + val sessionsByKey = listOf(first, second).associateBy { session -> + androidAccountCredentialSlotKey(session.accountId) + } + val ciphertexts = sessionsByKey.mapValues { (_, session) -> "encrypted-${session.accountId.storageKey}" } + val payloads = sessionsByKey.mapValues { (_, session) -> + encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(session)) + } + + val recovered = recoverAndroidIndependentCredentialSlotsForReset( + preferenceKeys = ciphertexts.keys, + readEncrypted = ciphertexts::get, + decrypt = { encrypted -> + payloads.getValue(ciphertexts.entries.single { entry -> entry.value == encrypted }.key) + }, + ) + + assertEquals( + setOf(first.accountId, second.accountId), + recovered.mapTo(linkedSetOf()) { slot -> slot.session.accountId }, + ) + assertEquals(ciphertexts.keys, recovered.mapTo(linkedSetOf()) { slot -> slot.preferenceKey }) + } + + @Test + fun malformedRegistryResetRejectsMismatchedOrUnreadableSlotIdentity() { + val session = NextcloudSession("https://cloud.example.test", "alice", "secret") + val wrongKey = "$ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX${"f".repeat(64)}" + val payload = encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(session)) + + assertFailsWith { + recoverAndroidIndependentCredentialSlotsForReset( + preferenceKeys = listOf(wrongKey), + readEncrypted = { "encrypted" }, + decrypt = { payload }, + ) + } + assertFailsWith { + recoverAndroidIndependentCredentialSlotsForReset( + preferenceKeys = listOf(androidAccountCredentialSlotKey(session.accountId)), + readEncrypted = { "encrypted" }, + decrypt = { "{malformed" }, + ) + } + } + + @Test + fun recoveredSlotsArePreparedAndJournaledBeforeDeletionAndCleanup() = runBlocking { + val first = resetSlot(NextcloudSession("https://one.example.test", "alice", "first-secret")) + val second = resetSlot(NextcloudSession("https://two.example.test", "bob", "second-secret")) + val events = mutableListOf() + val presentSlots = mutableSetOf(first.preferenceKey, second.preferenceKey) + val tombstones = mutableSetOf() + + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(first, second), + guard = AndroidAccountOperationGuard(), + prepareAccountRemoval = { session -> events += "prepare-${session.loginName}" }, + commitSlotRemoval = { slot, cleanup -> + events += "commit-${slot.session.loginName}" + presentSlots -= slot.preferenceKey + tombstones += cleanup.accountStorageKey + }, + rollbackSlotRemoval = { slot -> presentSlots += slot.preferenceKey }, + removeAccountOwnedState = { session -> + assertFalse(androidAccountCredentialSlotKey(session.accountId) in presentSlots) + assertTrue(session.accountId.storageKey in tombstones) + events += "cleanup-${session.loginName}" + }, + clearCleanup = { accountStorageKey -> tombstones -= accountStorageKey }, + recordCleanupFailure = { error("cleanup must succeed") }, + ) + + assertEquals( + listOf("prepare-alice", "commit-alice", "cleanup-alice", "prepare-bob", "commit-bob", "cleanup-bob"), + events, + ) + assertTrue(presentSlots.isEmpty()) + assertTrue(tombstones.isEmpty()) + } + + @Test + fun cleanupFailureKeepsOnlyItsTombstoneAndDoesNotDropLaterHealthyCleanup() = runBlocking { + val first = resetSlot(NextcloudSession("https://one.example.test", "alice", "first-secret")) + val second = resetSlot(NextcloudSession("https://two.example.test", "bob", "second-secret")) + val tombstones = mutableSetOf() + val removed = mutableSetOf() + val failures = mutableListOf() + + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(first, second), + guard = AndroidAccountOperationGuard(), + prepareAccountRemoval = {}, + commitSlotRemoval = { slot, cleanup -> + removed += slot.preferenceKey + tombstones += cleanup.accountStorageKey + }, + rollbackSlotRemoval = { error("committed slots must not be restored") }, + removeAccountOwnedState = { session -> + if (session == first.session) error("synthetic cleanup failure") + }, + clearCleanup = { accountStorageKey -> tombstones -= accountStorageKey }, + recordCleanupFailure = failures::add, + ) + + assertEquals(setOf(first.preferenceKey, second.preferenceKey), removed) + assertEquals(setOf(first.session.accountId.storageKey), tombstones) + assertEquals(1, failures.size) + } + + @Test + fun cancellationAfterCommittedSlotLeavesRetryTombstoneWithoutResurrection() = runBlocking { + val first = resetSlot(NextcloudSession("https://one.example.test", "alice", "first-secret")) + val second = resetSlot(NextcloudSession("https://two.example.test", "bob", "second-secret")) + val removed = mutableSetOf() + val restored = mutableSetOf() + val tombstones = mutableSetOf() + + assertFailsWith { + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(first, second), + guard = AndroidAccountOperationGuard(), + prepareAccountRemoval = {}, + commitSlotRemoval = { slot, cleanup -> + removed += slot.preferenceKey + tombstones += cleanup.accountStorageKey + }, + rollbackSlotRemoval = { slot -> restored += slot.preferenceKey }, + removeAccountOwnedState = { session -> + if (session == second.session) throw CancellationException("synthetic cancellation") + }, + clearCleanup = { accountStorageKey -> tombstones -= accountStorageKey }, + recordCleanupFailure = { error("cancellation must propagate") }, + ) + } + + assertEquals(setOf(first.preferenceKey, second.preferenceKey), removed) + assertTrue(restored.isEmpty()) + assertEquals(setOf(second.session.accountId.storageKey), tombstones) + } + + @Test + fun rollbackRestoredSlotRetriesPreexistingTombstoneBeforeResettingIt() = runBlocking { + val slot = resetSlot(NextcloudSession("https://one.example.test", "alice", "first-secret")) + val tombstones = mutableSetOf(slot.session.accountId.storageKey) + var retryFails = true + var commitAttempted = false + + assertFailsWith { + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(slot), + preexistingCleanupAccountStorageKeys = tombstones.toSet(), + retryPreexistingCleanup = { + if (retryFails) error("synthetic persisted cleanup failure") + tombstones -= it.session.accountId.storageKey + }, + guard = AndroidAccountOperationGuard(), + prepareAccountRemoval = {}, + commitSlotRemoval = { _, _ -> commitAttempted = true; error("synthetic commit failure") }, + rollbackSlotRemoval = { error("slot must remain untouched") }, + removeAccountOwnedState = { error("cleanup must not start") }, + clearCleanup = { tombstones -= it }, + recordCleanupFailure = { error("cleanup must not start") }, + ) + } + + assertFalse(commitAttempted) + assertEquals(setOf(slot.session.accountId.storageKey), tombstones) + + retryFails = false + assertFailsWith { + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(slot), + preexistingCleanupAccountStorageKeys = tombstones.toSet(), + retryPreexistingCleanup = { tombstones -= it.session.accountId.storageKey }, + guard = AndroidAccountOperationGuard(), + prepareAccountRemoval = {}, + commitSlotRemoval = { _, cleanup -> + commitAttempted = true + tombstones += cleanup.accountStorageKey + error("synthetic slot-removal commit failure") + }, + rollbackSlotRemoval = {}, + removeAccountOwnedState = { error("cleanup must not start") }, + clearCleanup = { tombstones -= it }, + recordCleanupFailure = { error("cleanup must not start") }, + ) + } + assertTrue(commitAttempted) + assertTrue(tombstones.isEmpty()) + } + + private fun resetSlot(session: NextcloudSession) = AndroidIndependentCredentialSlotReset( + preferenceKey = androidAccountCredentialSlotKey(session.accountId), + encrypted = "encrypted-${session.accountId.storageKey}", + session = session, + ) +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanupTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanupTest.kt new file mode 100644 index 000000000..70bbdc230 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanupTest.kt @@ -0,0 +1,75 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidMediaBackupAccountCleanupTest { + @Test + fun cleanupDeletesOnlyTheRemovedAccountsLedgerRowsAndIsIdempotent(): Unit = runBlocking { + val removed = "a".repeat(64) + val retained = "b".repeat(64) + val rows = mutableMapOf(removed to mutableSetOf("removed"), retained to mutableSetOf("retained")) + val cleanup = AndroidMediaBackupAccountCleanup { accountId -> rows.remove(accountId) } + + repeat(2) { cleanup.removeForAccount(removed) } + + assertEquals(mapOf(retained to setOf("retained")), rows) + } + + @Test + fun failedOpenLeavesRowsForJournaledRetry(): Unit = runBlocking { + val removed = "c".repeat(64) + val rows = mutableMapOf(removed to mutableSetOf("pending")) + var failOpen = true + val cleanup = AndroidMediaBackupAccountCleanup { accountId -> + if (failOpen) error("synthetic ledger open failure") + rows.remove(accountId) + } + + assertFailsWith { cleanup.removeForAccount(removed) } + assertTrue(rows[removed] == mutableSetOf("pending")) + failOpen = false + + cleanup.removeForAccount(removed) + + assertTrue(rows.isEmpty()) + } + + @Test + fun accountRemovalWaitsForAStartedLedgerWriterAndDeletesItsResult(): Unit = runBlocking { + val accountId = "d".repeat(64) + val rows = mutableMapOf>() + val guard = AndroidAccountOperationGuard() + val writerStarted = CompletableDeferred() + val finishWriter = CompletableDeferred() + var removalFinished = false + val cleanup = AndroidMediaBackupAccountCleanup { removedAccountId -> rows.remove(removedAccountId) } + val writer = async { + guard.withAccount(accountId) { + writerStarted.complete(Unit) + finishWriter.await() + rows.getOrPut(accountId, ::mutableSetOf).add("late") + } + } + writerStarted.await() + val removal = async(start = CoroutineStart.UNDISPATCHED) { + guard.withAccount(accountId) { + cleanup.removeForAccount(accountId) + removalFinished = true + } + } + + assertFalse(removalFinished) + finishWriter.complete(Unit) + writer.await() + removal.await() + assertTrue(rows.isEmpty()) + } +} 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..fc14f0f07 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -1,164 +1,1189 @@ 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 workerAccountResolutionRecoversALegacyAggregateBeforeEnumeration() { + val session = firstSession() + val registry = NextcloudAccountRegistry.Empty.upsertAndSelect(session.accountRecord()) + var aggregateRecoveryCount = 0 + + val resolved = resolveStoredAndroidAccountSession( + accountIdentity = NextcloudDocumentIds.accountKey(session), + listAccounts = { + recoverAndroidCredentialFreeRegistryForCredentialLoad(restored = null) { + aggregateRecoveryCount += 1 + registry + }?.accounts.orEmpty() + }, + loadSession = { accountId -> session.takeIf { it.accountId == accountId } }, + ) + + assertEquals(session, resolved) + assertEquals(1, aggregateRecoveryCount) + } + + @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 }) + assertEquals("failed", diagnostics.single().outcome) + 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 }) + assertEquals("failed", diagnostics.single().outcome) + 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")) + assertEquals("recovered", diagnostics.single().outcome) + 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 }) + assertEquals("unsupported", diagnostics.single().outcome) + 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 }, + ) + assertEquals("unsupported", diagnostics.single().outcome) + 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) + assertEquals("failed", diagnostic.outcome) 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 explicitResetDiscardsMalformedRegistryButPreservesFutureVersionState() { + assertTrue( + androidIndependentCredentialStateCanBeExplicitlyReset( + restoreAndroidCredentialFreeRegistry("{not-json"), + ), + ) + assertFalse( + androidIndependentCredentialStateCanBeExplicitlyReset( + restoreAndroidCredentialFreeRegistry("""{"version":99,"accounts":[]}"""), + ), ) + assertTrue(androidIndependentCredentialStateCanBeExplicitlyReset(null)) + } - assertEquals("alice", session.loginName) - assertEquals(listOf("ACCOUNT_REGISTRY_MIGRATION_FAILED"), diagnostics.mapNotNull { it.code }) + @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(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), + ) + }, + ) - private companion object { - const val ACCOUNT_REGISTRY_KEY = "account_registry_v1" + 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(NextcloudDocumentIds.cacheAccountId(original), pending.previewCacheIdentity) + assertFalse(pending.previewCacheIdentity == NextcloudDocumentIds.cacheAccountId(replacement)) + assertEquals(original.accountId.storageKey, pending.accountStorageKey) + } + + @Test + fun malformedPendingCleanupRowsAreIsolatedFromValidRecoveryWork() { + val valid = pendingAndroidAccountRemovalCleanup(firstSession()) + + val restored = restoreAndroidPendingAccountRemovalCleanups( + setOf(encodeAndroidPendingAccountRemovalCleanup(valid), "truncated-row"), + ) + + assertEquals(setOf(valid), restored.cleanups) + assertEquals(1, restored.malformedEntryCount) + } + + @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(), second.accountRecord()), restored.registry.accounts) + assertEquals(first, restored.activeSession) + + val roundTrip = decodeAndroidAccountCredentialState(encodeAndroidAccountCredentialState(restored)).state + assertEquals(restored, roundTrip) + } + + @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 corruptActiveCredentialSlotCanBeRemovedWithoutDroppingOtherAccounts() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + + val recovered = reconstructAndroidAccountCredentialStateForRemoval( + registry = registry, + accountId = second.accountId, + loadSession = { accountId -> first.takeIf { accountId == first.accountId } }, + ) + + val afterRemoval = requireNotNull(recovered).remove(second.accountId) + assertNull(afterRemoval.activeSession) + assertEquals(listOf(first.accountRecord()), afterRemoval.registry.accounts) + assertEquals(mapOf(first.accountId to first), afterRemoval.sessions) + } + + @Test + fun corruptActiveCredentialSlotCannotAuthorizeRemovingAnotherAccount() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + + val recovered = reconstructAndroidAccountCredentialStateForRemoval( + registry = registry, + accountId = first.accountId, + loadSession = { accountId -> first.takeIf { accountId == first.accountId } }, + ) + + assertNull(recovered) + } + + @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" }, + ) + } + + assertEquals(listOf("commit-transition"), events) + } + + @Test + fun accountTransitionPersistsHandoffCleanupBeforeItCanCommit() { + val writes = linkedMapOf() + + prepareAndroidExternalHandoffCleanup(recoveryRecordingEditor(writes, linkedSetOf())) + + assertEquals("pending", writes[ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY]) + } + + @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 failedUnavailableAccountRemovalRestoresRecoveredStateBeforeClearingCleanup() = runBlocking { + val recovered = AndroidAccountCredentialState.Empty + .upsertAndSelect(firstSession()) + .upsertAndSelect(secondSession()) + val removed = recovered.remove(firstSession().accountId) + var persisted = recovered + val events = mutableListOf() + + assertFailsWith { + removeAndroidAccountCredentialData( + active = false, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { + persisted = removed + events += "persist-removal" + error("synthetic commit result failure") + }, + rollbackInactiveRemoval = { + rollbackUnavailableAndroidAccountRemoval( + recovered = recovered, + persistRecovered = { state -> + persisted = state + events += "restore-recovered" + }, + clearCleanup = { events += "clear-cleanup" }, + ) + }, + ) + } + + assertEquals(recovered, persisted) + assertEquals( + listOf("persist-removal", "restore-recovered", "clear-cleanup"), + 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..b0d6c7465 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt @@ -1,8 +1,15 @@ package dev.obiente.nextcloudnative +import java.io.IOException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +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.assertTrue class NextcloudDocumentsContractTest { @Test @@ -21,4 +28,101 @@ 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) + } + + @Test + fun `remote revocation ambiguity still completes local removal`() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + revokeAndroidSessionAfterRemovalPreflight( + preflight = { events += "preflight" }, + revoke = { + events += "revoke" + throw IOException("synthetic ambiguous response") + }, + removeLocalAccount = { events += "remove-local" }, + ) + } + + assertEquals(listOf("preflight", "revoke", "remove-local"), events) + } + + @Test + fun `cancellation after remote revocation starts still completes local removal`() = runBlocking { + val revokeStarted = CompletableDeferred() + val removalCompleted = CompletableDeferred() + + val operation = launch { + revokeAndroidSessionAfterRemovalPreflight( + preflight = {}, + revoke = { + revokeStarted.complete(Unit) + awaitCancellation() + }, + removeLocalAccount = { removalCompleted.complete(Unit) }, + ) + } + revokeStarted.await() + operation.cancel() + operation.join() + + assertTrue(operation.isCancelled) + assertTrue(removalCompleted.isCompleted) + } } 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/changes/unreleased/436-deck-legacy-draft-recovery.md b/changes/unreleased/436-deck-legacy-draft-recovery.md new file mode 100644 index 000000000..36d2e851d --- /dev/null +++ b/changes/unreleased/436-deck-legacy-draft-recovery.md @@ -0,0 +1,7 @@ +category: fix +issue: none +pull: 436 +platforms: android, desktop +user-facing: yes + +Preserve replacement Deck drafts and prevent submitted drafts from reappearing after failed legacy cleanup. Explicit discard clears only the selected account's draft without requiring its encryption key. diff --git a/changes/unreleased/436-displaced-read-account-fence.md b/changes/unreleased/436-displaced-read-account-fence.md new file mode 100644 index 000000000..2294d537a --- /dev/null +++ b/changes/unreleased/436-displaced-read-account-fence.md @@ -0,0 +1,7 @@ +category: security +issue: none +pull: 436 +platforms: android, desktop +user-facing: yes + +Account removal now stops older dynamic reads even when a newer request replaced them during refresh, preventing retries with retired credentials after sign-in. diff --git a/changes/unreleased/desktop-credential-rollback-completion.md b/changes/unreleased/desktop-credential-rollback-completion.md new file mode 100644 index 000000000..f06037c76 --- /dev/null +++ b/changes/unreleased/desktop-credential-rollback-completion.md @@ -0,0 +1,7 @@ +category: fix +issue: 172 +pull: 436 +platforms: desktop +user-facing: yes + +Record successful desktop credential rollback before deleting its recovery secret, so interrupted cleanup can retry without locking accounts out. diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCache.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCache.kt index b7cdd563b..8639db591 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCache.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCache.kt @@ -8,6 +8,8 @@ import java.io.EOFException import java.io.File import java.io.FileInputStream import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.LinkOption import java.security.MessageDigest data class CachedDynamicApiResponse( @@ -109,7 +111,26 @@ class DynamicApiResponseCache( @Synchronized fun invalidateAccount(accountId: String) { requireAccountId(accountId) - accountDirectory(accountId).deleteRecursively() + val directory = accountDirectory(accountId) + val path = directory.toPath() + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return + check(Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(path)) { + "The dynamic API response cache account path is unsafe." + } + val entries = checkNotNull(directory.listFiles()) { + "Could not read the dynamic API response cache account directory." + } + entries.forEach { entry -> + check(Files.isRegularFile(entry.toPath(), LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(entry.toPath())) { + "The dynamic API response cache contains an unsafe entry." + } + check(entry.delete() && !Files.exists(entry.toPath(), LinkOption.NOFOLLOW_LINKS)) { + "Could not delete a dynamic API response cache entry." + } + } + check(directory.delete() && !Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + "Could not delete the dynamic API response cache account directory." + } } @Synchronized diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCacheTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCacheTest.kt index 70f4ffc7f..faf16b8a2 100644 --- a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCacheTest.kt +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCacheTest.kt @@ -4,6 +4,7 @@ import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull class DynamicApiResponseCacheTest { @@ -53,6 +54,21 @@ class DynamicApiResponseCacheTest { } } + @Test + fun accountInvalidationFailsClosedForAnUnsafeAccountPath() { + val root = Files.createTempDirectory("ncn-dynamic-api-cache-").toFile() + try { + root.resolve(account).writeText("not a cache directory") + + assertFailsWith { + DynamicApiResponseCache(root).invalidateAccount(account) + } + assertEquals("not a cache directory", root.resolve(account).readText()) + } finally { + root.deleteRecursively() + } + } + @Test fun requestInvalidationPreservesOtherCachedResponses() { val root = Files.createTempDirectory("ncn-dynamic-api-cache-").toFile() diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 108a1c5d0..7d0074d62 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,21 +1,21 @@ androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt|851 -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4241 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4230 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|1003 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|995 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1224 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1883 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirerTest.kt|1798 ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/AndroidCompatibilityVideoPlaybackService.kt|808 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityWorkspace.kt|1157 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt|919 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt|1838 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt|1809 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeActions.kt|1158 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2512 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt|2600 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt|973 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt|1386 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt|567 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt|1111 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt|1070 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt|1107 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt|1293 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt|2646 @@ -23,9 +23,9 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingDynamicUiCaptu ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt|1244 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface.kt|1114 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt|1234 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1892 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1883 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12432 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12348 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1724 @@ -53,11 +53,11 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.k ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt|884 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|883 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStore.kt|808 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6269 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2762 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1731 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6236 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2759 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1085 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt|2373 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt|2355 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCacheTest.kt|2479 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt|2890 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystemTest.kt|2149 diff --git a/ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.android.kt b/ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.android.kt index cc9217617..db7201520 100644 --- a/ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.android.kt +++ b/ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.android.kt @@ -42,6 +42,23 @@ internal actual fun rememberHomeWorkspaceLayoutStorage(): HomeWorkspaceLayoutSto } } +fun removeAndroidHomeWorkspaceAccountPreferences( + context: Context, + accountScopeDigest: String, + legacyAccountScopeDigest: String?, +) { + val preferences = context.applicationContext.getSharedPreferences( + HOME_WORKSPACE_PREFERENCES, + Context.MODE_PRIVATE, + ) + val keys = homeWorkspaceAccountPersistenceKeys(accountScopeDigest, legacyAccountScopeDigest) + synchronized(ANDROID_HOME_WORKSPACE_STORAGE_LOCK) { + val editor = preferences.edit() + keys.forEach(editor::remove) + check(editor.commit()) { "The home workspace account settings could not be removed." } + } +} + @Composable internal actual fun rememberHomeFormFactor(): HomeFormFactor { val configuration = LocalConfiguration.current diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt new file mode 100644 index 000000000..2c2d3ffe7 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt @@ -0,0 +1,21 @@ +package dev.obiente.nextcloudnative.app + +/** Removes process-local private state after credential removal has committed. */ +object AccountPrivateMemoryCleanup { + fun removeAccount(accountStorageKey: String) = AccountPrivateMemoryLifecycle.retireAccount(accountStorageKey) + + internal fun purgeRetiredAccount(accountStorageKey: String) { + require(accountStorageKey.length == 64 && accountStorageKey.all { it in '0'..'9' || it in 'a'..'f' }) + PreviewMemoryCache.purgeRetiredAccount(accountStorageKey) + sharedNextcloudNotesCache.purgeRetiredAccount(accountStorageKey) + sharedDynamicNativeMemoryCache.retireAccount(accountStorageKey) + sharedDashboardStatusMemoryCache.purgeRetiredAccount(accountStorageKey) + ContactsWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) + DeckWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) + sharedDocumentEditingCapabilitiesCache.purgeRetiredAccount(accountStorageKey) + SupportSettingsDraftRegistry.removeAccount(accountStorageKey) + removeCalendarWorkspaceMemory(accountStorageKey) + removeUserStatusWorkspaceMemory(accountStorageKey) + removeNextcloudNativeWorkspaceMemory(accountStorageKey) + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt new file mode 100644 index 000000000..9cccb8d17 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt @@ -0,0 +1,67 @@ +package dev.obiente.nextcloudnative.app + +/** One account incarnation allowed to publish into process-local private-memory stores. */ +internal class AccountPrivateMemoryProducer internal constructor( + val accountStorageKey: String, + internal val incarnation: Long, +) + +/** Serializes private-memory access with retirement and rejects stale async producers. */ +internal class AccountPrivateMemoryGate { + private val lock = DynamicNativeMemoryCacheLock() + private val closedAccounts = mutableSetOf() + private val accountIncarnations = mutableMapOf() + + fun producer(accountStorageKey: String): AccountPrivateMemoryProducer? = lock.withLock { + if (accountStorageKey in closedAccounts) return@withLock null + AccountPrivateMemoryProducer(accountStorageKey, accountIncarnations[accountStorageKey] ?: 0L) + } + + fun read(accountStorageKey: String, unavailable: T, action: () -> T): T = lock.withLock { + if (accountStorageKey in closedAccounts) unavailable else action() + } + + fun mutate( + accountStorageKey: String, + producer: AccountPrivateMemoryProducer?, + action: () -> Unit, + ): Boolean = lock.withLock { + val current = producer ?: return@withLock false + require(current.accountStorageKey == accountStorageKey) { + "The private-memory producer belongs to another account." + } + if (!accepts(current)) return@withLock false + action() + true + } + + fun retireAccount(accountStorageKey: String, purge: () -> Unit) = lock.withLock { + if (closedAccounts.add(accountStorageKey)) { + accountIncarnations[accountStorageKey] = (accountIncarnations[accountStorageKey] ?: 0L) + 1L + } + purge() + } + + fun activateAccount(accountStorageKey: String, prepare: () -> Unit = {}) = lock.withLock { + prepare() + closedAccounts.remove(accountStorageKey) + } + + private fun accepts(producer: AccountPrivateMemoryProducer): Boolean = + producer.accountStorageKey !in closedAccounts && + (accountIncarnations[producer.accountStorageKey] ?: 0L) == producer.incarnation +} + +internal val sharedAccountPrivateMemoryGate = AccountPrivateMemoryGate() + +/** Cross-platform lifecycle boundary for account-private process memory. */ +object AccountPrivateMemoryLifecycle { + fun retireAccount(accountStorageKey: String) = sharedAccountPrivateMemoryGate.retireAccount(accountStorageKey) { + AccountPrivateMemoryCleanup.purgeRetiredAccount(accountStorageKey) + } + + fun activateAccount(accountStorageKey: String) = sharedAccountPrivateMemoryGate.activateAccount( + accountStorageKey, + prepare = { sharedDynamicNativeMemoryCache.activateAccount(accountStorageKey) }, + ) +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt new file mode 100644 index 000000000..1bb7c3010 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt @@ -0,0 +1,227 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.mutableStateOf + +internal class PhotoTimelineUiState { + val timeline = mutableStateOf(PhotoTimelineState(pageSize = MAX_PHOTO_TIMELINE_PAGE_SIZE)) + val backupStatuses = mutableStateOf>(emptyMap()) + val initialLoadCompleted = mutableStateOf(false) +} + +internal object PhotoTimelineUiStateRepository { + private const val MAXIMUM_ACCOUNT_STATES = 4 + private val accountStates = linkedMapOf() + + fun stateFor(session: NextcloudSession): PhotoTimelineUiState { + val accountKey = previewCacheDigest(session) + accountStates.remove(accountKey)?.let { existing -> + accountStates[accountKey] = existing + return existing + } + val created = PhotoTimelineUiState() + accountStates[accountKey] = created + while (accountStates.size > MAXIMUM_ACCOUNT_STATES) accountStates.remove(accountStates.keys.first()) + return created + } + + fun removeAccount(accountStorageKey: String) { + accountStates.remove(accountStorageKey) + } +} + +internal sealed interface CalendarLoadState { + data object Loading : CalendarLoadState + data class Ready( + val month: CalendarMonth, + val timeWindow: GroupwareDavTimeWindow, + val calendars: List, + val events: List, + ) : CalendarLoadState + data class Error(val message: String) : CalendarLoadState +} + +internal object CalendarWorkspaceMemoryCache { + private val gate = sharedAccountPrivateMemoryGate + private val entries = linkedMapOf, CalendarLoadState.Ready>() + + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun get( + session: NextcloudSession, + userId: String, + month: CalendarMonth, + timeWindow: GroupwareDavTimeWindow, + ): CalendarLoadState.Ready? = gate.read(session.accountId.storageKey, null) { + val key = key(session, userId, month, timeWindow) + entries.remove(key)?.also { entries[key] = it } + } + + fun store( + session: NextcloudSession, + userId: String, + value: CalendarLoadState.Ready, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + val key = key(session, userId, value.month, value.timeWindow) + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_CALENDAR_MONTHS) entries.remove(entries.keys.first()) + } + } + + internal fun purgeRetiredAccount(accountStorageKey: String) { + entries.keys.removeAll { (account) -> account.storageKey == accountStorageKey } + } + + private fun key( + session: NextcloudSession, + userId: String, + month: CalendarMonth, + timeWindow: GroupwareDavTimeWindow, + ): Pair = session.accountId to + "$userId\n${month.year}-${month.month}\n${timeWindow.startUtc}-${timeWindow.endUtc}" +} + +internal sealed interface UserStatusSurfaceState { + data object Loading : UserStatusSurfaceState + data class Available( + val capabilities: NativeUserStatusCapabilities, + val status: NativeUserStatus, + val predefined: List, + ) : UserStatusSurfaceState + data class Failed(val message: String) : UserStatusSurfaceState +} + +internal object UserStatusWorkspaceMemoryCache { + private val gate = sharedAccountPrivateMemoryGate + private val entries = linkedMapOf() + + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun get(session: NextcloudSession): UserStatusSurfaceState.Available? = + gate.read(session.accountId.storageKey, null) { + val key = session.accountId + entries.remove(key)?.also { entries[key] = it } + } + + fun store( + session: NextcloudSession, + value: UserStatusSurfaceState.Available, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + val key = session.accountId + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_STATUS_ACCOUNTS) entries.remove(entries.keys.first()) + } + } + + internal fun purgeRetiredAccount(accountStorageKey: String) { + entries.keys.removeAll { account -> account.storageKey == accountStorageKey } + } +} + +internal object ActivityWorkspaceMemoryCache { + private val gate = sharedAccountPrivateMemoryGate + private val entries = linkedMapOf, ActivityTimelineState>() + + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun get(session: NextcloudSession, filterId: String): ActivityTimelineState? = + gate.read(session.accountId.storageKey, null) { + val key = session.accountId to filterId + entries.remove(key)?.also { entries[key] = it } + } + + fun store( + session: NextcloudSession, + filterId: String, + value: ActivityTimelineState, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + val key = session.accountId to filterId + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_ACTIVITY_ACCOUNTS) entries.remove(entries.keys.first()) + } + } + + internal fun purgeRetiredAccount(accountStorageKey: String) { + entries.keys.removeAll { (account) -> account.storageKey == accountStorageKey } + } +} + +internal object TalkWorkspaceMemoryCache { + private val gate = sharedAccountPrivateMemoryGate + private val rooms = linkedMapOf>() + private val messages = linkedMapOf, List>() + + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun rooms(session: NextcloudSession): List? = gate.read(session.accountId.storageKey, null) { + touch(rooms, session.accountId) + } + + fun storeRooms( + session: NextcloudSession, + value: List, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + store(rooms, session.accountId, value, MAXIMUM_RETAINED_TALK_ACCOUNTS) + } + } + + fun messages(session: NextcloudSession, roomToken: String): List? = + gate.read(session.accountId.storageKey, null) { touch(messages, session.accountId to roomToken) } + + fun storeMessages( + session: NextcloudSession, + roomToken: String, + value: List, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + store(messages, session.accountId to roomToken, value, MAXIMUM_RETAINED_TALK_ROOMS) + } + } + + internal fun purgeRetiredAccount(accountStorageKey: String) { + rooms.keys.removeAll { account -> account.storageKey == accountStorageKey } + messages.keys.removeAll { (account) -> account.storageKey == accountStorageKey } + } + + private fun touch(entries: LinkedHashMap, key: Key): T? = + entries.remove(key)?.also { entries[key] = it } + + private fun store(entries: LinkedHashMap, key: Key, value: T, maximum: Int) { + entries.remove(key) + entries[key] = value + while (entries.size > maximum) entries.remove(entries.keys.first()) + } +} + +internal fun removeCalendarWorkspaceMemory(accountStorageKey: String) = + CalendarWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) + +internal fun removeUserStatusWorkspaceMemory(accountStorageKey: String) = + UserStatusWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) + +internal fun removeNextcloudNativeWorkspaceMemory(accountStorageKey: String) { + PhotoTimelineUiStateRepository.removeAccount(accountStorageKey) + ActivityWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) + TalkWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) +} + +private const val MAXIMUM_RETAINED_CALENDAR_MONTHS = 24 +private const val MAXIMUM_RETAINED_STATUS_ACCOUNTS = 4 +private const val MAXIMUM_RETAINED_ACTIVITY_ACCOUNTS = 4 +private const val MAXIMUM_RETAINED_TALK_ACCOUNTS = 4 +private const val MAXIMUM_RETAINED_TALK_ROOMS = 16 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt index a320af6f6..2db11ea45 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt @@ -72,9 +72,10 @@ internal fun ChatScreen( val historyHeaderVisible = (hasMoreHistory && olderCursor != null) || historyError != null suspend fun refresh() { + val cacheProducer = TalkWorkspaceMemoryCache.producer(session) val page = services.listTalkMessagePage(session, room.token) messages = page.messages - TalkWorkspaceMemoryCache.storeMessages(session, room.token, page.messages) + TalkWorkspaceMemoryCache.storeMessages(session, room.token, page.messages, cacheProducer) olderCursor = page.olderCursor hasMoreHistory = page.hasMoreHistory } @@ -154,6 +155,7 @@ internal fun ChatScreen( loadingEarlier = true historyError = null scope.launch { + val cacheProducer = TalkWorkspaceMemoryCache.producer(session) runCatchingUnlessCancelled { services.listTalkMessagePage( session = session, @@ -169,6 +171,7 @@ internal fun ChatScreen( session, room.token, messages.orEmpty(), + cacheProducer, ) olderCursor = page.olderCursor hasMoreHistory = page.hasMoreHistory diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt index d9168ff01..d07f798b8 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt @@ -665,44 +665,6 @@ fun planUserStatusEdit( return request.requireSafe() } -data class CachedDashboardStatus( - val dashboard: NativeDashboardSnapshot, - val status: NativeUserStatus?, - val storedAtEpochSeconds: Long, -) - -/** Account-private process cache. It stores no password and expires quickly. */ -internal class DashboardStatusMemoryCache( - private val ttlSeconds: Long = DASHBOARD_STATUS_CACHE_TTL_SECONDS, -) { - private val entries = mutableMapOf() - - fun get(session: NextcloudSession, nowEpochSeconds: Long): CachedDashboardStatus? { - val entry = entries[session.accountId] ?: return null - return entry.takeIf { - nowEpochSeconds >= it.storedAtEpochSeconds && - nowEpochSeconds - it.storedAtEpochSeconds <= ttlSeconds - } ?: run { - entries.remove(session.accountId) - null - } - } - - fun store( - session: NextcloudSession, - dashboard: NativeDashboardSnapshot, - status: NativeUserStatus?, - nowEpochSeconds: Long, - ) { - require(nowEpochSeconds >= 0L) { "The dashboard cache timestamp is invalid." } - entries[session.accountId] = CachedDashboardStatus(dashboard, status, nowEpochSeconds) - } - - fun invalidate(session: NextcloudSession) { - entries.remove(session.accountId) - } -} - internal fun retainedDashboardRefreshSnapshot( cached: CachedDashboardStatus?, displayed: NativeDashboardSnapshot?, @@ -727,8 +689,6 @@ internal fun DashboardResponseBudget.settleFailedRead( } } -internal val sharedDashboardStatusMemoryCache = DashboardStatusMemoryCache() - private fun statusMutationRequest( method: NextcloudApiMethod, path: String, @@ -909,5 +869,5 @@ private const val MAX_PREDEFINED_STATUSES = 128 private const val MAX_STATUS_MESSAGE_LENGTH = 512 private const val MAX_STATUS_ICON_LENGTH = 32 private const val MAX_STATUS_EXPIRY_SECONDS = 366L * 24L * 60L * 60L -private const val DASHBOARD_STATUS_CACHE_TTL_SECONDS = 60L +internal const val DASHBOARD_STATUS_CACHE_TTL_SECONDS = 60L private const val STATUS_HEX = "0123456789ABCDEF" diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt new file mode 100644 index 000000000..9396f3362 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt @@ -0,0 +1,54 @@ +package dev.obiente.nextcloudnative.app + +data class CachedDashboardStatus( + val dashboard: NativeDashboardSnapshot, + val status: NativeUserStatus?, + val storedAtEpochSeconds: Long, +) + +/** Account-private process cache. It stores no password and expires quickly. */ +internal class DashboardStatusMemoryCache( + private val ttlSeconds: Long = DASHBOARD_STATUS_CACHE_TTL_SECONDS, + private val gate: AccountPrivateMemoryGate = AccountPrivateMemoryGate(), +) { + private val entries = mutableMapOf() + + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun get(session: NextcloudSession, nowEpochSeconds: Long): CachedDashboardStatus? = + gate.read(session.accountId.storageKey, null) { + val entry = entries[session.accountId] ?: return@read null + entry.takeIf { cached -> + nowEpochSeconds >= cached.storedAtEpochSeconds && + nowEpochSeconds - cached.storedAtEpochSeconds <= ttlSeconds + } ?: run { + entries.remove(session.accountId) + null + } + } + + fun store( + session: NextcloudSession, + dashboard: NativeDashboardSnapshot, + status: NativeUserStatus?, + nowEpochSeconds: Long, + producer: AccountPrivateMemoryProducer?, + ) { + require(nowEpochSeconds >= 0L) { "The dashboard cache timestamp is invalid." } + gate.mutate(session.accountId.storageKey, producer) { + entries[session.accountId] = CachedDashboardStatus(dashboard, status, nowEpochSeconds) + } + } + + fun invalidate(session: NextcloudSession) { + gate.read(session.accountId.storageKey, Unit) { entries.remove(session.accountId) } + } + + internal fun purgeRetiredAccount(accountStorageKey: String) { + entries.keys.removeAll { account -> account.storageKey == accountStorageKey } + } +} + +internal val sharedDashboardStatusMemoryCache = + DashboardStatusMemoryCache(gate = sharedAccountPrivateMemoryGate) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt index 58b5e8d26..ded33e7a0 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt @@ -444,6 +444,7 @@ internal fun rememberNativeDashboardState( val cached = sharedDashboardStatusMemoryCache.get(session, now) val previousSnapshot = retainedDashboardRefreshSnapshot(cached, displayed?.snapshot) val previousStatus = cached?.status ?: displayed?.status + val cacheProducer = sharedDashboardStatusMemoryCache.producer(session) val cachePolicy = if (refreshAttempt > 0 || recoveryAttempt > 0) { NextcloudApiCachePolicy.RefreshNetwork } else { @@ -510,7 +511,6 @@ internal fun rememberNativeDashboardState( loadingWidgetIds = pendingWidgetIds, ) state = DashboardSurfaceState.Available(snapshot, previousStatus) - val completedResults = Channel(capacity = plans.size) val requestLimiter = Semaphore(MAX_CONCURRENT_DASHBOARD_ITEM_REQUESTS) val responseBudget = DashboardResponseBudget() @@ -625,6 +625,7 @@ internal fun rememberNativeDashboardState( dashboard = result.snapshot, status = result.status, nowEpochSeconds = currentDashboardEpochSeconds(), + producer = cacheProducer, ) } state = DashboardSurfaceState.Available( @@ -1397,34 +1398,6 @@ private fun DashboardFailure(message: String, onRetry: () -> Unit) { } } -private sealed interface UserStatusSurfaceState { - data object Loading : UserStatusSurfaceState - data class Available( - val capabilities: NativeUserStatusCapabilities, - val status: NativeUserStatus, - val predefined: List, - ) : UserStatusSurfaceState - data class Failed(val message: String) : UserStatusSurfaceState -} - -private object UserStatusWorkspaceMemoryCache { - private val entries = linkedMapOf() - - fun get(session: NextcloudSession): UserStatusSurfaceState.Available? { - val key = key(session) - return entries.remove(key)?.also { entries[key] = it } - } - - fun store(session: NextcloudSession, value: UserStatusSurfaceState.Available) { - val key = key(session) - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_STATUS_ACCOUNTS) entries.remove(entries.keys.first()) - } - - private fun key(session: NextcloudSession): NextcloudAccountId = session.accountId -} - private enum class StatusExpiryChoice(val label: String, val seconds: Long?) { Never("No expiry", null), OneHour("1 hour", 60L * 60L), @@ -1458,8 +1431,8 @@ internal fun NativeUserStatusScreen( var mutationInProgress by remember(session) { mutableStateOf(false) } var mutationError by remember(session) { mutableStateOf(null) } val scope = rememberCoroutineScope() - LaunchedEffect(session, refreshAttempt) { + val cacheProducer = UserStatusWorkspaceMemoryCache.producer(session) val cached = UserStatusWorkspaceMemoryCache.get(session) if (cached != null) state = cached val retained = cached ?: state as? UserStatusSurfaceState.Available @@ -1493,7 +1466,7 @@ internal fun NativeUserStatusScreen( } }.onSuccess { loaded -> state = loaded - UserStatusWorkspaceMemoryCache.store(session, loaded) + UserStatusWorkspaceMemoryCache.store(session, loaded, cacheProducer) if (!draftInitialized) { customMessage = loaded.status.message.orEmpty() customIcon = loaded.status.icon.orEmpty().takeIf { @@ -1511,7 +1484,6 @@ internal fun NativeUserStatusScreen( } refreshing = false } - Column(modifier = Modifier.fillMaxSize()) { DashboardHeader( title = "User Status", @@ -1778,7 +1750,6 @@ internal fun NativeUserStatusScreen( } } -private const val MAXIMUM_RETAINED_STATUS_ACCOUNTS = 4 @Composable private fun CurrentUserStatusCard(status: NativeUserStatus) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt index 135eeca99..0cf769ad8 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt @@ -13,20 +13,34 @@ internal data class DeckWorkspaceMemorySnapshot( ) internal object DeckWorkspaceMemoryCache { + private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf() - fun get(session: NextcloudSession): DeckWorkspaceMemorySnapshot? { + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun get(session: NextcloudSession): DeckWorkspaceMemorySnapshot? = + gate.read(session.accountId.storageKey, null) { val key = key(session) - return entries.remove(key)?.also { entries[key] = it } + entries.remove(key)?.also { entries[key] = it } } - fun store(session: NextcloudSession, value: DeckWorkspaceMemorySnapshot) { - val key = key(session) - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_DECK_ACCOUNTS) entries.remove(entries.keys.first()) + fun store( + session: NextcloudSession, + value: DeckWorkspaceMemorySnapshot, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + val key = key(session) + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_DECK_ACCOUNTS) entries.remove(entries.keys.first()) + } + } + + internal fun purgeRetiredAccount(accountStorageKey: String) { + entries.remove(accountStorageKey) } - private fun key(session: NextcloudSession): String = - "${session.serverUrl.trimEnd('/')}\n${session.loginName}" + private fun key(session: NextcloudSession): String = session.accountId.storageKey } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt index 086154e83..d6f2fa219 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt @@ -1,8 +1,11 @@ package dev.obiente.nextcloudnative.app import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext /** * Coalesces identical authenticated reads without allowing account or request-generation reuse. @@ -15,14 +18,21 @@ import kotlinx.coroutines.sync.withLock class DynamicApiRequestCoalescer { private data class Key(val accountId: String, val requestIdentity: String) - private data class InFlight( + private class ReadLifetime { + var fenced = false + } + + private class InFlight( val accountGeneration: Long, val requestGeneration: Long, val result: CompletableDeferred, + val lifetime: ReadLifetime, ) private val mutex = Mutex() private val accountGenerations = mutableMapOf() + private val closedAccounts = mutableSetOf() + private val activeReads = mutableMapOf>() private val requestGenerations = mutableMapOf() private val inFlight = mutableMapOf>() @@ -31,15 +41,42 @@ class DynamicApiRequestCoalescer { requestIdentity: String, load: suspend () -> T, commit: (T) -> Unit = {}, + ): T { + val lifetime = mutex.withLock { + if (accountId in closedAccounts) throw DynamicReadAccountFencedException() + ReadLifetime().also { activeReads.getOrPut(accountId, ::mutableSetOf).add(it) } + } + try { + return executeReads(accountId, requestIdentity, load, commit, lifetime) + } finally { + withContext(NonCancellable) { + mutex.withLock { + val reads = activeReads[accountId] + reads?.remove(lifetime) + if (reads?.isEmpty() == true) activeReads.remove(accountId) + } + } + } + } + + private suspend fun executeReads( + accountId: String, + requestIdentity: String, + load: suspend () -> T, + commit: (T) -> Unit, + lifetime: ReadLifetime, ): T { while (true) { val key = Key(accountId, requestIdentity) var owner = false val entry = mutex.withLock { - inFlight[key] ?: InFlight( - accountGeneration = accountGenerations[accountId] ?: 0L, + if (lifetime.fenced || accountId in closedAccounts) throw DynamicReadAccountFencedException() + val accountGeneration = accountGenerations[accountId] ?: 0L + inFlight[key]?.takeIf { current -> current.accountGeneration == accountGeneration } ?: InFlight( + accountGeneration = accountGeneration, requestGeneration = requestGenerations[key] ?: 0L, result = CompletableDeferred(), + lifetime = lifetime, ).also { inFlight[key] = it owner = true @@ -47,55 +84,57 @@ class DynamicApiRequestCoalescer { } if (!owner) { try { - return entry.result.await() + val loaded = entry.result.await() + return mutex.withLock { + if (lifetime.fenced) throw DynamicReadAccountFencedException() + loaded + } } catch (_: DynamicReadInvalidatedException) { continue } } - val loaded = try { - load() - } catch (failure: Throwable) { - val invalidated = mutex.withLock { - val entryWasInvalidated = - (accountGenerations[accountId] ?: 0L) != entry.accountGeneration || - (requestGenerations[key] ?: 0L) != entry.requestGeneration - inFlight.remove(key, entry) - if (entryWasInvalidated) { - entry.result.completeExceptionally(DynamicReadInvalidatedException()) - } else { - entry.result.completeExceptionally(failure) - retireRequestGenerationIfIdle(key, entry.requestGeneration) + try { + val loaded = try { + load() + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + val invalidation = mutex.withLock { + val cause = invalidationCause(accountId, key, entry) + inFlight.remove(key, entry) + entry.result.completeExceptionally( + if (cause == InvalidationCause.None) failure else cause.exception(), + ) + cause } - entryWasInvalidated + if (invalidation == InvalidationCause.Invalidated) continue + if (invalidation == InvalidationCause.Fenced) throw DynamicReadAccountFencedException() + throw failure } - if (invalidated) continue - throw failure - } - val accepted = mutex.withLock { - if ( - (accountGenerations[accountId] ?: 0L) != entry.accountGeneration || - (requestGenerations[key] ?: 0L) != entry.requestGeneration - ) { - inFlight.remove(key, entry) - entry.result.completeExceptionally(DynamicReadInvalidatedException()) - false - } else { - try { + val invalidation = mutex.withLock { + val cause = invalidationCause(accountId, key, entry) + if (cause != InvalidationCause.None) { + entry.result.completeExceptionally(cause.exception()) + } else { commit(loaded) - inFlight.remove(key, entry) entry.result.complete(loaded) - retireRequestGenerationIfIdle(key, entry.requestGeneration) - true - } catch (failure: Throwable) { + } + inFlight.remove(key, entry) + cause + } + if (invalidation == InvalidationCause.None) return loaded + if (invalidation == InvalidationCause.Fenced) throw DynamicReadAccountFencedException() + } catch (failure: Throwable) { + entry.result.completeExceptionally(failure) + throw failure + } finally { + withContext(NonCancellable) { + mutex.withLock { inFlight.remove(key, entry) - entry.result.completeExceptionally(failure) retireRequestGenerationIfIdle(key, entry.requestGeneration) - throw failure } } } - if (accepted) return loaded } } @@ -107,6 +146,27 @@ class DynamicApiRequestCoalescer { } } + /** + * Invalidates an account and terminates reads that entered before the fence. + * The account remains closed until credential activation explicitly reopens it. + */ + suspend fun fenceAccount(accountId: String, invalidate: () -> Unit) { + mutex.withLock { + val generation = (accountGenerations[accountId] ?: 0L) + 1L + accountGenerations[accountId] = generation + closedAccounts += accountId + // Keep waiters and retry gaps fenced even after their old deduplication slot retires. + activeReads[accountId]?.forEach { it.fenced = true } + requestGenerations.keys.removeAll { it.accountId == accountId } + invalidate() + } + } + + /** Reopens reads only after the caller has persisted the exact account credentials. */ + suspend fun activateAccount(accountId: String) { + mutex.withLock { closedAccounts.remove(accountId) } + } + suspend fun invalidateRequest( accountId: String, requestIdentity: String, @@ -126,11 +186,41 @@ class DynamicApiRequestCoalescer { internal suspend fun retainedRequestGenerationCount(): Int = mutex.withLock { requestGenerations.size } + internal suspend fun activeReadCount(): Int = + mutex.withLock { activeReads.values.sumOf { it.size } } + private fun retireRequestGenerationIfIdle(key: Key, generation: Long) { if (key !in inFlight && requestGenerations[key] == generation) { requestGenerations.remove(key) } } + + private fun invalidationCause(accountId: String, key: Key, entry: InFlight): InvalidationCause { + if (entry.lifetime.fenced) return InvalidationCause.Fenced + val accountGeneration = accountGenerations[accountId] ?: 0L + if (accountGeneration != entry.accountGeneration) { + return InvalidationCause.Invalidated + } + return if ((requestGenerations[key] ?: 0L) != entry.requestGeneration) { + InvalidationCause.Invalidated + } else { + InvalidationCause.None + } + } + + private enum class InvalidationCause { + None, + Invalidated, + Fenced; + + fun exception(): Exception = when (this) { + None -> error("A current dynamic read has no invalidation failure.") + Invalidated -> DynamicReadInvalidatedException() + Fenced -> DynamicReadAccountFencedException() + } + } } private class DynamicReadInvalidatedException : Exception() + +internal class DynamicReadAccountFencedException : Exception("The account was removed while this read was running.") diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt index d4ca8722d..6a4bbb87a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt @@ -1,6 +1,8 @@ package dev.obiente.nextcloudnative.app import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.sameDynamicResourceAs import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -29,37 +31,51 @@ internal class DynamicNativeMemoryCache( val storedAt: TimeMark, ) - private val discoveries = linkedMapOf() + private val lock = DynamicNativeMemoryCacheLock() private val discoveryMetadata = linkedMapOf() private val discoveryFailures = linkedMapOf() private val screens = linkedMapOf() + private val closedAccounts = mutableSetOf() + private val accountIncarnations = mutableMapOf() + + fun producer(session: NextcloudSession): DynamicNativeMemoryCacheProducer? = + producer(session.dynamicAccountKey()) + + fun producer(key: DynamicScreenCacheKey): DynamicNativeMemoryCacheProducer? = producer(key.account) fun discovery( session: NextcloudSession, appId: String, freshOnly: Boolean = false, allowStaleDiscovery: Boolean = true, - ): DynamicDescriptorDiscovery? { + ): DynamicDescriptorDiscovery? = lock.withLock { val key = DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId) - val entry = discoveryMetadata.touch(key) ?: return null - if (!allowStaleDiscovery && freshOnly && entry.storedAt.elapsedNow() > discoveryFreshFor) return null - if (freshOnly && entry.storedAt.elapsedNow() > discoveryFreshFor) return null - return entry.discovery + if (key.account in closedAccounts) return@withLock null + val entry = discoveryMetadata.touch(key) ?: return@withLock null + if (!allowStaleDiscovery && freshOnly && entry.storedAt.elapsedNow() > discoveryFreshFor) { + return@withLock null + } + if (freshOnly && entry.storedAt.elapsedNow() > discoveryFreshFor) return@withLock null + entry.discovery } fun isDiscoveryFresh( session: NextcloudSession, appId: String, - ): Boolean = discoveryMetadata[DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId)] - ?.takeIf { entry -> + ): Boolean = lock.withLock { + val key = DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId) + if (key.account in closedAccounts) return@withLock false + discoveryMetadata[key]?.let { entry -> entry.discovery.versionStatus == DynamicContractVersionStatus.VerifiedCurrent && entry.storedAt.elapsedNow() <= discoveryFreshFor - } != null + } == true + } - fun shouldRetryDiscovery(session: NextcloudSession, appId: String): Boolean { + fun shouldRetryDiscovery(session: NextcloudSession, appId: String): Boolean = lock.withLock { val key = DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId) - val failure = discoveryFailures[key] ?: return true - return if (failure.elapsedNow() >= discoveryFailureCooldown) { + if (key.account in closedAccounts) return@withLock false + val failure = discoveryFailures[key] ?: return@withLock true + if (failure.elapsedNow() >= discoveryFailureCooldown) { discoveryFailures.remove(key) true } else { @@ -67,45 +83,86 @@ internal class DynamicNativeMemoryCache( } } - fun storeDiscovery(session: NextcloudSession, appId: String, discovery: DynamicDescriptorDiscovery) { + fun storeDiscovery( + session: NextcloudSession, + appId: String, + discovery: DynamicDescriptorDiscovery, + producer: DynamicNativeMemoryCacheProducer?, + ) = lock.withLock { val key = DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId) + val currentProducer = producer ?: return@withLock + require(currentProducer.accountStorageKey == key.account) { "The dynamic cache producer belongs to another account." } + if (!accepts(currentProducer)) return@withLock discoveryMetadata.remove(key) discoveryMetadata[key] = DiscoveryEntry(discovery = discovery, storedAt = timeSource.markNow()) - discoveries[key] = discovery while (discoveryMetadata.size > MAXIMUM_DISCOVERIES) discoveryMetadata.remove(discoveryMetadata.keys.first()) - while (discoveries.size > MAXIMUM_DISCOVERIES) discoveries.remove(discoveries.keys.first()) discoveryFailures.remove(key) } - fun screen(key: DynamicScreenCacheKey, freshOnly: Boolean = false): DynamicScreenSnapshot? { - if (!key.cacheable) return null - val entry = screens.touch(key) ?: return null - if (freshOnly && entry.storedAt.elapsedNow() > freshFor) return null - return entry.snapshot + fun screen(key: DynamicScreenCacheKey, freshOnly: Boolean = false): DynamicScreenSnapshot? = lock.withLock { + if (!key.cacheable || key.account in closedAccounts) return@withLock null + val entry = screens.touch(key) ?: return@withLock null + if (freshOnly && entry.storedAt.elapsedNow() > freshFor) return@withLock null + entry.snapshot } - fun markDiscoveryFailure(session: NextcloudSession, appId: String) { + fun markDiscoveryFailure( + session: NextcloudSession, + appId: String, + producer: DynamicNativeMemoryCacheProducer?, + ) = lock.withLock { val key = DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId) + val currentProducer = producer ?: return@withLock + require(currentProducer.accountStorageKey == key.account) { "The dynamic cache producer belongs to another account." } + if (!accepts(currentProducer)) return@withLock discoveryFailures.remove(key) discoveryFailures[key] = timeSource.markNow() - while (discoveries.size > MAXIMUM_DISCOVERIES) discoveries.remove(discoveries.keys.first()) while (discoveryFailures.size > MAXIMUM_DISCOVERIES) discoveryFailures.remove(discoveryFailures.keys.first()) } - fun storeScreen(key: DynamicScreenCacheKey, snapshot: DynamicScreenSnapshot) { - if (!key.cacheable) return + fun storeScreen( + key: DynamicScreenCacheKey, + snapshot: DynamicScreenSnapshot, + producer: DynamicNativeMemoryCacheProducer?, + ) = lock.withLock { + val currentProducer = producer ?: return@withLock + require(currentProducer.accountStorageKey == key.account) { "The dynamic cache producer belongs to another account." } + if (!key.cacheable || !accepts(currentProducer)) return@withLock screens.remove(key) screens[key] = ScreenEntry(snapshot.bounded(), timeSource.markNow()) while (screens.size > maximumScreens) screens.remove(screens.keys.first()) } - fun invalidateScreens(session: NextcloudSession, appId: String) { + fun invalidateScreens(session: NextcloudSession, appId: String) = lock.withLock { val account = session.dynamicAccountKey() screens.keys.removeAll { key -> key.account == account && key.appId == appId } } + /** Purges process-local state and rejects stale completions until exact credential activation. */ + fun retireAccount(accountStorageKey: String) = lock.withLock { + if (closedAccounts.add(accountStorageKey)) { + accountIncarnations[accountStorageKey] = (accountIncarnations[accountStorageKey] ?: 0L) + 1L + } + discoveryMetadata.keys.removeAll { key -> key.account == accountStorageKey } + discoveryFailures.keys.removeAll { key -> key.account == accountStorageKey } + screens.keys.removeAll { key -> key.account == accountStorageKey } + } + + /** Reopens an empty account cache only after the platform has persisted its exact credentials. */ + fun activateAccount(accountStorageKey: String) = lock.withLock { + closedAccounts.remove(accountStorageKey) + } + + private fun producer(accountStorageKey: String): DynamicNativeMemoryCacheProducer? = lock.withLock { + if (accountStorageKey in closedAccounts) return@withLock null + DynamicNativeMemoryCacheProducer(accountStorageKey, accountIncarnations[accountStorageKey] ?: 0L) + } + + private fun accepts(producer: DynamicNativeMemoryCacheProducer): Boolean = + producer.accountStorageKey !in closedAccounts && + (accountIncarnations[producer.accountStorageKey] ?: 0L) == producer.incarnation private fun DynamicScreenSnapshot.bounded(): DynamicScreenSnapshot { val boundedRelated = relatedRecords.entries .take(MAXIMUM_RELATED_RESOURCES) @@ -129,6 +186,11 @@ internal class DynamicNativeMemoryCache( } } +data class DynamicNativeMemoryCacheProducer( + val accountStorageKey: String, + val incarnation: Long, +) + internal data class DynamicDiscoveryCacheKey( val account: String, val appId: String, @@ -282,6 +344,16 @@ internal fun NativeRecord.dynamicPaginationRecordIdentity(resourceId: String): S } } +internal fun shouldShowDynamicRecordFallbackDetail( + viewResourceId: String, + viewComponent: NativeComponent, + selectedRecord: NativeRecord?, + selectedRecordResourceId: String?, +): Boolean = selectedRecord != null && + viewComponent != NativeComponent.detail && + viewComponent != NativeComponent.form && + selectedRecordResourceId?.sameDynamicResourceAs(viewResourceId) == true + private val DYNAMIC_SCREEN_SCOPE_RELATIONS = setOf( "accountid", "mailaccountid", @@ -299,3 +371,10 @@ private val DYNAMIC_SCREEN_SCOPE_RELATIONS = setOf( private fun NextcloudSession.dynamicAccountKey(): String = accountId.storageKey internal val sharedDynamicNativeMemoryCache = DynamicNativeMemoryCache() + +/** Compatibility entry point for callers that previously retired only the dynamic UI cache. */ +object DynamicNativeMemoryAccountLifecycle { + fun retireAccount(accountStorageKey: String) = AccountPrivateMemoryLifecycle.retireAccount(accountStorageKey) + + fun activateAccount(accountStorageKey: String) = AccountPrivateMemoryLifecycle.activateAccount(accountStorageKey) +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt new file mode 100644 index 000000000..cb5fe8470 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt @@ -0,0 +1,12 @@ +package dev.obiente.nextcloudnative.app + +/** Platform lock for synchronous cache access from Compose and background JVM owners. */ +internal class DynamicNativeMemoryCacheLock { + private val monitor = dynamicNativeMemoryCacheMonitor() + + fun withLock(action: () -> T): T = withDynamicNativeMemoryCacheLock(monitor, action) +} + +internal expect fun dynamicNativeMemoryCacheMonitor(): Any + +internal expect fun withDynamicNativeMemoryCacheLock(monitor: Any, action: () -> T): T diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt new file mode 100644 index 000000000..7c60f7603 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt @@ -0,0 +1,15 @@ +package dev.obiente.nextcloudnative.app + +/** + * Selecting a record without a destination keeps the current collection on screen. Its path + * bindings still belong to that collection and must survive the selection. Otherwise a child's + * generic `id` can replace the parent's generic `id` when the collection reloads. + */ +internal fun resolveDynamicRecordSelectionParameters( + currentViewId: String, + nextViewId: String, + currentParameters: Map, + explicitTargetParameters: Map?, + fallbackTargetParameters: Map, +): Map = explicitTargetParameters + ?: if (nextViewId == currentViewId) currentParameters else fallbackTargetParameters diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt index 235389d6d..59f84b9c6 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt @@ -80,46 +80,6 @@ internal data class CalendarMonth(val year: Int, val month: Int) { fun days(): Int = groupwareCalendarDaysInMonth(year, month) } -private sealed interface CalendarLoadState { - data object Loading : CalendarLoadState - data class Ready( - val month: CalendarMonth, - val timeWindow: GroupwareDavTimeWindow, - val calendars: List, - val events: List, - ) : CalendarLoadState - data class Error(val message: String) : CalendarLoadState -} - -private object CalendarWorkspaceMemoryCache { - private val entries = linkedMapOf, CalendarLoadState.Ready>() - - fun get( - session: NextcloudSession, - userId: String, - month: CalendarMonth, - timeWindow: GroupwareDavTimeWindow, - ): CalendarLoadState.Ready? { - val key = key(session, userId, month, timeWindow) - return entries.remove(key)?.also { entries[key] = it } - } - - fun store(session: NextcloudSession, userId: String, value: CalendarLoadState.Ready) { - val key = key(session, userId, value.month, value.timeWindow) - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_CALENDAR_MONTHS) entries.remove(entries.keys.first()) - } - - private fun key( - session: NextcloudSession, - userId: String, - month: CalendarMonth, - timeWindow: GroupwareDavTimeWindow, - ): Pair = session.accountId to - "$userId\n${month.year}-${month.month}\n${timeWindow.startUtc}-${timeWindow.endUtc}" -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun NativeGroupwareCalendarScreen( @@ -195,7 +155,7 @@ fun NativeGroupwareCalendarScreen( mutationOperationInProgress = true onMutationInProgressChanged(true) val saved = try { - services.saveDurableMutationRecovery(accountScope, DurableMutationRecoveryKind.Calendar, encoded) + services.saveDurableMutationRecovery(session, accountScope, DurableMutationRecoveryKind.Calendar, encoded) } catch (failure: CancellationException) { throw failure } catch (_: Exception) { @@ -312,8 +272,8 @@ fun NativeGroupwareCalendarScreen( selectedDate = today selectedEventId = null } - suspend fun reload() { + val cacheProducer = CalendarWorkspaceMemoryCache.producer(session) val reconciliationConfirmed = mutationPostcondition?.let { postcondition -> runCatchingPreservingCancellation { val response = services.executeGroupwareDav( @@ -358,7 +318,7 @@ fun NativeGroupwareCalendarScreen( CalendarLoadState.Ready(month, queryWindow, calendars, events) }.onSuccess { loaded -> state = loaded - CalendarWorkspaceMemoryCache.store(session, userId, loaded) + CalendarWorkspaceMemoryCache.store(session, userId, loaded, cacheProducer) if (mutationPostcondition != null) { if (reconciliationConfirmed) { if (!clearMutationRecovery()) return@onSuccess @@ -979,7 +939,7 @@ private val calendarMutationRecoveryJson = Json { ignoreUnknownKeys = true } -internal fun durableMutationAccountScope(session: NextcloudSession): String = +fun durableMutationAccountScope(session: NextcloudSession): String = publicContentSha256( listOf(session.serverUrl.trimEnd('/'), session.loginName) .joinToString("|") { value -> "${value.length}:$value" } @@ -1105,7 +1065,6 @@ private val MONTH_NAMES = listOf( "July", "August", "September", "October", "November", "December", ) private val WEEK_DAYS = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") -private const val MAXIMUM_RETAINED_CALENDAR_MONTHS = 24 private const val CALENDAR_MUTATION_RESULT_UNKNOWN_MESSAGE = "The server response was interrupted, so the calendar result is unknown. " + "Refresh to verify it before trying another change." diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt index 94a5afa85..bba0dbdee 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt @@ -121,7 +121,7 @@ fun NativeGroupwareContactsScreen( mutationOperationInProgress = true onMutationInProgressChanged(true) val saved = try { - services.saveDurableMutationRecovery(accountScope, DurableMutationRecoveryKind.Contacts, encoded) + services.saveDurableMutationRecovery(session, accountScope, DurableMutationRecoveryKind.Contacts, encoded) } catch (failure: CancellationException) { throw failure } catch (_: Exception) { @@ -215,6 +215,7 @@ fun NativeGroupwareContactsScreen( LaunchedEffect(session, userId, loadAttempt, mutationRecoveryLoaded) { if (!mutationRecoveryLoaded) return@LaunchedEffect + val cacheProducer = ContactsWorkspaceMemoryCache.producer(session) val reconciliationConfirmed = mutationPostcondition?.let { postcondition -> runCatchingPreservingCancellation { val response = services.executeGroupwareDav( @@ -258,7 +259,7 @@ fun NativeGroupwareContactsScreen( ContactsLoadState.Ready(addressBooks, contacts) to concurrentlyDeletedObjectCount }.onSuccess { loaded -> state = loaded.first - ContactsWorkspaceMemoryCache.store(session, userId, loaded.first) + ContactsWorkspaceMemoryCache.store(session, userId, loaded.first, cacheProducer) if (loaded.second > 0) { refreshError = "${loaded.second} contacts changed during refresh; the remaining contacts are current." } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt index 09be3cb67..8f04dcd29 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt @@ -10,18 +10,34 @@ internal sealed interface ContactsLoadState { } internal object ContactsWorkspaceMemoryCache { + private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf, ContactsLoadState.Ready>() - fun get(session: NextcloudSession, userId: String): ContactsLoadState.Ready? { - val key = session.accountId to userId - return entries.remove(key)?.also { entries[key] = it } + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun get(session: NextcloudSession, userId: String): ContactsLoadState.Ready? = + gate.read(session.accountId.storageKey, null) { + val key = session.accountId to userId + entries.remove(key)?.also { entries[key] = it } + } + + fun store( + session: NextcloudSession, + userId: String, + value: ContactsLoadState.Ready, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + val key = session.accountId to userId + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_CONTACT_ACCOUNTS) entries.remove(entries.keys.first()) + } } - fun store(session: NextcloudSession, userId: String, value: ContactsLoadState.Ready) { - val key = session.accountId to userId - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_CONTACT_ACCOUNTS) entries.remove(entries.keys.first()) + internal fun purgeRetiredAccount(accountStorageKey: String) { + entries.keys.removeAll { (account) -> account.storageKey == accountStorageKey } } } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt index ba21aa339..55e57e3c5 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt @@ -110,7 +110,7 @@ fun NativeGroupwareTasksScreen( recoveryVerification = TaskRecoveryVerification.Unknown onMutationInProgressChanged(true) val saved = try { - services.saveDurableMutationRecovery(accountScope, DurableMutationRecoveryKind.Tasks, encoded) + services.saveDurableMutationRecovery(session, accountScope, DurableMutationRecoveryKind.Tasks, encoded) } catch (failure: CancellationException) { throw failure } catch (_: Exception) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt index dfa532a84..49eb8d140 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt @@ -21,6 +21,21 @@ internal interface HomeWorkspaceLayoutStorage { } } +internal fun homeWorkspaceAccountPersistenceKeys( + accountScopeDigest: String, + legacyAccountScopeDigest: String? = null, +): Set = buildSet { + setOfNotNull(accountScopeDigest, legacyAccountScopeDigest).forEach { digest -> + require(digest.length == 64 && digest.all { character -> character in '0'..'9' || character in 'a'..'f' }) { + "The home workspace account scope must be a canonical SHA-256 digest." + } + add("apps:pins:1:$digest") + HomeFormFactor.entries.forEach { formFactor -> + add(HomeWorkspaceScope(digest, formFactor).persistenceKey) + } + } +} + internal data class HomeWorkspaceLayoutLoad( val layout: HomeWorkspaceLayout, val storageAuthoritative: Boolean = true, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt index c8d7fbd77..01ecfc93d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt @@ -82,6 +82,7 @@ fun NativeDeckScreen( requestedBoardId, requestedCardId, ) { + val cacheProducer = DeckWorkspaceMemoryCache.producer(session) DeckWorkspaceMemoryCache.store( session, DeckWorkspaceMemorySnapshot( @@ -93,6 +94,7 @@ fun NativeDeckScreen( requestedBoardId = requestedBoardId, requestedCardId = requestedCardId, ), + cacheProducer, ) } 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/NextcloudDocumentPreview.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt index e4a70acd9..0fa1a39fb 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt @@ -86,6 +86,7 @@ fun NextcloudDocumentPreview( ) } LaunchedEffect(services, session.serverUrl, session.loginName) { + val cacheProducer = sharedDocumentEditingCapabilitiesCache.producer(session) runCatching { services.loadDocumentEditingCapabilities( session, @@ -100,6 +101,7 @@ fun NextcloudDocumentPreview( session, result.value, result.responseEtag, + cacheProducer, ) } NextcloudConditionalRead.NotModified -> Unit diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt index 89ed2a0f4..bcadd1f39 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt @@ -10,6 +10,7 @@ class NextcloudFileRangeSession( val size: Long, private val readBlock: suspend (offset: Long, length: Int) -> ByteArray, private val closeBlock: () -> Unit = {}, + private val beginUseBlock: () -> (() -> Unit)? = { {} }, ) : AutoCloseable { init { require(size > 0L) { "A file range session must have a positive size." } @@ -17,6 +18,8 @@ class NextcloudFileRangeSession( suspend fun read(offset: Long, length: Int): ByteArray = readBlock(offset, length) + fun beginUse(): (() -> Unit)? = beginUseBlock() + override fun close() = closeBlock() } 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 31c7202dc..1670d485a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -304,31 +304,6 @@ internal fun NativeAppSchema.forDynamicContractVersion( private const val DYNAMIC_MUTATION_AUTHORITATIVE_READ_DELAY_MILLIS = 500L -private class PhotoTimelineUiState { - val timeline = mutableStateOf(PhotoTimelineState(pageSize = MAX_PHOTO_TIMELINE_PAGE_SIZE)) - val backupStatuses = mutableStateOf>(emptyMap()) - val initialLoadCompleted = mutableStateOf(false) -} - -private object PhotoTimelineUiStateRepository { - private const val MAXIMUM_ACCOUNT_STATES = 4 - private val accountStates = linkedMapOf() - - fun stateFor(session: NextcloudSession): PhotoTimelineUiState { - val accountKey = previewCacheDigest(session) - accountStates.remove(accountKey)?.let { existing -> - accountStates[accountKey] = existing - return existing - } - val created = PhotoTimelineUiState() - accountStates[accountKey] = created - while (accountStates.size > MAXIMUM_ACCOUNT_STATES) { - accountStates.remove(accountStates.keys.first()) - } - return created - } -} - private val mediaViewerNavigationRepository = MediaViewerNavigationRepository() private inline fun > enumSaver() = Saver( @@ -574,8 +549,7 @@ fun NextcloudNativeApp( LoginScreen( services = services, onLoggedIn = { authenticated -> - services.saveSession(authenticated) - session = authenticated + session = services.saveSession(authenticated) }, ) } @@ -2458,7 +2432,6 @@ private fun AuthenticatedApp( val cached = cachedAppDiscoveries[current.app.id] if (cached == null || candidate.acquisition != DynamicDescriptorAcquisition.MetadataFallback) { cachedAppDiscoveries[current.app.id] = candidate - sharedDynamicNativeMemoryCache.storeDiscovery(session, current.app.id, candidate) } val liveServerVersion = serverInfo?.version val active = screen as? Screen.AppInfo @@ -2890,7 +2863,6 @@ private fun AppInfoScreen( discoveryAttempt += 1 onRetryServerInfo() } - LaunchedEffect( app.id, session, @@ -2899,6 +2871,7 @@ private fun AppInfoScreen( serverVersionVerified, discoveryAttempt, ) { + val cacheProducer = sharedDynamicNativeMemoryCache.producer(session) discoveryProgress = DynamicDescriptorDiscoveryProgress( DynamicDescriptorDiscoveryPhase.CachedWorkspace, "Checking the saved workspace", @@ -2914,7 +2887,7 @@ private fun AppInfoScreen( if (retainedDiscovery != null) { discovery = retainedDiscovery onDiscovery(retainedDiscovery) - sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, retainedDiscovery) + sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, retainedDiscovery, cacheProducer) } val shouldRetry = discoveryAttempt > 0 || sharedDynamicNativeMemoryCache.shouldRetryDiscovery(session, app.id) || !sharedDynamicNativeMemoryCache.isDiscoveryFresh(session, app.id) @@ -2943,9 +2916,9 @@ private fun AppInfoScreen( val retainedCachedContract = resolvedDiscovery !== candidate onDiscovery(resolvedDiscovery) discovery = resolvedDiscovery - sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, resolvedDiscovery) + sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, resolvedDiscovery, cacheProducer) runCatching { - services.saveCachedDynamicAppDiscovery(session, resolvedDiscovery) + services.saveCachedDynamicAppDiscovery(session, resolvedDiscovery, cacheProducer) } if (retainedCachedContract) { discoveryError = @@ -2961,9 +2934,9 @@ private fun AppInfoScreen( if (retainedReadOnly != null) { onDiscovery(retainedReadOnly) discovery = retainedReadOnly - sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, retainedReadOnly) + sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, retainedReadOnly, cacheProducer) } - sharedDynamicNativeMemoryCache.markDiscoveryFailure(session, app.id) + sharedDynamicNativeMemoryCache.markDiscoveryFailure(session, app.id, cacheProducer) discoveryError = if (retainedDiscovery == null) { failure.message ?: "Could not discover this app's native API." } else { @@ -2972,7 +2945,6 @@ private fun AppInfoScreen( } } } - Column(modifier = Modifier.fillMaxSize().safeDrawingPadding()) { val resolved = discovery // The discovered screen owns its own contextual header. Keeping the @@ -3412,6 +3384,7 @@ private fun DynamicDiscoveredAppScreen( formRelationLoadAttempt, loadAttempt, ) { + val cacheProducer = sharedDynamicNativeMemoryCache.producer(session) val view = selectedView ?: return@LaunchedEffect val retainedMailPagination = retainedMailPaginationSnapshot( hasMailWorkspaceSemantics = descriptor.hasNativeMailWorkspaceSemantics(), @@ -3549,6 +3522,7 @@ private fun DynamicDiscoveredAppScreen( sharedDynamicNativeMemoryCache.storeScreen( cacheKey, DynamicScreenSnapshot(records, updatedRecords), + cacheProducer, ) } }.onFailure { failure -> @@ -3643,6 +3617,7 @@ private fun DynamicDiscoveredAppScreen( sharedDynamicNativeMemoryCache.storeScreen( cacheKey, DynamicScreenSnapshot(rows, updatedRecords), + cacheProducer, ) } }.onFailure { failure -> @@ -3666,6 +3641,7 @@ private fun DynamicDiscoveredAppScreen( sharedDynamicNativeMemoryCache.storeScreen( cacheKey, DynamicScreenSnapshot(records, updatedRecords), + cacheProducer, ) return@LaunchedEffect } @@ -3731,6 +3707,7 @@ private fun DynamicDiscoveredAppScreen( relatedRecords = updatedRecords, pagination = nextPagination?.toCheckpoint(), ), + cacheProducer, ) } }.onFailure { failure -> @@ -4716,6 +4693,7 @@ private fun DynamicDiscoveredAppScreen( pathParameters = pagingPathParameters, cacheable = pagingCacheable, ) + val cacheProducer = sharedDynamicNativeMemoryCache.producer(pagingRequestIdentity.cacheKey) val pagingRuntimeValues = pagingRecord?.toDynamicRuntimeValues().orEmpty().toMap() val values = pagingRuntimeValues + pagingPathParameters + @@ -4811,6 +4789,7 @@ private fun DynamicDiscoveredAppScreen( relatedRecords = updatedRecords, pagination = nextPagination?.toCheckpoint(), ), + cacheProducer, ) loadingMore = false }.onFailure { failure -> @@ -7092,49 +7071,6 @@ internal fun inheritDynamicParentParameters( !key.equals("id", ignoreCase = true) && key.endsWith("Id", ignoreCase = true) } -/** - * Selecting a record without a destination keeps the current collection on screen. Its path - * bindings still belong to that collection and must survive the selection. Otherwise a child's - * generic `id` can replace the parent's generic `id` when the collection reloads. - */ -internal fun resolveDynamicRecordSelectionParameters( - currentViewId: String, - nextViewId: String, - currentParameters: Map, - explicitTargetParameters: Map?, - fallbackTargetParameters: Map, -): Map = explicitTargetParameters - ?: if (nextViewId == currentViewId) currentParameters else fallbackTargetParameters - -internal fun shouldShowDynamicRecordFallbackDetail( - viewResourceId: String, - viewComponent: NativeComponent, - selectedRecord: NativeRecord?, - selectedRecordResourceId: String?, -): Boolean = selectedRecord != null && - viewComponent != NativeComponent.detail && - viewComponent != NativeComponent.form && - selectedRecordResourceId?.sameDynamicResourceAs(viewResourceId) == true - -private object ActivityWorkspaceMemoryCache { - private val entries = linkedMapOf, ActivityTimelineState>() - - fun get(session: NextcloudSession, filterId: String): ActivityTimelineState? { - val key = key(session, filterId) - return entries.remove(key)?.also { entries[key] = it } - } - - fun store(session: NextcloudSession, filterId: String, value: ActivityTimelineState) { - val key = key(session, filterId) - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_ACTIVITY_ACCOUNTS) entries.remove(entries.keys.first()) - } - - private fun key(session: NextcloudSession, filterId: String): Pair = - session.accountId to filterId -} - @Composable private fun ActivityScreen( services: NextcloudPlatformServices, @@ -7219,6 +7155,7 @@ private fun ActivityScreen( LaunchedEffect(session, activityInstalled, selectedServerFilterId, loadAttempt) { if (!activityInstalled) return@LaunchedEffect val filterId = selectedServerFilterId + val cacheProducer = ActivityWorkspaceMemoryCache.producer(session) timeline = timeline.beginActivityRefresh() runCatching { loadNextcloudActivityPage(filterId = filterId) { request -> @@ -7228,7 +7165,7 @@ private fun ActivityScreen( .onSuccess { page -> if (selectedServerFilterId != filterId) return@onSuccess timeline = timeline.applyActivityRefresh(page) - ActivityWorkspaceMemoryCache.store(session, filterId, timeline) + ActivityWorkspaceMemoryCache.store(session, filterId, timeline, cacheProducer) } .onFailure { failure -> if (selectedServerFilterId != filterId || failure is CancellationException) return@onFailure @@ -7240,6 +7177,7 @@ private fun ActivityScreen( if (!activityInstalled || olderPageAttempt == 0) return@LaunchedEffect val filterId = selectedServerFilterId val cursor = timeline.nextSince ?: return@LaunchedEffect + val cacheProducer = ActivityWorkspaceMemoryCache.producer(session) timeline = timeline.beginNextActivityPage() runCatching { loadNextcloudActivityPage(since = cursor, filterId = filterId) { request -> @@ -7249,7 +7187,7 @@ private fun ActivityScreen( .onSuccess { page -> if (selectedServerFilterId != filterId) return@onSuccess timeline = timeline.applyNextActivityPage(page) - ActivityWorkspaceMemoryCache.store(session, filterId, timeline) + ActivityWorkspaceMemoryCache.store(session, filterId, timeline, cacheProducer) } .onFailure { failure -> if (selectedServerFilterId != filterId || failure is CancellationException) return@onFailure @@ -11887,38 +11825,6 @@ private enum class MarkdownFileViewMode { Edit, } -internal object TalkWorkspaceMemoryCache { - private val rooms = linkedMapOf>() - private val messages = linkedMapOf, List>() - - fun rooms(session: NextcloudSession): List? = touch(rooms, session.accountId) - - fun storeRooms(session: NextcloudSession, value: List) { - store(rooms, session.accountId, value, MAXIMUM_RETAINED_TALK_ACCOUNTS) - } - - fun messages(session: NextcloudSession, roomToken: String): List? = - touch(messages, session.accountId to roomToken) - - fun storeMessages(session: NextcloudSession, roomToken: String, value: List) { - store( - messages, - session.accountId to roomToken, - value, - MAXIMUM_RETAINED_TALK_ROOMS, - ) - } - - private fun touch(entries: LinkedHashMap, key: Key): T? = - entries.remove(key)?.also { entries[key] = it } - - private fun store(entries: LinkedHashMap, key: Key, value: T, maximum: Int) { - entries.remove(key) - entries[key] = value - while (entries.size > maximum) entries.remove(entries.keys.first()) - } -} - @Composable private fun TalkScreen( services: NextcloudPlatformServices, @@ -11931,12 +11837,13 @@ private fun TalkScreen( var refreshing by remember(session) { mutableStateOf(false) } var loadAttempt by remember(session) { mutableStateOf(0) } LaunchedEffect(loadAttempt) { + val cacheProducer = TalkWorkspaceMemoryCache.producer(session) refreshing = rooms != null error = null runCatching { services.listTalkRooms(session) } .onSuccess { rooms = it - TalkWorkspaceMemoryCache.storeRooms(session, it) + TalkWorkspaceMemoryCache.storeRooms(session, it, cacheProducer) } .onFailure { error = it.message ?: "Could not load Talk conversations." } refreshing = false @@ -12423,6 +12330,3 @@ internal fun formatBytes(bytes: Long?): String = when { } private const val MAX_DYNAMIC_BATCH_RELATION_ERROR_LENGTH = 1_024 -private const val MAXIMUM_RETAINED_ACTIVITY_ACCOUNTS = 4 -private const val MAXIMUM_RETAINED_TALK_ACCOUNTS = 4 -private const val MAXIMUM_RETAINED_TALK_ROOMS = 16 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt index 48250ac91..21087bd3b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt @@ -118,9 +118,7 @@ internal fun NextcloudNotesScreen( navigationCommitInProgress: Boolean = false, onMutationInProgressChanged: (Boolean) -> Unit = {}, ) { - val accountScope = remember(session.serverUrl, session.loginName) { - durableMutationAccountScope(session) - } + val accountScope = remember(session.serverUrl, session.loginName) { durableMutationAccountScope(session) } var deletionRecoveryLoaded by remember(accountScope, services) { mutableStateOf(false) } var deletionRecoveryState by remember(accountScope, services) { mutableStateOf(null) } val deletionRecovery = remember(accountScope, deletionRecoveryState) { @@ -153,7 +151,6 @@ internal fun NextcloudNotesScreen( mutationInProgress, navigationCommitInProgress, ) - LaunchedEffect(accountScope, services, loadAttempt) { deletionRecoveryLoaded = false deletionRecoveryState = null @@ -169,14 +166,12 @@ internal fun NextcloudNotesScreen( error = "Note recovery storage could not be read securely. Check local storage and retry." } } - LaunchedEffect(accountScope, deletionRecoveryLoaded, deletionRecoveryState, deletionRecovery) { if (deletionRecoveryLoaded && deletionRecoveryState != null && deletionRecovery == null) { error = "The previous note-deletion recovery record cannot be read. Writes remain blocked." showRecoveryOptions = true } } - LaunchedEffect(pendingNavigationGuardActive, createdNoteToOpen) { onMutationInProgressChanged(pendingNavigationGuardActive) if (!pendingNavigationGuardActive) { @@ -196,9 +191,9 @@ internal fun NextcloudNotesScreen( DisposableEffect(Unit) { onDispose { onMutationInProgressChanged(false) } } - LaunchedEffect(session, loadAttempt, deletionRecoveryLoaded, deletionRecoveryState) { if (!deletionRecoveryLoaded) return@LaunchedEffect + val cacheProducer = sharedNextcloudNotesCache.producer(session) error = null refreshing = true val cachedEtag = notes?.let { sharedNextcloudNotesCache.listEtag(session) } @@ -206,7 +201,7 @@ internal fun NextcloudNotesScreen( when (val result = services.listNotesConditionally(session, cachedEtag)) { is NextcloudConditionalRead.Modified -> { notes = result.value - sharedNextcloudNotesCache.storeList(session, result.value, result.responseEtag) + sharedNextcloudNotesCache.storeList(session, result.value, cacheProducer, result.responseEtag) } NextcloudConditionalRead.NotModified -> Unit } @@ -221,7 +216,7 @@ internal fun NextcloudNotesScreen( ) ) { notes = removeVerifiedDeletedNote(notes, recovery.noteId) - sharedNextcloudNotesCache.remove(session, recovery.noteId) + sharedNextcloudNotesCache.remove(session, recovery.noteId, cacheProducer) deletionRecoveryState = null } else { error = "The verified note-deletion recovery record could not be cleared safely. Refreshing the current pending change." @@ -229,7 +224,7 @@ internal fun NextcloudNotesScreen( } } is NextcloudNotePresence.Present -> { - sharedNextcloudNotesCache.storeDetail(session, presence.note) + sharedNextcloudNotesCache.storeDetail(session, presence.note, cacheProducer) onOpenNote(presence.note) } } @@ -404,10 +399,10 @@ internal fun NextcloudNotesScreen( services = services, session = session, onDismiss = { createNoteInPath = null }, - onCreated = { created -> + onCreated = { created, cacheProducer -> notes = (notes.orEmpty() + created).distinctBy(NextcloudNote::id) - sharedNextcloudNotesCache.storeList(session, notes.orEmpty()) - sharedNextcloudNotesCache.storeDetail(session, created) + sharedNextcloudNotesCache.storeList(session, notes.orEmpty(), cacheProducer) + sharedNextcloudNotesCache.storeDetail(session, created, cacheProducer) createNoteInPath = null createdNoteToOpen = created }, @@ -423,11 +418,11 @@ internal fun NextcloudNotesScreen( services = services, session = session, onDismiss = { renameFolder = null }, - onReconciled = { refreshed -> + onReconciled = { refreshed, cacheProducer -> notes = refreshed - sharedNextcloudNotesCache.storeList(session, refreshed) + sharedNextcloudNotesCache.storeList(session, refreshed, cacheProducer) }, - onRenamed = { destination -> + onRenamed = { destination, cacheProducer -> val oldPrefix = folder.path + "/" notes = notes.orEmpty().map { note -> when { @@ -437,7 +432,7 @@ internal fun NextcloudNotesScreen( else -> note } } - sharedNextcloudNotesCache.storeList(session, notes.orEmpty()) + sharedNextcloudNotesCache.storeList(session, notes.orEmpty(), cacheProducer) currentPath = destination renameFolder = null }, @@ -453,16 +448,16 @@ internal fun NextcloudNotesScreen( services = services, session = session, onDismiss = { deleteFolder = null }, - onReconciled = { refreshed -> + onReconciled = { refreshed, cacheProducer -> notes = refreshed - sharedNextcloudNotesCache.storeList(session, refreshed) + sharedNextcloudNotesCache.storeList(session, refreshed, cacheProducer) }, - onDeleted = { + onDeleted = { cacheProducer -> val prefix = folder.path + "/" notes = notes.orEmpty().filterNot { note -> note.category == folder.path || note.category.startsWith(prefix) } - sharedNextcloudNotesCache.storeList(session, notes.orEmpty()) + sharedNextcloudNotesCache.storeList(session, notes.orEmpty(), cacheProducer) if (currentPath == folder.path || currentPath.startsWith(prefix)) { currentPath = noteFolderParent(folder.path) } @@ -604,7 +599,7 @@ private fun CreateNoteDialog( services: NextcloudPlatformServices, session: NextcloudSession, onDismiss: () -> Unit, - onCreated: (NextcloudNote) -> Unit, + onCreated: (NextcloudNote, AccountPrivateMemoryProducer?) -> Unit, onSubmittingChanged: (Boolean) -> Unit, ) { var title by remember(category) { mutableStateOf("") } @@ -648,12 +643,13 @@ private fun CreateNoteDialog( Button( enabled = title.isNotBlank() && !submitting, onClick = { + val cacheProducer = sharedNextcloudNotesCache.producer(session) submitting = true onSubmittingChanged(true) error = null scope.launch { try { - onCreated(services.createNote(session, title, content, category)) + onCreated(services.createNote(session, title, content, category), cacheProducer) } catch (failure: CancellationException) { throw failure } catch (failure: Exception) { @@ -682,8 +678,8 @@ private fun RenameNoteFolderDialog( services: NextcloudPlatformServices, session: NextcloudSession, onDismiss: () -> Unit, - onReconciled: (List) -> Unit, - onRenamed: (String) -> Unit, + onReconciled: (List, AccountPrivateMemoryProducer?) -> Unit, + onRenamed: (String, AccountPrivateMemoryProducer?) -> Unit, onSubmittingChanged: (Boolean) -> Unit, ) { var name by remember(folder.path) { mutableStateOf(folder.name) } @@ -715,18 +711,19 @@ private fun RenameNoteFolderDialog( val destination = runCatching { noteFolderRenameTarget(folder.path, name) } .onFailure { error = it.message } .getOrNull() ?: return@Button + val cacheProducer = sharedNextcloudNotesCache.producer(session) submitting = true onSubmittingChanged(true) scope.launch { try { services.renameNoteCategory(session, folder.path, destination) - onRenamed(destination) + onRenamed(destination, cacheProducer) } catch (failure: CancellationException) { throw failure } catch (failure: Exception) { (failure as? PartialNoteFolderMutationException) ?.refreshedSummaries - ?.let(onReconciled) + ?.let { refreshed -> onReconciled(refreshed, cacheProducer) } error = failure.message ?: "Could not rename the folder." } finally { submitting = false @@ -746,8 +743,8 @@ private fun DeleteNoteFolderDialog( services: NextcloudPlatformServices, session: NextcloudSession, onDismiss: () -> Unit, - onReconciled: (List) -> Unit, - onDeleted: () -> Unit, + onReconciled: (List, AccountPrivateMemoryProducer?) -> Unit, + onDeleted: (AccountPrivateMemoryProducer?) -> Unit, onSubmittingChanged: (Boolean) -> Unit, ) { var submitting by remember(folder.path) { mutableStateOf(false) } @@ -772,18 +769,19 @@ private fun DeleteNoteFolderDialog( Button( enabled = !submitting, onClick = { + val cacheProducer = sharedNextcloudNotesCache.producer(session) submitting = true onSubmittingChanged(true) scope.launch { try { services.deleteNoteCategory(session, folder.path) - onDeleted() + onDeleted(cacheProducer) } catch (failure: CancellationException) { throw failure } catch (failure: Exception) { (failure as? PartialNoteFolderMutationException) ?.refreshedSummaries - ?.let(onReconciled) + ?.let { refreshed -> onReconciled(refreshed, cacheProducer) } error = failure.message ?: "Could not delete the folder." } finally { submitting = false @@ -932,7 +930,6 @@ internal fun NextcloudNoteEditor( saveError = message } } - LaunchedEffect(accountScope, deletionRecoveryLoaded, deletionRecoveryState, deletionRecovery) { if (deletionRecoveryLoaded && deletionRecoveryState != null && deletionRecovery == null) { deleteError = "The previous note-deletion recovery record cannot be read. Writes remain blocked." @@ -951,6 +948,7 @@ internal fun NextcloudNoteEditor( ) return@LaunchedEffect } + val cacheProducer = sharedNextcloudNotesCache.producer(session) showDeleteConfirmation = true deleting = true try { @@ -964,7 +962,7 @@ internal fun NextcloudNoteEditor( ) ) { deletionRecoveryState = null - sharedNextcloudNotesCache.remove(session, recovery.noteId) + sharedNextcloudNotesCache.remove(session, recovery.noteId, cacheProducer) completeVerifiedNoteDeletion( onDeletingChanged = { deleting = it }, onMutationInProgressChanged = onMutationInProgressChanged, @@ -976,7 +974,7 @@ internal fun NextcloudNoteEditor( } } is NextcloudNotePresence.Present -> { - sharedNextcloudNotesCache.storeDetail(session, presence.note) + sharedNextcloudNotesCache.storeDetail(session, presence.note, cacheProducer) deleteError = "The previous deletion was not confirmed by the server. Retry it before leaving this note." } } @@ -990,6 +988,7 @@ internal fun NextcloudNoteEditor( } LaunchedEffect(note.id, session, loadAttempt) { + val cacheProducer = sharedNextcloudNotesCache.producer(session) loadError = null refreshing = true val expectedEtag = loaded.content?.let { loaded.etag } @@ -997,7 +996,7 @@ internal fun NextcloudNoteEditor( .onSuccess { result -> if (result is NextcloudConditionalRead.NotModified) return@onSuccess val fullNote = (result as NextcloudConditionalRead.Modified).value - sharedNextcloudNotesCache.storeDetail(session, fullNote) + sharedNextcloudNotesCache.storeDetail(session, fullNote, cacheProducer) val preserveDraft = title != originalTitle || noteDraftIsDirty( initialized = draftInitialized, content = content.text, @@ -1086,6 +1085,7 @@ internal fun NextcloudNoteEditor( } fun saveNote() { if (!dirty || readOnly || mutationInProgress || contentBytes > MAX_NOTE_BYTES) return + val cacheProducer = sharedNextcloudNotesCache.producer(session) saving = true saveError = null scope.launch { @@ -1101,7 +1101,7 @@ internal fun NextcloudNoteEditor( ) }.onSuccess { saved -> val savedContent = saved.content ?: content.text - sharedNextcloudNotesCache.storeDetail(session, saved.copy(content = savedContent)) + sharedNextcloudNotesCache.storeDetail(session, saved.copy(content = savedContent), cacheProducer) loaded = saved originalTitle = saved.title title = saved.title @@ -1359,6 +1359,7 @@ internal fun NextcloudNoteEditor( Button( enabled = deletionRecoveryLoaded && !deleting && deletionPreconditionAvailable, onClick = delete@{ + val cacheProducer = sharedNextcloudNotesCache.producer(session) deleting = true deleteError = null scope.launch { @@ -1378,6 +1379,7 @@ internal fun NextcloudNoteEditor( .encodeForDurableStorage() val saved = try { services.saveDurableMutationRecovery( + session, accountScope, DurableMutationRecoveryKind.NoteDeletion, encoded, @@ -1448,7 +1450,7 @@ internal fun NextcloudNoteEditor( return@launch } deletionRecoveryState = null - sharedNextcloudNotesCache.remove(session, note.id) + sharedNextcloudNotesCache.remove(session, note.id, cacheProducer) showDeleteConfirmation = false completeVerifiedNoteDeletion( onDeletingChanged = { deleting = it }, @@ -1457,7 +1459,7 @@ internal fun NextcloudNoteEditor( ) } is NextcloudNotePresence.Present -> { - sharedNextcloudNotesCache.storeDetail(session, presence.note) + sharedNextcloudNotesCache.storeDetail(session, presence.note, cacheProducer) deleteError = requestFailure ?: "The deletion has not appeared on the server yet. Retry it before leaving this note." loadAttempt += 1 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt index cab3dd22a..b8514d17f 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt @@ -1,40 +1,82 @@ package dev.obiente.nextcloudnative.app /** Small process-local cache used for stale-while-revalidate Notes screens. */ -internal class NextcloudNotesCache { +internal class NextcloudNotesCache( + private val gate: AccountPrivateMemoryGate = AccountPrivateMemoryGate(), +) { private val noteLists = mutableMapOf>() private val noteListEtags = mutableMapOf() private val noteDetails = mutableMapOf, NextcloudNote>() - fun list(session: NextcloudSession): List? = noteLists[session.accountId] + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = gate.producer(session.accountId.storageKey) - fun listEtag(session: NextcloudSession): String? = noteListEtags[session.accountId] + fun list(session: NextcloudSession): List? = + gate.read(session.accountId.storageKey, null) { noteLists[session.accountId] } + + fun listEtag(session: NextcloudSession): String? = + gate.read(session.accountId.storageKey, null) { noteListEtags[session.accountId] } fun detail(session: NextcloudSession, noteId: Long): NextcloudNote? = - noteDetails[session.accountId to noteId] + gate.read(session.accountId.storageKey, null) { noteDetails[session.accountId to noteId] } - fun storeList(session: NextcloudSession, notes: List, etag: String? = null) { + fun storeList( + session: NextcloudSession, + notes: List, + producer: AccountPrivateMemoryProducer?, + etag: String? = null, + ) { val account = session.accountId - noteLists[account] = notes - etag?.takeIf(String::isNotBlank)?.let { noteListEtags[account] = it } - ?: noteListEtags.remove(account) - notes.filter { it.content != null }.forEach { noteDetails[account to it.id] = it } + gate.mutate(account.storageKey, producer) { + noteLists[account] = notes + etag?.takeIf(String::isNotBlank)?.let { noteListEtags[account] = it } + ?: noteListEtags.remove(account) + notes.filter { it.content != null }.forEach { noteDetails[account to it.id] = it } + } } - fun storeDetail(session: NextcloudSession, note: NextcloudNote) { + fun storeDetail( + session: NextcloudSession, + note: NextcloudNote, + producer: AccountPrivateMemoryProducer?, + ) { val account = session.accountId - noteDetails[account to note.id] = note - noteLists[account] = noteLists[account]?.map { listed -> - if (listed.id == note.id) note.copy(content = null) else listed - } ?: return + gate.mutate(account.storageKey, producer) { + noteDetails[account to note.id] = note + noteLists[account]?.let { listedNotes -> + noteLists[account] = listedNotes.map { listed -> + if (listed.id == note.id) note.copy(content = null) else listed + } + } + } } - fun remove(session: NextcloudSession, noteId: Long) { + fun remove( + session: NextcloudSession, + noteId: Long, + producer: AccountPrivateMemoryProducer?, + ) { val account = session.accountId - noteDetails.remove(account to noteId) - noteLists[account] = noteLists[account]?.filterNot { note -> note.id == noteId } ?: return - noteListEtags.remove(account) + gate.mutate(account.storageKey, producer) { + noteDetails.remove(account to noteId) + noteLists[account]?.let { listedNotes -> + noteLists[account] = listedNotes.filterNot { note -> note.id == noteId } + noteListEtags.remove(account) + } + } + } + + fun retireAccount(accountStorageKey: String) = gate.retireAccount(accountStorageKey) { + purgeRetiredAccount(accountStorageKey) } + + fun activateAccount(accountStorageKey: String) = gate.activateAccount(accountStorageKey) + + internal fun purgeRetiredAccount(accountStorageKey: String) { + noteLists.keys.removeAll { account -> account.storageKey == accountStorageKey } + noteListEtags.keys.removeAll { account -> account.storageKey == accountStorageKey } + noteDetails.keys.removeAll { (account, _) -> account.storageKey == accountStorageKey } + } + } -internal val sharedNextcloudNotesCache = NextcloudNotesCache() +internal val sharedNextcloudNotesCache = NextcloudNotesCache(sharedAccountPrivateMemoryGate) 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..be3b4d09a 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.") @@ -625,6 +624,7 @@ interface NextcloudPlatformServices : DeckCardDraftPlatformServices { ): String? = null suspend fun saveDurableMutationRecovery( + session: NextcloudSession, accountScope: String, kind: DurableMutationRecoveryKind, encoded: String, @@ -646,6 +646,7 @@ interface NextcloudPlatformServices : DeckCardDraftPlatformServices { suspend fun saveCachedDynamicAppDiscovery( session: NextcloudSession, discovery: DynamicDescriptorDiscovery, + producer: DynamicNativeMemoryCacheProducer? = null, ) = Unit /** Loads one exact account/app/action/record mutation staged before a non-idempotent send. */ @@ -675,12 +676,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/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt index 85f9720b1..c81d30ea7 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt @@ -232,21 +232,36 @@ internal data class CachedDocumentEditingCapabilities( ) /** Small process-local capability cache; it never stores edit or WOPI tokens. */ -internal class NextcloudDocumentEditingCapabilitiesCache { +internal class NextcloudDocumentEditingCapabilitiesCache( + private val gate: AccountPrivateMemoryGate = AccountPrivateMemoryGate(), +) { private val entries = mutableMapOf() - fun get(session: NextcloudSession): CachedDocumentEditingCapabilities? = entries[previewCacheDigest(session)] + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun get(session: NextcloudSession): CachedDocumentEditingCapabilities? = + gate.read(session.accountId.storageKey, null) { entries[previewCacheDigest(session)] } fun store( session: NextcloudSession, capabilities: NextcloudDocumentEditingCapabilities, etag: String?, + producer: AccountPrivateMemoryProducer?, ) { - entries[previewCacheDigest(session)] = CachedDocumentEditingCapabilities( - capabilities = capabilities, - etag = etag?.takeIf(String::isNotBlank), - ) + gate.mutate(session.accountId.storageKey, producer) { + entries[previewCacheDigest(session)] = CachedDocumentEditingCapabilities( + capabilities = capabilities, + etag = etag?.takeIf(String::isNotBlank), + ) + } + } + + internal fun purgeRetiredAccount(accountStorageKey: String) { + entries.remove(accountStorageKey) } } -internal val sharedDocumentEditingCapabilitiesCache = NextcloudDocumentEditingCapabilitiesCache() +internal val sharedDocumentEditingCapabilitiesCache = NextcloudDocumentEditingCapabilitiesCache( + gate = sharedAccountPrivateMemoryGate, +) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt index 0c29be531..7f5c2c481 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt @@ -98,10 +98,11 @@ internal fun officeWorkspaceOperations( cachedFiles = { services.listFilesCachedWithSource(session, userId, it) }, files = { services.listFilesWithSource(session, userId, it) }, capabilities = { + val cacheProducer = sharedDocumentEditingCapabilitiesCache.producer(session) val cached = sharedDocumentEditingCapabilitiesCache.get(session) when (val result = services.loadDocumentEditingCapabilities(session, cached?.etag, cached?.capabilities)) { is NextcloudConditionalRead.Modified -> result.value.also { - sharedDocumentEditingCapabilitiesCache.store(session, it, result.responseEtag) + sharedDocumentEditingCapabilitiesCache.store(session, it, result.responseEtag, cacheProducer) } NextcloudConditionalRead.NotModified -> cached?.capabilities ?: error("Document editor metadata was not returned.") diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt index ddaa23722..fc0d14a69 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt @@ -9,23 +9,34 @@ import kotlinx.coroutines.CancellationException */ internal object PreviewMemoryCache { private const val MAX_BYTES = 24 * 1024 * 1024 + private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf() private var bytes = 0 - fun get(key: PreviewCacheKey): ByteArray? { - val value = entries.remove(key) ?: return null + fun producer(accountStorageKey: String): AccountPrivateMemoryProducer? = gate.producer(accountStorageKey) + + fun get(key: PreviewCacheKey): ByteArray? = gate.read(key.account, null) { + val value = entries.remove(key) ?: return@read null entries[key] = value - return value + value } - fun put(key: PreviewCacheKey, value: ByteArray) { + fun put(key: PreviewCacheKey, value: ByteArray, producer: AccountPrivateMemoryProducer?) { if (value.size > MAX_BYTES) return - entries.remove(key)?.let { bytes -= it.size } - entries[key] = value - bytes += value.size - while (bytes > MAX_BYTES && entries.isNotEmpty()) { - val oldestKey = entries.keys.first() - bytes -= entries.remove(oldestKey)?.size ?: 0 + gate.mutate(key.account, producer) { + entries.remove(key)?.let { bytes -= it.size } + entries[key] = value + bytes += value.size + while (bytes > MAX_BYTES && entries.isNotEmpty()) { + val oldestKey = entries.keys.first() + bytes -= entries.remove(oldestKey)?.size ?: 0 + } + } + } + + internal fun purgeRetiredAccount(accountStorageKey: String) { + entries.keys.filter { key -> key.account == accountStorageKey }.forEach { key -> + bytes -= entries.remove(key)?.size ?: 0 } } } @@ -63,7 +74,8 @@ internal suspend fun loadPreviewMemoryCached( load: suspend () -> ByteArray, ): ByteArray { if (key == null) return load() - return PreviewMemoryCache.get(key) ?: load().also { PreviewMemoryCache.put(key, it) } + val producer = PreviewMemoryCache.producer(key.account) + return PreviewMemoryCache.get(key) ?: load().also { PreviewMemoryCache.put(key, it, producer) } } internal suspend fun NextcloudPlatformServices.loadPreviewCached( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt index 2a1b8c1fd..8e785bdd7 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt @@ -24,6 +24,10 @@ internal object SupportSettingsDraftRegistry { } } } + + fun removeAccount(accountStorageKey: String) { + states.remove(accountStorageKey)?.clearDrafts() + } } private const val MAX_RETAINED_SUPPORT_DRAFT_ACCOUNTS = 4 diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt new file mode 100644 index 000000000..cb2430141 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt @@ -0,0 +1,224 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame + +class AccountPrivateMemoryCleanupTest { + @Test + fun `removal purges one account from shared workspace memory`() { + val removed = session("removed") + val retained = session("retained") + val removedKey = removed.accountId.storageKey + val retainedKey = retained.accountId.storageKey + AccountPrivateMemoryLifecycle.activateAccount(removedKey) + AccountPrivateMemoryLifecycle.activateAccount(retainedKey) + val removedPreview = PreviewCacheKey(removedKey, "core", 1L, "etag", 64, 64) + val retainedPreview = PreviewCacheKey(retainedKey, "core", 2L, "etag", 64, 64) + val removedPhotoState = PhotoTimelineUiStateRepository.stateFor(removed) + val retainedPhotoState = PhotoTimelineUiStateRepository.stateFor(retained) + val removedProducer = sharedAccountPrivateMemoryGate.producer(removedKey) + val retainedProducer = sharedAccountPrivateMemoryGate.producer(retainedKey) + val removedDynamicProducer = sharedDynamicNativeMemoryCache.producer(removed) + val retainedDynamicProducer = sharedDynamicNativeMemoryCache.producer(retained) + try { + PreviewMemoryCache.put(removedPreview, byteArrayOf(1), removedProducer) + PreviewMemoryCache.put(retainedPreview, byteArrayOf(2), retainedProducer) + sharedNextcloudNotesCache.storeDetail(removed, note(1L, "Removed"), removedProducer) + sharedNextcloudNotesCache.storeDetail(retained, note(2L, "Retained"), retainedProducer) + sharedDynamicNativeMemoryCache.storeScreen( + dynamicKey(removed), dynamicSnapshot(1), removedDynamicProducer, + ) + sharedDynamicNativeMemoryCache.storeScreen( + dynamicKey(retained), dynamicSnapshot(2), retainedDynamicProducer, + ) + sharedDashboardStatusMemoryCache.store( + removed, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 1L, removedProducer, + ) + sharedDashboardStatusMemoryCache.store( + retained, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 1L, retainedProducer, + ) + ContactsWorkspaceMemoryCache.store( + removed, "removed", ContactsLoadState.Ready(emptyList(), emptyList()), removedProducer, + ) + ContactsWorkspaceMemoryCache.store( + retained, "retained", ContactsLoadState.Ready(emptyList(), emptyList()), retainedProducer, + ) + DeckWorkspaceMemoryCache.store(removed, deckSnapshot(), removedProducer) + DeckWorkspaceMemoryCache.store(retained, deckSnapshot(), retainedProducer) + sharedDocumentEditingCapabilitiesCache.store( + removed, NextcloudDocumentEditingCapabilities.Unavailable, null, removedProducer, + ) + sharedDocumentEditingCapabilitiesCache.store( + retained, NextcloudDocumentEditingCapabilities.Unavailable, null, retainedProducer, + ) + ActivityWorkspaceMemoryCache.store( + removed, "all", ActivityTimelineState(initialized = true), removedProducer, + ) + ActivityWorkspaceMemoryCache.store( + retained, "all", ActivityTimelineState(initialized = true), retainedProducer, + ) + TalkWorkspaceMemoryCache.storeRooms( + removed, listOf(TalkRoom("removed", "Removed", null, 0)), removedProducer, + ) + TalkWorkspaceMemoryCache.storeRooms( + retained, listOf(TalkRoom("retained", "Retained", null, 0)), retainedProducer, + ) + + AccountPrivateMemoryCleanup.removeAccount(removedKey) + + assertNull(PreviewMemoryCache.get(removedPreview)) + assertContentEquals(byteArrayOf(2), PreviewMemoryCache.get(retainedPreview)) + assertNull(sharedNextcloudNotesCache.detail(removed, 1L)) + assertEquals("Retained", sharedNextcloudNotesCache.detail(retained, 2L)?.title) + assertNull(sharedDynamicNativeMemoryCache.screen(dynamicKey(removed))) + assertNotNull(sharedDynamicNativeMemoryCache.screen(dynamicKey(retained))) + assertNull(sharedDashboardStatusMemoryCache.get(removed, 1L)) + assertNotNull(sharedDashboardStatusMemoryCache.get(retained, 1L)) + assertNull(ContactsWorkspaceMemoryCache.get(removed, "removed")) + assertNotNull(ContactsWorkspaceMemoryCache.get(retained, "retained")) + assertNull(DeckWorkspaceMemoryCache.get(removed)) + assertNotNull(DeckWorkspaceMemoryCache.get(retained)) + assertNull(sharedDocumentEditingCapabilitiesCache.get(removed)) + assertNotNull(sharedDocumentEditingCapabilitiesCache.get(retained)) + assertNull(ActivityWorkspaceMemoryCache.get(removed, "all")) + assertNotNull(ActivityWorkspaceMemoryCache.get(retained, "all")) + assertNull(TalkWorkspaceMemoryCache.rooms(removed)) + assertEquals("retained", TalkWorkspaceMemoryCache.rooms(retained)?.single()?.token) + assertNotSame(removedPhotoState, PhotoTimelineUiStateRepository.stateFor(removed)) + assertSame(retainedPhotoState, PhotoTimelineUiStateRepository.stateFor(retained)) + } finally { + AccountPrivateMemoryCleanup.removeAccount(removedKey) + AccountPrivateMemoryCleanup.removeAccount(retainedKey) + AccountPrivateMemoryLifecycle.activateAccount(removedKey) + AccountPrivateMemoryLifecycle.activateAccount(retainedKey) + } + } + + @Test + fun `stale workspace completion cannot repopulate a reactivated account`() { + val account = session("crossing") + val accountKey = account.accountId.storageKey + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + val staleProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(accountKey)) + + AccountPrivateMemoryLifecycle.retireAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + TalkWorkspaceMemoryCache.storeRooms( + account, listOf(TalkRoom("stale", "Stale", null, 0)), staleProducer, + ) + + assertNull(TalkWorkspaceMemoryCache.rooms(account)) + val currentProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(accountKey)) + TalkWorkspaceMemoryCache.storeRooms( + account, listOf(TalkRoom("current", "Current", null, 0)), currentProducer, + ) + assertEquals("current", TalkWorkspaceMemoryCache.rooms(account)?.single()?.token) + assertFalse(staleProducer == currentProducer) + AccountPrivateMemoryCleanup.removeAccount(accountKey) + } + + @Test + fun `stale private reads cannot repopulate removed caches after reactivation`() { + val account = session("private-cache-race") + val accountKey = account.accountId.storageKey + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + val staleProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(accountKey)) + val dashboard = NativeDashboardSnapshot(emptyList(), emptyMap()) + val contacts = ContactsLoadState.Ready(emptyList(), emptyList()) + val status = userStatusState() + + AccountPrivateMemoryLifecycle.retireAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + sharedDashboardStatusMemoryCache.store(account, dashboard, null, 1L, staleProducer) + ContactsWorkspaceMemoryCache.store(account, "user", contacts, staleProducer) + UserStatusWorkspaceMemoryCache.store(account, status, staleProducer) + sharedDocumentEditingCapabilitiesCache.store( + account, NextcloudDocumentEditingCapabilities.Unavailable, null, staleProducer, + ) + DeckWorkspaceMemoryCache.store(account, deckSnapshot(), staleProducer) + PreviewMemoryCache.put( + PreviewCacheKey(accountKey, "core", 1L, "etag", 64, 64), byteArrayOf(1), staleProducer, + ) + + assertNull(sharedDashboardStatusMemoryCache.get(account, 1L)) + assertNull(ContactsWorkspaceMemoryCache.get(account, "user")) + assertNull(UserStatusWorkspaceMemoryCache.get(account)) + assertNull(sharedDocumentEditingCapabilitiesCache.get(account)) + assertNull(DeckWorkspaceMemoryCache.get(account)) + assertNull(PreviewMemoryCache.get(PreviewCacheKey(accountKey, "core", 1L, "etag", 64, 64))) + + val currentProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(accountKey)) + sharedDashboardStatusMemoryCache.store(account, dashboard, null, 2L, currentProducer) + ContactsWorkspaceMemoryCache.store(account, "user", contacts, currentProducer) + UserStatusWorkspaceMemoryCache.store(account, status, currentProducer) + sharedDocumentEditingCapabilitiesCache.store( + account, NextcloudDocumentEditingCapabilities.Unavailable, null, currentProducer, + ) + DeckWorkspaceMemoryCache.store(account, deckSnapshot(), currentProducer) + + assertNotNull(sharedDashboardStatusMemoryCache.get(account, 2L)) + assertNotNull(ContactsWorkspaceMemoryCache.get(account, "user")) + assertNotNull(UserStatusWorkspaceMemoryCache.get(account)) + assertNotNull(sharedDocumentEditingCapabilitiesCache.get(account)) + assertNotNull(DeckWorkspaceMemoryCache.get(account)) + AccountPrivateMemoryCleanup.removeAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + } + + private fun session(name: String) = NextcloudSession( + serverUrl = "https://$name.private-memory.example.test", + loginName = name, + appPassword = "password", + ) + + private fun note(id: Long, title: String) = NextcloudNote( + id = id, + title = title, + modified = 1L, + category = "Personal", + favorite = false, + readOnly = false, + content = "private", + etag = "etag-$id", + ) + + private fun dynamicKey(session: NextcloudSession) = + dynamicScreenCacheKey(session, "dashboard", "widgets", null, emptyMap()) + + private fun dynamicSnapshot(page: Int) = DynamicScreenSnapshot( + records = emptyList(), + relatedRecords = emptyMap(), + pagination = DynamicPaginationCheckpoint(page, "page-$page"), + ) + + private fun userStatusState() = UserStatusSurfaceState.Available( + capabilities = NativeUserStatusCapabilities(true, true, true, true), + status = NativeUserStatus( + userId = "user", + presence = NativeUserPresence.Online, + message = "Private status", + icon = null, + messageId = null, + clearAtEpochSeconds = null, + messageIsPredefined = false, + statusIsUserDefined = true, + ), + predefined = emptyList(), + ) + + private fun deckSnapshot() = DeckWorkspaceMemorySnapshot( + state = DeckWorkspaceState.Loading, + loadedBoards = emptyList(), + capabilities = null, + activeRoute = null, + requestedBoard = null, + requestedBoardId = null, + requestedCardId = null, + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusTest.kt index 44da132d3..6324aa9b2 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusTest.kt @@ -201,7 +201,7 @@ class DashboardStatusTest { val session = NextcloudSession("https://cloud.example.test", "person", "secret") val snapshot = NativeDashboardSnapshot(listOf(widget("calendar", setOf(2))), emptyMap()) val cache = DashboardStatusMemoryCache(ttlSeconds = 60L) - cache.store(session, snapshot, status = null, nowEpochSeconds = 100L) + cache.store(session, snapshot, status = null, nowEpochSeconds = 100L, producer = cache.producer(session)) val expired = cache.get(session, nowEpochSeconds = 161L) @@ -688,7 +688,7 @@ class DashboardStatusTest { ) val dashboard = NativeDashboardSnapshot(listOf(widget), mapOf("calendar" to emptyList())) - cache.store(first, dashboard, status = null, nowEpochSeconds = 1_000) + cache.store(first, dashboard, status = null, nowEpochSeconds = 1_000, producer = cache.producer(first)) assertEquals(dashboard, cache.get(rotated, 1_030)?.dashboard) assertNull(cache.get(second, 1_030)) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt index 19e7c16da..9be834860 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt @@ -1,12 +1,19 @@ package dev.obiente.nextcloudnative.app import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope +import kotlin.coroutines.CoroutineContext import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.fail class DynamicApiRequestCoalescerTest { @@ -72,6 +79,254 @@ class DynamicApiRequestCoalescerTest { assertEquals(listOf("new"), committed) } + @Test + fun `account removal fence terminates already entered reads without committing`() = runBlocking { + supervisorScope { + val coalescer = DynamicApiRequestCoalescer() + val readStarted = CompletableDeferred() + val finishRead = CompletableDeferred() + val committed = mutableListOf() + var loads = 0 + + val owner = async { + coalescer.execute("account-a", "GET items", load = { + loads += 1 + readStarted.complete(Unit) + finishRead.await() + "removed-account-data" + }, commit = committed::add) + } + readStarted.await() + val waiter = async(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { fail("must coalesce") }) + } + coalescer.fenceAccount("account-a") { committed.clear() } + finishRead.complete(Unit) + + assertFailsWith { owner.await() } + assertFailsWith { waiter.await() } + assertEquals(1, loads) + assertEquals(emptyList(), committed) + } + } + + @Test + fun `read invoked after an account removal fence stays closed until activation`() = runBlocking { + val coalescer = DynamicApiRequestCoalescer() + + coalescer.fenceAccount("account-a") {} + + assertFailsWith { + coalescer.execute("account-a", "GET items", load = { fail("must remain closed") }) + } + coalescer.activateAccount("account-a") + assertEquals( + "re-added-account-data", + coalescer.execute("account-a", "GET items", load = { "re-added-account-data" }), + ) + } + + @Test + fun `readding an account does not let its stale read commit`() = runBlocking { + supervisorScope { + val coalescer = DynamicApiRequestCoalescer() + val readStarted = CompletableDeferred() + val finishRead = CompletableDeferred() + val committed = mutableListOf() + val stale = async { + coalescer.execute("account-a", "GET items", load = { + readStarted.complete(Unit) + finishRead.await() + "removed-account-data" + }, commit = committed::add) + } + + readStarted.await() + coalescer.fenceAccount("account-a") { committed.clear() } + coalescer.activateAccount("account-a") + val replacement = async { + coalescer.execute("account-a", "GET items", load = { "replacement-account-data" }, commit = committed::add) + } + finishRead.complete(Unit) + + assertFailsWith { stale.await() } + assertEquals("replacement-account-data", replacement.await()) + assertEquals(listOf("replacement-account-data"), committed) + } + } + + @Test + fun `displaced owner and waiter remain fenced after replacement finishes and account reopens`() = runBlocking { + assertDisplacedOwnerRemainsFenced(failLoad = false) + } + + @Test + fun `displaced failed owner cannot retry old credentials after account reopens`() = runBlocking { + assertDisplacedOwnerRemainsFenced(failLoad = true) + } + + private suspend fun assertDisplacedOwnerRemainsFenced(failLoad: Boolean) = supervisorScope { + val coalescer = DynamicApiRequestCoalescer() + val finishOldRead = CompletableDeferred() + val committed = mutableListOf() + var oldCredentialLoads = 0 + val owner = async(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { + oldCredentialLoads += 1 + finishOldRead.await() + if (failLoad) error("retired credential transport failed") + "retired account data" + }, commit = committed::add) + } + val waiter = async(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { fail("must not retry retired waiter") }) + } + coalescer.invalidateAccount("account-a") { committed.clear() } + assertEquals("replacement", coalescer.execute("account-a", "GET items", load = { "replacement" })) + assertEquals(2, coalescer.activeReadCount()) + + coalescer.fenceAccount("account-a") { committed.clear() } + coalescer.activateAccount("account-a") + assertEquals("new credentials", coalescer.execute("account-a", "GET items", load = { "new credentials" })) + finishOldRead.complete(Unit) + + assertFailsWith { owner.await() } + assertFailsWith { waiter.await() } + assertEquals(1, oldCredentialLoads) + assertEquals(emptyList(), committed) + assertEquals(0, coalescer.activeReadCount()) + } + + @Test + fun `queued invalidated waiter stays fenced after its owner finishes and account reopens`() = runBlocking { + supervisorScope { + val coalescer = DynamicApiRequestCoalescer() + val dispatcher = QueuedReadDispatcher() + val finishFirstLoad = CompletableDeferred() + var ownerLoads = 0 + var retiredWaiterLoads = 0 + val owner = async(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { + ownerLoads += 1 + if (ownerLoads == 1) finishFirstLoad.await() + "owner-$ownerLoads" + }) + } + val waiter = async(dispatcher, start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { + retiredWaiterLoads += 1 + "retired waiter credentials" + }) + } + try { + assertEquals(2, coalescer.activeReadCount()) + coalescer.invalidateRequest("account-a", "GET items") {} + finishFirstLoad.complete(Unit) + assertEquals("owner-2", owner.await()) + assertEquals(1, coalescer.activeReadCount()) + assertEquals(1, dispatcher.pendingCount) + assertEquals(0, coalescer.retainedRequestGenerationCount()) + + coalescer.fenceAccount("account-a") {} + coalescer.activateAccount("account-a") + dispatcher.runAll() + + assertFailsWith { waiter.await() } + assertEquals(0, retiredWaiterLoads) + assertEquals(0, coalescer.activeReadCount()) + assertEquals("new credentials", coalescer.execute("account-a", "GET items", load = { "new credentials" })) + } finally { + owner.cancel() + waiter.cancel() + dispatcher.runAll() + } + } + } + + @Test + fun `queued successful waiter cannot deliver retired data after account reopens`() = runBlocking { + supervisorScope { + val coalescer = DynamicApiRequestCoalescer() + val dispatcher = QueuedReadDispatcher() + val finishLoad = CompletableDeferred() + val owner = async(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { + finishLoad.await() + "retired account data" + }) + } + val waiter = async(dispatcher, start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { fail("must not reload retired waiter") }) + } + try { + finishLoad.complete(Unit) + assertEquals("retired account data", owner.await()) + assertEquals(1, coalescer.activeReadCount()) + assertEquals(1, dispatcher.pendingCount) + + coalescer.fenceAccount("account-a") {} + coalescer.activateAccount("account-a") + dispatcher.runAll() + + assertFailsWith { waiter.await() } + assertEquals(0, coalescer.activeReadCount()) + assertEquals("new credentials", coalescer.execute("account-a", "GET items", load = { "new credentials" })) + } finally { + owner.cancel() + waiter.cancel() + dispatcher.runAll() + } + } + } + + @Test + fun `displaced cancellation retires only its own owner and preserves another account`() = runBlocking { + val coalescer = DynamicApiRequestCoalescer() + val otherRelease = CompletableDeferred() + val cancelled = launch(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { awaitCancellation() }) + } + coalescer.invalidateAccount("account-a") {} + assertEquals("replacement", coalescer.execute("account-a", "GET items", load = { "replacement" })) + val other = async(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-b", "GET items", load = { otherRelease.await(); "other" }) + } + coalescer.fenceAccount("account-a") {} + cancelled.cancelAndJoin() + assertEquals(1, coalescer.activeReadCount()) + otherRelease.complete(Unit) + assertEquals("other", other.await()) + assertEquals(0, coalescer.activeReadCount()) + } + + @Test + fun `failed commit retires its active owner`() = runBlocking { + val coalescer = DynamicApiRequestCoalescer() + assertFailsWith { + coalescer.execute("account-a", "GET items", load = { "loaded" }, commit = { error("cache failed") }) + } + assertEquals(0, coalescer.activeReadCount()) + assertEquals("fresh", coalescer.execute("account-a", "GET items", load = { "fresh" })) + assertEquals(0, coalescer.activeReadCount()) + } + + @Test + fun `cancelled owner remains cancelled and releases its in flight entry`() = runBlocking { + val coalescer = DynamicApiRequestCoalescer() + var loads = 0 + val cancelled = launch(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { + loads += 1 + awaitCancellation() + }) + } + + cancelled.cancelAndJoin() + + assertEquals("fresh", coalescer.execute("account-a", "GET items", load = { "fresh" })) + assertEquals(1, loads) + } + @Test fun `request invalidation retries only the matching in flight read`() = runBlocking { val coalescer = DynamicApiRequestCoalescer() @@ -177,4 +432,17 @@ class DynamicApiRequestCoalescerTest { assertEquals(0, coalescer.retainedRequestGenerationCount()) } + + private class QueuedReadDispatcher : CoroutineDispatcher() { + private val pending = ArrayDeque() + val pendingCount: Int get() = pending.size + + override fun dispatch(context: CoroutineContext, block: Runnable) { + pending.addLast(block) + } + + fun runAll() { + while (pending.isNotEmpty()) pending.removeFirst().run() + } + } } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt index 23a475c78..5747d1879 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt @@ -10,8 +10,13 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlin.time.Duration.Companion.minutes import kotlin.time.TestTimeSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking class DynamicNativeMemoryCacheTest { private val session = NextcloudSession("https://cloud.example.test", "alice", "never-cache-this") @@ -227,6 +232,96 @@ class DynamicNativeMemoryCacheTest { assertEquals("3", cache.screen(otherAccount)?.records?.single()?.id) } + @Test + fun `retirement purges only the exact account across every cache class`() { + val cache = DynamicNativeMemoryCache() + val otherSession = session.copy(loginName = "bob") + val targetScreen = dynamicScreenCacheKey(session, "mail", "messages.list", null, emptyMap()) + val otherScreen = dynamicScreenCacheKey(otherSession, "mail", "messages.list", null, emptyMap()) + cache.storeDiscovery(session, "mail", discovery("mail")) + cache.storeDiscovery(otherSession, "mail", discovery("mail")) + cache.markDiscoveryFailure(session, "mail") + cache.markDiscoveryFailure(otherSession, "mail") + cache.storeScreen(targetScreen, snapshot("target")) + cache.storeScreen(otherScreen, snapshot("other")) + + cache.retireAccount(session.accountId.storageKey) + + assertNull(cache.discovery(session, "mail")) + assertNull(cache.screen(targetScreen)) + assertFalse(cache.shouldRetryDiscovery(session, "mail")) + assertEquals("mail", cache.discovery(otherSession, "mail")?.descriptor?.app?.id) + assertEquals("other", cache.screen(otherScreen)?.records?.single()?.id) + assertFalse(cache.shouldRetryDiscovery(otherSession, "mail")) + + cache.activateAccount(session.accountId.storageKey) + + assertNull(cache.discovery(session, "mail")) + assertNull(cache.screen(targetScreen)) + assertTrue(cache.shouldRetryDiscovery(session, "mail")) + assertEquals("other", cache.screen(otherScreen)?.records?.single()?.id) + } + + @Test + fun `completion crossing retirement and reactivation cannot store into the new incarnation`() { + val cache = DynamicNativeMemoryCache() + val key = dynamicScreenCacheKey(session, "mail", "messages.list", null, emptyMap()) + val staleProducer = requireNotNull(cache.producer(session)) + + cache.retireAccount(session.accountId.storageKey) + cache.activateAccount(session.accountId.storageKey) + cache.storeDiscovery(session, "mail", discovery("mail"), staleProducer) + cache.markDiscoveryFailure(session, "mail", staleProducer) + cache.storeScreen(key, snapshot("late"), staleProducer) + + assertNull(cache.discovery(session, "mail")) + assertNull(cache.screen(key)) + assertTrue(cache.shouldRetryDiscovery(session, "mail")) + + val currentProducer = requireNotNull(cache.producer(session)) + cache.storeDiscovery(session, "mail", discovery("mail"), currentProducer) + cache.storeScreen(key, snapshot("current"), currentProducer) + + assertEquals("mail", cache.discovery(session, "mail")?.descriptor?.app?.id) + assertEquals("current", cache.screen(key)?.records?.single()?.id) + } + + @Test + fun `concurrent cache access stays safe across retirement`() = runBlocking { + val cache = DynamicNativeMemoryCache(maximumScreens = 8) + val accountStorageKey = session.accountId.storageKey + + List(12) { worker -> + async(Dispatchers.Default) { + repeat(200) { iteration -> + val appId = "app-${iteration % 4}" + val key = dynamicScreenCacheKey( + session, + appId, + "view-$worker", + iteration.toString(), + emptyMap(), + ) + cache.storeDiscovery(session, appId, discovery(appId)) + cache.markDiscoveryFailure(session, appId) + cache.storeScreen(key, snapshot("$worker-$iteration")) + cache.discovery(session, appId) + cache.isDiscoveryFresh(session, appId) + cache.shouldRetryDiscovery(session, appId) + cache.screen(key) + if (iteration % 11 == 0) cache.invalidateScreens(session, appId) + } + } + }.awaitAll() + + cache.retireAccount(accountStorageKey) + + repeat(4) { app -> + assertNull(cache.discovery(session, "app-$app")) + assertFalse(cache.shouldRetryDiscovery(session, "app-$app")) + } + } + @Test fun `dynamic response identity is stable across query order and contains no credentials`() { val first = NextcloudApiRequest( @@ -246,4 +341,37 @@ class DynamicNativeMemoryCacheTest { first.copy(maximumResponseBytes = first.maximumResponseBytes + 1L).dynamicReadCacheIdentity(), ) } + + private fun discovery(appId: String) = DynamicDescriptorDiscovery( + descriptor = DynamicAppDescriptor( + descriptorVersion = DYNAMIC_APP_DESCRIPTOR_VERSION, + app = AppIdentity(appId, appId, "1.0.0"), + endpointPolicy = EndpointPolicy( + serverOrigin = "https://cloud.example.test", + approvedApiPrefixes = listOf("/ocs/v2.php/apps/$appId"), + ), + ), + sourcePath = "signed-package/openapi.json", + acquisition = DynamicDescriptorAcquisition.SignedAppStorePackage, + versionStatus = DynamicContractVersionStatus.VerifiedCurrent, + ) + + private fun snapshot(id: String) = DynamicScreenSnapshot( + records = listOf(NativeRecord(id, mapOf("id" to id))), + relatedRecords = emptyMap(), + ) + + private fun DynamicNativeMemoryCache.storeDiscovery( + session: NextcloudSession, + appId: String, + discovery: DynamicDescriptorDiscovery, + ) = storeDiscovery(session, appId, discovery, requireNotNull(producer(session))) + + private fun DynamicNativeMemoryCache.markDiscoveryFailure(session: NextcloudSession, appId: String) = + markDiscoveryFailure(session, appId, requireNotNull(producer(session))) + + private fun DynamicNativeMemoryCache.storeScreen( + key: DynamicScreenCacheKey, + snapshot: DynamicScreenSnapshot, + ) = storeScreen(key, snapshot, requireNotNull(producer(key))) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt index 4b640519f..0276eb840 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt @@ -14,6 +14,26 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class HomeWorkspaceLayoutTest { + @Test + fun `account cleanup keys include canonical and legacy workspace state`() { + val current = "a".repeat(64) + val legacy = "b".repeat(64) + + assertEquals( + setOf( + "apps:pins:1:$current", + "home:1:p:$current", + "home:1:t:$current", + "home:1:d:$current", + "apps:pins:1:$legacy", + "home:1:p:$legacy", + "home:1:t:$legacy", + "home:1:d:$legacy", + ), + homeWorkspaceAccountPersistenceKeys(current, legacy), + ) + } + @Test fun `coordinator defers preference reads until its effect runs`() = runBlocking { val storage = RecordingHomeWorkspaceStorage() diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaBackupLedgerTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaBackupLedgerTest.kt index fc5ade0a8..234f87504 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaBackupLedgerTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaBackupLedgerTest.kt @@ -151,6 +151,20 @@ class MediaBackupLedgerTest { store.close() } + @Test + fun deletingAnAccountRetainsOtherAccountsRows() = runBlocking { + val otherAccount = "fedcba9876543210fedcba9876543210" + val store = MediaBackupLedgerStore(BundledSQLiteDriver().open(":memory:")) + store.upsert(pendingRecord(accountId, "external:removed", 1_000)) + store.upsert(pendingRecord(otherAccount, "external:retained", 2_000)) + + store.deleteAccount(accountId) + + assertEquals(null, store.load(accountId, "external:removed")) + assertEquals("external:retained", store.load(otherAccount, "external:retained")?.localKey) + store.close() + } + @Test fun snapshotReturnsSummaryAndPageFromOneLedgerRead() = runBlocking { val store = MediaBackupLedgerStore(BundledSQLiteDriver().open(":memory:")) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt index ab5a1ceda..4a3d044a7 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt @@ -98,7 +98,7 @@ class NextcloudAccountIdentityTest { val cache = NextcloudNotesCache() val upper = session() val lower = upper.copy(serverUrl = "https://cloud.example.test/cloud") - cache.storeList(upper, listOf(note(id = 1, title = "Upper"))) + cache.storeList(upper, listOf(note(id = 1, title = "Upper")), cache.producer(upper)) assertNotNull(cache.list(upper)) assertNull(cache.list(lower)) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCacheTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCacheTest.kt index cf9ed154d..946dd78d6 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCacheTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCacheTest.kt @@ -1,5 +1,11 @@ package dev.obiente.nextcloudnative.app +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -11,7 +17,7 @@ class NextcloudNotesCacheTest { val firstLogin = session("alice", "first password") val sameLoginNewPassword = session("alice", "rotated password") val otherLogin = session("bob", "first password") - cache.storeList(firstLogin, listOf(note(1, "Alice note"))) + cache.storeList(firstLogin, listOf(note(1, "Alice note")), requireNotNull(cache.producer(firstLogin))) assertEquals("Alice note", cache.list(sameLoginNewPassword)?.single()?.title) assertNull(cache.list(otherLogin)) @@ -21,9 +27,10 @@ class NextcloudNotesCacheTest { fun savedDetailUpdatesListMetadataButKeepsFullContentSeparate() { val cache = NextcloudNotesCache() val session = session("alice", "password") - cache.storeList(session, listOf(note(1, "Before"))) + val producer = requireNotNull(cache.producer(session)) + cache.storeList(session, listOf(note(1, "Before")), producer) - cache.storeDetail(session, note(1, "After", content = "# Full content")) + cache.storeDetail(session, note(1, "After", content = "# Full content"), producer) assertEquals("After", cache.list(session)?.single()?.title) assertNull(cache.list(session)?.single()?.content) @@ -34,15 +41,111 @@ class NextcloudNotesCacheTest { fun listEtagTracksTheMetadataPayloadWithoutEnteringDetailCache() { val cache = NextcloudNotesCache() val session = session("alice", "password") + val producer = requireNotNull(cache.producer(session)) - cache.storeList(session, listOf(note(1, "Metadata only")), etag = "\"list-v1\"") + cache.storeList(session, listOf(note(1, "Metadata only")), producer, etag = "\"list-v1\"") assertEquals("\"list-v1\"", cache.listEtag(session)) assertNull(cache.detail(session, 1)) - cache.storeList(session, listOf(note(1, "Changed metadata")), etag = null) + cache.storeList(session, listOf(note(1, "Changed metadata")), producer, etag = null) assertNull(cache.listEtag(session)) } + @Test + fun `retirement purges target note data and preserves another account`() { + val cache = NextcloudNotesCache() + val target = session("alice", "password") + val other = session("bob", "password") + val targetProducer = requireNotNull(cache.producer(target)) + val otherProducer = requireNotNull(cache.producer(other)) + cache.storeList(target, listOf(note(1, "Target", content = "private")), targetProducer, "target-etag") + cache.storeDetail(target, note(1, "Target", content = "private"), targetProducer) + cache.storeList(other, listOf(note(2, "Other", content = "retained")), otherProducer, "other-etag") + cache.storeDetail(other, note(2, "Other", content = "retained"), otherProducer) + + cache.retireAccount(target.accountId.storageKey) + + assertNull(cache.list(target)) + assertNull(cache.listEtag(target)) + assertNull(cache.detail(target, 1L)) + assertEquals("Other", cache.list(other)?.single()?.title) + assertEquals("other-etag", cache.listEtag(other)) + assertEquals("retained", cache.detail(other, 2L)?.content) + } + + @Test + fun `stale note producer cannot write or remove after reactivation`() { + val cache = NextcloudNotesCache() + val session = session("alice", "password") + val staleProducer = requireNotNull(cache.producer(session)) + cache.storeList(session, listOf(note(1, "Before")), staleProducer, "before-etag") + + cache.retireAccount(session.accountId.storageKey) + cache.storeList(session, listOf(note(1, "Closed")), staleProducer, "closed-etag") + assertNull(cache.list(session)) + cache.activateAccount(session.accountId.storageKey) + + val currentProducer = requireNotNull(cache.producer(session)) + cache.storeList(session, listOf(note(1, "Current")), currentProducer, "current-etag") + cache.storeDetail(session, note(1, "Current", content = "current body"), currentProducer) + cache.storeList(session, listOf(note(1, "Late")), staleProducer, "late-etag") + cache.storeDetail(session, note(1, "Late", content = "late body"), staleProducer) + cache.remove(session, 1L, staleProducer) + + assertEquals("Current", cache.list(session)?.single()?.title) + assertEquals("current-etag", cache.listEtag(session)) + assertEquals("current body", cache.detail(session, 1L)?.content) + } + + @Test + fun `same screen can cache a new request after a crossing completion is rejected`() = runBlocking { + val cache = NextcloudNotesCache() + val session = session("reactivated", "password") + val started = CompletableDeferred() + val release = CompletableDeferred() + val staleProducer = requireNotNull(cache.producer(session)) + val crossingRequest = async(start = CoroutineStart.UNDISPATCHED) { + started.complete(Unit) + release.await() + cache.storeDetail(session, note(1, "Crossing", content = "stale"), staleProducer) + } + started.await() + + cache.retireAccount(session.accountId.storageKey) + cache.activateAccount(session.accountId.storageKey) + release.complete(Unit) + crossingRequest.await() + assertNull(cache.detail(session, 1L)) + + val retryProducer = requireNotNull(cache.producer(session)) + cache.storeDetail(session, note(1, "Retried", content = "current"), retryProducer) + + assertEquals("current", cache.detail(session, 1L)?.content) + } + + @Test + fun `concurrent note access remains safe across retirement`() = runBlocking { + val cache = NextcloudNotesCache() + val session = session("parallel", "password") + val producer = requireNotNull(cache.producer(session)) + val workers = List(8) { worker -> + async(Dispatchers.Default) { + repeat(100) { iteration -> + val id = (worker * 100 + iteration).toLong() + cache.storeDetail(session, note(id, "Note $id", content = "body"), producer) + cache.detail(session, id) + cache.remove(session, id, producer) + } + } + } + val retirement = async(Dispatchers.Default) { cache.retireAccount(session.accountId.storageKey) } + (workers + retirement).awaitAll() + + assertNull(cache.list(session)) + assertNull(cache.listEtag(session)) + repeat(800) { id -> assertNull(cache.detail(session, id.toLong())) } + } + private fun session(login: String, password: String) = NextcloudSession( serverUrl = "https://cloud.example.test/", loginName = login, diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflowTest.kt index 01a609a33..b98ab2cd6 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflowTest.kt @@ -162,7 +162,7 @@ class OfficeDocumentWorkflowTest { val first = NextcloudSession("https://cloud.example", "ada", "secret") val second = NextcloudSession("https://cloud.example", "grace", "secret") - cache.store(first, officeCapabilities(), "\"cap-v1\"") + cache.store(first, officeCapabilities(), "\"cap-v1\"", cache.producer(first)) assertEquals("\"cap-v1\"", cache.get(first)?.etag) assertEquals(null, cache.get(second)) @@ -175,7 +175,7 @@ class OfficeDocumentWorkflowTest { fun capabilityCacheDoesNotConflateCaseSensitiveServerInstallationPaths() { val cache = NextcloudDocumentEditingCapabilitiesCache() val session = NextcloudSession("https://cloud.example/Cloud", "ada", "secret") - cache.store(session, officeCapabilities(), "\"cap-v1\"") + cache.store(session, officeCapabilities(), "\"cap-v1\"", cache.producer(session)) assertEquals(null, cache.get(session.copy(serverUrl = "https://cloud.example/cloud"))) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCacheTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCacheTest.kt index b0021acff..b7233b0dd 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCacheTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCacheTest.kt @@ -1,6 +1,12 @@ package dev.obiente.nextcloudnative.app +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -65,4 +71,72 @@ class PreviewMemoryCacheTest { assertContentEquals(byteArrayOf(1), repeated) assertContentEquals(byteArrayOf(3), second) } + + @Test + fun loadStartedBeforeRetirementCannotPublishAfterReactivation() = runBlocking { + val session = NextcloudSession("https://preview-incarnation.example.test", "user", "secret") + val accountKey = session.accountId.storageKey + val key = PreviewCacheKey(accountKey, "core", 103L, "etag", 64, 64) + val loadStarted = CompletableDeferred() + val allowLoadToFinish = CompletableDeferred() + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + try { + val pending = async(Dispatchers.Default) { + loadPreviewMemoryCached(key) { + loadStarted.complete(Unit) + allowLoadToFinish.await() + byteArrayOf(4) + } + } + loadStarted.await() + + AccountPrivateMemoryLifecycle.retireAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + allowLoadToFinish.complete(Unit) + + assertContentEquals(byteArrayOf(4), pending.await()) + assertNull(PreviewMemoryCache.get(key)) + + assertContentEquals(byteArrayOf(5), loadPreviewMemoryCached(key) { byteArrayOf(5) }) + assertContentEquals(byteArrayOf(5), PreviewMemoryCache.get(key)) + } finally { + allowLoadToFinish.complete(Unit) + AccountPrivateMemoryCleanup.removeAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + } + } + + @Test + fun concurrentRetirementAndPublicationKeepsThePreviewMapConsistent(): Unit = runBlocking { + val session = NextcloudSession("https://preview-race.example.test", "user", "secret") + val accountKey = session.accountId.storageKey + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + try { + withContext(Dispatchers.Default) { + coroutineScope { + val publishers = List(4) { publisher -> + async { + repeat(100) { revision -> + val key = PreviewCacheKey( + accountKey, "core", publisher.toLong(), "etag-$revision", 64, 64, + ) + loadPreviewMemoryCached(key) { byteArrayOf(revision.toByte()) } + PreviewMemoryCache.get(key) + } + } + } + val retirements = async { + repeat(50) { + AccountPrivateMemoryLifecycle.retireAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + } + } + (publishers + retirements).awaitAll() + } + } + } finally { + AccountPrivateMemoryCleanup.removeAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + } + } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt new file mode 100644 index 000000000..7c6762b25 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt @@ -0,0 +1,101 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.StandardOpenOption +import java.security.MessageDigest +import java.util.prefs.Preferences + +private const val KEY_VIRTUAL_FILE_ROOT_PREFIX = "vfp-root." +private const val KEY_VIRTUAL_FILE_PRIMARY_CACHE_PREFIX = "vfpc-primary." +private const val KEY_VIRTUAL_FILE_OVERFLOW_CACHE_PREFIX = "vfpc-overflow." + +internal data class VirtualRangeRevision( + val relativePath: String, + val remoteRevision: String, + val fileSize: Long, +) { + init { + FileOfflineKey("account", relativePath) + require(remoteRevision.isNotBlank() && remoteRevision.none(Char::isISOControl)) + require(fileSize > 0L) + } +} + +internal fun desktopFileCacheAccountId(session: NextcloudSession): String = + MessageDigest.getInstance("SHA-256") + .digest("${session.serverUrl}\u0000${session.loginName}".encodeToByteArray()) + .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } + +internal fun defaultDesktopFileReadCache(): DesktopFileReadCache = + DesktopFileReadCache(File(desktopCacheRoot(), "nextcloud-native/files")) + +internal fun defaultDesktopVirtualRangeCache( + policy: () -> VirtualFileCachePolicy, +): DesktopVirtualRangeCache = DesktopVirtualRangeCache( + root = File(desktopCacheRoot(), "nextcloud-native/virtual-ranges"), + policy = policy, +) + +internal fun purgeDesktopAccountCacheDirectory(root: File, accountId: String) { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + val directory = File(root, accountId) + if (Files.notExists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) return + require(Files.isDirectory(directory.toPath(), LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(directory.toPath())) { + "The desktop cache account directory is invalid." + } + val entries = Files.newDirectoryStream(directory.toPath()).use { stream -> stream.toList() } + require(entries.all { entry -> Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS) }) { + "The desktop cache account directory contains an unsafe entry." + } + entries.forEach(Files::delete) + syncDesktopCacheDirectory(directory) + Files.delete(directory.toPath()) + syncDesktopCacheDirectory(root) +} + +internal suspend fun removeDesktopAccountPrivateStorage( + accountId: String, + syncEngine: DesktopFileSyncEngine, + files: DesktopFileReadCache, + ranges: DesktopVirtualRangeCache, + preferences: Preferences, +) { + syncEngine.removeAccountPairs(accountId) + files.removeAccount(accountId) + ranges.removeAccount(accountId) + removeDesktopAccountVirtualFilePreferences(preferences, accountId) +} + +internal fun removeDesktopAccountVirtualFilePreferences(preferences: Preferences, accountId: String) { + preferences.remove(virtualFileProviderRootPreferenceKey(accountId)) + preferences.remove(virtualFileCachePreferenceKey(KEY_VIRTUAL_FILE_PRIMARY_CACHE_PREFIX, accountId)) + preferences.remove(virtualFileCachePreferenceKey(KEY_VIRTUAL_FILE_OVERFLOW_CACHE_PREFIX, accountId)) + preferences.flush() +} + +internal fun virtualFileProviderRootPreferenceKey(accountId: String): String = + desktopAccountPreferenceKey(KEY_VIRTUAL_FILE_ROOT_PREFIX, accountId) + +internal fun virtualFileCachePreferenceKey(prefix: String, accountId: String): String { + require(prefix == KEY_VIRTUAL_FILE_PRIMARY_CACHE_PREFIX || prefix == KEY_VIRTUAL_FILE_OVERFLOW_CACHE_PREFIX) + return desktopAccountPreferenceKey(prefix, accountId) +} + +private fun desktopAccountPreferenceKey(prefix: String, accountId: String): String { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + return "$prefix$accountId".also { key -> check(key.length <= Preferences.MAX_KEY_LENGTH) } +} + +private fun desktopCacheRoot(): File { + val xdgCache = System.getenv("XDG_CACHE_HOME")?.takeIf(String::isNotBlank) + return xdgCache?.let(::File) ?: File(System.getProperty("user.home"), ".cache") +} + +private fun syncDesktopCacheDirectory(directory: File) { + if (!System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) { + FileChannel.open(directory.toPath(), StandardOpenOption.READ).use { channel -> channel.force(true) } + } +} 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..a366aadef --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -0,0 +1,799 @@ +package dev.obiente.nextcloudnative.app + +import java.util.prefs.Preferences +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException + +internal class DesktopCredentialRollbackRecoveryUnavailableException( + cause: Throwable? = null, +) : NextcloudSessionStorageUnavailableException( + "The pending desktop credential rollback could not be completed safely.", + cause, +) + +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) + private val legacyCleanupJournal = DesktopLegacyCredentialCleanupJournal( + preferences, + flushPreferences, + ) { recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", "account-credentials.migrate") } + private val malformedCredentialRemovalJournalReported = AtomicBoolean(false) + + 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 accountOwnership(accountId: String): DesktopAccountOwnership { + val read = readRegistry() + val knownAccounts = read.registry?.accounts + if (knownAccounts != null) { + return if (knownAccounts.any { account -> desktopFileCacheAccountId(account) == accountId }) { + DesktopAccountOwnership.Present + } else { + DesktopAccountOwnership.Absent + } + } + val legacyMatches = readLegacyAccountRecord()?.let(::desktopFileCacheAccountId) == accountId + if (legacyMatches) return DesktopAccountOwnership.Present + return if (read.encoded == null) DesktopAccountOwnership.Absent else DesktopAccountOwnership.Unknown + } + + 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) } + ?: when { + read.encoded == null -> NextcloudAccountRegistry.Empty + read.unsupportedVersion -> throw unsupportedRegistryForMutation() + else -> throw malformedRegistryForMutation() + } + 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 rollbackReference = desktopAccountCredentialRollbackReference(persistedSession.accountId) + val previousSecret = loadSecretForRollback(secretReference) + check(previousRecord == null || previousSecret != null) { + "The existing account credential could not be read for safe replacement." + } + persistPendingCredentialSave(persistedSession) + try { + if (previousSecret != null) { + secretStore.save(rollbackReference, previousRecord?.loginName, previousSecret) + } + markPendingCredentialSaveSecretWriting() + saveSecret(persistedSession) + markPendingCredentialSaveSecretWritten() + persistAccountState(encodedRegistry, updatedRegistry.activeAccount) + } catch (failure: Exception) { + var credentialRollbackCompleted = false + try { + persistPendingCredentialSavePhase(CREDENTIAL_SAVE_ROLLBACK) + if (previousSecret == null) { + secretStore.clear(secretReference) + } else { + secretStore.save( + secretReference, + previousRecord?.loginName, + previousSecret, + ) + } + persistPendingCredentialSavePhase(CREDENTIAL_SAVE_ROLLBACK_COMPLETED) + secretStore.clear(rollbackReference) + credentialRollbackCompleted = true + } catch (rollbackFailure: Exception) { + failure.addSuppressed(rollbackFailure) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.persist", + rollbackFailure, + ) + } + if (credentialRollbackCompleted) clearPendingCredentialSave() + throw failure + } + if (previousSecret != null) secretStore.clear(rollbackReference) + 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 + } + retryPendingLegacyCredentialCleanup(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) + 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) + val phase = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_PHASE, null) + if (server == null && login == null && phase == null) return + if (server.isNullOrBlank() || login.isNullOrBlank()) { + credentialRollbackRecoveryUnavailable() + } + val accountId = try { + deriveNextcloudAccountId(server, login) + } catch (failure: Exception) { + credentialRollbackRecoveryUnavailable(failure) + } + val registryRead = readRegistry() + if (registryRead.encoded != null && registryRead.registry == null) { + credentialRollbackRecoveryUnavailable() + } + val knownPhases = setOf( + null, + CREDENTIAL_SAVE_PREPARED, + CREDENTIAL_SAVE_SECRET_WRITING, + CREDENTIAL_SAVE_SECRET_WRITTEN, + CREDENTIAL_SAVE_ROLLBACK, + CREDENTIAL_SAVE_ROLLBACK_COMPLETED, + ) + if (phase !in knownPhases) { + credentialRollbackRecoveryUnavailable() + } + val registry = registryRead.registry + val credentialCommitted = registry?.accounts?.any { account -> account.id == accountId } == true + val secretReference = desktopAccountSecretReference(accountId) + val rollbackReference = desktopAccountCredentialRollbackReference(accountId) + if (!credentialCommitted) { + try { + secretStore.clear(secretReference) + secretStore.clear(rollbackReference) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + credentialRollbackRecoveryUnavailable(failure) + } + } else if (phase == CREDENTIAL_SAVE_SECRET_WRITING || phase == CREDENTIAL_SAVE_ROLLBACK) { + val rollbackSecret = try { + secretStore.load(rollbackReference) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + credentialRollbackRecoveryUnavailable(failure) + } + if (rollbackSecret == null) { + credentialRollbackRecoveryUnavailable() + } + try { + secretStore.save(secretReference, registry.accounts.first { it.id == accountId }.loginName, rollbackSecret) + persistPendingCredentialSavePhase(CREDENTIAL_SAVE_ROLLBACK_COMPLETED) + secretStore.clear(rollbackReference) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + credentialRollbackRecoveryUnavailable(failure) + } + } else if (phase == CREDENTIAL_SAVE_SECRET_WRITTEN) { + val selected = requireNotNull(registry.select(accountId)) + try { + persistAccountState(prepareRegistry(selected), selected.activeAccount) + secretStore.clear(rollbackReference) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + credentialRollbackRecoveryUnavailable(failure) + } + } + if (phase == CREDENTIAL_SAVE_PREPARED || phase == CREDENTIAL_SAVE_ROLLBACK_COMPLETED) { + try { + secretStore.clear(rollbackReference) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + credentialRollbackRecoveryUnavailable(failure) + } + } + clearPendingCredentialSave() + } + + private fun retryPendingCredentialRemoval() { + val pending = readPendingCredentialRemovals() + pending.accountIds.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 + } + if (!reconcileLegacyAccountMetadata(registry.activeAccount)) 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 readPendingCredentialRemovals(): DesktopPendingCredentialRemovals { + val encoded = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null) + ?: return DesktopPendingCredentialRemovals.Empty + val accountIds = linkedSetOf() + val malformedEntries = mutableListOf() + encoded.split(',').forEach { storageKey -> + try { + accountIds += NextcloudAccountId(storageKey) + } catch (_: IllegalArgumentException) { + malformedEntries += storageKey + } + } + if (malformedEntries.isNotEmpty() && malformedCredentialRemovalJournalReported.compareAndSet(false, true)) { + runCatching { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_REMOVAL_JOURNAL_INVALID", + "account-credentials.recover", + ) + } + } + return DesktopPendingCredentialRemovals(accountIds, malformedEntries) + } + + private fun reconcileLegacyAccountMetadata(activeAccount: NextcloudAccountRecord?): Boolean { + val previousServer = preferences.get(KEY_SERVER, null) + val previousLogin = preferences.get(KEY_LOGIN, null) + return try { + preferences.putOrRemove(KEY_SERVER, activeAccount?.serverUrl) + preferences.putOrRemove(KEY_LOGIN, activeAccount?.loginName) + flushPreferences() + true + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + preferences.putOrRemove(KEY_SERVER, previousServer) + preferences.putOrRemove(KEY_LOGIN, previousLogin) + runCatching(flushPreferences) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_CLEAR_FAILED", + "account-credentials.recover", + failure, + ) + false + } + } + + private fun clearPendingCredentialRemoval(accountId: NextcloudAccountId) { + val previous = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null) + val pending = readPendingCredentialRemovals() + val remaining = pending.accountIds - accountId + try { + preferences.putOrRemove( + KEY_PENDING_CREDENTIAL_REMOVALS, + pending.encode(remaining), + ) + 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) + val previousPhase = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_PHASE, null) + try { + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_SERVER, session.serverUrl) + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, session.loginName) + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_PHASE, CREDENTIAL_SAVE_PREPARED) + 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) + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_PHASE, previousPhase) + runCatching(flushPreferences) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", + "account-credentials.persist", + failure, + ) + throw failure + } + } + + private fun markPendingCredentialSaveSecretWritten() { + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_PHASE, CREDENTIAL_SAVE_SECRET_WRITTEN) + flushPreferences() + } + + private fun markPendingCredentialSaveSecretWriting() { + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_PHASE, CREDENTIAL_SAVE_SECRET_WRITING) + flushPreferences() + } + + private fun credentialRollbackRecoveryUnavailable(failure: Exception? = null): Nothing { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + failure, + ) + throw DesktopCredentialRollbackRecoveryUnavailableException(failure) + } + + private fun persistPendingCredentialSavePhase(phase: String) { + val previousPhase = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_PHASE, null) + try { + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_PHASE, phase) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_PHASE, previousPhase) + runCatching(flushPreferences) + 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) + val phase = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_PHASE, null) + if (server == null && login == null) return + try { + preferences.remove(KEY_PENDING_CREDENTIAL_SAVE_SERVER) + preferences.remove(KEY_PENDING_CREDENTIAL_SAVE_LOGIN) + preferences.remove(KEY_PENDING_CREDENTIAL_SAVE_PHASE) + 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) + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_PHASE, phase) + runCatching(flushPreferences) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + failure, + ) + throw DesktopCredentialRollbackRecoveryUnavailableException(failure) + } + } + + private fun retryPendingLegacyCredentialCleanup(expected: NextcloudSession? = null) { + legacyCleanupJournal.pending() + .filter { cleanup -> expected == null || + expected.serverUrl == cleanup.serverUrl && expected.loginName == cleanup.loginName + } + .forEach(::retryPendingLegacyCredentialCleanup) + } + + private fun retryPendingLegacyCredentialCleanup(cleanup: DesktopPendingLegacyCredentialCleanup) { + val cleanupAllowed = try { + val accountId = deriveNextcloudAccountId(cleanup.serverUrl, cleanup.loginName) + 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(cleanup.serverUrl, cleanup.loginName)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", + "account-credentials.migrate", + ) + return + } + try { + legacyCleanupJournal.clear(cleanup) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", + "account-credentials.migrate", + ) + } + } + + private fun persistPendingLegacyCredentialCleanup(session: NextcloudSession) { + val previous = legacyCleanupJournal.snapshot() + try { + legacyCleanupJournal.prepareAdd(DesktopPendingLegacyCredentialCleanup(session.serverUrl, session.loginName)) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + legacyCleanupJournal.restore(previous) + 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 credentialRemovals = pendingCredentialRemoval?.let { accountId -> + val pending = readPendingCredentialRemovals() + requireNotNull(pending.encode(pending.accountIds + accountId)) + } + val previous = DesktopAccountPreferenceSnapshot( + registry = registryStore.read(), + server = preferences.get(KEY_SERVER, null), + login = preferences.get(KEY_LOGIN, null), + pendingLegacyCleanups = legacyCleanupJournal.snapshot(), + pendingCredentialRemovals = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null), + ) + try { + pendingLegacyCleanupAccount?.let { account -> + legacyCleanupJournal.prepareAdd( + DesktopPendingLegacyCredentialCleanup(account.serverUrl, account.loginName), + ) + } + credentialRemovals?.let { removals -> + preferences.put(KEY_PENDING_CREDENTIAL_REMOVALS, removals) + } + if (pendingLegacyCleanupAccount != null || pendingCredentialRemoval != null) flushPreferences() + registryStore.write(encodedRegistry) + preferences.putOrRemove(KEY_SERVER, activeAccount?.serverUrl) + preferences.putOrRemove(KEY_LOGIN, activeAccount?.loginName) + flushPreferences() + } catch (failure: Exception) { + runCatching { previous.restore(preferences, registryStore, legacyCleanupJournal) } + 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 unsupportedRegistryForMutation(): IllegalStateException { + recordCredentialDiagnostic("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED", "account-registry.persist") + return IllegalStateException("The local account registry was written by a newer app version.") + } + + private fun malformedRegistryForMutation(): IllegalStateException { + recordCredentialDiagnostic("ACCOUNT_REGISTRY_MALFORMED", "account-registry.persist") + return IllegalStateException("The local account registry is malformed and cannot be replaced safely.") + } + + 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 DesktopPendingCredentialRemovals( + val accountIds: Set, + val malformedEntries: List, + ) { + fun encode(accountIds: Set): String? { + if (accountIds.isEmpty() && malformedEntries.isEmpty()) return null + return (accountIds.map(NextcloudAccountId::storageKey) + malformedEntries) + .joinToString(",") + } + + companion object { + val Empty = DesktopPendingCredentialRemovals(emptySet(), emptyList()) + } + } + + private data class DesktopAccountPreferenceSnapshot( + val registry: String?, + val server: String?, + val login: String?, + val pendingLegacyCleanups: DesktopLegacyCredentialCleanupSnapshot, + val pendingCredentialRemovals: String?, + ) { + fun restore( + preferences: Preferences, + registryStore: DesktopAccountRegistryPreferenceStore, + legacyCleanupJournal: DesktopLegacyCredentialCleanupJournal, + ) { + registryStore.write(registry) + preferences.putOrRemove(KEY_SERVER, server) + preferences.putOrRemove(KEY_LOGIN, login) + legacyCleanupJournal.restore(pendingLegacyCleanups) + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_REMOVALS, pendingCredentialRemovals) + } + } + + private companion object { + const val KEY_SERVER = "server" + const val KEY_LOGIN = "login" + const val KEY_PENDING_CREDENTIAL_SAVE_SERVER = "accountCredentialSaveServer" + const val KEY_PENDING_CREDENTIAL_SAVE_LOGIN = "accountCredentialSaveLogin" + const val KEY_PENDING_CREDENTIAL_SAVE_PHASE = "accountCredentialSavePhase" + const val KEY_PENDING_CREDENTIAL_REMOVALS = "accountCredentialRemovals" + const val CREDENTIAL_SAVE_PREPARED = "prepared" + const val CREDENTIAL_SAVE_SECRET_WRITING = "secret-writing" + const val CREDENTIAL_SAVE_SECRET_WRITTEN = "secret-written" + const val CREDENTIAL_SAVE_ROLLBACK = "rollback" + const val CREDENTIAL_SAVE_ROLLBACK_COMPLETED = "rollback-completed" + } +} + +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 fun desktopDurableMutationAccountScope(account: NextcloudAccountRecord): String = + durableMutationAccountScope(account.toSession(appPassword = "")) + +internal fun desktopAccountPersistenceScopeDigests(account: NextcloudAccountRecord): AccountPersistenceScopeDigests = + accountPersistenceScopeDigests(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..bd5968a6f --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -0,0 +1,206 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +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 suspend fun DesktopAccountOperationGuard.withAccountPrivateStatePublication( + expectedSession: NextcloudSession, + resolveSession: suspend () -> NextcloudSession?, + unavailable: suspend () -> Result, + publish: suspend () -> Result, +): Result = serialize { + val current = resolveSession() + if (current == expectedSession) publish() else unavailable() +} + +internal suspend fun DesktopAccountOperationGuard.persistSessionAndActivateDynamicReads( + persist: suspend () -> NextcloudSession, + activate: suspend (NextcloudSession) -> Unit, +): NextcloudSession = serializeWhenSyncIdle { + val persisted = persist() + withContext(NonCancellable) { activate(persisted) } + currentCoroutineContext().ensureActive() + persisted +} + +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 }, + commitStatusObserved: (Boolean?) -> Unit = {}, + finishCommittedRemoval: () -> Unit = {}, + removeCredential: () -> Boolean, +): Boolean { + return try { + clearProviderPreference() + removeCredential().also { removed -> + commitStatusObserved(removed) + if (!removed) restoreProviderPreference(providerWasEnabled) + } + } catch (failure: Throwable) { + val committed = try { + removalCommitted() + } catch (statusFailure: Throwable) { + failure.addSuppressed(statusFailure) + null + } + commitStatusObserved(committed) + when (committed) { + false -> runCatching { restoreProviderPreference(providerWasEnabled) } + .exceptionOrNull() + ?.let(failure::addSuppressed) + true -> runCatching(finishCommittedRemoval) + .exceptionOrNull() + ?.let(failure::addSuppressed) + null -> Unit + } + throw failure + } +} + +internal fun shouldResumeDesktopWritesAfterRemovalFailure( + removalCommitted: Boolean, + remoteRevocationAttempted: Boolean, + credentialRemovalStatus: Boolean?, +): Boolean = !removalCommitted && !remoteRevocationAttempted && credentialRemovalStatus == false + +internal fun recoverDesktopAccountAfterPrecommitFailure( + restoreProviderPreference: () -> Unit, + resumeVirtualFileSystem: () -> Unit, + resumeWindowsCloudFiles: () -> Unit = {}, + reopenSession: () -> Unit, + restartLifecycle: () -> Unit, +): Throwable? { + var recoveryFailure: Throwable? = null + listOf( + restoreProviderPreference, resumeVirtualFileSystem, resumeWindowsCloudFiles, reopenSession, restartLifecycle, + ).forEach { action -> + runCatching(action).exceptionOrNull()?.let { failure -> + recoveryFailure?.addSuppressed(failure) ?: run { recoveryFailure = failure } + } + } + return recoveryFailure +} + +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..9de08d2fe --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -0,0 +1,605 @@ +package dev.obiente.nextcloudnative.app + +import java.util.prefs.Preferences +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext + +internal const val DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE = + "This account has cleanup state written by a newer app version." + +internal fun unknownCleanupStateRejection() = + VirtualFileStorageActionResult.Rejected(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE) + +internal fun requireDesktopAccountActivationAllowed(blockedByUnknownCleanup: Boolean) { + check(!blockedByUnknownCleanup) { DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE } +} + +internal fun DesktopAccountSyncPairCleanupJournal.requireAccountActivationAllowed(record: NextcloudAccountRecord) = + requireDesktopAccountActivationAllowed( + blocksAccountActivation(desktopFileCacheAccountId(record), record.id.storageKey), + ) + +internal fun loadDesktopSessionAfterCleanupGate( + record: NextcloudAccountRecord?, + cleanupJournal: DesktopAccountSyncPairCleanupJournal, + load: () -> NextcloudSession?, + publish: (NextcloudSession) -> Unit, +): NextcloudSession? { + requireDesktopAccountActivationAllowed(cleanupJournal.blocksAllAccountActivation()) + record?.let(cleanupJournal::requireAccountActivationAllowed) + return load()?.also(publish) +} + +internal enum class DesktopAccountSyncPairCleanupPhase { + Prepared, + Committed, + Unknown, +} + +internal enum class DesktopAccountOwnership { + Present, + Absent, + Unknown, +} + +internal data class DesktopAccountSyncPairCleanup( + val accountId: String, + val phase: DesktopAccountSyncPairCleanupPhase, + val durableMutationAccountScope: String? = null, + val accountStorageKey: String? = null, + val legacyAccountScopeDigest: String? = null, +) + +internal fun DesktopAccountSyncPairCleanup.matchesAccountActivation( + accountId: String, + accountStorageKey: String, +): Boolean = this.accountId == accountId || this.accountStorageKey == accountStorageKey + +internal class DesktopAccountSyncPairCleanupJournal( + private val preferences: Preferences, + private val recordMalformed: () -> Unit = {}, +) { + private val malformedReported = AtomicBoolean() + + fun prepare( + accountId: String, + durableMutationAccountScope: String? = null, + accountStorageKey: String? = null, + ) = prepare(accountId, durableMutationAccountScope, accountStorageKey, legacyAccountScopeDigest = null) + + fun prepare( + accountId: String, + durableMutationAccountScope: String?, + accountStorageKey: String?, + legacyAccountScopeDigest: String?, + ) = persist( + accountId, + DesktopAccountSyncPairCleanupPhase.Prepared, + durableMutationAccountScope, + accountStorageKey, + legacyAccountScopeDigest, + ) + + fun commit(accountId: String) { + val current = decode(accountId, preferences.get(cleanupKey(accountId), null)) + check(current.phase != DesktopAccountSyncPairCleanupPhase.Unknown) { + "The desktop account sync cleanup journal phase is unsupported." + } + persist( + accountId, + DesktopAccountSyncPairCleanupPhase.Committed, + current.durableMutationAccountScope, + current.accountStorageKey, + current.legacyAccountScopeDigest, + ) + } + + fun clear(accountId: String) { + validateDesktopSyncPairCleanupAccountId(accountId) + preferences.remove(cleanupKey(accountId)) + preferences.flush() + } + + fun blocksAccountActivation(accountId: String, accountStorageKey: String? = null): Boolean { + validateDesktopSyncPairCleanupAccountId(accountId) + require(accountStorageKey == null || accountStorageKey.matches(ACCOUNT_STORAGE_KEY_PATTERN)) { + "The desktop account storage cleanup identity is invalid." + } + val blocked = if (accountStorageKey == null) { + val encoded = preferences.get(cleanupKey(accountId), null) + encoded != null && decode(accountId, encoded).phase == DesktopAccountSyncPairCleanupPhase.Unknown + } else { + pending().any { cleanup -> + cleanup.phase == DesktopAccountSyncPairCleanupPhase.Unknown && + (cleanup.accountStorageKey == null || cleanup.matchesAccountActivation(accountId, accountStorageKey)) + } + } + if (blocked) recordMalformedOnce() + return blocked + } + + fun blocksAllAccountActivation(): Boolean { + val blocked = preferences.keys().asSequence() + .filter { key -> key.startsWith(KEY_PREFIX) } + .any { key -> + val accountId = key.removePrefix(KEY_PREFIX) + val cleanup = runCatching { + validateDesktopSyncPairCleanupAccountId(accountId) + decode(accountId, preferences.get(key, null)) + }.getOrNull() + cleanup == null || + cleanup.phase == DesktopAccountSyncPairCleanupPhase.Unknown && cleanup.accountStorageKey == null + } + if (blocked) recordMalformedOnce() + return blocked + } + + fun pending(): List { + var malformedEntryFound = false + val cleanups = preferences.keys() + .asSequence() + .filter { key -> key.startsWith(KEY_PREFIX) } + .mapNotNull { key -> + val accountId = key.removePrefix(KEY_PREFIX) + val cleanup = runCatching { + validateDesktopSyncPairCleanupAccountId(accountId) + decode(accountId, preferences.get(key, null)).also { cleanup -> + if (cleanup.phase == DesktopAccountSyncPairCleanupPhase.Unknown) malformedEntryFound = true + } + }.getOrNull() + if (cleanup == null) malformedEntryFound = true + cleanup + } + .toList() + if (malformedEntryFound) recordMalformedOnce() + check(cleanups.size <= MAX_LOCAL_ACCOUNTS) { + "The desktop account sync cleanup journal is too large." + } + return cleanups + } + + fun pendingForAccountActivation(accountId: String, accountStorageKey: String): List { + validateDesktopSyncPairCleanupAccountId(accountId) + require(accountStorageKey.matches(ACCOUNT_STORAGE_KEY_PATTERN)) { + "The desktop account storage cleanup identity is invalid." + } + return pending().filter { cleanup -> cleanup.matchesAccountActivation(accountId, accountStorageKey) } + } + + private fun persist( + accountId: String, + phase: DesktopAccountSyncPairCleanupPhase, + durableMutationAccountScope: String?, + accountStorageKey: String?, + legacyAccountScopeDigest: String?, + ) { + validateDesktopSyncPairCleanupAccountId(accountId) + require( + durableMutationAccountScope == null || durableMutationAccountScope.isCanonicalGroupwareMutationAccountScope(), + ) { "The desktop durable mutation cleanup identity is invalid." } + require(accountStorageKey == null || accountStorageKey.matches(ACCOUNT_STORAGE_KEY_PATTERN)) { + "The desktop account storage cleanup identity is invalid." + } + require(legacyAccountScopeDigest == null || legacyAccountScopeDigest.matches(ACCOUNT_STORAGE_KEY_PATTERN)) { + "The desktop legacy workspace cleanup identity is invalid." + } + val key = cleanupKey(accountId) + val current = preferences.get(key, null)?.let { decode(accountId, it) } + check(current == null || current.phase != DesktopAccountSyncPairCleanupPhase.Unknown) { + "The desktop account sync cleanup journal phase is unsupported." + } + 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( + key, + encode(phase, durableMutationAccountScope, accountStorageKey, legacyAccountScopeDigest), + ) + preferences.flush() + } + + private fun decode(accountId: String, encoded: String?): DesktopAccountSyncPairCleanup { + val legacyPhase = when (encoded) { + PREPARED -> DesktopAccountSyncPairCleanupPhase.Prepared + COMMITTED -> DesktopAccountSyncPairCleanupPhase.Committed + else -> null + } + if (legacyPhase != null) return DesktopAccountSyncPairCleanup(accountId, legacyPhase) + val fields = encoded?.split(VALUE_SEPARATOR).orEmpty() + val phase = when (fields.getOrNull(1)) { + PREPARED -> DesktopAccountSyncPairCleanupPhase.Prepared + COMMITTED -> DesktopAccountSyncPairCleanupPhase.Committed + else -> DesktopAccountSyncPairCleanupPhase.Unknown + } + val scope = fields.getOrNull(2)?.takeIf(String::isCanonicalGroupwareMutationAccountScope) + val accountStorageKey = fields.getOrNull(3)?.takeIf { it.matches(ACCOUNT_STORAGE_KEY_PATTERN) } + val legacyAccountScopeDigest = fields.getOrNull(4)?.takeIf { it.matches(ACCOUNT_STORAGE_KEY_PATTERN) } + return if (fields.size == 3 && fields[0] == VALUE_VERSION && scope != null) { + DesktopAccountSyncPairCleanup(accountId, phase, scope) + } else if ( + fields.size == 4 && fields[0] == VALUE_VERSION_WITH_ACCOUNT_STORAGE && + scope != null && accountStorageKey != null + ) { + DesktopAccountSyncPairCleanup(accountId, phase, scope, accountStorageKey) + } else if ( + fields.size == 5 && fields[0] == VALUE_VERSION_WITH_LEGACY_ACCOUNT_SCOPE && + scope != null && accountStorageKey != null && legacyAccountScopeDigest != null + ) { + DesktopAccountSyncPairCleanup(accountId, phase, scope, accountStorageKey, legacyAccountScopeDigest) + } else if ( + fields.size > 5 && fields[0] == VALUE_VERSION_WITH_LEGACY_ACCOUNT_SCOPE && + scope != null && accountStorageKey != null && legacyAccountScopeDigest != null + ) { + DesktopAccountSyncPairCleanup( + accountId, + DesktopAccountSyncPairCleanupPhase.Unknown, + scope, + accountStorageKey, + legacyAccountScopeDigest, + ) + } else { + DesktopAccountSyncPairCleanup(accountId, DesktopAccountSyncPairCleanupPhase.Unknown) + } + } + + private fun encode( + phase: DesktopAccountSyncPairCleanupPhase, + scope: String?, + accountStorageKey: String?, + legacyAccountScopeDigest: String?, + ): String { + val encodedPhase = if (phase == DesktopAccountSyncPairCleanupPhase.Prepared) PREPARED else COMMITTED + if (legacyAccountScopeDigest != null) { + requireNotNull(scope) + requireNotNull(accountStorageKey) + return listOf( + VALUE_VERSION_WITH_LEGACY_ACCOUNT_SCOPE, + encodedPhase, + scope, + accountStorageKey, + legacyAccountScopeDigest, + ).joinToString(VALUE_SEPARATOR) + } + if (accountStorageKey != null) { + requireNotNull(scope) + return listOf(VALUE_VERSION_WITH_ACCOUNT_STORAGE, encodedPhase, scope, accountStorageKey) + .joinToString(VALUE_SEPARATOR) + } + return scope?.let { "$VALUE_VERSION$VALUE_SEPARATOR$encodedPhase$VALUE_SEPARATOR$it" } ?: encodedPhase + } + + private fun recordMalformedOnce() { + if (malformedReported.compareAndSet(false, true)) runCatching(recordMalformed) + } + + 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" + const val VALUE_VERSION = "v2" + const val VALUE_VERSION_WITH_ACCOUNT_STORAGE = "v3" + const val VALUE_VERSION_WITH_LEGACY_ACCOUNT_SCOPE = "v4" + const val VALUE_SEPARATOR = "|" + val ACCOUNT_STORAGE_KEY_PATTERN = Regex("[0-9a-f]{64}") + } +} + +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 loadDesktopRemoteRevocationSession( + activeAccountId: NextcloudAccountId?, + expectedSession: NextcloudSession?, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): NextcloudSession? { + if (expectedSession == null) return null + val activeSession = activeAccountId?.let(loadSession) + check(activeSession == expectedSession) { + "The account changed before its remote session could be revoked." + } + return activeSession +} + +internal fun removeDesktopAccountCredential( + preferences: Preferences, + providerAccountId: String?, + credentialStillExists: () -> Boolean, + commitStatusObserved: (Boolean?) -> Unit = {}, + finishCommittedRemoval: () -> Unit = {}, + 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() }, + commitStatusObserved = commitStatusObserved, + finishCommittedRemoval = finishCommittedRemoval, + removeCredential = removeCredential, + ) +} + +internal fun setDesktopVirtualFileProviderPreference( + preferences: Preferences, + accountId: String, + enabled: Boolean, +) { + val key = virtualFileProviderPreferenceKey(accountId) + if (enabled) preferences.putBoolean(key, true) else preferences.remove(key) + preferences.flush() +} + +internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( + accountId: String, + durableMutationAccountScope: String? = null, + accountStorageKey: String? = null, + legacyAccountScopeDigest: String? = null, + prepareCleanup: suspend (String, String?, String?, String?) -> Unit, + commitCleanup: suspend (String) -> Unit, + clearCleanup: suspend (String) -> Unit, + accountOwnership: (String) -> DesktopAccountOwnership, + removeCredential: suspend () -> Boolean, + removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, + retireCommittedAccount: () -> Unit = {}, + recordCleanupFailure: suspend (Exception) -> Unit, +): Boolean { + prepareCleanup(accountId, durableMutationAccountScope, accountStorageKey, legacyAccountScopeDigest) + val removed = try { + removeCredential() + } catch (failure: Throwable) { + val ownership = runCatching { accountOwnership(accountId) } + if (ownership.getOrNull() == DesktopAccountOwnership.Absent) { + runCatching(retireCommittedAccount).exceptionOrNull()?.let(failure::addSuppressed) + } + runCatching { + when (ownership.getOrThrow()) { + DesktopAccountOwnership.Present -> clearCleanup(accountId) + DesktopAccountOwnership.Absent -> commitCleanup(accountId) + DesktopAccountOwnership.Unknown -> Unit + } + }.exceptionOrNull()?.let(failure::addSuppressed) + throw failure + } + if (!removed) { + clearCleanup(accountId) + return false + } + retireCommittedAccount() + try { + commitCleanup(accountId) + removeSyncPairs( + DesktopAccountSyncPairCleanup( + accountId, + DesktopAccountSyncPairCleanupPhase.Committed, + durableMutationAccountScope, + accountStorageKey, + legacyAccountScopeDigest, + ), + ) + clearCleanup(accountId) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + runCatching { recordCleanupFailure(failure) } + } + return true +} + +internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( + accountId: String?, + durableMutationAccountScope: String? = null, + accountStorageKey: String? = null, + legacyAccountScopeDigest: String? = null, + cleanupJournal: DesktopAccountSyncPairCleanupJournal, + accountOwnership: (String) -> DesktopAccountOwnership, + commitRemoval: suspend () -> Unit, + removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, + retireCommittedAccount: () -> Unit = {}, +) { + if (accountId == null) { + commitRemoval() + return + } + removeDesktopAccountBeforeSyncPairCleanup( + accountId = accountId, + durableMutationAccountScope = durableMutationAccountScope, + accountStorageKey = accountStorageKey, + legacyAccountScopeDigest = legacyAccountScopeDigest, + prepareCleanup = cleanupJournal::prepare, + commitCleanup = cleanupJournal::commit, + clearCleanup = cleanupJournal::clear, + accountOwnership = accountOwnership, + removeCredential = { + commitRemoval() + true + }, + removeSyncPairs = removeSyncPairs, + retireCommittedAccount = retireCommittedAccount, + recordCleanupFailure = { failure -> + recordDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(accountId, failure)) + }, + ) +} + +internal suspend fun commitDesktopAccountRemovalBeforeVirtualFileTeardown( + commitRemoval: suspend () -> Unit, + teardownVirtualFiles: () -> Unit, +) { + commitRemoval() + teardownVirtualFiles() +} + +internal suspend fun completeDesktopSignOutAfterRemoteRevocation( + session: Session?, + revokeRemoteSession: suspend (Session) -> Unit, + completeLocalRemoval: suspend () -> Unit, +) { + if (session == null) { + completeLocalRemoval() + return + } + var revocationFailure: Throwable? = null + try { + revokeRemoteSession(session) + } catch (failure: Exception) { + revocationFailure = failure + } + try { + withContext(NonCancellable) { completeLocalRemoval() } + } catch (failure: Throwable) { + revocationFailure?.let(failure::addSuppressed) + throw failure + } + revocationFailure?.let { throw it } + currentCoroutineContext().ensureActive() +} + +internal fun finishCommittedDesktopAccountRemoval( + markRemovalCommitted: () -> Unit, + teardownVirtualFiles: () -> Unit, + clearDiagnosticIdentity: () -> Unit, + clearIntakeIdentity: () -> Unit, +) { + markRemovalCommitted() + var firstFailure: Throwable? = null + listOf(teardownVirtualFiles, clearDiagnosticIdentity, clearIntakeIdentity).forEach { action -> + runCatching(action).onFailure { failure -> + if (firstFailure == null) firstFailure = failure else firstFailure.addSuppressed(failure) + } + } + firstFailure?.let { throw it } +} + +internal suspend fun retryDesktopAccountSyncPairCleanup( + cleanup: DesktopAccountSyncPairCleanup, + accountOwnership: (String) -> DesktopAccountOwnership, + removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, + clearCleanup: suspend (String) -> Unit, + reactivatePresentAccount: (DesktopAccountSyncPairCleanup) -> Unit = {}, +) { + when (cleanup.phase) { + DesktopAccountSyncPairCleanupPhase.Unknown -> return + DesktopAccountSyncPairCleanupPhase.Prepared -> { + when (accountOwnership(cleanup.accountId)) { + DesktopAccountOwnership.Present -> { + clearCleanup(cleanup.accountId) + reactivatePresentAccount(cleanup) + return + } + DesktopAccountOwnership.Unknown -> return + DesktopAccountOwnership.Absent -> Unit + } + } + DesktopAccountSyncPairCleanupPhase.Committed -> Unit + } + removeSyncPairs(cleanup) + clearCleanup(cleanup.accountId) +} + +internal suspend fun retryPendingDesktopAccountSyncPairCleanups( + cleanupJournal: DesktopAccountSyncPairCleanupJournal, + accountOwnership: (String) -> DesktopAccountOwnership, + removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, + recordCleanupFailure: (String, Exception) -> Unit, + reactivatePresentAccount: (DesktopAccountSyncPairCleanup) -> Unit = {}, +) { + cleanupJournal.pending().forEach { cleanup -> + try { + retryDesktopAccountSyncPairCleanup( + cleanup, + accountOwnership, + removeSyncPairs, + cleanupJournal::clear, + reactivatePresentAccount, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + runCatching { recordCleanupFailure(cleanup.accountId, failure) } + } + } +} + +internal suspend fun recoverDesktopBackgroundAccountSyncPairCleanups( + retry: suspend () -> Unit, + recordFailure: (Exception) -> Unit, +) { + try { + retry() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + runCatching { recordFailure(failure) } + } +} + +internal suspend fun retryDesktopAccountSyncPairCleanupsBounded( + maximumAttempts: Int = 3, + waitBeforeNextAttempt: suspend () -> Unit = { delay(1_000L) }, + retryPending: suspend () -> Boolean, +) { + require(maximumAttempts > 0) + repeat(maximumAttempts) { attempt -> + if (!retryPending()) return + if (attempt + 1 < maximumAttempts) waitBeforeNextAttempt() + } +} + +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(), + ) + +internal fun desktopAccountSyncPairCleanupJournalFailureDiagnostic(failure: Exception) = + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.Sync, + operation = "account.remove-sync-cleanup-journal", + outcome = "failed", + exception = failure.toSupportDiagnosticExceptionDraft(), + ) + +internal fun desktopAccountSyncPairCleanupJournalMalformedDiagnostic() = + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Sync, + operation = "account.remove-sync-cleanup-journal", + outcome = "unknown-entry-preserved", + ) 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..6e684dc58 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountSecretReference.kt @@ -0,0 +1,25 @@ +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", + ), + ) + +internal fun desktopAccountCredentialRollbackReference(accountId: NextcloudAccountId): DesktopSecretReference = + DesktopSecretReference( + targetName = "Obiente/NextcloudNative/session-rollback/v1/${accountId.storageKey}", + label = "Nextcloud Native account credential rollback", + attributes = linkedMapOf( + "application" to "dev.obiente.nextcloudnative", + "purpose" to "account-session-rollback", + "account" to accountId.storageKey, + "schema" to "1", + ), + ) 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/DesktopDeckCardDraftAccountGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftAccountGuard.kt new file mode 100644 index 000000000..3846ac880 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftAccountGuard.kt @@ -0,0 +1,13 @@ +package dev.obiente.nextcloudnative.app + +internal suspend fun withDesktopDeckCardDraftSession( + expectedSession: NextcloudSession, + guard: DesktopAccountOperationGuard, + accountCredentials: DesktopAccountCredentialPersistence, + action: suspend () -> Result, +): Result = guard.withAccountPrivateStatePublication( + expectedSession = expectedSession, + resolveSession = { accountCredentials.loadSession(expectedSession.accountId) }, + unavailable = { error("The account changed before the Deck draft operation could complete.") }, + publish = action, +) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt index d3b211317..dc97cef09 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt @@ -36,6 +36,7 @@ internal class DesktopDeckCardDraftStore( ) { @Synchronized fun load(session: NextcloudSession, key: DeckCardDraftKey): PersistedDeckCardDraft? { + migrateLegacyEntry(session.accountId.storageKey, desktopFileCacheAccountId(session), key) val file = draftFile(session, key) val quarantine = quarantineFile(file) if (quarantine.exists()) { @@ -44,32 +45,42 @@ internal class DesktopDeckCardDraftStore( } if (!file.exists()) return null val encryptionKey = keyProvider.encryptionKey() - return readAuthenticated(file, encryptionKey, key).draft + return readAuthenticated(file, encryptionKey, key, session.accountId.storageKey).draft } @Synchronized fun save(session: NextcloudSession, persisted: PersistedDeckCardDraft) { + check(migrateLegacyEntry(session.accountId.storageKey, desktopFileCacheAccountId(session), persisted.key)) { + "The previous Deck card draft could not be retired before saving its replacement." + } + migrateLegacyEntries(session) val updatedAtEpochMillis = nowEpochMillis() require(updatedAtEpochMillis >= 0L) { "The Deck draft timestamp is invalid." } val file = draftFile(session, persisted.key) val encryptionKey = keyProvider.encryptionKey() if (file.exists()) { - readAuthenticated(file, encryptionKey, persisted.key) + readAuthenticated(file, encryptionKey, persisted.key, session.accountId.storageKey) } else { - ensureCapacityForNewDraft(encryptionKey) + ensureCapacityForNewDraft(session.accountId.storageKey, encryptionKey) } - val plaintext = encodePlaintext(persisted, updatedAtEpochMillis) + val plaintext = encodePlaintext( + persisted, + updatedAtEpochMillis, + session.accountId.storageKey, + file.name, + ) require(plaintext.size <= MAX_PLAINTEXT_BYTES) { "The Deck card draft is too large." } val envelope = encrypt(plaintext, file.name, encryptionKey) require(envelope.size.toLong() <= MAX_ENVELOPE_BYTES) { "The Deck card draft is too large." } val verified = decode(envelope, file.name, encryptionKey) + requireStorageOwner(verified, session.accountId.storageKey, file.name) check(verified.draft == persisted && verified.updatedAtEpochMillis == updatedAtEpochMillis) { "The Deck card draft could not be verified." } ensurePrivateDirectory() clearQuarantineBeforeSave(file) publish(file, envelope) - prune(encryptionKey) + prune(session.accountId.storageKey, encryptionKey) } @Synchronized @@ -78,10 +89,23 @@ internal class DesktopDeckCardDraftStore( key: DeckCardDraftKey, discardUnreadable: Boolean = false, ) { + if (discardUnreadable) { + check(!Files.isSymbolicLink(root.toPath())) { + "Desktop Deck draft storage must not be a symbolic link." + } + val legacy = File(root, legacyStorageFileName(desktopFileCacheAccountId(session), key)) + val file = draftFile(session, key) + check( + deleteDurably(legacy) && deleteDurably(legacyQuarantineFile(legacy)) && + deleteDurably(file) && deleteDurably(quarantineFile(file)), + ) { "The Deck card draft could not be cleared." } + return + } + migrateLegacyEntry(session.accountId.storageKey, desktopFileCacheAccountId(session), key) val file = draftFile(session, key) if (file.exists() && !discardUnreadable) { val encryptionKey = keyProvider.encryptionKey() - readAuthenticated(file, encryptionKey, key) + readAuthenticated(file, encryptionKey, key, session.accountId.storageKey) } check(!Files.isSymbolicLink(root.toPath())) { "Desktop Deck draft storage must not be a symbolic link." @@ -93,11 +117,16 @@ internal class DesktopDeckCardDraftStore( @Synchronized fun quarantineAfterSubmit(session: NextcloudSession, key: DeckCardDraftKey) { + val legacy = File(root, legacyStorageFileName(desktopFileCacheAccountId(session), key)) + val legacyMarker = legacyQuarantineFile(legacy) val file = draftFile(session, key) val quarantine = quarantineFile(file) ensurePrivateDirectory() + publish(legacyMarker, SUBMITTED_MARKER_BYTES) publish(quarantine, SUBMITTED_MARKER_BYTES) - if (deleteDurably(file)) deleteDurably(quarantine) + if (deleteDurably(legacy) && deleteDurably(file) && deleteDurably(legacyMarker)) { + deleteDurably(quarantine) + } } @Synchronized @@ -109,27 +138,163 @@ internal class DesktopDeckCardDraftStore( val files = checkNotNull(root.listFiles()) { "Desktop Deck draft storage cannot be inspected for reset." }.filter { file -> - file.name.matches(DRAFT_FILE_PATTERN) || file.name.matches(SUBMITTED_FILE_PATTERN) + file.name.matches(DRAFT_FILE_PATTERN) || file.name.matches(SUBMITTED_FILE_PATTERN) || + file.name.matches(LEGACY_DRAFT_FILE_PATTERN) || file.name.matches(LEGACY_SUBMITTED_FILE_PATTERN) } check(files.all(::deleteDurably)) { "Saved Deck card drafts could not be discarded." } } + @Synchronized + fun migrateLegacyEntries(session: NextcloudSession) { + val encryptionKey = try { + keyProvider.encryptionKey() + } catch (_: Exception) { + return + } + root.listFiles().orEmpty() + .filter { file -> file.name.matches(LEGACY_DRAFT_FILE_PATTERN) } + .forEach { file -> + val stored = try { + readAuthenticated(file, encryptionKey) + } catch (_: DesktopDeckDraftRecoveryException) { + null + } ?: return@forEach + if ( + stored.accountStorageKey == null && + stored.storageFileName == null && + legacyStorageFileName(desktopFileCacheAccountId(session), stored.draft.key) == file.name + ) { + migrateLegacyEntry( + session.accountId.storageKey, + desktopFileCacheAccountId(session), + stored.draft.key, + stored, + encryptionKey, + ) + } + } + } + + @Synchronized + fun removeAccount(accountStorageKey: String, legacyAccountIdentity: String) { + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(accountStorageKey)) + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(legacyAccountIdentity)) + if (!root.exists()) return + check(root.isDirectory && !Files.isSymbolicLink(root.toPath())) { + "Desktop Deck draft storage cannot be removed safely." + } + val files = checkNotNull(root.listFiles()) { + "Desktop Deck draft storage cannot be inspected for account removal." + } + val targets = files.filter { file -> + file.name.startsWith(accountDraftPrefix(accountStorageKey)) || + file.name.startsWith(accountSubmittedPrefix(accountStorageKey)) + }.toMutableSet() + val encryptionKey = try { + keyProvider.encryptionKey() + } catch (_: Exception) { + null + } + if (encryptionKey != null) { + files.filter { file -> file.name.matches(LEGACY_DRAFT_FILE_PATTERN) }.forEach { file -> + val stored = try { + readAuthenticated(file, encryptionKey) + } catch (_: DesktopDeckDraftRecoveryException) { + null + } ?: return@forEach + if ( + stored.accountStorageKey == null && + stored.storageFileName == null && + legacyStorageFileName(legacyAccountIdentity, stored.draft.key) == file.name + ) { + targets += file + targets += legacyQuarantineFile(file) + } + } + } + check(targets.all(::deleteDurably)) { + "Saved Deck card drafts for the account could not be removed." + } + } + internal fun storageFileName(session: NextcloudSession, key: DeckCardDraftKey): String { + return storageFileName(session.accountId.storageKey, key) + } + + private fun storageFileName(accountStorageKey: String, key: DeckCardDraftKey): String { val scope = listOf( - desktopFileCacheAccountId(session), key.boardId.toString(), key.stackId.toString(), key.cardId?.toString() ?: "new", ).joinToString(separator = ":") - return "$FILE_PREFIX${sha256Hex(scope)}$FILE_SUFFIX" + return "${accountDraftPrefix(accountStorageKey)}${sha256Hex(scope)}$FILE_SUFFIX" + } + + internal fun legacyStorageFileName(accountIdentity: String, key: DeckCardDraftKey): String { + val scope = listOf( + accountIdentity, + key.boardId.toString(), + key.stackId.toString(), + key.cardId?.toString() ?: "new", + ).joinToString(separator = ":") + return "$LEGACY_FILE_PREFIX${sha256Hex(scope)}$FILE_SUFFIX" } private fun draftFile(session: NextcloudSession, key: DeckCardDraftKey): File = File(root, storageFileName(session, key)) private fun quarantineFile(draftFile: File): File { - val digest = draftFile.name.removePrefix(FILE_PREFIX).removeSuffix(FILE_SUFFIX) - return File(root, "$SUBMITTED_FILE_PREFIX$digest$SUBMITTED_FILE_SUFFIX") + val identity = draftFile.name.removePrefix(FILE_PREFIX).removeSuffix(FILE_SUFFIX) + return File(root, "$SUBMITTED_FILE_PREFIX$identity$SUBMITTED_FILE_SUFFIX") + } + + private fun legacyQuarantineFile(draftFile: File): File { + val digest = draftFile.name.removePrefix(LEGACY_FILE_PREFIX).removeSuffix(FILE_SUFFIX) + return File(root, "$LEGACY_SUBMITTED_FILE_PREFIX$digest$SUBMITTED_FILE_SUFFIX") + } + + private fun migrateLegacyEntry( + accountStorageKey: String, + legacyAccountIdentity: String, + key: DeckCardDraftKey, + decodedLegacy: StoredDeckCardDraft? = null, + providedEncryptionKey: ByteArray? = null, + ): Boolean { + val legacyFile = File(root, legacyStorageFileName(legacyAccountIdentity, key)) + val legacyMarker = legacyQuarantineFile(legacyFile) + val target = File(root, storageFileName(accountStorageKey, key)) + val targetMarker = quarantineFile(target) + if (!legacyFile.exists()) { + if (!legacyMarker.exists()) return true + ensurePrivateDirectory() + publish(targetMarker, SUBMITTED_MARKER_BYTES) + return deleteDurably(legacyMarker) + } + val encryptionKey = providedEncryptionKey ?: keyProvider.encryptionKey() + val legacy = decodedLegacy ?: readAuthenticated(legacyFile, encryptionKey, key) + if ( + legacy.accountStorageKey != null || legacy.storageFileName != null || + legacy.draft.key != key + ) { + throw DesktopDeckDraftRecoveryException( + IllegalArgumentException("The legacy Deck draft identity does not match."), + ) + } + val plaintext = encodePlaintext( + legacy.draft, + legacy.updatedAtEpochMillis, + accountStorageKey, + target.name, + ) + val envelope = encrypt(plaintext, target.name, encryptionKey) + ensurePrivateDirectory() + if (legacyMarker.exists()) publish(targetMarker, SUBMITTED_MARKER_BYTES) + if (target.exists()) { + readAuthenticated(target, encryptionKey, key, accountStorageKey) + } else { + publish(target, envelope) + } + return deleteDurably(legacyFile) && deleteDurably(legacyMarker) } private fun clearQuarantineBeforeSave(draftFile: File) { @@ -143,8 +308,12 @@ internal class DesktopDeckCardDraftStore( private fun encodePlaintext( persisted: PersistedDeckCardDraft, updatedAtEpochMillis: Long, + accountStorageKey: String, + storageFileName: String, ): ByteArray = JSONObject() .put("version", PLAINTEXT_FORMAT_VERSION) + .put("accountStorageKey", accountStorageKey) + .put("storageFileName", storageFileName) .put("updatedAtEpochMillis", updatedAtEpochMillis) .put("boardId", persisted.key.boardId) .put("stackId", persisted.key.stackId) @@ -162,6 +331,7 @@ internal class DesktopDeckCardDraftStore( file: File, encryptionKey: ByteArray, expectedKey: DeckCardDraftKey? = null, + expectedAccountStorageKey: String? = null, ): StoredDeckCardDraft = try { if (!file.isSafeRegularFile() || file.length() !in 1..MAX_ENVELOPE_BYTES) { throw DesktopDeckDraftRecoveryException( @@ -174,6 +344,7 @@ internal class DesktopDeckCardDraftStore( IllegalArgumentException("The Deck draft resource identity does not match."), ) } + expectedAccountStorageKey?.let { requireStorageOwner(stored, it, file.name) } stored } catch (failure: DesktopDeckDraftRecoveryException) { throw failure @@ -207,11 +378,20 @@ internal class DesktopDeckCardDraftStore( val plaintext = cipher.doFinal(ciphertext) require(plaintext.size <= MAX_PLAINTEXT_BYTES) { "The Deck draft is too large." } val value = JSONObject(plaintext.decodeToString()) - require(value.getInt("version") == PLAINTEXT_FORMAT_VERSION) { + val version = value.getInt("version") + require(version == LEGACY_PLAINTEXT_FORMAT_VERSION || version == PLAINTEXT_FORMAT_VERSION) { "The Deck draft format is unsupported." } val updatedAtEpochMillis = value.getLong("updatedAtEpochMillis") require(updatedAtEpochMillis >= 0L) { "The Deck draft timestamp is invalid." } + val accountStorageKey = value.optString("accountStorageKey").takeIf(String::isNotBlank) + val storageFileName = value.optString("storageFileName").takeIf(String::isNotBlank) + require( + version == LEGACY_PLAINTEXT_FORMAT_VERSION && accountStorageKey == null && storageFileName == null || + version == PLAINTEXT_FORMAT_VERSION && + accountStorageKey?.matches(ACCOUNT_STORAGE_KEY_PATTERN) == true && + storageFileName?.matches(DRAFT_FILE_PATTERN) == true, + ) { "The Deck draft account storage metadata is invalid." } StoredDeckCardDraft( draft = PersistedDeckCardDraft( key = DeckCardDraftKey( @@ -233,11 +413,21 @@ internal class DesktopDeckCardDraftStore( ), ), updatedAtEpochMillis = updatedAtEpochMillis, + accountStorageKey = accountStorageKey, + storageFileName = storageFileName, ) } catch (failure: Exception) { throw DesktopDeckDraftRecoveryException(failure) } + private fun requireStorageOwner(stored: StoredDeckCardDraft, expectedOwner: String, expectedFileName: String) { + if (stored.accountStorageKey != expectedOwner || stored.storageFileName != expectedFileName) { + throw DesktopDeckDraftRecoveryException( + IllegalArgumentException("The Deck draft account storage identity does not match."), + ) + } + } + private fun encrypt( plaintext: ByteArray, fileName: String, @@ -261,12 +451,12 @@ internal class DesktopDeckCardDraftStore( .encodeToByteArray() } - private fun prune(encryptionKey: ByteArray) { + private fun prune(accountStorageKey: String, encryptionKey: ByteArray) { val files = root.listFiles().orEmpty() - .filter { it.name.matches(DRAFT_FILE_PATTERN) } + .filter { it.name.startsWith(accountDraftPrefix(accountStorageKey)) } val entries = files.mapNotNull { file -> val stored = try { - readAuthenticated(file, encryptionKey) + readAuthenticated(file, encryptionKey, expectedAccountStorageKey = accountStorageKey) } catch (_: DesktopDeckDraftRecoveryException) { // A keyring or filesystem failure can make valid ciphertext temporarily unreadable. // Preserve it so a later app process can authenticate and recover the draft. @@ -282,14 +472,14 @@ internal class DesktopDeckCardDraftStore( files.filter { it.name in namesToPrune }.forEach(::deleteDraft) } - private fun ensureCapacityForNewDraft(encryptionKey: ByteArray) { + private fun ensureCapacityForNewDraft(accountStorageKey: String, encryptionKey: ByteArray) { val files = root.listFiles().orEmpty() - .filter { it.name.matches(DRAFT_FILE_PATTERN) } + .filter { it.name.startsWith(accountDraftPrefix(accountStorageKey)) } val overflow = files.size + 1 - DeckCardDraftRetention.MAX_ENTRIES if (overflow <= 0) return val readableFiles = files.count { file -> try { - readAuthenticated(file, encryptionKey) + readAuthenticated(file, encryptionKey, expectedAccountStorageKey = accountStorageKey) true } catch (_: DesktopDeckDraftRecoveryException) { false @@ -374,15 +564,24 @@ internal class DesktopDeckCardDraftStore( private data class StoredDeckCardDraft( val draft: PersistedDeckCardDraft, val updatedAtEpochMillis: Long, + val accountStorageKey: String?, + val storageFileName: String?, ) + private fun accountDraftPrefix(accountStorageKey: String) = "$FILE_PREFIX${accountStorageKey}_" + + private fun accountSubmittedPrefix(accountStorageKey: String) = "$SUBMITTED_FILE_PREFIX${accountStorageKey}_" + internal companion object { - const val FILE_PREFIX = "draft_" + const val FILE_PREFIX = "draft_v2_" + const val LEGACY_FILE_PREFIX = "draft_" const val FILE_SUFFIX = ".json.enc" - const val SUBMITTED_FILE_PREFIX = "submitted_" + const val SUBMITTED_FILE_PREFIX = "submitted_v2_" + const val LEGACY_SUBMITTED_FILE_PREFIX = "submitted_" const val SUBMITTED_FILE_SUFFIX = ".marker" const val ENVELOPE_FORMAT_VERSION = 1 - const val PLAINTEXT_FORMAT_VERSION = 1 + const val LEGACY_PLAINTEXT_FORMAT_VERSION = 1 + const val PLAINTEXT_FORMAT_VERSION = 2 const val AES_KEY_BYTES = 32 const val GCM_NONCE_BYTES = 12 const val GCM_TAG_BYTES = 16 @@ -392,8 +591,11 @@ internal class DesktopDeckCardDraftStore( const val MAX_ENVELOPE_BYTES = 256L * 1024L const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding" const val AES_ALGORITHM = "AES" - val DRAFT_FILE_PATTERN = Regex("^draft_[0-9a-f]{64}\\.json\\.enc$") - val SUBMITTED_FILE_PATTERN = Regex("^submitted_[0-9a-f]{64}\\.marker$") + val DRAFT_FILE_PATTERN = Regex("^draft_v2_[0-9a-f]{64}_[0-9a-f]{64}\\.json\\.enc$") + val SUBMITTED_FILE_PATTERN = Regex("^submitted_v2_[0-9a-f]{64}_[0-9a-f]{64}\\.marker$") + val LEGACY_DRAFT_FILE_PATTERN = Regex("^draft_[0-9a-f]{64}\\.json\\.enc$") + val LEGACY_SUBMITTED_FILE_PATTERN = Regex("^submitted_[0-9a-f]{64}\\.marker$") + private val ACCOUNT_STORAGE_KEY_PATTERN = Regex("[0-9a-f]{64}") val SUBMITTED_MARKER_BYTES = "confirmed\n".encodeToByteArray() } } @@ -419,7 +621,10 @@ internal fun desktopDeckLegacySecretRequired( if (!root.exists()) return false if (!root.isDirectory) return true val entries = listFiles(root) ?: return true - return entries.any { file -> file.name.matches(DesktopDeckCardDraftStore.DRAFT_FILE_PATTERN) } + return entries.any { file -> + file.name.matches(DesktopDeckCardDraftStore.DRAFT_FILE_PATTERN) || + file.name.matches(DesktopDeckCardDraftStore.LEGACY_DRAFT_FILE_PATTERN) + } } internal fun interface DesktopDeckDraftKeyProvider { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStore.kt index eb691eb8f..b4c1761b2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStore.kt @@ -98,6 +98,34 @@ internal class DesktopDurableMutationRecoveryStore( }.getOrDefault(false) } + fun removeAccount(accountScope: String) { + require(accountScope.isCanonicalGroupwareMutationAccountScope()) { "The mutation account scope is invalid." } + if (!Files.exists(root.toPath(), LinkOption.NOFOLLOW_LINKS)) return + val privacy = requirePrivateDirectory(root) + withExclusiveStoreLock(root, privacy) { + val targets = DurableMutationRecoveryKind.entries.flatMap { kind -> + val target = target(accountScope, kind) + listOf(target, File(root, ".${target.name}.part")) + } + var deleted = false + targets.forEach { candidate -> + val path = candidate.toPath() + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + check(Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(path)) { + "Mutation recovery state is not a regular file." + } + requirePrivatePath(candidate, privacy, directory = false) + check(Files.deleteIfExists(path)) { "Could not delete mutation recovery state." } + deleted = true + } + } + if (deleted) syncDirectory(root, privacy) + check(targets.none { Files.exists(it.toPath(), LinkOption.NOFOLLOW_LINKS) }) { + "Could not remove all mutation recovery state for the account." + } + } + } + private fun target(accountScope: String, kind: DurableMutationRecoveryKind): File { require(accountScope.isCanonicalGroupwareMutationAccountScope()) { "The mutation account scope is invalid." } return File(root, "${kind.storageKey}-$accountScope.json") diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCache.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCache.kt new file mode 100644 index 000000000..71301d278 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCache.kt @@ -0,0 +1,117 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.io.FileOutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** Serializes persisted dynamic discovery publications with desktop account retirement. */ +internal class DesktopDynamicDiscoveryCache(private val root: File) { + private val lock = Any() + private val retiredAccounts = mutableSetOf() + private val accountIncarnations = mutableMapOf() + + fun load(accountStorageKey: String, cacheAccountId: String, appId: String): String? = synchronized(lock) { + if (accountStorageKey in retiredAccounts) return@synchronized null + val target = cacheFile(cacheAccountId, appId) ?: return@synchronized null + if (!target.isFile || target.length() !in 1..MAX_PERSISTED_DYNAMIC_DISCOVERY_BYTES.toLong()) { + return@synchronized null + } + runCatching(target::readText).getOrNull() + } + + fun save( + accountStorageKey: String, + cacheAccountId: String, + appId: String, + encoded: String, + producer: DynamicNativeMemoryCacheProducer?, + ) = synchronized(lock) { + val current = producer ?: return@synchronized + require(current.accountStorageKey == accountStorageKey) { + "The dynamic discovery producer belongs to another account." + } + if ( + accountStorageKey in retiredAccounts || + current.incarnation != (accountIncarnations[accountStorageKey] ?: 0L) + ) { + return@synchronized + } + val target = cacheFile(cacheAccountId, appId) ?: return@synchronized + check(root.mkdirs() || root.isDirectory) { "Could not create the dynamic contract cache." } + val temporary = File(root, "${target.name}.part") + try { + FileOutputStream(temporary).use { output -> + output.write(encoded.encodeToByteArray()) + output.fd.sync() + } + try { + Files.move( + temporary.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + temporary.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } + } finally { + temporary.delete() + } + } + + fun fenceAccount(accountStorageKey: String) = synchronized(lock) { + fenceAccountLocked(accountStorageKey) + } + + fun retireAccount(accountStorageKey: String?, cacheAccountId: String) = synchronized(lock) { + accountStorageKey?.let(::fenceAccountLocked) + require(cacheAccountId.matches(ACCOUNT_CACHE_ID)) { "The dynamic discovery cache account is invalid." } + if (!root.exists()) return@synchronized + check(root.isDirectory) { "The dynamic contract cache is unavailable." } + val files = root.listFiles() ?: error("Could not inspect the dynamic contract cache.") + files.forEach { file -> + check(file.isFile && file.name.matches(ACCOUNT_CACHE_FILE)) { + "The dynamic contract cache contains an unexpected entry." + } + } + files.filter { file -> file.name.startsWith("$cacheAccountId-") } + .forEach { file -> + check(file.delete() || !file.exists()) { "Could not clear the dynamic contract cache." } + } + } + + fun activateAccount(accountStorageKey: String) = synchronized(lock) { + retiredAccounts -= accountStorageKey + } + + private fun fenceAccountLocked(accountStorageKey: String) { + if (retiredAccounts.add(accountStorageKey)) { + accountIncarnations[accountStorageKey] = (accountIncarnations[accountStorageKey] ?: 0L) + 1L + } + } + + private fun cacheFile(cacheAccountId: String, appId: String): File? { + if (!cacheAccountId.matches(ACCOUNT_CACHE_ID) || !appId.isSafeDynamicDiscoveryCacheAppId()) return null + return File(root, "$cacheAccountId-$appId.json") + } + + private companion object { + val ACCOUNT_CACHE_ID = Regex("[0-9a-f]{64}") + val ACCOUNT_CACHE_FILE = Regex("${ACCOUNT_CACHE_ID.pattern}-[A-Za-z0-9._-]{1,128}\\.json(?:\\.part)?") + } +} + +internal object DesktopDynamicDiscoveryCacheCoordinator { + private val instances = mutableMapOf() + + fun get(root: File): DesktopDynamicDiscoveryCache = synchronized(this) { + val key = root.absoluteFile.normalize().path + instances.getOrPut(key) { DesktopDynamicDiscoveryCache(root) } + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileCacheReservations.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileCacheReservations.kt new file mode 100644 index 000000000..7457878e3 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileCacheReservations.kt @@ -0,0 +1,50 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean + +internal class DesktopExternalFileCacheReservations { + private val monitor = Any() + private val reservedBytesByRoot = mutableMapOf() + + fun reserve( + root: File, + availableBytes: Long, + declaredByteCount: Long?, + ): DesktopExternalFileCacheReservation { + require(root.isDirectory) + require(availableBytes >= 0L) + require(declaredByteCount == null || declaredByteCount >= 0L) + val key = root.canonicalFile.path + return synchronized(monitor) { + val alreadyReserved = reservedBytesByRoot[key] ?: 0L + val unreserved = (availableBytes - alreadyReserved).coerceAtLeast(0L) + val reserved = declaredByteCount ?: unreserved + check(reserved <= unreserved) { + "Concurrent desktop external-file copies already use the cache limit." + } + if (reserved > 0L) reservedBytesByRoot[key] = alreadyReserved + reserved + DesktopExternalFileCacheReservation(reserved) { + if (reserved > 0L) { + synchronized(monitor) { + val remaining = requireNotNull(reservedBytesByRoot[key]) - reserved + if (remaining == 0L) reservedBytesByRoot.remove(key) else reservedBytesByRoot[key] = remaining + } + } + } + } + } +} + +internal class DesktopExternalFileCacheReservation( + val maximumBytes: Long, + private val release: () -> Unit, +) : AutoCloseable { + private val closed = AtomicBoolean(false) + + override fun close() { + if (closed.compareAndSet(false, true)) release() + } +} + +internal val sharedDesktopExternalFileCacheReservations = DesktopExternalFileCacheReservations() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt index a68c66b7f..5390427dd 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt @@ -7,10 +7,16 @@ import java.awt.Frame import java.awt.GraphicsEnvironment import java.io.File import java.io.FileOutputStream +import java.io.IOException import java.io.RandomAccessFile import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.FileVisitResult import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor import java.nio.file.StandardCopyOption +import java.nio.file.attribute.BasicFileAttributes import java.util.UUID import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Dispatchers @@ -27,8 +33,16 @@ internal class DesktopExternalFileHandoff( private val launchFile: (File) -> Boolean = ::launchDesktopFile, private val exportFile: (File) -> DesktopStagedFileExport = ::exportDesktopStagedFile, private val reservations: DesktopStagingSpaceReservations = sharedDesktopStagingSpaceReservations, + private val cacheReservations: DesktopExternalFileCacheReservations = sharedDesktopExternalFileCacheReservations, + private val maximumCacheBytes: Long = MAX_DESKTOP_EXTERNAL_FILE_CACHE_BYTES, ) { + init { + require(maximumCacheBytes in 1L..MAX_DESKTOP_EXTERNAL_FILE_CACHE_BYTES) + pruneLegacyDesktopExternalFileCache(root) + } + suspend fun launch( + accountId: String, file: NextcloudFile, action: ExternalFileHandoffAction, capability: ExternalFileHandoffCapability, @@ -40,7 +54,7 @@ internal class DesktopExternalFileHandoff( validateDownloadedExternalFile(file, content, capability.maximumInMemoryFileBytes)?.let { rejection -> return@withContext DesktopStagedExternalFile.Rejected(rejection) } - DesktopStagedExternalFile.Ready(stageDetachedCopy(file.name, content.bytes)) + DesktopStagedExternalFile.Ready(stageDetachedCopy(accountId, file.name, content.bytes)) } if (staged is DesktopStagedExternalFile.Rejected) return staged.result staged as DesktopStagedExternalFile.Ready @@ -48,6 +62,7 @@ internal class DesktopExternalFileHandoff( } suspend fun launchStreamed( + accountId: String, file: NextcloudFile, action: ExternalFileHandoffAction, capability: ExternalFileHandoffCapability, @@ -56,6 +71,7 @@ internal class DesktopExternalFileHandoff( validateExternalFileHandoff(file, action, capability)?.let { return it } val staged = withContext(Dispatchers.IO) { stageStreamedCopy( + accountId = accountId, sourceName = file.name, declaredByteCount = file.size, expectedEtag = requireSafeFileRangeEtag(requireNotNull(file.etag)), @@ -66,6 +82,7 @@ internal class DesktopExternalFileHandoff( } suspend fun launchDetached( + accountId: String, attachment: DeckAttachment, action: ExternalFileHandoffAction, capability: ExternalFileHandoffCapability, @@ -77,6 +94,7 @@ internal class DesktopExternalFileHandoff( validateDeckAttachmentHandoff(attachment, action, capability)?.let { return it } val staged = withContext(Dispatchers.IO) { stageStreamedCopy( + accountId = accountId, sourceName = attachment.name, declaredByteCount = attachment.byteCount, download = download, @@ -95,7 +113,7 @@ internal class DesktopExternalFileHandoff( if (launchFile(staged)) { ExternalFileHandoffResult.Launched(action) } else { - staged.parentFile?.deleteRecursively() + staged.parentFile?.let { deleteDesktopExternalFileTree(it.toPath()) } ExternalFileHandoffResult.NoCompatibleApplication(action) } } @@ -107,117 +125,197 @@ internal class DesktopExternalFileHandoff( ExternalFileHandoffResult.NoCompatibleApplication(action) } } finally { - staged.parentFile?.deleteRecursively() + staged.parentFile?.let { deleteDesktopExternalFileTree(it.toPath()) } } } } private suspend fun stageStreamedCopy( + accountId: String, sourceName: String, declaredByteCount: Long?, expectedEtag: String? = null, download: suspend (FileOutputStream, Long) -> DesktopDetachedDownload, ): File { - check(root.isDirectory || root.mkdirs()) { "Could not create the desktop external-file cache." } - val canonicalRoot = root.canonicalFile - pruneDesktopExternalFileCache(canonicalRoot, declaredByteCount ?: 0L) - val reservation = reservations.reserve( - root = canonicalRoot, - declaredByteCount = declaredByteCount, - reserveBytes = STAGED_FILE_FREE_SPACE_RESERVE_BYTES, + val canonicalRoot = prepareAccountRoot(accountId) + val globalRoot = requireNotNull(canonicalRoot.parentFile) + val cacheMaximumBytes = pruneDesktopExternalFileCache( + globalRoot, + declaredByteCount ?: 0L, + maximumBytes = maximumCacheBytes, ) - reservation.use { - val maximumBytes = reservation.maximumBytes - val operationDirectory = File(canonicalRoot, UUID.randomUUID().toString()) - check(operationDirectory.mkdir()) { "Could not create a private desktop handoff directory." } - check(operationDirectory.canonicalFile.parentFile == canonicalRoot) { - "Unsafe desktop handoff directory." - } - val target = File(operationDirectory, sanitizeExternalFileName(sourceName)) - check(target.canonicalFile.parentFile == operationDirectory.canonicalFile) { - "Unsafe desktop handoff filename." - } - val temporary = File.createTempFile("payload-", ".tmp", operationDirectory) - try { - val downloaded = FileOutputStream(temporary).use { output -> - download(output, maximumBytes).also { - output.fd.sync() + cacheReservations.reserve(globalRoot, cacheMaximumBytes, declaredByteCount).use { cacheReservation -> + val reservation = reservations.reserve( + root = canonicalRoot, + declaredByteCount = declaredByteCount, + reserveBytes = STAGED_FILE_FREE_SPACE_RESERVE_BYTES, + ) + reservation.use { + val maximumBytes = minOf(reservation.maximumBytes, cacheReservation.maximumBytes) + val operationDirectory = File(canonicalRoot, UUID.randomUUID().toString()) + check(operationDirectory.mkdir()) { "Could not create a private desktop handoff directory." } + requireSafeDesktopExternalFileOperation(canonicalRoot, operationDirectory) + val target = File(operationDirectory, sanitizeExternalFileName(sourceName)) + requireSafeDesktopExternalFileTarget(operationDirectory, target) + val temporary = File.createTempFile("payload-", ".tmp", operationDirectory) + try { + val downloaded = FileOutputStream(temporary).use { output -> + download(output, maximumBytes).also { + output.fd.sync() + } } - } - check(downloaded.byteCount in 0L..maximumBytes) - expectedEtag?.let { expected -> - check(downloaded.etag == expected) { - "The file changed while it was being prepared. Refresh and try again." + check(downloaded.byteCount in 0L..maximumBytes) + expectedEtag?.let { expected -> + check(downloaded.etag == expected) { + "The file changed while it was being prepared. Refresh and try again." + } } + verifyDownloadedDeckAttachmentSize(declaredByteCount, downloaded.byteCount) + check(temporary.length() == downloaded.byteCount) { + "The desktop attachment cache copy is incomplete." + } + moveAtomicallyOrReplace(temporary, target, replaceExisting = false) + check(target.isFile && target.length() == downloaded.byteCount) { + "Could not publish the desktop attachment cache copy." + } + check(target.setWritable(false, false) || !target.canWrite()) { + "Could not make the detached desktop attachment read-only." + } + return target + } catch (failure: Throwable) { + temporary.delete() + deleteDesktopExternalFileTree(operationDirectory.toPath()) + throw failure } - verifyDownloadedDeckAttachmentSize(declaredByteCount, downloaded.byteCount) - check(temporary.length() == downloaded.byteCount) { - "The desktop attachment cache copy is incomplete." - } - moveAtomicallyOrReplace(temporary, target, replaceExisting = false) - check(target.isFile && target.length() == downloaded.byteCount) { - "Could not publish the desktop attachment cache copy." - } - check(target.setWritable(false, false) || !target.canWrite()) { - "Could not make the detached desktop attachment read-only." - } - return target - } catch (failure: Throwable) { - temporary.delete() - operationDirectory.deleteRecursively() - throw failure } } } - private fun stageDetachedCopy(sourceName: String, bytes: ByteArray): File { + private fun stageDetachedCopy(accountId: String, sourceName: String, bytes: ByteArray): File { require(bytes.size.toLong() <= MAX_IN_MEMORY_EXTERNAL_FILE_HANDOFF_BYTES) - check(root.isDirectory || root.mkdirs()) { "Could not create the desktop external-file cache." } - val canonicalRoot = root.canonicalFile - pruneDesktopExternalFileCache(canonicalRoot, bytes.size.toLong()) - reservations.reserve( - root = canonicalRoot, - declaredByteCount = bytes.size.toLong(), - reserveBytes = STAGED_FILE_FREE_SPACE_RESERVE_BYTES, - ).use { - val operationDirectory = File(canonicalRoot, UUID.randomUUID().toString()) - check(operationDirectory.mkdir()) { "Could not create a private desktop handoff directory." } - check(operationDirectory.canonicalFile.parentFile == canonicalRoot) { - "Unsafe desktop handoff directory." - } - val target = File(operationDirectory, sanitizeExternalFileName(sourceName)) - check(target.canonicalFile.parentFile == operationDirectory.canonicalFile) { - "Unsafe desktop handoff filename." - } - val temporary = File.createTempFile("payload-", ".tmp", operationDirectory) - try { - FileOutputStream(temporary).use { output -> - output.write(bytes) - output.fd.sync() - } - check(temporary.length() == bytes.size.toLong()) { "The desktop handoff copy is incomplete." } - moveAtomicallyOrReplace(temporary, target, replaceExisting = false) - check(target.isFile && target.length() == bytes.size.toLong()) { - "Could not publish the desktop handoff copy." - } - check(target.setWritable(false, false) || !target.canWrite()) { - "Could not make the detached desktop copy read-only." + val canonicalRoot = prepareAccountRoot(accountId) + val globalRoot = requireNotNull(canonicalRoot.parentFile) + val cacheMaximumBytes = pruneDesktopExternalFileCache( + globalRoot, + bytes.size.toLong(), + maximumBytes = maximumCacheBytes, + ) + cacheReservations.reserve(globalRoot, cacheMaximumBytes, bytes.size.toLong()).use { + reservations.reserve( + root = canonicalRoot, + declaredByteCount = bytes.size.toLong(), + reserveBytes = STAGED_FILE_FREE_SPACE_RESERVE_BYTES, + ).use { + val operationDirectory = File(canonicalRoot, UUID.randomUUID().toString()) + check(operationDirectory.mkdir()) { "Could not create a private desktop handoff directory." } + requireSafeDesktopExternalFileOperation(canonicalRoot, operationDirectory) + val target = File(operationDirectory, sanitizeExternalFileName(sourceName)) + requireSafeDesktopExternalFileTarget(operationDirectory, target) + val temporary = File.createTempFile("payload-", ".tmp", operationDirectory) + try { + FileOutputStream(temporary).use { output -> + output.write(bytes) + output.fd.sync() + } + check(temporary.length() == bytes.size.toLong()) { "The desktop handoff copy is incomplete." } + moveAtomicallyOrReplace(temporary, target, replaceExisting = false) + check(target.isFile && target.length() == bytes.size.toLong()) { + "Could not publish the desktop handoff copy." + } + check(target.setWritable(false, false) || !target.canWrite()) { + "Could not make the detached desktop copy read-only." + } + return target + } catch (failure: Throwable) { + temporary.delete() + deleteDesktopExternalFileTree(operationDirectory.toPath()) + throw failure } - return target - } catch (failure: Throwable) { - temporary.delete() - operationDirectory.deleteRecursively() - throw failure } } } + fun removeAccount(accountId: String) { + requireDesktopExternalFileHandoffAccountId(accountId) + pruneLegacyDesktopExternalFileCache(root, removeAll = true) + val rootPath = root.toPath().toAbsolutePath().normalize() + if (!Files.exists(rootPath, LinkOption.NOFOLLOW_LINKS)) return + check(Files.isDirectory(rootPath, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(rootPath)) { + "The desktop external-file cache root is not a safe directory." + } + val accountPath = rootPath.resolve(accountId) + if (!Files.exists(accountPath, LinkOption.NOFOLLOW_LINKS)) return + check(accountPath.parent == rootPath && !Files.isSymbolicLink(accountPath)) { + "Unsafe desktop external-file account directory." + } + check(Files.isDirectory(accountPath, LinkOption.NOFOLLOW_LINKS)) { + "The desktop external-file account entry is not a directory." + } + check(deleteDesktopExternalFileTree(accountPath) && !Files.exists(accountPath, LinkOption.NOFOLLOW_LINKS)) { + "Could not clear this account's desktop external-file copies." + } + } + + private fun prepareAccountRoot(accountId: String): File { + requireDesktopExternalFileHandoffAccountId(accountId) + pruneLegacyDesktopExternalFileCache(root) + val rootPath = root.toPath().toAbsolutePath().normalize() + if (!Files.exists(rootPath, LinkOption.NOFOLLOW_LINKS)) Files.createDirectories(rootPath) + check(Files.isDirectory(rootPath, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(rootPath)) { + "The desktop external-file cache root is not a safe directory." + } + val accountPath = rootPath.resolve(accountId) + if (!Files.exists(accountPath, LinkOption.NOFOLLOW_LINKS)) Files.createDirectory(accountPath) + check(Files.isDirectory(accountPath, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(accountPath)) { + "Could not create the desktop external-file account cache." + } + check(accountPath.parent == rootPath) { + "Unsafe desktop external-file account directory." + } + return accountPath.toFile() + } + private sealed interface DesktopStagedExternalFile { data class Ready(val file: File) : DesktopStagedExternalFile data class Rejected(val result: ExternalFileHandoffResult.Rejected) : DesktopStagedExternalFile } } +private fun requireSafeDesktopExternalFileOperation(accountRoot: File, operationDirectory: File) { + val operationPath = operationDirectory.toPath() + check( + Files.isDirectory(operationPath, LinkOption.NOFOLLOW_LINKS) && + !Files.isSymbolicLink(operationPath) && + Files.isSameFile(requireNotNull(operationPath.parent), accountRoot.toPath()), + ) { "Unsafe desktop handoff directory." } +} + +private fun requireSafeDesktopExternalFileTarget(operationDirectory: File, target: File) { + check(Files.isSameFile(requireNotNull(target.toPath().parent), operationDirectory.toPath())) { + "Unsafe desktop handoff filename." + } +} + +internal suspend fun DesktopAccountOperationGuard.withExternalFileHandoffSession( + expectedSession: NextcloudSession, + resolveSession: suspend () -> NextcloudSession?, + handoff: suspend () -> Result, +): Result = withAccountPrivateStatePublication( + expectedSession = expectedSession, + resolveSession = resolveSession, + unavailable = { error("The account changed before the external file copy could be published.") }, + publish = handoff, +) + +private fun requireDesktopExternalFileHandoffAccountId(accountId: String) { + require(accountId.isDesktopExternalFileHandoffAccountId()) { + "The desktop external-file account identity is invalid." + } +} + +private fun String.isDesktopExternalFileHandoffAccountId(): Boolean = + length == 64 && all { character -> character in '0'..'9' || character in 'a'..'f' } + internal enum class DesktopStagedFileExport { Exported, Cancelled, @@ -235,34 +333,138 @@ internal fun desktopExternalFileHandoffDirectory(): File { return File(cacheRoot, "nextcloud-native/external-open") } +internal fun pruneLegacyDesktopExternalFileCache( + root: File, + nowMillis: Long = System.currentTimeMillis(), + removeAll: Boolean = false, +) { + val rootPath = root.toPath().toAbsolutePath().normalize() + if (!Files.exists(rootPath, LinkOption.NOFOLLOW_LINKS)) return + if (!Files.isDirectory(rootPath, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(rootPath)) return + Files.newDirectoryStream(rootPath).use { entries -> + entries.forEach { entry -> + val name = entry.fileName.toString() + if (!name.matches(LEGACY_EXTERNAL_FILE_OPERATION_DIRECTORY)) return@forEach + if (!Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry)) { + val deleted = deleteDesktopExternalFileTree(entry) + check(!removeAll || deleted) { "Could not clear a legacy desktop external-file cache entry." } + return@forEach + } + val modified = Files.getLastModifiedTime(entry, LinkOption.NOFOLLOW_LINKS).toMillis() + if (removeAll || nowMillis >= modified && nowMillis - modified > DESKTOP_EXTERNAL_FILE_MAX_AGE_MILLIS) { + val deleted = deleteDesktopExternalFileTree(entry) + check(!removeAll || deleted) { "Could not clear a legacy desktop external-file copy." } + } + } + } +} + internal fun pruneDesktopExternalFileCache( root: File, requiredBytes: Long, nowMillis: Long = System.currentTimeMillis(), -) { - require(root.isDirectory) { "The desktop external-file cache root is not a directory." } + maximumBytes: Long = MAX_DESKTOP_EXTERNAL_FILE_CACHE_BYTES, +): Long { + val rootPath = root.toPath().toAbsolutePath().normalize() + require(Files.isDirectory(rootPath, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(rootPath)) { + "The desktop external-file cache root is not a safe directory." + } require(requiredBytes >= 0L) - val entries = root.listFiles().orEmpty().sortedBy(File::lastModified).toMutableList() - entries.filter { nowMillis - it.lastModified() > DESKTOP_EXTERNAL_FILE_MAX_AGE_MILLIS }.forEach { expired -> - expired.deleteRecursively() - entries.remove(expired) + require(maximumBytes > 0L && requiredBytes <= maximumBytes) { + "The desktop external-file copy exceeds the cache limit." } - var storedBytes = entries.fold(0L) { total, entry -> - saturatingDesktopFileBytes(total, desktopRecursiveFileBytes(entry)) + val entries = desktopExternalFileCacheEntries(rootPath).sortedBy(DesktopExternalFileCacheEntry::modifiedAt) + .toMutableList() + entries.filter { entry -> + nowMillis >= entry.modifiedAt && nowMillis - entry.modifiedAt > DESKTOP_EXTERNAL_FILE_MAX_AGE_MILLIS + }.forEach { expired -> + if (deleteDesktopExternalFileTree(expired.path)) entries.remove(expired) } - val retainedBeforeCopy = if (requiredBytes >= MAX_DESKTOP_EXTERNAL_FILE_CACHE_BYTES) { - 0L - } else { - MAX_DESKTOP_EXTERNAL_FILE_CACHE_BYTES - requiredBytes + var storedBytes = entries.fold(0L) { total, entry -> + saturatingDesktopFileBytes(total, entry.bytes) } + val retainedBeforeCopy = maximumBytes - requiredBytes val iterator = entries.filter { entry -> - nowMillis >= entry.lastModified() && - nowMillis - entry.lastModified() >= DESKTOP_EXTERNAL_FILE_MINIMUM_RETENTION_MILLIS + nowMillis >= entry.modifiedAt && + nowMillis - entry.modifiedAt >= DESKTOP_EXTERNAL_FILE_MINIMUM_RETENTION_MILLIS }.iterator() while (storedBytes > retainedBeforeCopy && iterator.hasNext()) { val oldest = iterator.next() - val bytes = desktopRecursiveFileBytes(oldest) - if (oldest.deleteRecursively()) storedBytes = (storedBytes - bytes).coerceAtLeast(0L) + if (deleteDesktopExternalFileTree(oldest.path)) { + storedBytes = (storedBytes - oldest.bytes).coerceAtLeast(0L) + } + } + check(storedBytes <= retainedBeforeCopy) { + "Recent desktop external-file copies already use the cache limit." + } + return maximumBytes - storedBytes +} + +private data class DesktopExternalFileCacheEntry( + val path: Path, + val bytes: Long, + val modifiedAt: Long, +) + +private fun desktopExternalFileCacheEntries(root: Path): List = buildList { + Files.newDirectoryStream(root).use { rootEntries -> + rootEntries.forEach { entry -> + val name = entry.fileName.toString() + when { + name.matches(LEGACY_EXTERNAL_FILE_OPERATION_DIRECTORY) -> addDesktopExternalFileCacheEntry(entry) + name.isDesktopExternalFileHandoffAccountId() -> { + if (!Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry)) { + check(deleteDesktopExternalFileTree(entry)) { + "Could not clear an unsafe desktop external-file account entry." + } + return@forEach + } + Files.newDirectoryStream(entry).use { accountEntries -> + accountEntries.forEach { operation -> addDesktopExternalFileCacheEntry(operation) } + } + } + } + } + } +} + +private fun MutableList.addDesktopExternalFileCacheEntry(path: Path) { + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)) { + check(deleteDesktopExternalFileTree(path)) { + "Could not clear an unsafe desktop external-file cache entry." + } + return + } + add( + DesktopExternalFileCacheEntry( + path = path, + bytes = desktopExternalFileTreeBytes(path), + modifiedAt = Files.getLastModifiedTime(path, LinkOption.NOFOLLOW_LINKS).toMillis(), + ), + ) +} + +internal fun deleteDesktopExternalFileTree(root: Path): Boolean { + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) return true + return try { + Files.walkFileTree(root, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + if (!attrs.isSymbolicLink) file.toFile().setWritable(true, false) + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(directory: Path, failure: IOException?): FileVisitResult { + failure?.let { throw it } + Files.delete(directory) + return FileVisitResult.CONTINUE + } + }) + !Files.exists(root, LinkOption.NOFOLLOW_LINKS) + } catch (_: IOException) { + false + } catch (_: SecurityException) { + false } } @@ -360,13 +562,20 @@ private fun moveAtomicallyOrReplace(source: File, destination: File, replaceExis } } -private fun desktopRecursiveFileBytes(file: File): Long = when { - file.isFile -> file.length() - file.isDirectory -> file.listFiles().orEmpty().sumOf(::desktopRecursiveFileBytes) - else -> 0L +private fun desktopExternalFileTreeBytes(root: Path): Long { + var total = 0L + Files.walkFileTree(root, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + total = saturatingDesktopFileBytes(total, attrs.size()) + return FileVisitResult.CONTINUE + } + }) + return total } private const val DESKTOP_EXTERNAL_FILE_MINIMUM_RETENTION_MILLIS = 60L * 60L * 1000L +private val LEGACY_EXTERNAL_FILE_OPERATION_DIRECTORY = + Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") private fun saturatingDesktopFileBytes(left: Long, right: Long): Long = if (right > Long.MAX_VALUE - left) Long.MAX_VALUE else left + right diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt index 7b22ae225..94a2abbae 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt @@ -10,31 +10,6 @@ import java.util.prefs.Preferences import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -internal data class DesktopCachedFileContent( - val bytes: ByteArray, - val mimeType: String?, - val etag: String, -) - -internal data class DesktopCachedFileListing( - val files: List, - val fetchedAtEpochMillis: Long, -) - -internal data class DesktopCachedVirtualListing( - val nodes: List, - val fetchedAtEpochMillis: Long, - val freshAtEpochMillis: Long = fetchedAtEpochMillis, -) - -internal data class DesktopVirtualFileCacheSummary( - val policy: VirtualFileCachePolicy, - val cachedBytes: Long, - val reclaimableBytes: Long, - val entryCount: Int, - val availableFreeBytes: Long, -) - /** * Disposable, account-private Files read cache for desktop. * @@ -56,6 +31,7 @@ internal class DesktopFileReadCache( ) { private val loadedIndexes = LinkedHashMap(16, 0.75f, true) private val failedVirtualListingInvalidations = mutableMapOf>() + private val lifecycle = DesktopFileReadCacheLifecycle() private val virtualListingInvalidationPreferences = preferences.node( "linux-virtual-metadata-invalidations-v1", ) @@ -74,7 +50,7 @@ internal class DesktopFileReadCache( @Synchronized fun cachedListingPaths(accountId: String): Set = - load(accountId).let { index -> + if (!lifecycle.isActive(accountId)) emptySet() else load(accountId).let { index -> buildSet { index.listings.mapTo(this, CachedListingV1::path) index.listingShards.mapTo(this, CachedListingShardReferenceV1::path) @@ -83,6 +59,7 @@ internal class DesktopFileReadCache( @Synchronized fun cachedListingSnapshot(accountId: String, path: String): DesktopCachedFileListing? { + if (!lifecycle.isActive(accountId)) return null val normalized = path.cachePath() val index = load(accountId) val listing = index.listings.firstOrNull { it.path == normalized } @@ -95,7 +72,7 @@ internal class DesktopFileReadCache( @Synchronized fun cachedVirtualListingPaths(accountId: String): Set = - load(accountId).let { index -> + if (!lifecycle.isActive(accountId)) emptySet() else load(accountId).let { index -> buildSet { index.virtualListings.mapTo(this, CachedVirtualListingV1::path) index.virtualListingShards.mapTo(this, CachedVirtualListingShardReferenceV1::path) @@ -104,6 +81,7 @@ internal class DesktopFileReadCache( @Synchronized fun cachedVirtualListingSnapshot(accountId: String, path: String): DesktopCachedVirtualListing? { + if (!lifecycle.isActive(accountId)) return null val normalized = path.cachePath() val index = load(accountId) val listing = index.virtualListings.firstOrNull { it.path == normalized } @@ -120,6 +98,7 @@ internal class DesktopFileReadCache( @Synchronized fun failedVirtualListingInvalidations(accountId: String): Set { + if (!lifecycle.isActive(accountId)) return emptySet() failedVirtualListingInvalidations[accountId]?.let { return it.toSet() } return if (virtualListingInvalidationPreferences.getBoolean(accountId, false)) { setOf("") @@ -129,7 +108,12 @@ internal class DesktopFileReadCache( } @Synchronized - fun replaceFailedVirtualListingInvalidations(accountId: String, paths: Set) { + fun replaceFailedVirtualListingInvalidations( + accountId: String, + paths: Set, + cacheProducer: DesktopFileReadCacheProducer? = producer(accountId), + ) { + if (!lifecycle.accepts(accountId, cacheProducer)) return if (paths.isEmpty()) { failedVirtualListingInvalidations.remove(accountId) virtualListingInvalidationPreferences.remove(accountId) @@ -142,13 +126,32 @@ internal class DesktopFileReadCache( virtualListingInvalidationPreferences.flush() } + @Synchronized + fun removeAccount(accountId: String) { + retireAccount(accountId) + try { + purgeDesktopAccountCacheDirectory(root, accountId) + virtualListingInvalidationPreferences.remove(accountId) + virtualListingInvalidationPreferences.flush() + } finally { + loadedIndexes.remove(accountId) + failedVirtualListingInvalidations.remove(accountId) + } + } + + @Synchronized fun retireAccount(accountId: String) = lifecycle.retire(accountId) + @Synchronized fun producer(accountId: String): DesktopFileReadCacheProducer? = lifecycle.producer(accountId) + @Synchronized fun activateAccount(accountId: String) = lifecycle.activate(accountId) + @Synchronized fun storeListing( accountId: String, path: String, files: List, nowEpochMillis: Long = System.currentTimeMillis(), + cacheProducer: DesktopFileReadCacheProducer? = producer(accountId), ) { + if (!lifecycle.accepts(accountId, cacheProducer)) return require(nowEpochMillis >= 0L) require(files.size <= MAX_FILES_PER_LISTING) { "The folder contains too many cacheable entries." } val normalized = path.cachePath() @@ -181,7 +184,9 @@ internal class DesktopFileReadCache( files: List, fetchedAtEpochMillis: Long, nowEpochMillis: Long = System.currentTimeMillis(), + cacheProducer: DesktopFileReadCacheProducer? = producer(accountId), ): Boolean { + if (!lifecycle.accepts(accountId, cacheProducer)) return false require(fetchedAtEpochMillis >= 0L) require(nowEpochMillis >= 0L) val normalized = path.cachePath() @@ -197,7 +202,7 @@ internal class DesktopFileReadCache( ) { return false } - storeListing(accountId, normalized, files, fetchedAtEpochMillis) + storeListing(accountId, normalized, files, fetchedAtEpochMillis, cacheProducer) return true } @@ -209,7 +214,9 @@ internal class DesktopFileReadCache( fetchedAtEpochMillis: Long, freshAtEpochMillis: Long = fetchedAtEpochMillis, nowEpochMillis: Long = System.currentTimeMillis(), + cacheProducer: DesktopFileReadCacheProducer? = producer(accountId), ): Boolean { + if (!lifecycle.accepts(accountId, cacheProducer)) return false require(fetchedAtEpochMillis >= 0L) require(freshAtEpochMillis >= fetchedAtEpochMillis) require(nowEpochMillis >= 0L) @@ -252,6 +259,7 @@ internal class DesktopFileReadCache( path: String, maximumBytes: Long, ): DesktopCachedFileContent? { + if (!lifecycle.isActive(accountId)) return null require(maximumBytes > 0L) val normalized = path.cachePath() val record = load(accountId).content.firstOrNull { it.path == normalized } ?: return null @@ -283,7 +291,9 @@ internal class DesktopFileReadCache( path: String, content: NextcloudFileContent, nowEpochMillis: Long = System.currentTimeMillis(), + cacheProducer: DesktopFileReadCacheProducer? = producer(accountId), ): Boolean { + if (!lifecycle.accepts(accountId, cacheProducer)) return false require(nowEpochMillis >= 0L) val normalized = path.cachePath() val etag = content.etag?.takeIf(String::isNotBlank) ?: return false @@ -397,7 +407,8 @@ internal class DesktopFileReadCache( ): VirtualFileEvictionPlan = applyEviction(accountId, requestedBytesToFree, nowEpochMillis) @Synchronized - fun invalidate(accountId: String, path: String) { + fun invalidate(accountId: String, path: String, cacheProducer: DesktopFileReadCacheProducer? = producer(accountId)): Boolean { + if (!lifecycle.accepts(accountId, cacheProducer)) return false val normalized = path.cachePath() val parent = normalized.parentCachePath() val accountDirectory = accountDirectory(accountId) @@ -444,6 +455,7 @@ internal class DesktopFileReadCache( content = index.content.filterNot { it in removed }, ), ) + return true } private fun CacheIndexV1.bounded(): CacheIndexV1 { @@ -1355,18 +1367,6 @@ private data class MetadataShardIndexGroup( } } -internal fun desktopFileCacheAccountId(session: NextcloudSession): String = - sha256Hex("${session.serverUrl}\u0000${session.loginName}") - -private fun desktopFilesCacheDirectory(): File { - val xdgCache = System.getenv("XDG_CACHE_HOME")?.takeIf(String::isNotBlank) - val cacheRoot = xdgCache?.let(::File) ?: File(System.getProperty("user.home"), ".cache") - return File(cacheRoot, "nextcloud-native/files") -} - -internal fun defaultDesktopFileReadCache(): DesktopFileReadCache = - DesktopFileReadCache(desktopFilesCacheDirectory()) - private fun String.cachePath(): String { require(length <= 8_192) require(none { it == '\u0000' || it == '\n' || it == '\r' || it == '\\' }) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheLifecycle.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheLifecycle.kt new file mode 100644 index 000000000..feda98b42 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheLifecycle.kt @@ -0,0 +1,37 @@ +package dev.obiente.nextcloudnative.app + +internal class DesktopFileReadCacheProducer internal constructor( + internal val accountId: String, + internal val incarnation: Long, +) + +/** Account incarnations for file-cache operations, called while the cache monitor is held. */ +internal class DesktopFileReadCacheLifecycle { + private val retiredAccounts = mutableSetOf() + private val incarnations = mutableMapOf() + + fun producer(accountId: String): DesktopFileReadCacheProducer? = + if (accountId in retiredAccounts) null else DesktopFileReadCacheProducer( + accountId, + incarnations[accountId] ?: 0L, + ) + + fun accepts(accountId: String, producer: DesktopFileReadCacheProducer?): Boolean { + val current = producer ?: return false + return current.accountId == accountId && accountId !in retiredAccounts && + current.incarnation == (incarnations[accountId] ?: 0L) + } + + fun retire(accountId: String) { + if (retiredAccounts.add(accountId)) incarnations[accountId] = (incarnations[accountId] ?: 0L) + 1L + } + + fun activate(accountId: String) { + retiredAccounts.remove(accountId) + } + + fun isActive(accountId: String): Boolean = accountId !in retiredAccounts +} + +internal fun DesktopFileReadCache.producerFor(session: NextcloudSession): Pair = + desktopFileCacheAccountId(session).let { accountId -> accountId to producer(accountId) } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheModels.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheModels.kt new file mode 100644 index 000000000..9ee1a95e3 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheModels.kt @@ -0,0 +1,26 @@ +package dev.obiente.nextcloudnative.app + +internal data class DesktopCachedFileContent( + val bytes: ByteArray, + val mimeType: String?, + val etag: String, +) + +internal data class DesktopCachedFileListing( + val files: List, + val fetchedAtEpochMillis: Long, +) + +internal data class DesktopCachedVirtualListing( + val nodes: List, + val fetchedAtEpochMillis: Long, + val freshAtEpochMillis: Long = fetchedAtEpochMillis, +) + +internal data class DesktopVirtualFileCacheSummary( + val policy: VirtualFileCachePolicy, + val cachedBytes: Long, + val reclaimableBytes: Long, + val entryCount: Int, + val availableFreeBytes: Long, +) 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 8a918f197..a988455d8 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/DesktopHomeWorkspaceLayoutStorage.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorage.kt index ddd73e932..cd4080bc9 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorage.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorage.kt @@ -36,6 +36,15 @@ internal class DesktopHomeWorkspaceLayoutStorage( } } + fun removeAccount(accountScopeDigest: String, legacyAccountScopeDigest: String? = null) { + val keys = homeWorkspaceAccountPersistenceKeys(accountScopeDigest, legacyAccountScopeDigest) + withExclusiveAccess { + preferences.sync() + keys.forEach(preferences::remove) + preferences.flush() + } + } + private fun withExclusiveAccess(operation: () -> T): T { val path = lockFile.toPath().toAbsolutePath().normalize() return desktopHomeWorkspaceProcessLocks.computeIfAbsent(path.toString()) { ReentrantLock() }.withLock { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLegacyCredentialCleanupJournal.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLegacyCredentialCleanupJournal.kt new file mode 100644 index 000000000..ec19a29e8 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLegacyCredentialCleanupJournal.kt @@ -0,0 +1,118 @@ +package dev.obiente.nextcloudnative.app + +import java.util.prefs.Preferences +import java.util.concurrent.atomic.AtomicBoolean + +internal data class DesktopPendingLegacyCredentialCleanup( + val serverUrl: String, + val loginName: String, +) + +internal data class DesktopLegacyCredentialCleanupSnapshot( + val slots: List>, + val legacyServer: String?, + val legacyLogin: String?, +) + +internal class DesktopLegacyCredentialCleanupJournal( + private val preferences: Preferences, + private val flush: () -> Unit, + private val recordMalformed: () -> Unit, +) { + private val malformedReported = AtomicBoolean(false) + + fun pending(): List { + var malformed = false + val cleanups = buildList { + repeat(MAX_LOCAL_ACCOUNTS) { index -> + val serverKey = serverKey(index) + val loginKey = loginKey(index) + val cleanup = decode(serverKey, loginKey) + if (cleanup != null) { + add(cleanup) + } else if (preferences.get(serverKey, null) != null || preferences.get(loginKey, null) != null) { + malformed = true + } + } + val legacyCleanup = decode(LEGACY_SERVER_KEY, LEGACY_LOGIN_KEY) + if (legacyCleanup != null) { + add(legacyCleanup) + } else if (preferences.get(LEGACY_SERVER_KEY, null) != null || + preferences.get(LEGACY_LOGIN_KEY, null) != null + ) { + malformed = true + } + }.distinct() + if (malformed && malformedReported.compareAndSet(false, true)) runCatching(recordMalformed) + return cleanups + } + + fun prepareAdd(cleanup: DesktopPendingLegacyCredentialCleanup) { + if (cleanup in pending()) return + val slot = (0 until MAX_LOCAL_ACCOUNTS).firstOrNull { index -> + preferences.get(serverKey(index), null) == null && preferences.get(loginKey(index), null) == null + } ?: error("The legacy credential cleanup journal is full.") + preferences.put(serverKey(slot), cleanup.serverUrl) + preferences.put(loginKey(slot), cleanup.loginName) + } + + fun clear(cleanup: DesktopPendingLegacyCredentialCleanup) { + val previous = snapshot() + try { + repeat(MAX_LOCAL_ACCOUNTS) { index -> + if (decode(serverKey(index), loginKey(index)) == cleanup) { + preferences.remove(serverKey(index)) + preferences.remove(loginKey(index)) + } + } + if (decode(LEGACY_SERVER_KEY, LEGACY_LOGIN_KEY) == cleanup) { + preferences.remove(LEGACY_SERVER_KEY) + preferences.remove(LEGACY_LOGIN_KEY) + } + flush() + } catch (failure: Exception) { + restore(previous) + runCatching(flush) + throw failure + } + } + + fun snapshot() = DesktopLegacyCredentialCleanupSnapshot( + slots = (0 until MAX_LOCAL_ACCOUNTS).map { index -> + preferences.get(serverKey(index), null) to preferences.get(loginKey(index), null) + }, + legacyServer = preferences.get(LEGACY_SERVER_KEY, null), + legacyLogin = preferences.get(LEGACY_LOGIN_KEY, null), + ) + + fun restore(snapshot: DesktopLegacyCredentialCleanupSnapshot) { + snapshot.slots.forEachIndexed { index, (server, login) -> + preferences.restoreString(serverKey(index), server) + preferences.restoreString(loginKey(index), login) + } + preferences.restoreString(LEGACY_SERVER_KEY, snapshot.legacyServer) + preferences.restoreString(LEGACY_LOGIN_KEY, snapshot.legacyLogin) + } + + private fun decode(serverKey: String, loginKey: String): DesktopPendingLegacyCredentialCleanup? { + val server = preferences.get(serverKey, null)?.takeIf(String::isNotBlank) ?: return null + val login = preferences.get(loginKey, null)?.takeIf(String::isNotBlank) ?: return null + return runCatching { + deriveNextcloudAccountId(server, login) + DesktopPendingLegacyCredentialCleanup(server, login) + }.getOrNull() + } + + private fun serverKey(index: Int) = "$SLOT_PREFIX.$index.server" + private fun loginKey(index: Int) = "$SLOT_PREFIX.$index.login" + + private companion object { + const val SLOT_PREFIX = "accountLegacyCleanupV2" + const val LEGACY_SERVER_KEY = "accountLegacyCleanupServer" + const val LEGACY_LOGIN_KEY = "accountLegacyCleanupLogin" + } +} + +private fun Preferences.restoreString(key: String, value: String?) { + if (value == null) remove(key) else put(key, value) +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt new file mode 100644 index 000000000..c0c7914de --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt @@ -0,0 +1,45 @@ +package dev.obiente.nextcloudnative.app + +internal data class DetachedDesktopLinuxProvider( + val fileSystem: DesktopLinuxProviderFileSystem, + val metadataBackend: CachingLinuxVirtualFileBackend?, + val accountId: String?, +) + +internal interface DesktopLinuxProviderFileSystem { + fun disableReads() + fun unmount() +} + +internal fun detachedDesktopLinuxProvider( + fileSystem: LinuxNextcloudVirtualFileSystem?, + metadataBackend: CachingLinuxVirtualFileBackend?, + accountId: String?, +): DetachedDesktopLinuxProvider? = fileSystem?.let { + DetachedDesktopLinuxProvider(it, metadataBackend, accountId) +} + +internal class DesktopLinuxProviderCleanupSlot { + private val lock = Any() + private var pending: DetachedDesktopLinuxProvider? = null + + fun unmountOrRetain(provider: DetachedDesktopLinuxProvider) { + try { + provider.fileSystem.unmount() + } catch (failure: Throwable) { + runCatching(provider.fileSystem::disableReads).exceptionOrNull()?.let(failure::addSuppressed) + synchronized(lock) { + check(pending == null) + pending = provider + } + throw failure + } + } + + fun retry() { + val provider = synchronized(lock) { pending.also { pending = null } } ?: return + unmountOrRetain(provider) + } + + fun pendingForTest(): DetachedDesktopLinuxProvider? = synchronized(lock) { pending } +} 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 694516d1d..b32a2cddc 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -13,7 +13,6 @@ import java.awt.datatransfer.StringSelection import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File -import java.io.FileOutputStream import java.io.IOException import java.net.URI import java.net.URLDecoder @@ -24,8 +23,6 @@ import java.nio.file.Files import java.nio.file.LinkOption import java.nio.file.Path import java.nio.file.StandardCopyOption -import java.nio.file.attribute.PosixFilePermission -import java.nio.file.attribute.PosixFilePermissions import java.security.MessageDigest import java.util.Base64 import java.util.UUID @@ -125,17 +122,12 @@ private const val MAX_VIRTUAL_FOLDER_DISCOVERED_ENTRIES = 100_000 private const val MAX_VIRTUAL_FOLDER_STABILITY_ATTEMPTS = 3 private const val VIRTUAL_FOLDER_REFRESH_INTERVAL_MILLIS = 6L * 60L * 60L * 1_000L private const val VIRTUAL_FOLDER_REFRESH_RETRY_MILLIS = 30L * 60L * 1_000L -private const val KEY_WINDOWS_CLOUD_FILES_ROOT = "windows-cloud-files-root" -private const val KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX = "wcfr." private const val KEY_WINDOWS_CLOUD_FILES_PRESERVED_ROOT_PREFIX = "wcfpr." private const val KEY_WINDOWS_CLOUD_FILES_RECOVERY_CURSOR = "windows-cloud-files-recovery-cursor" -private const val MAX_WINDOWS_CLOUD_FILES_RECOVERY_ROOTS_PER_ATTEMPT = 16 -private const val KEY_VIRTUAL_FILE_ROOT_PREFIX = "vfp-root." private const val KEY_VIRTUAL_FILE_PRIMARY_CACHE_PREFIX = "vfpc-primary." private const val KEY_VIRTUAL_FILE_OVERFLOW_CACHE_PREFIX = "vfpc-overflow." private const val VIRTUAL_FILE_PRIMARY_PREFERENCE_VERSION = "v2" private const val VIRTUAL_FILE_OVERFLOW_PREFERENCE_VERSION = "v2" -private const val WINDOWS_CLOUD_FILES_ROOT_SUFFIX = "-v2" private fun isLinuxDesktop(): Boolean = System.getProperty("os.name").orEmpty().lowercase().contains("linux") @@ -166,16 +158,6 @@ private fun desktopLinuxVirtualFileMountPoint( File(location.parentPath, location.folderName).absoluteFile.normalize() } -private fun virtualFileProviderRootPreferenceKey(accountId: String): String { - require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) - return "$KEY_VIRTUAL_FILE_ROOT_PREFIX$accountId".also { key -> check(key.length <= Preferences.MAX_KEY_LENGTH) } -} - -private fun virtualFileCachePreferenceKey(prefix: String, accountId: String): String { - require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) - return "$prefix$accountId".also { key -> check(key.length <= Preferences.MAX_KEY_LENGTH) } -} - private data class DesktopVirtualFileCacheTiers( val configuration: VirtualFileCacheTierConfiguration, val primaryIdentity: String?, @@ -385,21 +367,6 @@ internal fun virtualFileLocationActionMessage(prefix: String, targetPath: String return "$prefix$displayedTarget." } -internal fun desktopWindowsCloudFilesRoot( - accountId: String, - userHome: File = File(System.getProperty("user.home")), -): File { - require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) - return File(File(userHome, "Nextcloud Native"), accountId + WINDOWS_CLOUD_FILES_ROOT_SUFFIX) -} - -internal fun windowsCloudFilesRootPreferenceKey(accountId: String): String { - require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) - return "$KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX$accountId".also { key -> - check(key.length <= Preferences.MAX_KEY_LENGTH) - } -} - internal fun windowsCloudFilesPreservedRootPreferenceKey(accountId: String): String { require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) return "$KEY_WINDOWS_CLOUD_FILES_PRESERVED_ROOT_PREFIX$accountId".also { key -> @@ -488,23 +455,6 @@ internal fun persistedWindowsCloudFilesRecoveryRoots( } .toMap() -internal fun pageWindowsCloudFilesRecoveryRoots( - roots: Map, - startAfterAccountId: String?, - limit: Int = MAX_WINDOWS_CLOUD_FILES_RECOVERY_ROOTS_PER_ATTEMPT, -): Map { - require(limit > 0) - if (roots.isEmpty()) return emptyMap() - val ordered = roots.entries.sortedBy(Map.Entry::key) - val startIndex = startAfterAccountId - ?.let { cursor -> ordered.indexOfFirst { it.key > cursor } } - ?.takeIf { it >= 0 } - ?: 0 - return (0 until minOf(limit, ordered.size)) - .map { offset -> ordered[(startIndex + offset) % ordered.size] } - .associate(Map.Entry::toPair) -} - internal fun pagedPersistedWindowsCloudFilesRecoveryRoots( preferences: Preferences = Preferences.userRoot().node("dev/obiente/nextcloudnative"), ): Map { @@ -520,98 +470,6 @@ internal fun pagedPersistedWindowsCloudFilesRecoveryRoots( return page } -private fun desktopLegacyWindowsCloudFilesRoot(accountId: String, userHome: File): File = - File(File(userHome, "Nextcloud Native"), accountId) - -internal fun unregisterSupersededWindowsCloudFilesRoot( - preferences: Preferences, - accountId: String, - userHome: File, - api: WindowsCloudFilesApi, -) { - require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) - val legacyRoot = validatedWindowsCloudFilesRoot(desktopLegacyWindowsCloudFilesRoot(accountId, userHome), userHome) - api.unregisterSyncRoot(legacyRoot) - clearWindowsCloudFilesRootPreferences(preferences, accountId, legacyRoot) -} - -private fun clearWindowsCloudFilesRootPreferences( - preferences: Preferences, - accountId: String, - removedRoot: Path, -) { - listOf(KEY_WINDOWS_CLOUD_FILES_ROOT, windowsCloudFilesRootPreferenceKey(accountId)).forEach { key -> - val savedRoot = preferences.get(key, null) - ?.let(::File) - ?.toPath() - ?.toAbsolutePath() - ?.normalize() - if (savedRoot == removedRoot) preferences.remove(key) - } -} - -internal fun unregisterWindowsCloudFilesRootForUninstall( - preferences: Preferences = Preferences.userRoot().node("dev/obiente/nextcloudnative"), - userHome: File = File(System.getProperty("user.home")), - apiFactory: () -> WindowsCloudFilesApi = ::JnaWindowsCloudFilesApi, -) { - val rootsByPreference = linkedMapOf>() - fun addRoot(root: File?, preferenceKey: String? = null) { - if (root == null) return - val validated = validatedWindowsCloudFilesRoot(root, userHome) - rootsByPreference.getOrPut(validated) { linkedSetOf() } - .apply { preferenceKey?.let(::add) } - } - addRoot( - preferences.get(KEY_WINDOWS_CLOUD_FILES_ROOT, null)?.let(::File), - KEY_WINDOWS_CLOUD_FILES_ROOT, - ) - preferences.keys().filter { it.startsWith(KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX) }.forEach { key -> - addRoot(preferences.get(key, null)?.let(::File), key) - } - val sessionAccountId = preferences.get("server", null)?.let { server -> - preferences.get("login", null)?.let { login -> - desktopFileCacheAccountId(NextcloudSession(server, login, "unused")) - } - } - sessionAccountId?.let { accountId -> - addRoot( - desktopWindowsCloudFilesRoot(accountId, userHome), - windowsCloudFilesRootPreferenceKey(accountId), - ) - addRoot(desktopLegacyWindowsCloudFilesRoot(accountId, userHome)) - } - if (rootsByPreference.isEmpty()) return - val api = apiFactory() - var firstFailure: Throwable? = null - try { - rootsByPreference.entries - .sortedByDescending { (root) -> root.fileName.toString().endsWith(WINDOWS_CLOUD_FILES_ROOT_SUFFIX) } - .forEach { (root, preferenceKeys) -> - runCatching { api.unregisterSyncRoot(root) } - .onSuccess { preferenceKeys.forEach(preferences::remove) } - .onFailure { failure -> if (firstFailure == null) firstFailure = failure } - } - } finally { - api.close() - } - firstFailure?.let { throw it } -} - -private fun validatedWindowsCloudFilesRoot(root: File, userHome: File): Path { - val expectedParent = File(userHome, "Nextcloud Native").toPath().toAbsolutePath().normalize() - val normalizedRoot = root.toPath().toAbsolutePath().normalize() - val name = normalizedRoot.fileName.toString() - val accountId = name.removeSuffix(WINDOWS_CLOUD_FILES_ROOT_SUFFIX) - check( - normalizedRoot.parent == expectedParent && - accountId.length == 64 && - accountId.all { it in '0'..'9' || it in 'a'..'f' } && - (name == accountId || name == accountId + WINDOWS_CLOUD_FILES_ROOT_SUFFIX), - ) { "The stored Windows Cloud Files root is invalid." } - return normalizedRoot -} - internal fun virtualFileProviderPreferenceKey(accountId: String): String { require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) return "vfp-active.$accountId".also { key -> @@ -794,27 +652,6 @@ private fun desktopContractCacheDirectory(name: String): File { return File(cacheRoot, "nextcloud-native/contracts/$name") } -internal fun desktopPendingDynamicMutationDirectory( - osName: String = System.getProperty("os.name").orEmpty(), - environment: Map = System.getenv(), - userHome: File = File(System.getProperty("user.home")), -): File = when { - osName.startsWith("Windows", ignoreCase = true) -> { - val localAppData = environment["LOCALAPPDATA"]?.takeIf(String::isNotBlank) - ?.let(::File) - ?: File(userHome, "AppData/Local") - File(localAppData, "Nextcloud Native/State/Pending Mutations") - } - osName.startsWith("Mac", ignoreCase = true) -> - File(userHome, "Library/Application Support/Nextcloud Native/Pending Mutations") - else -> { - val stateRoot = environment["XDG_STATE_HOME"]?.takeIf(String::isNotBlank) - ?.let(::File) - ?: File(userHome, ".local/state") - File(stateRoot, "nextcloud-native/pending-mutations-v1") - } -}.absoluteFile - internal const val DESKTOP_PROJECT_CONTENT_CONNECT_TIMEOUT_SECONDS = 10L internal const val DESKTOP_PROJECT_CONTENT_READ_TIMEOUT_SECONDS = 30L internal const val DESKTOP_PROJECT_CONTENT_WRITE_TIMEOUT_SECONDS = 30L @@ -849,144 +686,6 @@ internal fun publishDesktopProjectContentCache(temporary: File, destination: Fil } } -private val PENDING_MUTATION_DIRECTORY_PERMISSIONS = setOf( - PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE, - PosixFilePermission.OWNER_EXECUTE, -) -private val PENDING_MUTATION_FILE_PERMISSIONS = setOf( - PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE, -) - -internal fun ensurePrivatePendingMutationDirectory(directory: File) { - Files.createDirectories(directory.toPath()) - setPendingMutationPosixPermissions(directory.toPath(), PENDING_MUTATION_DIRECTORY_PERMISSIONS) -} - -internal fun setPrivatePendingMutationFilePermissions(file: File) { - setPendingMutationPosixPermissions(file.toPath(), PENDING_MUTATION_FILE_PERMISSIONS) -} - -private fun setPendingMutationPosixPermissions(path: Path, permissions: Set) { - if (Files.getFileStore(path).supportsFileAttributeView("posix")) { - Files.setPosixFilePermissions(path, permissions) - } -} - -private fun createPrivatePendingMutationTemporary(directory: File, targetName: String): Path { - val directoryPath = directory.toPath() - return if (Files.getFileStore(directoryPath).supportsFileAttributeView("posix")) { - Files.createTempFile( - directoryPath, - "$targetName-", - ".part", - PosixFilePermissions.asFileAttribute(PENDING_MUTATION_FILE_PERMISSIONS), - ) - } else { - Files.createTempFile(directoryPath, "$targetName-", ".part") - } -} - -internal fun writePrivatePendingMutationFile( - directory: File, - target: File, - bytes: ByteArray, -) { - require(target.parentFile?.absoluteFile == directory.absoluteFile) { - "The pending mutation target must be inside its private directory." - } - ensurePrivatePendingMutationDirectory(directory) - val temporary = createPrivatePendingMutationTemporary(directory, target.name) - try { - FileOutputStream(temporary.toFile()).use { output -> - output.write(bytes) - output.fd.sync() - } - try { - Files.move( - temporary, - target.toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING, - ) - } catch (_: AtomicMoveNotSupportedException) { - Files.move( - temporary, - target.toPath(), - StandardCopyOption.REPLACE_EXISTING, - ) - } - setPrivatePendingMutationFilePermissions(target) - } finally { - Files.deleteIfExists(temporary) - } -} - -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 = {}, @@ -997,6 +696,10 @@ class DesktopNextcloudServices( supportIntakeRoot: File? = null, ) : NextcloudPlatformServices, AutoCloseable { private val preferences = Preferences.userRoot().node("dev/obiente/nextcloudnative") + private val homeWorkspaceLayoutStorage = DesktopHomeWorkspaceLayoutStorage( + preferences.node("home-workspace"), + desktopHomeWorkspaceLockFile(), + ) private val ownsTemporarySupportDiagnosticsRoot = providedSupportDiagnostics == null && supportDiagnosticsRoot == null private val resolvedSupportDiagnosticsRoot = supportDiagnosticsRoot ?: if (providedSupportDiagnostics == null) { Files.createTempDirectory("nextcloud-native-test-diagnostics").toFile() @@ -1013,7 +716,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) }, @@ -1039,7 +750,9 @@ class DesktopNextcloudServices( catalogCache = FileAppStoreCatalogCache(desktopContractCacheDirectory("catalogs")), verifiedContractCache = FileVerifiedContractCache(desktopContractCacheDirectory("verified")), ) - private val dynamicDiscoveryCacheDirectory = desktopContractCacheDirectory("discoveries-v1") + private val dynamicDiscoveryCache = DesktopDynamicDiscoveryCacheCoordinator.get( + desktopContractCacheDirectory("discoveries-v1"), + ) private val pendingDynamicMutationDirectory = desktopPendingDynamicMutationDirectory() private val fileReadCache = defaultDesktopFileReadCache() private val virtualRangeCaches = mutableMapOf() @@ -1060,6 +773,7 @@ class DesktopNextcloudServices( private var linuxVirtualMetadataBackend: CachingLinuxVirtualFileBackend? = null private var linuxVirtualFileMountIdentity: String? = null private var linuxVirtualFileFailure: String? = null + private val linuxProviderCleanup = DesktopLinuxProviderCleanupSlot() @Volatile private var windowsCloudFilesProvider: WindowsCloudFilesProvider? = null @Volatile @@ -1112,6 +826,7 @@ class DesktopNextcloudServices( accountId: String, cache: DesktopVirtualRangeCache, ) { + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return if (sessionClearing) return if (synchronized(virtualFileProviderLock) { accountId in virtualFileCacheTierMutations }) return if (cache.hasUnavailableRetainedOverflowRecords(accountId, relativePath)) return @@ -1507,14 +1222,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() @@ -1553,9 +1274,10 @@ class DesktopNextcloudServices( userId: String, accountId: String, path: String, + cacheProducer: DesktopFileReadCacheProducer?, ) { synchronized(virtualFileProviderLock) { - runCatching { invalidateDesktopFileMetadata(accountId, path) } + if (!runCatching { invalidateDesktopFileMetadata(accountId, path, cacheProducer) }.getOrDefault(false)) return val cache = runCatching { virtualRangeCache(accountId) }.getOrNull() ?: return val roots = runCatching { cache.retainedFoldersAffectedByListingChanges(accountId, listOf(path)) @@ -1623,6 +1345,7 @@ class DesktopNextcloudServices( if (!isLinuxDesktop()) return session ?: return val accountId = desktopFileCacheAccountId(session) + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return val cache = virtualRangeCache(accountId) val kept = cache.loadFolderRetention(accountId).rules.filter { rule -> rule.retention == VirtualFolderRetention.KeepOnDevice @@ -1636,16 +1359,14 @@ class DesktopNextcloudServices( private val fileSyncEngine = DesktopFileSyncEngine( minimumFreeSpaceBytes = { fileReadCache.loadPolicy().minimumFreeSpaceBytes }, onRemoteMutationCommitted = { session, userId, path -> - refreshRetainedFoldersAfterMutation( - session, - userId, - desktopFileCacheAccountId(session), - path, - ) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) }, ) + private val accountSyncPairCleanupJournal = DesktopAccountSyncPairCleanupJournal( + preferences, + ) { recordSupportDiagnostic(desktopAccountSyncPairCleanupJournalMalformedDiagnostic()) } 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 +1400,10 @@ class DesktopNextcloudServices( backgroundFileSyncJob = serviceScope.launch { restoreConfirmedStartOnLoginRegistration() while (isActive) { + recoverDesktopBackgroundAccountSyncPairCleanups( + retry = { accountOperationGuard.serializeWhenSyncIdle { retryPendingAccountSyncPairCleanups() } }, + recordFailure = { recordSupportDiagnostic(desktopAccountSyncPairCleanupJournalFailureDiagnostic(it)) }, + ) if (!isFileSyncPaused()) { runCatching { syncAllFileSyncPairs(DesktopFileSyncRunSource.Background) } } @@ -1935,12 +1660,31 @@ 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.", ) } val accountId = desktopFileCacheAccountId(session) + val cacheProducer = fileReadCache.producer(accountId) + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return unknownCleanupStateRejection() + runCatching(linuxProviderCleanup::retry).exceptionOrNull()?.let { + return VirtualFileStorageActionResult.Rejected(it.message ?: "The earlier Linux mount is still active.") + } var windowsCloudFilesRecoveryNotice = if (isWindowsDesktop()) { persistedWindowsCloudFilesRecoveryNotice(preferences, accountId) } else { @@ -1955,7 +1699,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 +1825,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}.", ) } @@ -2117,7 +1861,7 @@ class DesktopNextcloudServices( tree = DesktopFileSyncRemoteTree(session, userId, ""), onCommitted = { path -> runCatching { virtualRangeCache(accountId).invalidate(accountId, path) } - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) recoveredWritebackPaths += path }, ) @@ -2146,7 +1890,7 @@ class DesktopNextcloudServices( }, ), afterMutationInvalidated = { path -> - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) }, ) metadataBackendReference = metadataBackend @@ -2173,7 +1917,7 @@ class DesktopNextcloudServices( throw failure } } - VirtualFileStorageActionResult.Completed( + return VirtualFileStorageActionResult.Completed( "Virtual files mounted at ${desktopLinuxVirtualFileMountPoint(preferences, accountId).absolutePath}.", ) } @@ -2182,28 +1926,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( @@ -2617,6 +2371,7 @@ class DesktopNextcloudServices( // A retained-metadata persistence callback can briefly enter virtualFileProviderLock. // Closing its backend while holding the same lock reverses that order and deadlocks. runCatching { providersToClose.first?.unmount() } + runCatching(linuxProviderCleanup::retry) runCatching { providersToClose.second?.close() } supportIntake.close() supportDiagnostics.close() @@ -2624,16 +2379,13 @@ class DesktopNextcloudServices( if (ownsTemporarySupportDiagnosticsRoot) requireNotNull(resolvedSupportDiagnosticsRoot).deleteRecursively() } - private fun invalidateDesktopFileMetadata(accountId: String, path: String) { - synchronized(virtualFileProviderLock) { - val mountedBackend = linuxVirtualMetadataBackend - ?.takeIf { linuxVirtualFileMountIdentity == accountId } - if (mountedBackend != null) { - mountedBackend.invalidateAfterExternalMutation(path) - } else { - fileReadCache.invalidate(accountId, path) - } - } + private fun invalidateDesktopFileMetadata( + accountId: String, path: String, cacheProducer: DesktopFileReadCacheProducer?, + ): Boolean = synchronized(virtualFileProviderLock) { + if (!fileReadCache.invalidate(accountId, path, cacheProducer)) return@synchronized false + linuxVirtualMetadataBackend?.takeIf { linuxVirtualFileMountIdentity == accountId } + ?.invalidateAfterExternalMutation(path) + true } override suspend fun chooseFileSyncLocalRoot(initialRootHint: String?): FileSyncLocalRoot? = @@ -2674,7 +2426,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 +2455,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 +2510,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 +2562,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 +2611,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 +2699,26 @@ 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 + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) { + return@syncRun FileSyncCenterActionResult.Rejected(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE) + } 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, @@ -3520,10 +3304,20 @@ class DesktopNextcloudServices( ): String? = withContext(Dispatchers.IO) { durableMutationRecovery.load(accountScope, kind) } override suspend fun saveDurableMutationRecovery( + session: NextcloudSession, accountScope: String, kind: DurableMutationRecoveryKind, encoded: String, - ): Boolean = withContext(Dispatchers.IO) { durableMutationRecovery.save(accountScope, kind, encoded) } + ): Boolean = withContext(Dispatchers.IO) { + if (durableMutationAccountScope(session) != accountScope) return@withContext false + accountOperationGuard.withAccountPrivateStatePublication( + expectedSession = session, + resolveSession = { loadSession(session.accountId) }, + unavailable = { false }, + ) { + durableMutationRecovery.save(accountScope, kind, encoded) + } + } override suspend fun clearDurableMutationRecovery( accountScope: String, @@ -3537,49 +3331,26 @@ class DesktopNextcloudServices( session: NextcloudSession, appId: String, ): DynamicDescriptorDiscovery? = withContext(Dispatchers.IO) { - val target = dynamicDiscoveryCacheFile(session, appId) ?: return@withContext null - if (!target.isFile || target.length() !in 1..MAX_PERSISTED_DYNAMIC_DISCOVERY_BYTES.toLong()) { - return@withContext null - } - runCatching { target.readText() } - .getOrNull() + dynamicDiscoveryCache.load( + session.accountId.storageKey, + desktopFileCacheAccountId(session), + appId, + ) ?.let { encoded -> decodePersistedDynamicDiscovery(encoded, appId, session.serverUrl) } } - override suspend fun saveCachedDynamicAppDiscovery( session: NextcloudSession, discovery: DynamicDescriptorDiscovery, + producer: DynamicNativeMemoryCacheProducer?, ) = withContext(Dispatchers.IO) { val encoded = encodePersistedDynamicDiscovery(discovery) ?: return@withContext - val target = dynamicDiscoveryCacheFile(session, discovery.descriptor.app.id) ?: return@withContext - check(dynamicDiscoveryCacheDirectory.mkdirs() || dynamicDiscoveryCacheDirectory.isDirectory) { - "Could not create the dynamic contract cache." - } - val temporary = File(dynamicDiscoveryCacheDirectory, "${target.name}.part") - temporary.outputStream().buffered().use { output -> - output.write(encoded.encodeToByteArray()) - output.flush() - } - try { - Files.move( - temporary.toPath(), - target.toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING, - ) - } catch (_: AtomicMoveNotSupportedException) { - Files.move( - temporary.toPath(), - target.toPath(), - StandardCopyOption.REPLACE_EXISTING, - ) - } - Unit - } - - private fun dynamicDiscoveryCacheFile(session: NextcloudSession, appId: String): File? { - if (!appId.isSafeDynamicDiscoveryCacheAppId()) return null - return File(dynamicDiscoveryCacheDirectory, "${desktopFileCacheAccountId(session)}-$appId.json") + dynamicDiscoveryCache.save( + session.accountId.storageKey, + desktopFileCacheAccountId(session), + discovery.descriptor.app.id, + encoded, + producer, + ) } override suspend fun loadPendingDynamicMutation( @@ -3616,17 +3387,23 @@ class DesktopNextcloudServices( targetRecordId: String, values: Map, ) = withContext(Dispatchers.IO) { - val encoded = requireNotNull( - encodePersistedDynamicMutation(appId, actionId, targetRecordId, values), - ) { "The pending dynamic mutation is invalid." } - val target = requireNotNull(pendingDynamicMutationFile(session, appId, actionId, targetRecordId)) { - "The pending dynamic mutation identity is invalid." - } - writePrivatePendingMutationFile( - directory = pendingDynamicMutationDirectory, - target = target, - bytes = encoded.encodeToByteArray(), - ) + accountOperationGuard.withAccountPrivateStatePublication( + expectedSession = session, + resolveSession = { loadSession(session.accountId) }, + unavailable = { error("The account changed before the pending mutation could be recorded.") }, + ) { + val encoded = requireNotNull( + encodePersistedDynamicMutation(appId, actionId, targetRecordId, values), + ) { "The pending dynamic mutation is invalid." } + val target = requireNotNull(pendingDynamicMutationFile(session, appId, actionId, targetRecordId)) { + "The pending dynamic mutation identity is invalid." + } + writePrivatePendingMutationFile( + directory = pendingDynamicMutationDirectory, + target = target, + bytes = encoded.encodeToByteArray(), + ) + } Unit } @@ -3636,8 +3413,14 @@ class DesktopNextcloudServices( actionId: String, targetRecordId: String, ) = withContext(Dispatchers.IO) { - pendingDynamicMutationFile(session, appId, actionId, targetRecordId)?.let { target -> - check(!target.exists() || target.delete()) { "Could not clear the pending mutation." } + accountOperationGuard.withAccountPrivateStatePublication( + expectedSession = session, + resolveSession = { loadSession(session.accountId) }, + unavailable = { error("The account changed before the pending mutation could be cleared.") }, + ) { + pendingDynamicMutationFile(session, appId, actionId, targetRecordId)?.let { target -> + check(!target.exists() || target.delete()) { "Could not clear the pending mutation." } + } } Unit } @@ -3658,240 +3441,484 @@ class DesktopNextcloudServices( "${desktopFileCacheAccountId(session)}-$appId-$digest.json", ) } - override fun loadSession(): NextcloudSession? = 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) { + val activeId = accountCredentials.activeAccountId() + val record = accountCredentials.listAccounts().firstOrNull { account -> account.id == activeId } + val session = loadDesktopSessionAfterCleanupGate( + record, accountSyncPairCleanupJournal, accountCredentials::loadActiveSession, + accountSessionPublication::publish, + ) + if (session == 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) } + session } - override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { + val persistedSession = accountOperationGuard.persistSessionAndActivateDynamicReads( + persist = { + retryPendingAccountSyncPairCleanup( + desktopFileCacheAccountId(session), + session.accountId.storageKey, + ) + 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, + ) + accountCredentials.saveSession(session).also(accountSessionPublication::publish) + } + }, + activate = { + AccountPrivateMemoryLifecycle.activateAccount(it.accountId.storageKey) + dynamicDiscoveryCache.activateAccount(it.accountId.storageKey) + dynamicApiRequestCoalescer.activateAccount(desktopFileCacheAccountId(it).also(fileReadCache::activateAccount)) + 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 { - 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(), + val record = accountCredentials.listAccounts().firstOrNull { account -> account.id == accountId } + loadDesktopSessionAfterCleanupGate( + record, accountSyncPairCleanupJournal, { accountCredentials.loadSession(accountId) }, + accountSessionPublication::register, + ) + } + override suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = + withContext(Dispatchers.IO) { + accountOperationGuard.serialize operation@{ + if (activeAccountId() == accountId) { + listAccounts().firstOrNull { it.id == accountId } + ?.let(accountSyncPairCleanupJournal::requireAccountActivationAllowed) + 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), + record.id.storageKey, + ) + } + sessionPublicationGuard.serialize { + accountCredentials.selectAccount(accountId)?.also { session -> + accountSessionPublication.publish(session) + } + } + }, + reopen = { synchronized(fileRangeSessionLock) { sessionClearing = false } }, + ) + }, + restart = ::startDesktopSyncLifecycle, ) - } 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" + } + } + private fun hasLiveAccountResources(): Boolean = + synchronized(fileRangeSessionLock) { activeFileRangeSessions.isNotEmpty() } || + synchronized(virtualFolderHydrationJobs) { + hasLiveVirtualFolderHydrationJobs(virtualFolderHydrationJobs.values) + } || + 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) + val durableMutationScope = desktopDurableMutationAccountScope(account) + val accountPersistenceScopes = desktopAccountPersistenceScopeDigests(account) + requireDesktopAccountRemovalReady(providerAccountId, isLinuxDesktop()) + accountOperationGuard.withSyncRunLock { + fileSyncEngine.requireAccountRemovalReady(providerAccountId) + val removed = removeDesktopAccountBeforeSyncPairCleanup( + accountId = providerAccountId, + durableMutationAccountScope = durableMutationScope, + accountStorageKey = account.id.storageKey, + legacyAccountScopeDigest = accountPersistenceScopes.legacy, + prepareCleanup = accountSyncPairCleanupJournal::prepare, + commitCleanup = accountSyncPairCleanupJournal::commit, + clearCleanup = accountSyncPairCleanupJournal::clear, + accountOwnership = ::desktopAccountOwnership, + removeCredential = { sessionPublicationGuard.serialize { + removeDesktopAccountCredential(preferences, providerAccountId, { + accountCredentials.listAccounts().any { account -> account.id == accountId } + }) { + accountCredentials.removeAccount(accountId) + } + } }, + removeSyncPairs = ::removeDesktopAccountOwnedState, + retireCommittedAccount = { + fenceDesktopAccountPrivateCaches(account.id.storageKey, providerAccountId) }, - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - throw failure + ) { + recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(providerAccountId, it)) + } + removed + } } - 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() } 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 activeFileRangeSessions.toList() } var cleared = false + var quiescedLinuxFileSystem: LinuxNextcloudVirtualFileSystem? = null + var quiescedWindowsCloudFiles: WindowsCloudFilesProvider? = null + var linuxFileSystemQuiesced = false + var windowsCloudFilesQuiesced = false + var providerPreferenceAccountId: String? = null + var providerWasEnabledBeforeRemoval = false + var remoteRevocationAttempted = false + var credentialRemovalStatus: Boolean? = false + var removalFailure: Throwable? = null try { - val accountId = desktopStoredSessionAccountId(preferences) + val activeAccountId = activeAccountId() + val activeRecord = activeAccountId?.let { id -> + listAccounts().firstOrNull { account -> account.id == id } + } + val activeSession = loadDesktopRemoteRevocationSession(activeAccountId, expectedSession, ::loadSession) + val accountId = activeSession?.let(::desktopFileCacheAccountId) + ?: activeRecord?.let(::desktopFileCacheAccountId) + val durableMutationScope = activeSession?.let(::durableMutationAccountScope) + ?: activeRecord?.let(::desktopDurableMutationAccountScope) + val accountStorageKey = activeSession?.accountId?.storageKey ?: activeRecord?.id?.storageKey + val accountPersistenceScopes = activeSession?.let(::accountPersistenceScopeDigests) + ?: activeRecord?.let(::desktopAccountPersistenceScopeDigests) 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 { + quiescedLinuxFileSystem = synchronized(virtualFileProviderLock) { + linuxVirtualFileSystem?.takeIf { linuxVirtualFileMountIdentity == accountId } + } + linuxFileSystemQuiesced = quiescedLinuxFileSystem?.quiesceWrites() == true + check(quiescedLinuxFileSystem == null || linuxFileSystemQuiesced) { + "Close files being edited through the Linux virtual filesystem before removing this account." + } + quiescedWindowsCloudFiles = synchronized(virtualFileProviderLock) { + windowsCloudFilesProvider?.takeIf { windowsCloudFilesIdentity == accountId } + } + windowsCloudFilesQuiesced = quiescedWindowsCloudFiles?.quiesceWritesForAccountRemoval() == true + check(quiescedWindowsCloudFiles == null || windowsCloudFilesQuiesced) { + "Finish local Windows Cloud Files changes before removing this account." + } + accountId?.let { currentAccountId -> + providerPreferenceAccountId = currentAccountId + val key = virtualFileProviderPreferenceKey(currentAccountId) + providerWasEnabledBeforeRemoval = preferences.getBoolean(key, false) + setDesktopVirtualFileProviderPreference(preferences, currentAccountId, enabled = false) + } + accountId + ?.also { requireDesktopAccountRemovalReady(it, isLinuxDesktop()) } + ?.let { fileSyncEngine.requireAccountRemovalReady(it) } + completeDesktopSignOutAfterRemoteRevocation(expectedSession, { session -> + remoteRevocationAttempted = true + 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(), - ) + } + val teardownVirtualFiles = { + val linuxProvider = synchronized(virtualFileProviderLock) { + detachedDesktopLinuxProvider( + linuxVirtualFileSystem, linuxVirtualMetadataBackend, linuxVirtualFileMountIdentity, + ).also { + linuxVirtualFileSystem = null + linuxVirtualMetadataBackend = null + linuxVirtualFileMountIdentity = null + linuxVirtualFileFailure = null + } } - if (isWindowsDesktop()) { - val uninstallFailure = runCatching { - unregisterWindowsCloudFilesRootForUninstall(preferences, userHome = userHome) - }.exceptionOrNull() - if (uninstallFailure != null) { - windowsCloudFilesFailure = windowsCloudFilesFailure ?: ( - uninstallFailure.message ?: windowsCloudFilesFailureMessage - ) + linuxProvider?.let(linuxProviderCleanup::unmountOrRetain) + synchronized(virtualFileProviderLock) { + val windowsCloudFilesFailureMessage = "Could not remove the Windows Cloud Files root." + val provider = windowsCloudFilesProvider + try { + if (provider != null) { + provider.removeSyncRoot() + } + windowsCloudFilesFailure = null + } catch (failure: Throwable) { + windowsCloudFilesFailure = failure.message ?: windowsCloudFilesFailureMessage supportDiagnostics.record( SupportDiagnosticEventDraft( severity = SupportDiagnosticSeverity.Error, component = SupportDiagnosticComponent.VirtualFiles, - operation = "cloud-files.signout-cleanup-retry", + operation = "cloud-files.signout-cleanup", outcome = "failed", - fields = accountId?.let { - listOf( - SupportDiagnosticFieldDraft( - "account", - it, - SupportDiagnosticValuePrivacy.Identifier, - ), - ) - }.orEmpty(), - exception = uninstallFailure.toSupportDiagnosticExceptionDraft(), + fields = desktopAccountDiagnosticFields(accountId), + exception = failure.toSupportDiagnosticExceptionDraft(), ), ) + } finally { + runCatching { provider?.close() } + windowsCloudFilesProvider = null + windowsCloudFilesIdentity = null + if (isWindowsDesktop() && accountId != null) { + val uninstallFailure = runCatching { + unregisterWindowsCloudFilesRootsForAccountRemoval( + preferences = preferences, + accountId = accountId, + userHome = userHome, + ) + }.exceptionOrNull() + windowsCloudFilesFailure = windowsCloudFilesFailureAfterFallbackCleanup( + windowsCloudFilesFailure, uninstallFailure, windowsCloudFilesFailureMessage, + ) + if (uninstallFailure != null) { + 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 finishCommittedRemoval = { + finishCommittedDesktopAccountRemoval( + markRemovalCommitted = { cleared = true }, + teardownVirtualFiles = teardownVirtualFiles, + clearDiagnosticIdentity = { supportDiagnostics.setActiveAccountIdentity(null) }, + clearIntakeIdentity = { supportIntake.setActiveAccountIdentity(null) }, + ) + } + try { + clearDesktopActiveAccountBeforeSyncPairCleanup( + accountId, durableMutationScope, accountStorageKey, accountPersistenceScopes?.legacy, + accountSyncPairCleanupJournal, ::desktopAccountOwnership, + { + commitDesktopAccountRemovalBeforeVirtualFileTeardown( + commitRemoval = { + var committedFailure = false + try { + sessionPublicationGuard.serialize { + check( + activeAccountId == null || removeDesktopAccountCredential( + preferences, accountId, + credentialStillExists = { + accountCredentials.listAccounts().any { it.id == activeAccountId } + }, + commitStatusObserved = { credentialRemovalStatus = it }, + finishCommittedRemoval = { committedFailure = true }, + ) { accountCredentials.removeAccount(activeAccountId) }, + ) + } + } catch (failure: Throwable) { + if (committedFailure) runCatching(finishCommittedRemoval) + .exceptionOrNull()?.let(failure::addSuppressed) + throw failure + } + }, + teardownVirtualFiles = finishCommittedRemoval, + ) + }, + ::removeDesktopAccountOwnedState, + ::recordSupportDiagnostic, + retireCommittedAccount = { + if (accountStorageKey != null && accountId != null) { + fenceDesktopAccountPrivateCaches(accountStorageKey, accountId) + } + }, + ) + } finally { + if (cleared && accountId != null) schedulePendingAccountSyncPairCleanupRetry() + } + } } - mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot( - phase = DesktopFileSyncTrayPhase.Idle, + } catch (failure: Throwable) { removalFailure = failure; throw failure } finally { + val reopen = shouldResumeDesktopWritesAfterRemovalFailure( + cleared, remoteRevocationAttempted, credentialRemovalStatus, ) - 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(), - ), + if (reopen) { + val recoveryFailure = recoverDesktopAccountAfterPrecommitFailure( + restoreProviderPreference = { providerPreferenceAccountId?.let { + setDesktopVirtualFileProviderPreference(preferences, it, providerWasEnabledBeforeRemoval) + } }, + resumeVirtualFileSystem = { if (linuxFileSystemQuiesced) quiescedLinuxFileSystem?.resumeWrites() }, + resumeWindowsCloudFiles = { + if (windowsCloudFilesQuiesced) quiescedWindowsCloudFiles?.resumeWritesAfterAccountRemovalFailure() + }, + reopenSession = { synchronized(fileRangeSessionLock) { sessionClearing = false } }, + restartLifecycle = { + if (desktopStoredSessionAccountId(preferences) != null) startDesktopSyncLifecycle() + }, ) - if (failure is DesktopSecretDeletionRecoveryUnavailableException || - failure is DesktopSecretLegacyCleanupUnavailableException) throw failure - } - 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 } - if (desktopStoredSessionAccountId(preferences) != null) startDesktopSyncLifecycle() + recoveryFailure?.let { removalFailure?.addSuppressed(it) ?: throw it } } } } + private suspend fun retryPendingAccountSyncPairCleanup(accountId: String, accountStorageKey: String) { + accountSyncPairCleanupJournal.pendingForAccountActivation(accountId, accountStorageKey).forEach { cleanup -> + retryDesktopAccountSyncPairCleanup( + cleanup, ::desktopAccountOwnership, ::removeDesktopAccountOwnedState, + accountSyncPairCleanupJournal::clear, ::reactivateDesktopMemoryAfterAbortedRemoval, + ) + } + requireDesktopAccountActivationAllowed( + accountSyncPairCleanupJournal.blocksAccountActivation(accountId, accountStorageKey), + ) + } + private suspend fun retryPendingAccountSyncPairCleanups() = + retryPendingDesktopAccountSyncPairCleanups( + accountSyncPairCleanupJournal, ::desktopAccountOwnership, ::removeDesktopAccountOwnedState, + { accountId, failure -> + recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(accountId, failure)) + }, + ::reactivateDesktopMemoryAfterAbortedRemoval, + ) + + private fun reactivateDesktopMemoryAfterAbortedRemoval(cleanup: DesktopAccountSyncPairCleanup) { + cleanup.accountStorageKey?.let(AccountPrivateMemoryLifecycle::activateAccount) + fileReadCache.activateAccount(cleanup.accountId) + } + + private fun schedulePendingAccountSyncPairCleanupRetry() = serviceScope.launch { + retryDesktopAccountSyncPairCleanupsBounded { + var pending = true + recoverDesktopBackgroundAccountSyncPairCleanups( + retry = { accountOperationGuard.serializeWhenSyncIdle { + retryPendingAccountSyncPairCleanups() + pending = accountSyncPairCleanupJournal.pending().isNotEmpty() + } }, + recordFailure = { recordSupportDiagnostic(desktopAccountSyncPairCleanupJournalFailureDiagnostic(it)) }, + ) + pending + } + } + private suspend fun removeDesktopAccountOwnedState(cleanup: DesktopAccountSyncPairCleanup) { + val accountId = cleanup.accountId + dynamicDiscoveryCache.retireAccount(cleanup.accountStorageKey, accountId) + clearDesktopDynamicApiState(accountId, dynamicApiRequestCoalescer, dynamicApiReadCache) + supportIntake.removeAccount(accountId) + removeDesktopPendingDynamicMutations(pendingDynamicMutationDirectory, accountId) + cleanup.durableMutationAccountScope?.let(durableMutationRecovery::removeAccount) + cleanup.accountStorageKey?.let { deckCardDrafts.removeAccount(it, accountId) } + cleanup.accountStorageKey?.let(AccountPrivateMemoryLifecycle::retireAccount) + cleanup.accountStorageKey?.let { accountStorageKey -> + homeWorkspaceLayoutStorage.removeAccount(accountStorageKey, cleanup.legacyAccountScopeDigest) + } + externalFileHandoff.removeAccount(accountId) + removeDesktopAccountPrivateStorage( + accountId, fileSyncEngine, fileReadCache, virtualRangeCache(accountId), preferences, + ) + if (!isWindowsDesktop()) return + try { + unregisterWindowsCloudFilesRootsForAccountRemoval( + preferences = preferences, + accountId = accountId, + ) + } catch (failure: Throwable) { + recordVirtualFileFailure( + operation = "cloud-files.account-removal-cleanup", + accountId = accountId, + root = desktopWindowsCloudFilesRoot(accountId).toPath(), + failure = failure, + ) + throw failure + } + } + + private fun fenceDesktopAccountPrivateCaches(accountStorageKey: String, fileCacheAccountId: String) { + fileReadCache.retireAccount(fileCacheAccountId) + AccountPrivateMemoryLifecycle.retireAccount(accountStorageKey) + dynamicDiscoveryCache.fenceAccount(accountStorageKey) + } + + private fun desktopAccountOwnership(accountId: String): DesktopAccountOwnership = + sessionPublicationGuard.serialize { accountCredentials.accountOwnership(accountId) } + override suspend fun prepareDeckCardDraftRecovery(session: NextcloudSession) = withContext(Dispatchers.IO) { + withDesktopDeckCardDraftSession(session, accountOperationGuard, accountCredentials) { + deckCardDrafts.migrateLegacyEntries(session) + } + } override suspend fun loadDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, ): PersistedDeckCardDraft? = withContext(Dispatchers.IO) { - deckCardDrafts.load(session, key) + withDesktopDeckCardDraftSession(session, accountOperationGuard, accountCredentials) { + deckCardDrafts.load(session, key) + } } - override suspend fun saveDeckCardDraft( session: NextcloudSession, draft: PersistedDeckCardDraft, ) = withContext(Dispatchers.IO) { - deckCardDrafts.save(session, draft) + withDesktopDeckCardDraftSession(session, accountOperationGuard, accountCredentials) { + deckCardDrafts.save(session, draft) + } } - override suspend fun clearDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, discardUnreadable: Boolean, ) = withContext(Dispatchers.IO) { - deckCardDrafts.clear(session, key, discardUnreadable) + withDesktopDeckCardDraftSession(session, accountOperationGuard, accountCredentials) { + deckCardDrafts.clear(session, key, discardUnreadable) + } } override suspend fun quarantineSubmittedDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, ) = withContext(Dispatchers.IO) { - deckCardDrafts.quarantineAfterSubmit(session, key) + withDesktopDeckCardDraftSession(session, accountOperationGuard, accountCredentials) { + deckCardDrafts.quarantineAfterSubmit(session, key) + } } override suspend fun discardAllDeckCardDrafts() = withContext(Dispatchers.IO) { deckCardDrafts.discardAll() @@ -3901,11 +3928,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) @@ -3924,19 +3949,23 @@ class DesktopNextcloudServices( action: ExternalFileHandoffAction, ): ExternalFileHandoffResult { val capability = (externalFileHandoffSupport as ExternalFileHandoffSupport.Available).capability - return externalFileHandoff.launchStreamed(file, action, capability) { output, maximumBytes -> - val expectedEtag = requireSafeFileRangeEtag(requireNotNull(file.etag)) - downloadDesktopDetachedFile( - noRedirectHttpClient, session, buildNextcloudFileUrl(session.serverUrl, userId, file.path), - output, maximumBytes, USER_AGENT, - failureMessage = { status -> "Opening the file in another app failed (HTTP $status)." }, - limitMessage = "The file exceeds the platform byte representation.", - requestHeaders = mapOf("If-Match" to expectedEtag), - handoffEtag = expectedEtag, - onNetworkFailure = { started, attempt, failure -> - recordDesktopStreamingFailure(session, "external_file", started, attempt, failure) - }, - ) + return accountOperationGuard.withExternalFileHandoffSession(session, { loadSession(session.accountId) }) { + externalFileHandoff.launchStreamed( + desktopFileCacheAccountId(session), file, action, capability, + ) { output, maximumBytes -> + val expectedEtag = requireSafeFileRangeEtag(requireNotNull(file.etag)) + downloadDesktopDetachedFile( + noRedirectHttpClient, session, buildNextcloudFileUrl(session.serverUrl, userId, file.path), + output, maximumBytes, USER_AGENT, + failureMessage = { status -> "Opening the file in another app failed (HTTP $status)." }, + limitMessage = "The file exceeds the platform byte representation.", + requestHeaders = mapOf("If-Match" to expectedEtag), + handoffEtag = expectedEtag, + onNetworkFailure = { started, attempt, failure -> + recordDesktopStreamingFailure(session, "external_file", started, attempt, failure) + }, + ) + } } } @@ -3955,18 +3984,22 @@ class DesktopNextcloudServices( ocsApiRequest = true, ).requireSafe() val capability = (externalFileHandoffSupport as ExternalFileHandoffSupport.Available).capability - return externalFileHandoff.launchDetached(attachment, action, capability) { output, maximumBytes -> - downloadDesktopDetachedFile( - noRedirectHttpClient, session, buildNextcloudApiUrl(session.serverUrl, requestSpec), - output, maximumBytes, USER_AGENT, - failureMessage = { status -> "Opening the Deck attachment failed (HTTP $status)." }, - limitMessage = "The Deck attachment exceeds the platform byte representation.", - accept = "*/*", - requestHeaders = mapOf("OCS-APIRequest" to "true"), - onNetworkFailure = { started, attempt, failure -> - recordDesktopStreamingFailure(session, "deck_attachment", started, attempt, failure) - }, - ) + return accountOperationGuard.withExternalFileHandoffSession(session, { loadSession(session.accountId) }) { + externalFileHandoff.launchDetached( + desktopFileCacheAccountId(session), attachment, action, capability, + ) { output, maximumBytes -> + downloadDesktopDetachedFile( + noRedirectHttpClient, session, buildNextcloudApiUrl(session.serverUrl, requestSpec), + output, maximumBytes, USER_AGENT, + failureMessage = { status -> "Opening the Deck attachment failed (HTTP $status)." }, + limitMessage = "The Deck attachment exceeds the platform byte representation.", + accept = "*/*", + requestHeaders = mapOf("OCS-APIRequest" to "true"), + onNetworkFailure = { started, attempt, failure -> + recordDesktopStreamingFailure(session, "deck_attachment", started, attempt, failure) + }, + ) + } } } @@ -4086,7 +4119,7 @@ class DesktopNextcloudServices( userId: String, path: String, ): NextcloudFileListing = withContext(Dispatchers.IO) { - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) val requestStartedAtEpochMillis = System.currentTimeMillis().coerceAtLeast(0L) try { val response = request( @@ -4101,7 +4134,7 @@ class DesktopNextcloudServices( accountId = accountId, path = path, files = files, - fetchedAtEpochMillis = requestStartedAtEpochMillis, + fetchedAtEpochMillis = requestStartedAtEpochMillis, cacheProducer = cacheProducer, ) } NextcloudFileListing(files, NextcloudFileListingSource.Network) @@ -4191,9 +4224,9 @@ class DesktopNextcloudServices( ).conflictConditionHeaders(), ) } - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun refreshMetadata() { - runCatching { refreshRetainedFoldersAfterMutation(session, userId, accountId, safePath) } + runCatching { refreshRetainedFoldersAfterMutation(session, userId, accountId, safePath, cacheProducer) } } val response = request( method = "PROPPATCH", @@ -4371,7 +4404,7 @@ class DesktopNextcloudServices( maxBytes: Long, ): NextcloudFileContent = withContext(Dispatchers.IO) { require(maxBytes > 0) { "The download size limit must be greater than zero." } - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) val cached = fileReadCache.cachedContent(accountId, path, maxBytes) try { var response = request( @@ -4397,7 +4430,7 @@ class DesktopNextcloudServices( response.status == 304 && cached != null -> NextcloudFileContent(cached.bytes, cached.mimeType, cached.etag) response.status == 404 -> { - runCatching { refreshRetainedFoldersAfterMutation(session, userId, accountId, path) } + runCatching { refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) } error("The file no longer exists on the server.") } response.status >= 500 && cached != null -> @@ -4405,7 +4438,7 @@ class DesktopNextcloudServices( response.status !in 200..299 -> error("Downloading the file failed (HTTP ${response.status}).") else -> NextcloudFileContent(response.body, response.contentType, response.etag).also { content -> - runCatching { fileReadCache.storeContent(accountId, path, content) } + runCatching { fileReadCache.storeContent(accountId, path, content, cacheProducer = cacheProducer) } } } } catch (failure: IOException) { @@ -4514,8 +4547,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() @@ -4637,9 +4676,9 @@ class DesktopNextcloudServices( version: NextcloudFileVersion, ): Unit = withContext(Dispatchers.IO) { val specification = fileVersionRestoreRequest(userId, file, version) - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun queueAffectedMetadataRefresh() = - refreshRetainedFoldersAfterMutation(session, userId, accountId, file.path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, file.path, cacheProducer) val response = request( method = specification.method, url = session.serverUrl + specification.relativePath, @@ -4656,12 +4695,7 @@ class DesktopNextcloudServices( when (val result = classifyFileVersionRestoreHttpResponse(response.status)) { FileVersionRestoreHttpResult.Restored -> { runCatching { - refreshRetainedFoldersAfterMutation( - session, - userId, - accountId, - file.path, - ) + refreshRetainedFoldersAfterMutation(session, userId, accountId, file.path, cacheProducer) } } is FileVersionRestoreHttpResult.Rejected -> error(result.message) @@ -4684,24 +4718,28 @@ class DesktopNextcloudServices( ) val expectedHandoffEtag = requireSafeFileRangeEtag(requireNotNull(historicalCopy.etag)) val specification = fileVersionContentRequest(userId, fileId, version.id) - return externalFileHandoff.launchStreamed(historicalCopy, action, capability) { output, maximumBytes -> - downloadDesktopDetachedFile( - noRedirectHttpClient, session, session.serverUrl + specification.relativePath, - output, maximumBytes, USER_AGENT, - failureMessage = { status -> "Downloading the historical version failed (HTTP $status)." }, - limitMessage = "The historical version exceeds the platform byte representation.", - handoffEtag = expectedHandoffEtag, - validateResponseEtag = { returnedEtag -> - if (version.etag != null && returnedEtag != null) { - check(requireSafeFileRangeEtag(returnedEtag) == requireSafeFileRangeEtag(version.etag)) { - "The historical version changed while it was being exported." + return accountOperationGuard.withExternalFileHandoffSession(session, { loadSession(session.accountId) }) { + externalFileHandoff.launchStreamed( + desktopFileCacheAccountId(session), historicalCopy, action, capability, + ) { output, maximumBytes -> + downloadDesktopDetachedFile( + noRedirectHttpClient, session, session.serverUrl + specification.relativePath, + output, maximumBytes, USER_AGENT, + failureMessage = { status -> "Downloading the historical version failed (HTTP $status)." }, + limitMessage = "The historical version exceeds the platform byte representation.", + handoffEtag = expectedHandoffEtag, + validateResponseEtag = { returnedEtag -> + if (version.etag != null && returnedEtag != null) { + check(requireSafeFileRangeEtag(returnedEtag) == requireSafeFileRangeEtag(version.etag)) { + "The historical version changed while it was being exported." + } } - } - }, - onNetworkFailure = { started, attempt, failure -> - recordDesktopStreamingFailure(session, "file_version", started, attempt, failure) - }, - ) + }, + onNetworkFailure = { started, attempt, failure -> + recordDesktopStreamingFailure(session, "file_version", started, attempt, failure) + }, + ) + } } } @@ -4713,9 +4751,9 @@ class DesktopNextcloudServices( expectedEtag: String, ): SavedTextFile = withContext(Dispatchers.IO) { val specification = textFileDavSaveRequest(text, expectedEtag) - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun queueAffectedMetadataRefresh() = - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) val response = request( "PUT", buildNextcloudFileUrl(session.serverUrl, userId, path), @@ -4730,12 +4768,12 @@ class DesktopNextcloudServices( val etag = response.etag ?: runCatchingPreservingCancellation { loadFileEtag(session, userId, path) }.getOrNull() runCatching { - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) etag?.let { fileReadCache.storeContent( - accountId, - path, + accountId, path, NextcloudFileContent(specification.body, specification.contentType, it), + cacheProducer = cacheProducer, ) } } @@ -4752,9 +4790,9 @@ class DesktopNextcloudServices( require(utf8.size.toLong() <= MAX_EDITABLE_TEXT_BYTES) { "Text files larger than ${MAX_EDITABLE_TEXT_BYTES / (1024 * 1024)} MiB cannot be created in the app." } - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun queueAffectedMetadataRefresh() = - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) val response = request( "PUT", buildNextcloudFileUrl(session.serverUrl, userId, path), @@ -4769,12 +4807,12 @@ class DesktopNextcloudServices( check(response.status in 200..299) { "Creating the text file failed (HTTP ${response.status})." } check(response.status == 201) { "The server did not confirm that a new text file was created." } runCatching { - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) response.etag?.let { fileReadCache.storeContent( - accountId, - path, + accountId, path, NextcloudFileContent(utf8, "text/plain; charset=utf-8", it), + cacheProducer = cacheProducer, ) } } @@ -4786,9 +4824,9 @@ class DesktopNextcloudServices( userId: String, path: String, ): Boolean = withContext(Dispatchers.IO) { - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun queueAffectedMetadataRefresh() = - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) val response = request( method = "MKCOL", url = buildNextcloudFileUrl(session.serverUrl, userId, path), @@ -4802,7 +4840,7 @@ class DesktopNextcloudServices( if (response.status !in 200..299) throw fileOperationException(response.status) check(response.status == 201) { "The server did not confirm that a new folder was created." } runCatching { - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) } true } @@ -4821,12 +4859,12 @@ class DesktopNextcloudServices( put("Overwrite", if (spec.overwrite) "T" else "F") } } - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun invalidateAffectedMetadata() { runCatching { - refreshRetainedFoldersAfterMutation(session, userId, accountId, spec.sourcePath) + refreshRetainedFoldersAfterMutation(session, userId, accountId, spec.sourcePath, cacheProducer) spec.destinationPath?.let { destination -> - refreshRetainedFoldersAfterMutation(session, userId, accountId, destination) + refreshRetainedFoldersAfterMutation(session, userId, accountId, destination, cacheProducer) } } } @@ -5530,7 +5568,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( @@ -5544,20 +5581,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), @@ -5577,7 +5617,7 @@ class DesktopNextcloudServices( .documentElement.firstText(DAV, "getetag") } - private fun request( + private suspend fun request( method: String, url: String, session: NextcloudSession? = null, @@ -5596,7 +5636,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 { @@ -5935,8 +5981,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" @@ -6079,8 +6123,6 @@ internal fun requireVirtualFolderListingCapacity( internal fun isCompleteRetainedTreeListing(listingPath: String, retainedRoot: String): Boolean = listingPath == retainedRoot || listingPath.startsWith("$retainedRoot/") -internal fun Job?.occupiesVirtualFolderHydrationSlot(): Boolean = this != null && !isCompleted - internal fun removeVirtualFolderHydrationJobIfOwned( jobs: MutableMap, key: String, @@ -6109,16 +6151,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, @@ -6161,73 +6193,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/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt new file mode 100644 index 000000000..81c06452b --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt @@ -0,0 +1,141 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache +import java.io.File +import java.io.FileOutputStream +import java.nio.channels.FileChannel +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.PosixFilePermissions + +internal suspend fun clearDesktopDynamicApiState( + accountId: String, + coalescer: DynamicApiRequestCoalescer, + cache: DynamicApiResponseCache, +) = coalescer.fenceAccount(accountId) { cache.invalidateAccount(accountId) } + +internal fun desktopPendingDynamicMutationDirectory( + osName: String = System.getProperty("os.name").orEmpty(), + environment: Map = System.getenv(), + userHome: File = File(System.getProperty("user.home")), +): File = when { + osName.startsWith("Windows", ignoreCase = true) -> { + val localAppData = environment["LOCALAPPDATA"]?.takeIf(String::isNotBlank) + ?.let(::File) + ?: File(userHome, "AppData/Local") + File(localAppData, "Nextcloud Native/State/Pending Mutations") + } + osName.startsWith("Mac", ignoreCase = true) -> + File(userHome, "Library/Application Support/Nextcloud Native/Pending Mutations") + else -> { + val stateRoot = environment["XDG_STATE_HOME"]?.takeIf(String::isNotBlank) + ?.let(::File) + ?: File(userHome, ".local/state") + File(stateRoot, "nextcloud-native/pending-mutations-v1") + } +}.absoluteFile + +private val PENDING_MUTATION_DIRECTORY_PERMISSIONS = setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, +) +private val PENDING_MUTATION_FILE_PERMISSIONS = setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, +) + +internal fun ensurePrivatePendingMutationDirectory(directory: File) { + Files.createDirectories(directory.toPath()) + setPendingMutationPosixPermissions(directory.toPath(), PENDING_MUTATION_DIRECTORY_PERMISSIONS) +} + +internal fun setPrivatePendingMutationFilePermissions(file: File) { + setPendingMutationPosixPermissions(file.toPath(), PENDING_MUTATION_FILE_PERMISSIONS) +} + +private fun setPendingMutationPosixPermissions(path: Path, permissions: Set) { + if (Files.getFileStore(path).supportsFileAttributeView("posix")) { + Files.setPosixFilePermissions(path, permissions) + } +} + +private fun createPrivatePendingMutationTemporary(directory: File, targetName: String): Path { + val directoryPath = directory.toPath() + return if (Files.getFileStore(directoryPath).supportsFileAttributeView("posix")) { + Files.createTempFile( + directoryPath, + "$targetName-", + ".part", + PosixFilePermissions.asFileAttribute(PENDING_MUTATION_FILE_PERMISSIONS), + ) + } else { + Files.createTempFile(directoryPath, "$targetName-", ".part") + } +} + +internal fun writePrivatePendingMutationFile(directory: File, target: File, bytes: ByteArray) { + require(target.parentFile?.absoluteFile == directory.absoluteFile) { + "The pending mutation target must be inside its private directory." + } + ensurePrivatePendingMutationDirectory(directory) + val temporary = createPrivatePendingMutationTemporary(directory, target.name) + try { + FileOutputStream(temporary.toFile()).use { output -> + output.write(bytes) + output.fd.sync() + } + try { + Files.move(temporary, target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, target.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + setPrivatePendingMutationFilePermissions(target) + } finally { + Files.deleteIfExists(temporary) + } +} + +internal fun removeDesktopPendingDynamicMutations(directory: File, accountId: String) { + require(accountId.isCanonicalGroupwareMutationAccountScope()) { + "The pending mutation cleanup account identity is invalid." + } + val directoryPath = directory.toPath().toAbsolutePath().normalize() + if (!Files.exists(directoryPath, LinkOption.NOFOLLOW_LINKS)) return + check(Files.isDirectory(directoryPath, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(directoryPath)) { + "The pending mutation store is not a safe directory." + } + val ownedPrefix = "$accountId-" + val ownedName = Regex( + "^$accountId-[A-Za-z0-9._:-]{1,256}-[0-9a-f]{64}\\.json(?:-[^/]{1,128}\\.part)?$", + ) + var deleted = false + Files.newDirectoryStream(directoryPath).use { entries -> + entries.forEach { entry -> + val name = entry.fileName.toString() + if (!name.startsWith(ownedPrefix)) return@forEach + check(ownedName.matches(name)) { "The pending mutation store contains an unsafe account entry." } + check(entry.toAbsolutePath().normalize().parent == directoryPath) { + "The pending mutation entry escapes its private directory." + } + check(Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(entry)) { + "The pending mutation account entry is not a regular file." + } + check(Files.deleteIfExists(entry)) { "Could not delete a pending mutation account entry." } + deleted = true + } + } + if (deleted && Files.getFileStore(directoryPath).supportsFileAttributeView("posix")) { + FileChannel.open(directoryPath, StandardOpenOption.READ).use { channel -> channel.force(true) } + } + Files.newDirectoryStream(directoryPath).use { entries -> + check(entries.none { it.fileName.toString().startsWith(ownedPrefix) }) { + "Could not remove all pending mutation state for the account." + } + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycle.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycle.kt new file mode 100644 index 000000000..a42d37958 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycle.kt @@ -0,0 +1,8 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.Job + +internal fun Job?.occupiesVirtualFolderHydrationSlot(): Boolean = this != null && !isCompleted + +internal fun hasLiveVirtualFolderHydrationJobs(jobs: Iterable): Boolean = + jobs.any { job -> job.occupiesVirtualFolderHydrationSlot() } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt index 8548be970..cb47650fc 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt @@ -36,18 +36,6 @@ internal data class DesktopVirtualRangeCacheSummary( val tierAttention: String? = null, ) -internal data class VirtualRangeRevision( - val relativePath: String, - val remoteRevision: String, - val fileSize: Long, -) { - init { - FileOfflineKey("account", relativePath) - require(remoteRevision.isNotBlank() && remoteRevision.none(Char::isISOControl)) - require(fileSize > 0L) - } -} - private data class ActiveVirtualRangeRevision( val file: FileOfflineKey, val remoteRevision: String, @@ -1216,16 +1204,36 @@ internal class DesktopVirtualRangeCache( @Synchronized fun removeCopiedPrimaryAccount(accountId: String) { check(activePaths.keys.none { it.accountId == accountId } && activeRevisions.keys.none { it.file.accountId == accountId }) - val directory = accountDirectory(accountId) - if (!directory.isDirectory || Files.isSymbolicLink(directory.toPath())) return - directory.listFiles().orEmpty() - .filter { it.isFile && !Files.isSymbolicLink(it.toPath()) } - .forEach(File::delete) - directory.delete() + purgeDesktopAccountCacheDirectory(root, accountId) loadedIndexes.remove(accountId) recoveredAccounts.remove(accountId) } + @Synchronized + fun removeAccount(accountId: String) { + check( + activePaths.keys.none { it.accountId == accountId } && + activeRevisions.keys.none { it.file.accountId == accountId }, + ) { "Close files from this account before removing its cache." } + val configuredOverflow = overflowRoot + try { + if (configuredOverflow != null) { + check(isOverflowRootAvailable(configuredOverflow)) { + "Reconnect the overflow cache drive before removing this account." + } + } + purgeDesktopAccountCacheDirectory(root, accountId) + configuredOverflow?.let { overflow -> purgeDesktopAccountCacheDirectory(overflow, accountId) } + } finally { + loadedIndexes.remove(accountId) + recoveredAccounts.remove(accountId) + recoveredOverflowAccounts.remove(accountId) + dirtyAccessTimeAccounts.remove(accountId) + lastAccessTimePersistence.remove(accountId) + deferredInvalidationRevisions.removeAll { revision -> revision.file.accountId == accountId } + } + } + @Synchronized fun freeUp(accountId: String, requestedBytes: Long): VirtualFileEvictionPlan = applyEviction(accountId, requestedBytes, System.currentTimeMillis()) @@ -2526,17 +2534,6 @@ internal class DesktopVirtualRangeCache( } } -internal fun defaultDesktopVirtualRangeCache( - policy: () -> VirtualFileCachePolicy, -): DesktopVirtualRangeCache { - val xdgCache = System.getenv("XDG_CACHE_HOME")?.takeIf(String::isNotBlank) - val cacheRoot = xdgCache?.let(::File) ?: File(System.getProperty("user.home"), ".cache") - return DesktopVirtualRangeCache( - root = File(cacheRoot, "nextcloud-native/virtual-ranges"), - policy = policy, - ) -} - @Serializable private data class RangeCacheIndex( val version: Int = 2, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt new file mode 100644 index 000000000..8e81178b7 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt @@ -0,0 +1,229 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.util.prefs.Preferences + +internal fun windowsCloudFilesFailureAfterFallbackCleanup( + providerFailure: String?, + fallbackFailure: Throwable?, + defaultMessage: String, +): String? = fallbackFailure?.let { failure -> + providerFailure ?: failure.message ?: defaultMessage +} + +internal const val KEY_WINDOWS_CLOUD_FILES_ROOT = "windows-cloud-files-root" +internal const val KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX = "wcfr." +internal const val WINDOWS_CLOUD_FILES_ROOT_SUFFIX = "-v2" +private const val WINDOWS_CLOUD_FILES_REMOVAL_RECOVERY_SUFFIX = "-removal-recovery" + +internal fun desktopWindowsCloudFilesRoot( + accountId: String, + userHome: File = File(System.getProperty("user.home")), +): File { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + return File(File(userHome, "Nextcloud Native"), accountId + WINDOWS_CLOUD_FILES_ROOT_SUFFIX) +} + +internal fun windowsCloudFilesRootPreferenceKey(accountId: String): String { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + return "$KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX$accountId".also { key -> + check(key.length <= Preferences.MAX_KEY_LENGTH) + } +} + +internal fun desktopLegacyWindowsCloudFilesRoot(accountId: String, userHome: File): File = + File(File(userHome, "Nextcloud Native"), accountId) + +internal fun unregisterSupersededWindowsCloudFilesRoot( + preferences: Preferences, + accountId: String, + userHome: File, + api: WindowsCloudFilesApi, +) { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + val legacyRoot = validatedWindowsCloudFilesRoot(desktopLegacyWindowsCloudFilesRoot(accountId, userHome), userHome) + api.unregisterSyncRoot(legacyRoot) + clearWindowsCloudFilesRootPreferences(preferences, accountId, legacyRoot) +} + +internal fun clearWindowsCloudFilesRootPreferences( + preferences: Preferences, + accountId: String, + removedRoot: Path, +) { + listOf(KEY_WINDOWS_CLOUD_FILES_ROOT, windowsCloudFilesRootPreferenceKey(accountId)).forEach { key -> + val savedRoot = preferences.get(key, null) + ?.let(::File) + ?.toPath() + ?.toAbsolutePath() + ?.normalize() + if (savedRoot == removedRoot) preferences.remove(key) + } +} + +internal fun unregisterWindowsCloudFilesRootForUninstall( + preferences: Preferences = Preferences.userRoot().node("dev/obiente/nextcloudnative"), + userHome: File = File(System.getProperty("user.home")), + apiFactory: () -> WindowsCloudFilesApi = ::JnaWindowsCloudFilesApi, +) { + val rootsByPreference = linkedMapOf>() + fun addRoot(root: File?, preferenceKey: String? = null) { + if (root == null) return + val validated = validatedWindowsCloudFilesRoot(root, userHome) + rootsByPreference.getOrPut(validated) { linkedSetOf() } + .apply { preferenceKey?.let(::add) } + } + addRoot( + preferences.get(KEY_WINDOWS_CLOUD_FILES_ROOT, null)?.let(::File), + KEY_WINDOWS_CLOUD_FILES_ROOT, + ) + preferences.keys().filter { it.startsWith(KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX) }.forEach { key -> + addRoot(preferences.get(key, null)?.let(::File), key) + } + val sessionAccountId = preferences.get("server", null)?.let { server -> + preferences.get("login", null)?.let { login -> + desktopFileCacheAccountId(NextcloudSession(server, login, "unused")) + } + } + sessionAccountId?.let { accountId -> + addRoot( + desktopWindowsCloudFilesRoot(accountId, userHome), + windowsCloudFilesRootPreferenceKey(accountId), + ) + addRoot(desktopLegacyWindowsCloudFilesRoot(accountId, userHome)) + } + if (rootsByPreference.isEmpty()) return + val api = apiFactory() + var firstFailure: Throwable? = null + try { + rootsByPreference.entries + .sortedByDescending { (root) -> root.fileName.toString().endsWith(WINDOWS_CLOUD_FILES_ROOT_SUFFIX) } + .forEach { (root, preferenceKeys) -> + runCatching { api.unregisterSyncRoot(root) } + .onSuccess { preferenceKeys.forEach(preferences::remove) } + .onFailure { failure -> if (firstFailure == null) firstFailure = failure } + } + } finally { + api.close() + } + firstFailure?.let { throw it } +} + +internal fun unregisterWindowsCloudFilesRootsForAccountRemoval( + preferences: Preferences, + accountId: String, + userHome: File = File(System.getProperty("user.home")), + apiFactory: () -> WindowsCloudFilesApi = ::JnaWindowsCloudFilesApi, +) { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + val currentRoot = validatedWindowsCloudFilesRoot(desktopWindowsCloudFilesRoot(accountId, userHome), userHome) + val legacyRoot = validatedWindowsCloudFilesRoot(desktopLegacyWindowsCloudFilesRoot(accountId, userHome), userHome) + val roots = listOf(currentRoot, legacyRoot) + val recoveryRoot = windowsCloudFilesRemovalRecoveryRoot(accountId, userHome) + val existingRecoveryRoot = persistedWindowsCloudFilesPreservedRoot(preferences, accountId) + check(existingRecoveryRoot == null || existingRecoveryRoot == recoveryRoot) { + "Review and acknowledge the previous preserved Windows Cloud Files folder before removing this account." + } + val api = apiFactory() + var firstFailure: Throwable? = null + try { + roots.forEach { root -> + runCatching { api.unregisterSyncRoot(root) } + .onFailure { failure -> if (firstFailure == null) firstFailure = failure } + } + firstFailure?.let { throw it } + removeOrPreserveWindowsCloudFilesRoots( + preferences = preferences, + accountId = accountId, + recoveryRoot = recoveryRoot, + roots = roots, + api = api, + ) + roots.forEach { root -> clearWindowsCloudFilesRootPreferences(preferences, accountId, root) } + } finally { + api.close() + } +} + +private fun windowsCloudFilesRemovalRecoveryRoot(accountId: String, userHome: File): Path = + File(File(userHome, "Nextcloud Native"), accountId + WINDOWS_CLOUD_FILES_REMOVAL_RECOVERY_SUFFIX) + .toPath() + .toAbsolutePath() + .normalize() + +private fun removeOrPreserveWindowsCloudFilesRoots( + preferences: Preferences, + accountId: String, + recoveryRoot: Path, + roots: List, + api: WindowsCloudFilesApi, +) { + val stagedRoots = roots.mapIndexed { index, root -> root to recoveryRoot.resolve("root-$index") } + val recoveryRegistered = persistedWindowsCloudFilesPreservedRoot(preferences, accountId) == recoveryRoot + if (stagedRoots.none { (root, staged) -> + Files.exists(root, LinkOption.NOFOLLOW_LINKS) || Files.exists(staged, LinkOption.NOFOLLOW_LINKS) + } && !recoveryRegistered + ) return + if (Files.notExists(recoveryRoot, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectory(recoveryRoot) + try { + persistWindowsCloudFilesPreservedRoot(preferences, accountId, recoveryRoot) + } catch (failure: Throwable) { + runCatching { Files.deleteIfExists(recoveryRoot) }.exceptionOrNull()?.let(failure::addSuppressed) + throw failure + } + } else { + check(recoveryRegistered) { "The Windows Cloud Files removal recovery folder is not owned by this account." } + check(Files.isDirectory(recoveryRoot, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(recoveryRoot)) { + "The Windows Cloud Files removal recovery folder is invalid." + } + persistWindowsCloudFilesPreservedRoot(preferences, accountId, recoveryRoot) + } + stagedRoots.forEach { (root, staged) -> + val sourceExists = Files.exists(root, LinkOption.NOFOLLOW_LINKS) + val stagedExists = Files.exists(staged, LinkOption.NOFOLLOW_LINKS) + check(!sourceExists || !stagedExists) { "Windows Cloud Files removal found duplicate recovery roots." } + if (sourceExists) Files.move(root, staged) + if (Files.exists(staged, LinkOption.NOFOLLOW_LINKS) && windowsCloudFilesTreeIsDisposable(staged, api)) { + deleteWindowsCloudFilesTree(staged) + } + } + Files.list(recoveryRoot).use { entries -> + if (entries.findAny().isPresent) return + } + Files.delete(recoveryRoot) + acknowledgeWindowsCloudFilesPreservedRoot(preferences, accountId) +} + +private fun windowsCloudFilesTreeIsDisposable(root: Path, api: WindowsCloudFilesApi): Boolean = + runCatching { + Files.walk(root).use { entries -> + entries.filter { path -> path != root }.allMatch { path -> + !Files.isSymbolicLink(path) && + api.inspectPlaceholder(path).state == WindowsCloudPlaceholderEntryState.InSync + } + } + }.getOrDefault(false) + +private fun deleteWindowsCloudFilesTree(root: Path) { + Files.walk(root).use { entries -> + entries.sorted(Comparator.reverseOrder()).forEach(Files::delete) + } +} + +internal fun validatedWindowsCloudFilesRoot(root: File, userHome: File): Path { + val expectedParent = File(userHome, "Nextcloud Native").toPath().toAbsolutePath().normalize() + val normalizedRoot = root.toPath().toAbsolutePath().normalize() + val name = normalizedRoot.fileName.toString() + val accountId = name.removeSuffix(WINDOWS_CLOUD_FILES_ROOT_SUFFIX) + check( + normalizedRoot.parent == expectedParent && + accountId.length == 64 && + accountId.all { it in '0'..'9' || it in 'a'..'f' } && + (name == accountId || name == accountId + WINDOWS_CLOUD_FILES_ROOT_SUFFIX), + ) { "The stored Windows Cloud Files root is invalid." } + return normalizedRoot +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxFuseLifecycle.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxFuseLifecycle.kt new file mode 100644 index 000000000..a22e01dc3 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxFuseLifecycle.kt @@ -0,0 +1,75 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.ByteBuffer +import java.nio.channels.SeekableByteChannel +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import jnr.posix.POSIXFactory + +internal fun linuxEffectiveProcessUid(): Long = Integer.toUnsignedLong(POSIXFactory.getPOSIX().geteuid()) + +internal fun linuxEffectiveProcessGid(): Long = Integer.toUnsignedLong(POSIXFactory.getPOSIX().getegid()) + +internal fun linuxFuseConnectionIdForMount( + mountPoint: Path, + mountInfo: String = runCatching { Files.readString(Path.of("/proc/self/mountinfo")) }.getOrDefault(""), +): Int? { + val encodedMountPoint = mountPoint.toAbsolutePath().normalize().toString() + .replace("\\", "\\134") + .replace(" ", "\\040") + .replace("\t", "\\011") + .replace("\n", "\\012") + return mountInfo.lineSequence().firstNotNullOfOrNull { line -> + val fields = line.split(' ') + val separator = fields.indexOf("-") + if ( + fields.size < 7 || separator < 6 || separator + 2 >= fields.size || + fields[4] != encodedMountPoint || + fields[separator + 1].let { type -> type != "fuse" && !type.startsWith("fuse.") } || + fields[separator + 2] != "nextcloud-native" + ) return@firstNotNullOfOrNull null + fields[2].substringAfter(':', "").toIntOrNull() + } +} + +internal fun openLinuxFuseAbortHandle(connectionId: Int): LinuxFuseAbortHandle? { + require(connectionId >= 0) + return openLinuxFuseAbortHandle(Path.of("/sys/fs/fuse/connections", connectionId.toString(), "abort")) +} + +internal fun openLinuxFuseAbortHandle(path: Path): LinuxFuseAbortHandle? = runCatching { + ChannelLinuxFuseAbortHandle(Files.newByteChannel(path, StandardOpenOption.WRITE)) +}.getOrNull() + +internal interface LinuxFuseAbortHandle : AutoCloseable { + fun abortBestEffort() +} + +internal fun runLinuxFuseUnmountLifecycle( + abortHandle: LinuxFuseAbortHandle?, + detach: () -> Unit, + cleanup: (detached: Boolean) -> Unit, +) { + var detached = false + try { + detach() + detached = true + } finally { + abortHandle?.abortBestEffort() + runCatching { abortHandle?.close() } + cleanup(detached) + } +} + +private class ChannelLinuxFuseAbortHandle( + private val channel: SeekableByteChannel, +) : LinuxFuseAbortHandle { + override fun abortBestEffort() { + runCatching { channel.write(ByteBuffer.wrap("1\n".encodeToByteArray())) } + } + + override fun close() = channel.close() +} + +internal const val MAX_UNSIGNED_UNIX_ID = 0xffff_ffffL diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt index 2dc9b5724..67e67d7db 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt @@ -1,10 +1,7 @@ package dev.obiente.nextcloudnative.app -import java.nio.ByteBuffer -import java.nio.channels.SeekableByteChannel import java.nio.file.Files import java.nio.file.Path -import java.nio.file.StandardOpenOption import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ExecutorService @@ -13,7 +10,6 @@ import java.util.concurrent.Semaphore import java.util.concurrent.atomic.AtomicLong import jnr.ffi.Pointer import jnr.ffi.Platform -import jnr.posix.POSIXFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import ru.serce.jnrfuse.ErrorCodes @@ -129,6 +125,7 @@ internal interface LinuxVirtualMetadataStore { internal class DesktopLinuxVirtualMetadataStore( private val cache: DesktopFileReadCache, private val accountId: String, + private val cacheProducer: DesktopFileReadCacheProducer? = cache.producer(accountId), ) : LinuxVirtualMetadataStore { override fun load(path: String): LinuxVirtualDirectorySnapshot? { val listing = cache.cachedVirtualListingSnapshot(accountId, path) ?: return null @@ -138,7 +135,6 @@ internal class DesktopLinuxVirtualMetadataStore( freshAtEpochMillis = listing.freshAtEpochMillis, ) } - override fun store(path: String, snapshot: LinuxVirtualDirectorySnapshot): Boolean = cache.storeVirtualListingUnlessNewer( accountId = accountId, @@ -146,16 +142,16 @@ internal class DesktopLinuxVirtualMetadataStore( nodes = snapshot.nodes, fetchedAtEpochMillis = snapshot.fetchedAtEpochMillis, freshAtEpochMillis = snapshot.freshAtEpochMillis, + cacheProducer = cacheProducer, ) - - override fun invalidate(path: String) = cache.invalidate(accountId, path) + override fun invalidate(path: String) { cache.invalidate(accountId, path, cacheProducer) } override fun retainedPaths(): Set = cache.cachedVirtualListingPaths(accountId) override fun failedInvalidations(): Set = cache.failedVirtualListingInvalidations(accountId) override fun replaceFailedInvalidations(paths: Set) = - cache.replaceFailedVirtualListingInvalidations(accountId, paths) + cache.replaceFailedVirtualListingInvalidations(accountId, paths, cacheProducer) } internal class RetainedLinuxVirtualMetadataStore( @@ -1100,9 +1096,12 @@ internal class LinuxNextcloudVirtualFileSystem( private val maximumOpenDirectoryEntries: Int = DEFAULT_MAX_OPEN_DIRECTORY_ENTRIES, private val beforeDirectoryHandleRemoval: () -> Unit = {}, private val unmountOperation: (LinuxNextcloudVirtualFileSystem) -> Unit = { fileSystem -> fileSystem.umount() }, + private val fuseAbortHandleProvider: (Path?) -> LinuxFuseAbortHandle? = { mountPoint -> + mountPoint?.let(::linuxFuseConnectionIdForMount)?.let(::openLinuxFuseAbortHandle) + }, private val mountOwnerUid: Long = linuxEffectiveProcessUid(), private val mountOwnerGid: Long = linuxEffectiveProcessGid(), -) : FuseStubFS() { +) : FuseStubFS(), DesktopLinuxProviderFileSystem { @Volatile private var mountedAt: Path? = null private val nextHandle = AtomicLong(1L) @@ -1116,13 +1115,18 @@ internal class LinuxNextcloudVirtualFileSystem( private var openDirectoryEntries = 0L private val pendingCreatedFiles = ConcurrentHashMap() private val namespaceLock = Any() + private val readsEnabled = java.util.concurrent.atomic.AtomicBoolean(true) + private val writeLifecycle = LinuxVirtualWriteLifecycle( + hasOpenWriteHandles = { writeHandles.isNotEmpty() }, + hasPendingCreatedFiles = { pendingCreatedFiles.isNotEmpty() }, + ) init { require(maximumOpenDirectoryEntries > 0) require(mountOwnerUid in 0L..MAX_UNSIGNED_UNIX_ID) require(mountOwnerGid in 0L..MAX_UNSIGNED_UNIX_ID) } - override fun getattr(path: String, stat: FileStat): Int = fuseResult { + override fun getattr(path: String, stat: FileStat): Int = fuseReadResult { val normalized = path.linuxVirtualPath() val pending = pendingCreatedFiles[normalized]?.delegate val node = visibleNode(normalized) @@ -1132,7 +1136,7 @@ internal class LinuxNextcloudVirtualFileSystem( 0 } - override fun opendir(path: String, fileInfo: FuseFileInfo): Int = fuseResult { + override fun opendir(path: String, fileInfo: FuseFileInfo): Int = fuseReadResult { val id = openAndRegisterDirectorySnapshot(path) fileInfo.fh.set(id) 0 @@ -1144,7 +1148,7 @@ internal class LinuxNextcloudVirtualFileSystem( filler: FuseFillDir, offset: Long, fileInfo: FuseFileInfo, - ): Int = fuseResult { + ): Int = fuseReadResult { val normalized = path.linuxVirtualPath() val handleId = fileInfo.fh.get() val existingHandle = directoryHandles[handleId]?.takeIf { it.path == normalized } @@ -1177,10 +1181,11 @@ internal class LinuxNextcloudVirtualFileSystem( 0 } - override fun open(path: String, fileInfo: FuseFileInfo): Int = fuseResult { + override fun open(path: String, fileInfo: FuseFileInfo): Int = fuseMutationResult { val normalized = path.linuxVirtualPath() val flags = fileInfo.flags.intValue() val writeAccess = flags and OPEN_ACCESS_MASK != OPEN_READ_ONLY + if (!writeAccess && !readsEnabled.get()) return -ErrorCodes.EIO() pendingCreatedFiles[normalized]?.let { pending -> if (writeAccess && flags and OPEN_TRUNCATE != 0) pending.delegate.truncate(0L) fileInfo.fh.set(registerWriteHandle(pending, writable = writeAccess)) @@ -1215,7 +1220,7 @@ internal class LinuxNextcloudVirtualFileSystem( requestedSize: Long, offset: Long, fileInfo: FuseFileInfo, - ): Int = fuseResult { + ): Int = fuseReadResult { if (offset < 0L || requestedSize < 0L || requestedSize > Int.MAX_VALUE) return -ErrorCodes.EINVAL() val id = fileInfo.fh.get() if (id == EMPTY_FILE_HANDLE) return 0 @@ -1233,22 +1238,29 @@ internal class LinuxNextcloudVirtualFileSystem( override fun release(path: String, fileInfo: FuseFileInfo): Int = fuseResult { val id = fileInfo.fh.get() - if (id != EMPTY_FILE_HANDLE) { - synchronized(namespaceLock) { - readHandlePaths.remove(id) - readHandles.remove(id)?.close() + val writeRelease = writeHandles.containsKey(id) + val releaseStarted = !writeRelease || writeLifecycle.beginRelease() + if (!releaseStarted) return 0 + try { + if (id != EMPTY_FILE_HANDLE) { + synchronized(namespaceLock) { + readHandlePaths.remove(id) + readHandles.remove(id)?.close() + } + releaseWriteHandle(id) } - releaseWriteHandle(id) + } finally { + if (writeRelease) writeLifecycle.endOperation() } 0 } - override fun access(path: String, mask: Int): Int = fuseResult { + override fun access(path: String, mask: Int): Int = fuseReadResult { val normalized = path.linuxVirtualPath() if (pendingCreatedFiles.containsKey(normalized) || visibleNode(normalized) != null) 0 else -ErrorCodes.ENOENT() } - override fun create(path: String, mode: Long, fi: FuseFileInfo?): Int = fuseResult { + override fun create(path: String, mode: Long, fi: FuseFileInfo?): Int = fuseMutationResult { val fileInfo = fi ?: return -ErrorCodes.EINVAL() val normalized = path.linuxVirtualPath() val parent = visibleNode(normalized.substringBeforeLast('/', "")) @@ -1268,7 +1280,7 @@ internal class LinuxNextcloudVirtualFileSystem( 0 } - override fun mkdir(path: String, mode: Long): Int = fuseResult { + override fun mkdir(path: String, mode: Long): Int = fuseMutationResult { val normalized = path.linuxVirtualPath() val parent = visibleNode(normalized.substringBeforeLast('/', "")) ?: return -ErrorCodes.ENOENT() @@ -1282,7 +1294,7 @@ internal class LinuxNextcloudVirtualFileSystem( override fun rmdir(path: String): Int = deletePath(path, expectDirectory = true) - override fun rename(oldPath: String, newPath: String): Int = fuseResult { + override fun rename(oldPath: String, newPath: String): Int = fuseMutationResult { synchronized(namespaceLock) { val sourcePath = oldPath.linuxVirtualPath() val destination = newPath.linuxVirtualPath() @@ -1316,7 +1328,7 @@ internal class LinuxNextcloudVirtualFileSystem( } } - override fun truncate(path: String, size: Long): Int = fuseResult { + override fun truncate(path: String, size: Long): Int = fuseMutationResult { val normalized = path.linuxVirtualPath() pendingCreatedFiles[normalized]?.let { pending -> pending.delegate.truncate(size) @@ -1332,7 +1344,7 @@ internal class LinuxNextcloudVirtualFileSystem( 0 } - override fun write(path: String, buf: Pointer, size: Long, offset: Long, fi: FuseFileInfo): Int = fuseResult { + override fun write(path: String, buf: Pointer, size: Long, offset: Long, fi: FuseFileInfo): Int = fuseMutationResult { if (offset < 0L || size < 0L || size > Int.MAX_VALUE) return -ErrorCodes.EINVAL() val reference = writeHandles[fi.fh.get()] ?: return -ErrorCodes.EBADF() if (!reference.writable) return -ErrorCodes.EBADF() @@ -1341,7 +1353,7 @@ internal class LinuxNextcloudVirtualFileSystem( reference.shared.delegate.write(offset, bytes) } - override fun flush(path: String, fi: FuseFileInfo): Int = fuseResult { + override fun flush(path: String, fi: FuseFileInfo): Int = fuseMutationResult { writeHandles[fi.fh.get()]?.shared?.delegate?.flush() 0 } @@ -1372,16 +1384,17 @@ internal class LinuxNextcloudVirtualFileSystem( mountedAt = mountPoint.toAbsolutePath().normalize() } - fun unmount() { - var detached = false - val fuseConnectionId = mountedAt?.let(::linuxFuseConnectionIdForMount) - val fuseAbortHandle = fuseConnectionId?.let(::openLinuxFuseAbortHandle) - try { - unmountOperation(this) - detached = true - fuseAbortHandle?.abortBestEffort() - } finally { - runCatching { fuseAbortHandle?.close() } + internal fun quiesceWrites(): Boolean = writeLifecycle.tryQuiesce() + + internal fun resumeWrites() = writeLifecycle.resume() + + override fun disableReads() = readsEnabled.set(false) + override fun unmount() { + val fuseAbortHandle = fuseAbortHandleProvider(mountedAt) + runLinuxFuseUnmountLifecycle( + abortHandle = fuseAbortHandle, + detach = { unmountOperation(this) }, + ) { detached -> readHandles.values.forEach { runCatching(it::close) } writeHandles.values.map(LinuxOpenWriteReference::shared).distinct().forEach { shared -> runCatching(shared.delegate::close) @@ -1569,7 +1582,7 @@ internal class LinuxNextcloudVirtualFileSystem( private fun visibleNode(path: String): LinuxVirtualFileNode? = backend.resolve(path) - private fun deletePath(path: String, expectDirectory: Boolean): Int = fuseResult { + private fun deletePath(path: String, expectDirectory: Boolean): Int = fuseMutationResult { synchronized(namespaceLock) { val normalized = path.linuxVirtualPath() if (pendingCreatedFiles.containsKey(normalized)) return -ErrorCodes.EBUSY() @@ -1604,6 +1617,18 @@ internal class LinuxNextcloudVirtualFileSystem( -ErrorCodes.EIO() } + private inline fun fuseReadResult(operation: () -> Int): Int = + if (readsEnabled.get()) fuseResult(operation) else -ErrorCodes.EIO() + + private inline fun fuseMutationResult(operation: () -> Int): Int = fuseResult { + writeLifecycle.beginMutation() + try { + operation() + } finally { + writeLifecycle.endOperation() + } + } + private companion object { const val DIRECTORY_PERMISSIONS = 0b111101101 // 0755 const val FILE_PERMISSIONS = 0b110100100 // 0644 @@ -1616,65 +1641,10 @@ internal class LinuxNextcloudVirtualFileSystem( } } -private fun linuxEffectiveProcessUid(): Long = Integer.toUnsignedLong(POSIXFactory.getPOSIX().geteuid()) - -private fun linuxEffectiveProcessGid(): Long = Integer.toUnsignedLong(POSIXFactory.getPOSIX().getegid()) - -internal fun linuxFuseConnectionIdForMount( - mountPoint: Path, - mountInfo: String = runCatching { Files.readString(Path.of("/proc/self/mountinfo")) }.getOrDefault(""), -): Int? { - val encodedMountPoint = mountPoint.toAbsolutePath().normalize().toString() - .replace("\\", "\\134") - .replace(" ", "\\040") - .replace("\t", "\\011") - .replace("\n", "\\012") - return mountInfo.lineSequence().firstNotNullOfOrNull { line -> - val fields = line.split(' ') - val separator = fields.indexOf("-") - if ( - fields.size < 7 || - separator < 6 || - separator + 2 >= fields.size || - fields[4] != encodedMountPoint || - fields[separator + 1].let { type -> type != "fuse" && !type.startsWith("fuse.") } || - fields[separator + 2] != "nextcloud-native" - ) { - return@firstNotNullOfOrNull null - } - fields[2].substringAfter(':', "").toIntOrNull() - } -} - -private fun openLinuxFuseAbortHandle(connectionId: Int): LinuxFuseAbortHandle? { - require(connectionId >= 0) - return openLinuxFuseAbortHandle( - Path.of("/sys/fs/fuse/connections", connectionId.toString(), "abort"), - ) -} - -internal fun openLinuxFuseAbortHandle(path: Path): LinuxFuseAbortHandle? = runCatching { - LinuxFuseAbortHandle(Files.newByteChannel(path, StandardOpenOption.WRITE)) -}.getOrNull() - -internal class LinuxFuseAbortHandle( - private val channel: SeekableByteChannel, -) : AutoCloseable { - fun abortBestEffort() { - runCatching { channel.write(ByteBuffer.wrap("1\n".encodeToByteArray())) } - } - - override fun close() = channel.close() -} - -private const val MAX_UNSIGNED_UNIX_ID = 0xffff_ffffL - /** Stable across refreshes and app restarts so file managers can reconcile large directory models. */ internal fun stableLinuxVirtualInode(path: String): Long { var hash = -0x340d631b7bdddcdbL - path.forEach { character -> - hash = (hash xor character.code.toLong()) * 0x100000001b3L - } + path.forEach { character -> hash = (hash xor character.code.toLong()) * 0x100000001b3L } return (hash and Long.MAX_VALUE).coerceAtLeast(2L) } @@ -1690,7 +1660,7 @@ private data class LinuxOpenDirectoryEntry( val node: LinuxVirtualFileNode?, ) -private class LinuxVirtualFileSystemException(val errorCode: Int) : RuntimeException() +internal class LinuxVirtualFileSystemException(val errorCode: Int) : RuntimeException() private class LinuxSharedWriteHandle( val delegate: LinuxVirtualFileWriteHandle, @@ -1719,12 +1689,8 @@ private fun String.linuxVirtualPath(): String { if (character != '/') continue require(index > segmentStart) val segmentLength = index - segmentStart - require( - segmentLength != 1 || this[segmentStart] != '.', - ) - require( - segmentLength != 2 || this[segmentStart] != '.' || this[segmentStart + 1] != '.', - ) + require(segmentLength != 1 || this[segmentStart] != '.') + require(segmentLength != 2 || this[segmentStart] != '.' || this[segmentStart + 1] != '.') segmentStart = index + 1 } return if (start == 0 && end == length) this else substring(start, end) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGate.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGate.kt new file mode 100644 index 000000000..543d17af0 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGate.kt @@ -0,0 +1,71 @@ +package dev.obiente.nextcloudnative.app + +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import ru.serce.jnrfuse.ErrorCodes + +internal class LinuxVirtualMutationGate { + private enum class State { Open, Draining, Quiesced } + + private val lock = ReentrantLock() + private val drained = lock.newCondition() + private var state = State.Open + private var active = 0 + + fun begin() = lock.withLock { + if (state != State.Open) throw LinuxVirtualFileSystemException(ErrorCodes.EBUSY()) + active += 1 + } + + fun beginRelease(): Boolean = lock.withLock { + if (state == State.Quiesced) return false + active += 1 + true + } + + fun end() = lock.withLock { + check(active > 0) + active -= 1 + if (active == 0) drained.signalAll() + } + + fun tryQuiesce(canQuiesce: () -> Boolean): Boolean = lock.withLock { + if (state == State.Quiesced) return true + check(state == State.Open) + state = State.Draining + while (active > 0) drained.awaitUninterruptibly() + if (canQuiesce()) { + state = State.Quiesced + true + } else { + state = State.Open + false + } + } + + fun resume() = lock.withLock { + check(state == State.Quiesced) + state = State.Open + } + + fun isAcceptingNewOperations(): Boolean = lock.withLock { state == State.Open } +} + +internal class LinuxVirtualWriteLifecycle( + private val hasOpenWriteHandles: () -> Boolean, + private val hasPendingCreatedFiles: () -> Boolean, +) { + private val gate = LinuxVirtualMutationGate() + + fun beginMutation() = gate.begin() + + fun beginRelease(): Boolean = gate.beginRelease() + + fun endOperation() = gate.end() + + fun tryQuiesce(): Boolean = gate.tryQuiesce { + !hasOpenWriteHandles() && !hasPendingCreatedFiles() + } + + fun resume() = gate.resume() +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesPath.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesPath.kt new file mode 100644 index 000000000..c98057291 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesPath.kt @@ -0,0 +1,65 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path + +internal fun requireWindowsCloudCallbackPath(root: Path, normalizedPath: String, identityPath: String) { + // Windows can report the same directory through a long path in CFAPI while java.io.tmpdir or a + // configured root still contains an 8.3 component such as RUNNER~1. Compare real filesystem + // paths so the containment check does not reject that legitimate alias. + val absoluteRoot = root.windowsCloudRealPath() + val callbackTarget = Path.of(normalizedPath).windowsCloudRealPath() + require(callbackTarget.startsWith(absoluteRoot)) { "The Cloud Files callback escaped its sync root." } + val relative = if (callbackTarget == absoluteRoot) { + "" + } else { + absoluteRoot.relativize(callbackTarget).joinToString("/") { it.toString() }.windowsCloudPath() + } + require(relative == identityPath) { "The Cloud Files callback path does not match its identity." } +} + +private fun Path.windowsCloudRealPath(): Path { + val absolute = toAbsolutePath().normalize() + var existing = absolute + while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) { + existing = requireNotNull(existing.parent) { "The Cloud Files callback path has no existing ancestor." } + } + val realAncestor = existing.toRealPath(LinkOption.NOFOLLOW_LINKS) + return if (existing == absolute) realAncestor else realAncestor.resolve(existing.relativize(absolute)).normalize() +} + +internal fun windowsWildcardMatches(pattern: String, name: String): Boolean { + if (pattern == "*" || pattern == "*.*") return true + var patternIndex = 0 + var nameIndex = 0 + var starIndex = -1 + var retryNameIndex = -1 + while (nameIndex < name.length) { + if ( + patternIndex < pattern.length && + (pattern[patternIndex] == '?' || pattern[patternIndex].equals(name[nameIndex], true)) + ) { + patternIndex += 1 + nameIndex += 1 + } else if (patternIndex < pattern.length && pattern[patternIndex] == '*') { + starIndex = patternIndex++ + retryNameIndex = nameIndex + } else if (starIndex >= 0) { + patternIndex = starIndex + 1 + nameIndex = ++retryNameIndex + } else { + return false + } + } + while (patternIndex < pattern.length && pattern[patternIndex] == '*') patternIndex += 1 + return patternIndex == pattern.length +} + +internal fun String.windowsCloudPath(): String { + val normalized = trim('/', '\\').replace('\\', '/') + if (normalized.isEmpty()) return "" + require(normalized.split('/').none { it.isEmpty() || it == "." || it == ".." }) + require('\u0000' !in normalized) + return normalized +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt index 679fce48f..6778e7fd1 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt @@ -356,6 +356,7 @@ internal class WindowsCloudFilesProvider( private val writebackAttempts = ConcurrentHashMap() private val namespaceMutationLock = Any() private val callbacksPaused = AtomicBoolean(false) + private val accountRemovalPaused = AtomicBoolean(false) private val corruptRootRecoveryLifecycleLock = Any() private val corruptRootStableAccessLock = Any() private val corruptRootRecoveryClaimed = AtomicBoolean(false) @@ -380,6 +381,9 @@ internal class WindowsCloudFilesProvider( private set @Volatile private var watchService: WatchService? = null @Volatile private var watcherThread: Thread? = null + private val accountRemovalQuiescence = WindowsCloudFilesRemovalQuiescence( + ::pauseCallbacksForAccountRemoval, ::accountRemovalMutationState, ::resumeCallbacksAndReplayLocalChanges, + ) fun start() { check(connection.get() == 0L) { "The Windows Cloud Files provider is already connected." } @@ -390,7 +394,10 @@ internal class WindowsCloudFilesProvider( val rootIdentity = WindowsCloudFileIdentity(backend.accountId, "", "root", 0L, true) val encodedRootIdentity = WindowsCloudFileIdentityCodec.encode(rootIdentity) api.registerSyncRoot(root, backend.displayName, encodedRootIdentity) - connection.set(connectWithRegistrationRecovery(encodedRootIdentity)) + connection.set(connectWindowsCloudFilesWithRegistrationRecovery(root, this, api) { + prepareRootDirectory() + api.registerSyncRoot(root, backend.displayName, encodedRootIdentity) + }) try { try { populateDirectory("", root) @@ -433,6 +440,7 @@ internal class WindowsCloudFilesProvider( val claimDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(quiescenceTimeoutSeconds) while (!claimCorruptRootRecovery()) { check(!runtimeStopping.get()) { "Windows Cloud Files is stopping." } + check(!accountRemovalPaused.get()) { "Windows Cloud Files is paused for account removal." } runtimeRecoveryFailure.get()?.let { throw it } if (corruptRootRecoveryGeneration.get() != expectedGeneration) return check(System.nanoTime() < claimDeadline) { @@ -490,8 +498,9 @@ internal class WindowsCloudFilesProvider( "The Windows Cloud Files connection changed during corrupt-root recovery." } } - awaitPathOperationQuiescence( + awaitWindowsCloudFilesPathOperationQuiescence( System.nanoTime() + TimeUnit.SECONDS.toNanos(quiescenceTimeoutSeconds), + ::accountRemovalMutationState, ) api.unregisterSyncRoot(root) val preserved = try { @@ -625,7 +634,7 @@ internal class WindowsCloudFilesProvider( internal fun isCorruptRootRecoveryInProgress(): Boolean = corruptRootRecoveryClaimed.get() private fun claimCorruptRootRecovery(): Boolean = synchronized(corruptRootRecoveryLifecycleLock) { - !runtimeStopping.get() && corruptRootRecoveryClaimed.compareAndSet(false, true) + !runtimeStopping.get() && !accountRemovalPaused.get() && corruptRootRecoveryClaimed.compareAndSet(false, true) } private fun scheduleCorruptRootRecoveryAfterStartup( @@ -697,22 +706,6 @@ internal class WindowsCloudFilesProvider( ?.let(failure::addSuppressed) } - private fun connectWithRegistrationRecovery(syncRootIdentity: ByteArray): Long = - try { - api.connect(root, this) - } catch (firstFailure: WindowsCloudFilesOperationException) { - if (!isWindowsCloudFilesRegistrationMissingResult(firstFailure.hResult)) throw firstFailure - api.unregisterSyncRoot(root) - prepareRootDirectory() - api.registerSyncRoot(root, backend.displayName, syncRootIdentity) - try { - api.connect(root, this) - } catch (retryFailure: Throwable) { - retryFailure.addSuppressed(firstFailure) - throw retryFailure - } - } - private fun prepareRootDirectory() { Files.createDirectories(root) check(!Files.isSymbolicLink(root)) { "The Windows Cloud Files root cannot be a symlink." } @@ -754,7 +747,7 @@ internal class WindowsCloudFilesProvider( else -> throw failure } val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds) - awaitWritebackRecovery(deadline) + awaitWindowsCloudFilesWritebackRecovery(deadline, ::accountRemovalMutationState) } deferredCorruption?.let { corruption -> val expectedGeneration = requireNotNull(deferredCorruptionGeneration) @@ -831,37 +824,6 @@ internal class WindowsCloudFilesProvider( } } - private fun awaitPathOperationQuiescence(deadline: Long) { - while ( - (destructiveCallbackOperations.get() > 0 || pathOperations.isNotEmpty() || - synchronized(queuedPathOperations) { queuedPathOperations.isNotEmpty() }) && - System.nanoTime() < deadline - ) { - Thread.sleep(25L) - } - check( - destructiveCallbackOperations.get() == 0 && pathOperations.isEmpty() && - synchronized(queuedPathOperations) { queuedPathOperations.isEmpty() }, - ) { "Timed out while quiescing callbacks and local edits before Windows Cloud Files recovery." } - } - - private fun awaitWritebackRecovery(deadline: Long) { - while ( - (pendingWritebacks.isNotEmpty() || pathOperations.isNotEmpty() || - synchronized(queuedPathOperations) { queuedPathOperations.isNotEmpty() }) && - System.nanoTime() < deadline - ) { - Thread.sleep(25L) - } - check(failedWritebacks.isEmpty()) { - "Local edits in the legacy Windows Cloud Files root could not be uploaded safely." - } - check( - pendingWritebacks.isEmpty() && pathOperations.isEmpty() && - synchronized(queuedPathOperations) { queuedPathOperations.isEmpty() }, - ) { "Timed out while uploading local edits from the legacy Windows Cloud Files root." } - } - override fun fetchData(info: WindowsCloudCallbackInfo, requiredOffset: Long, requiredLength: Long) { if (callbacksPaused.get()) return val cancellation = AtomicBoolean(false) @@ -1050,7 +1012,10 @@ internal class WindowsCloudFilesProvider( if (!Files.exists(normalized) || api.placeholderState(normalized) != WindowsCloudPlaceholderState.Absent) return val relative = root.toAbsolutePath().normalize().relativize(normalized) .joinToString("/") { it.toString() }.windowsCloudPath() - submitPathOperation(relative) { + submitPathOperation( + relative, + deferredWhenPaused = { if (!runtimeStopping.get()) deferredLocalChanges.add(normalized) }, + ) { if (Files.isDirectory(normalized)) uploadLocalTree(normalized) else uploadLocalEntry(normalized, relative) } } @@ -1169,6 +1134,10 @@ internal class WindowsCloudFilesProvider( closeApi() } + internal fun quiesceWritesForAccountRemoval(timeoutSeconds: Long = DEFAULT_CORRUPT_ROOT_QUIESCENCE_TIMEOUT_SECONDS) = + accountRemovalQuiescence.tryQuiesce(timeoutSeconds) + internal fun resumeWritesAfterAccountRemovalFailure() = resumeCallbacksAndReplayLocalChanges() + override fun close() { stopRuntime() closeApi() @@ -1177,6 +1146,7 @@ internal class WindowsCloudFilesProvider( private fun stopRuntime() { synchronized(corruptRootRecoveryLifecycleLock) { runtimeStopping.set(true) + accountRemovalPaused.set(false) callbacksPaused.set(true) } awaitCorruptRootRecoveryCompletion( @@ -1223,18 +1193,76 @@ internal class WindowsCloudFilesProvider( } private fun resumeCallbacksAndReplayLocalChanges() { - val replay = synchronized(namespaceMutationLock) { - if (runtimeStopping.get()) return - callbacksPaused.set(false) - deferredLocalChanges.toList().also(deferredLocalChanges::removeAll) + if (connection.get() == 0L && !runtimeStopping.get()) connection.set(api.connect(root, this)) + if (watchService == null && initialPopulationSucceeded && !runtimeStopping.get()) startLocalWatcher() + val replay = synchronized(corruptRootRecoveryLifecycleLock) { + synchronized(namespaceMutationLock) { + if (runtimeStopping.get()) return + accountRemovalPaused.set(false) + callbacksPaused.set(false) + deferredLocalChanges.toList().also(deferredLocalChanges::removeAll) + } } replay.forEach(::scheduleLocalChange) } + private fun pauseCallbacksForAccountRemoval(): Boolean { + val paused = synchronized(corruptRootRecoveryLifecycleLock) { + if ( + runtimeStopping.get() || corruptRootRecoveryClaimed.get() || runtimeRecoveryFailure.get() != null + ) return@synchronized false + synchronized(namespaceMutationLock) { + if (callbacksPaused.get()) false else true.also { + accountRemovalPaused.set(it) + callbacksPaused.set(it) + } + } + } + if (!paused) return false + val key = connection.get() + if (key != 0L) { + api.disconnect(key) + check(connection.compareAndSet(key, 0L)) { + "The Windows Cloud Files connection changed during account removal." + } + } + stopLocalWatcherForAccountRemoval() + val deadline = System.nanoTime() + + TimeUnit.SECONDS.toNanos(DEFAULT_CORRUPT_ROOT_QUIESCENCE_TIMEOUT_SECONDS) + awaitWindowsCloudFilesPathOperationQuiescence(deadline, ::accountRemovalMutationState) + recoverLocalPlaceholders(failClosed = true, allowWhilePaused = true) + awaitWindowsCloudFilesWritebackRecovery(deadline, ::accountRemovalMutationState) + recoverUnmanagedLocalEntries(failClosed = true) + synchronized(namespaceMutationLock) { deferredLocalChanges.clear() } + return true + } + + private fun accountRemovalMutationState() = synchronized(queuedPathOperations) { + WindowsCloudFilesMutationState( + pendingWritebacks.size, failedWritebacks.size, pathOperations.size, + queuedPathOperations.size, destructiveCallbackOperations.get(), + pendingLocalChanges.size, deferredLocalChanges.size, + ) + } + + private fun stopLocalWatcherForAccountRemoval() { + runCatching { watchService?.close() } + watcherThread?.interrupt() + watcherThread = null + watchService = null + val scheduled = synchronized(namespaceMutationLock) { + pendingLocalChanges.values.toList().also { pendingLocalChanges.clear() } + } + scheduled.forEach { it.cancel(false) } + localChangeScheduler.submit(Runnable {}).get( + DEFAULT_CORRUPT_ROOT_QUIESCENCE_TIMEOUT_SECONDS, + TimeUnit.SECONDS, + ) + } + private fun closeApi() { if (apiClosed.compareAndSet(false, true)) api.close() } - private fun populateDirectory(relativePath: String, localDirectory: Path) { val identities = backend.list(relativePath) val missing = ArrayList() @@ -1843,14 +1871,23 @@ internal class WindowsCloudFilesProvider( return absoluteRoot.relativize(target).joinToString("/") { it.toString() }.windowsCloudPath() } - private fun submitPathOperation(path: String, block: () -> Unit) { - if (callbacksPaused.get()) return - failedWritebacks -= path - writebackAttempts.remove(path) - val shouldSchedule = synchronized(queuedPathOperations) { - if (callbacksPaused.get()) return - queuedPathOperations[path] = block - pathOperations.add(path) + private fun submitPathOperation( + path: String, + deferredWhenPaused: () -> Unit = {}, + allowWhilePaused: Boolean = false, + block: () -> Unit, + ) { + val shouldSchedule = synchronized(namespaceMutationLock) { + if (callbacksPaused.get() && !allowWhilePaused) { + deferredWhenPaused() + return + } + failedWritebacks -= path + writebackAttempts.remove(path) + synchronized(queuedPathOperations) { + queuedPathOperations[path] = block + pathOperations.add(path) + } } if (shouldSchedule) schedulePathOperationDrain(path) } @@ -1962,15 +1999,21 @@ internal class WindowsCloudFilesProvider( } private fun scheduleLocalChange(path: Path) { - pendingLocalChanges.remove(path)?.cancel(false) - pendingLocalChanges[path] = localChangeScheduler.schedule( - { - pendingLocalChanges.remove(path) - runCatching { localEntryChanged(path) } - }, - LOCAL_CHANGE_SETTLE_MILLIS, - TimeUnit.MILLISECONDS, - ) + synchronized(namespaceMutationLock) { + if (callbacksPaused.get()) { + if (!runtimeStopping.get()) deferredLocalChanges.add(path) + return + } + pendingLocalChanges.remove(path)?.cancel(false) + pendingLocalChanges[path] = localChangeScheduler.schedule( + { + pendingLocalChanges.remove(path) + runCatching { localEntryChanged(path) } + }, + LOCAL_CHANGE_SETTLE_MILLIS, + TimeUnit.MILLISECONDS, + ) + } } private fun recoverLocalChanges() { @@ -2043,7 +2086,10 @@ internal class WindowsCloudFilesProvider( } } - private fun recoverLocalPlaceholders(failClosed: Boolean = false) { + private fun recoverLocalPlaceholders( + failClosed: Boolean = false, + allowWhilePaused: Boolean = false, + ) { val recover = { Files.walk(root).use { paths -> paths.filter { path -> path != root && !Files.isSymbolicLink(path) }.forEach { local -> @@ -2066,7 +2112,11 @@ internal class WindowsCloudFilesProvider( knownIdentities[original.path] = original if (state != WindowsCloudPlaceholderState.Dirty || original.directory) return@forEach if (!pendingWritebacks.add(original.path)) return@forEach - submitPathOperation(original.path) { + submitPathOperation( + original.path, + deferredWhenPaused = { if (!runtimeStopping.get()) deferredLocalChanges.add(local) }, + allowWhilePaused = allowWhilePaused, + ) { val current = requireNotNull(api.placeholderIdentity(local)) { "The dirty Windows placeholder has no recoverable identity." }.let(WindowsCloudFileIdentityCodec::decode) @@ -2278,74 +2328,6 @@ internal class WindowsCloudFilesProvider( } } -private class AtomicLongState { - @Volatile private var value: Long = 0L - @Synchronized fun get(): Long = value - @Synchronized fun set(next: Long) { value = next } - @Synchronized fun compareAndSet(expected: Long, next: Long): Boolean { - if (value != expected) return false - value = next - return true - } -} - -internal fun requireWindowsCloudCallbackPath(root: Path, normalizedPath: String, identityPath: String) { - // Windows can report the same directory through a long path in CFAPI while java.io.tmpdir or a - // configured root still contains an 8.3 component such as RUNNER~1. Compare real filesystem - // paths so the containment check does not reject that legitimate alias. - val absoluteRoot = root.windowsCloudRealPath() - val callbackTarget = Path.of(normalizedPath).windowsCloudRealPath() - require(callbackTarget.startsWith(absoluteRoot)) { "The Cloud Files callback escaped its sync root." } - val relative = if (callbackTarget == absoluteRoot) { - "" - } else { - absoluteRoot.relativize(callbackTarget).joinToString("/") { it.toString() }.windowsCloudPath() - } - require(relative == identityPath) { "The Cloud Files callback path does not match its identity." } -} - -private fun Path.windowsCloudRealPath(): Path { - val absolute = toAbsolutePath().normalize() - var existing = absolute - while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) { - existing = requireNotNull(existing.parent) { "The Cloud Files callback path has no existing ancestor." } - } - val realAncestor = existing.toRealPath(LinkOption.NOFOLLOW_LINKS) - return if (existing == absolute) realAncestor else realAncestor.resolve(existing.relativize(absolute)).normalize() -} - -private fun windowsWildcardMatches(pattern: String, name: String): Boolean { - if (pattern == "*" || pattern == "*.*") return true - var patternIndex = 0 - var nameIndex = 0 - var starIndex = -1 - var retryNameIndex = -1 - while (nameIndex < name.length) { - if (patternIndex < pattern.length && (pattern[patternIndex] == '?' || pattern[patternIndex].equals(name[nameIndex], true))) { - patternIndex += 1 - nameIndex += 1 - } else if (patternIndex < pattern.length && pattern[patternIndex] == '*') { - starIndex = patternIndex++ - retryNameIndex = nameIndex - } else if (starIndex >= 0) { - patternIndex = starIndex + 1 - nameIndex = ++retryNameIndex - } else { - return false - } - } - while (patternIndex < pattern.length && pattern[patternIndex] == '*') patternIndex += 1 - return patternIndex == pattern.length -} - -private fun String.windowsCloudPath(): String { - val normalized = trim('/', '\\').replace('\\', '/') - if (normalized.isEmpty()) return "" - require(normalized.split('/').none { it.isEmpty() || it == "." || it == ".." }) - require('\u0000' !in normalized) - return normalized -} - private const val WINDOWS_CLOUD_ALIGNMENT = 4 * 1024L private const val MAX_WINDOWS_WRITEBACK_ATTEMPTS = 5 private const val MAX_WINDOWS_DIRECTORY_REFRESH_ATTEMPTS = 4 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRecoveryPaging.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRecoveryPaging.kt new file mode 100644 index 000000000..3dc1e555f --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRecoveryPaging.kt @@ -0,0 +1,22 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Path + +internal fun pageWindowsCloudFilesRecoveryRoots( + roots: Map, + startAfterAccountId: String?, + limit: Int = MAX_WINDOWS_CLOUD_FILES_RECOVERY_ROOTS_PER_ATTEMPT, +): Map { + require(limit > 0) + if (roots.isEmpty()) return emptyMap() + val ordered = roots.entries.sortedBy(Map.Entry::key) + val startIndex = startAfterAccountId + ?.let { cursor -> ordered.indexOfFirst { it.key > cursor } } + ?.takeIf { it >= 0 } + ?: 0 + return (0 until minOf(limit, ordered.size)) + .map { offset -> ordered[(startIndex + offset) % ordered.size] } + .associate(Map.Entry::toPair) +} + +private const val MAX_WINDOWS_CLOUD_FILES_RECOVERY_ROOTS_PER_ATTEMPT = 16 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRemovalQuiescence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRemovalQuiescence.kt new file mode 100644 index 000000000..7c603dc64 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRemovalQuiescence.kt @@ -0,0 +1,130 @@ +package dev.obiente.nextcloudnative.app + +import java.util.concurrent.TimeUnit + +internal data class WindowsCloudFilesMutationState( + val pendingWritebackCount: Int, + val failedWritebackCount: Int, + val pathOperationCount: Int, + val queuedPathOperationCount: Int, + val destructiveCallbackCount: Int, + val pendingLocalChangeCount: Int = 0, + val deferredLocalChangeCount: Int = 0, +) { + val idle: Boolean + get() = pendingWritebackCount == 0 && pathOperationCount == 0 && + queuedPathOperationCount == 0 && destructiveCallbackCount == 0 && + pendingLocalChangeCount == 0 && deferredLocalChangeCount == 0 + + val writebackFailedWithoutRetry: Boolean + get() = failedWritebackCount > 0 && pathOperationCount == 0 && queuedPathOperationCount == 0 +} + +internal class WindowsCloudFilesRemovalQuiescence( + private val pauseCallbacks: () -> Boolean, + private val mutationState: () -> WindowsCloudFilesMutationState, + private val resumeCallbacks: () -> Unit, + private val nanoTime: () -> Long = System::nanoTime, + private val awaitProgress: () -> Unit = { Thread.sleep(POLL_MILLIS) }, +) { + fun tryQuiesce(timeoutSeconds: Long): Boolean { + require(timeoutSeconds > 0L) + try { + if (!pauseCallbacks()) return false + val deadline = nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds) + var state = mutationState() + while (!state.idle && !state.writebackFailedWithoutRetry && nanoTime() < deadline) { + awaitProgress() + state = mutationState() + } + check(state.failedWritebackCount == 0) { + "Local edits in the Windows Cloud Files root could not be uploaded safely." + } + check(state.idle) { + "Timed out while uploading local edits and finishing Windows Cloud Files operations." + } + return true + } catch (failure: Throwable) { + if (failure is InterruptedException) Thread.currentThread().interrupt() + runCatching(resumeCallbacks).exceptionOrNull()?.let(failure::addSuppressed) + throw failure + } + } + + private companion object { + const val POLL_MILLIS = 25L + } +} + +internal fun awaitWindowsCloudFilesPathOperationQuiescence( + deadline: Long, + mutationState: () -> WindowsCloudFilesMutationState, +) { + var state = mutationState() + while ( + (state.destructiveCallbackCount > 0 || state.pathOperationCount > 0 || state.queuedPathOperationCount > 0) && + System.nanoTime() < deadline + ) { + Thread.sleep(25L) + state = mutationState() + } + check(state.destructiveCallbackCount == 0 && state.pathOperationCount == 0 && state.queuedPathOperationCount == 0) { + "Timed out while quiescing callbacks and local edits before Windows Cloud Files recovery." + } +} + +internal fun awaitWindowsCloudFilesWritebackRecovery( + deadline: Long, + mutationState: () -> WindowsCloudFilesMutationState, +) { + var state = mutationState() + while ( + (state.pendingWritebackCount > 0 || state.pathOperationCount > 0 || state.queuedPathOperationCount > 0) && + !state.writebackFailedWithoutRetry && + System.nanoTime() < deadline + ) { + Thread.sleep(25L) + state = mutationState() + } + check(state.failedWritebackCount == 0) { + "Local edits in the legacy Windows Cloud Files root could not be uploaded safely." + } + check(state.pendingWritebackCount == 0 && state.pathOperationCount == 0 && state.queuedPathOperationCount == 0) { + "Timed out while uploading local edits from the legacy Windows Cloud Files root." + } +} + +internal class AtomicLongState { + @Volatile private var value: Long = 0L + + @Synchronized fun get(): Long = value + + @Synchronized fun set(next: Long) { + value = next + } + + @Synchronized fun compareAndSet(expected: Long, next: Long): Boolean { + if (value != expected) return false + value = next + return true + } +} + +internal fun connectWindowsCloudFilesWithRegistrationRecovery( + root: java.nio.file.Path, + callbacks: WindowsCloudFilesCallbacks, + api: WindowsCloudFilesApi, + recoverRegistration: () -> Unit, +): Long = try { + api.connect(root, callbacks) +} catch (firstFailure: WindowsCloudFilesOperationException) { + if (!isWindowsCloudFilesRegistrationMissingResult(firstFailure.hResult)) throw firstFailure + api.unregisterSyncRoot(root) + recoverRegistration() + try { + api.connect(root, callbacks) + } catch (retryFailure: Throwable) { + retryFailure.addSuppressed(firstFailure) + throw retryFailure + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemovalTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemovalTest.kt new file mode 100644 index 000000000..5c2b735e5 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemovalTest.kt @@ -0,0 +1,105 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import java.util.UUID +import java.util.prefs.Preferences +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopAccountCacheRemovalTest { + @Test + fun accountRemovalPurgesPrivateFilesPersistentInvalidationsAndRangeIndexes() { + val root = Files.createTempDirectory("desktop-account-cache-removal-").toFile() + val filesRoot = root.resolve("files").apply { mkdir() } + val rangesRoot = root.resolve("ranges").apply { mkdir() } + val preferences = Preferences.userRoot().node("desktop-account-cache-removal-${UUID.randomUUID()}") + try { + val files = DesktopFileReadCache(filesRoot, preferences = preferences) + val ranges = DesktopVirtualRangeCache(rangesRoot) { testPolicy() } + files.storeContent( + ACCOUNT_ID, + "Private/secret.txt", + NextcloudFileContent("private bytes".encodeToByteArray(), "text/plain", "etag-1"), + ) + files.replaceFailedVirtualListingInvalidations(ACCOUNT_ID, setOf("Private")) + ranges.storeBlock(ACCOUNT_ID, "Private/secret.bin", "etag-2", 4L, 0L, "data".encodeToByteArray()) + + files.removeAccount(ACCOUNT_ID) + ranges.removeAccount(ACCOUNT_ID) + + assertFalse(filesRoot.resolve(ACCOUNT_ID).exists()) + assertFalse(rangesRoot.resolve(ACCOUNT_ID).exists()) + assertNull(files.cachedContent(ACCOUNT_ID, "Private/secret.txt", 64)) + assertTrue(files.failedVirtualListingInvalidations(ACCOUNT_ID).isEmpty()) + assertNull(ranges.readBlock(ACCOUNT_ID, "Private/secret.bin", "etag-2", 4L, 0L, 4)) + } finally { + preferences.removeNode() + root.deleteRecursively() + } + } + + @Test + fun unavailableOverflowKeepsPrimaryCacheForJournalRetry() { + val root = Files.createTempDirectory("desktop-account-cache-overflow-").toFile() + val primary = root.resolve("primary").apply { mkdir() } + val overflow = root.resolve("overflow").apply { mkdir() } + val disconnected = root.resolve("disconnected") + try { + val ranges = DesktopVirtualRangeCache( + root = primary, + overflowRoot = overflow, + initializeOverflowMarker = true, + policy = { testPolicy() }, + ) + ranges.storeBlock(ACCOUNT_ID, "Private/secret.bin", "etag-1", 4L, 0L, "data".encodeToByteArray()) + overflow.resolve(ACCOUNT_ID).apply { mkdir() }.resolve("private.block").writeText("private") + assertTrue(overflow.renameTo(disconnected)) + + assertFailsWith { ranges.removeAccount(ACCOUNT_ID) } + assertTrue(primary.resolve(ACCOUNT_ID).isDirectory) + assertTrue(disconnected.resolve(ACCOUNT_ID).isDirectory) + + assertTrue(disconnected.renameTo(overflow)) + ranges.removeAccount(ACCOUNT_ID) + assertFalse(primary.resolve(ACCOUNT_ID).exists()) + assertFalse(overflow.resolve(ACCOUNT_ID).exists()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun accountRemovalPurgesVirtualFileLocationPreferences() { + val preferences = Preferences.userRoot().node("desktop-account-locations-${UUID.randomUUID()}") + try { + val rootKey = virtualFileProviderRootPreferenceKey(ACCOUNT_ID) + val primaryKey = "vfpc-primary.$ACCOUNT_ID" + val overflowKey = "vfpc-overflow.$ACCOUNT_ID" + preferences.put(rootKey, "/private/mount") + preferences.put(primaryKey, "/private/primary") + preferences.put(overflowKey, "/private/overflow") + preferences.flush() + + removeDesktopAccountVirtualFilePreferences(preferences, ACCOUNT_ID) + + assertNull(preferences.get(rootKey, null)) + assertNull(preferences.get(primaryKey, null)) + assertNull(preferences.get(overflowKey, null)) + } finally { + preferences.removeNode() + } + } + + private companion object { + const val ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + fun testPolicy() = VirtualFileCachePolicy( + automaticCleanup = false, + minimumFreeSpaceBytes = 0L, + unusedFileAgeMillis = null, + ) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRetryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRetryTest.kt new file mode 100644 index 000000000..43e1fbb11 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRetryTest.kt @@ -0,0 +1,41 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.runBlocking + +class DesktopAccountCleanupRetryTest { + @Test + fun retryStopsAsSoonAsTheCommittedCleanupIsFinished() = runBlocking { + var attempts = 0 + var waits = 0 + + retryDesktopAccountSyncPairCleanupsBounded( + maximumAttempts = 3, + waitBeforeNextAttempt = { waits += 1 }, + ) { + attempts += 1 + attempts < 2 + } + + assertEquals(2, attempts) + assertEquals(1, waits) + } + + @Test + fun retryStopsAtTheBoundAndLeavesDurableRecoveryToRestart() = runBlocking { + var attempts = 0 + var waits = 0 + + retryDesktopAccountSyncPairCleanupsBounded( + maximumAttempts = 3, + waitBeforeNextAttempt = { waits += 1 }, + ) { + attempts += 1 + true + } + + assertEquals(3, attempts) + assertEquals(2, waits) + } +} 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..4327c5465 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -0,0 +1,1056 @@ +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(14, 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 == 3) 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 preparedReauthenticationCrashKeepsTheCurrentSelectionAndOldSecret() = + withStore { preferences, secrets -> + val inactive = firstSession() + val active = secondSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(inactive) + persistence.saveSession(active) + secrets.crashSaveOnAttempt = secrets.saveCount + 1 + + assertFailsWith { + persistence.saveSession(inactive.copy(appPassword = "replacement-password")) + } + assertEquals("prepared", preferences.get("accountCredentialSavePhase", null)) + + val restarted = persistence(preferences, secrets) + assertEquals(active, restarted.loadActiveSession()) + assertEquals(inactive, restarted.loadSession(inactive.accountId)) + assertNull(preferences.get("accountCredentialSavePhase", null)) + } + + @Test + fun startupRecoveryBlocksCredentialAccessWhenRegistryVersionIsUnreadable() = + 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(), + ) + + assertFailsWith { + 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 failedReplacementRollbackRestoresThePreviousCredentialFromTheSecureJournalOnRestart() = + withStore { preferences, secrets -> + val original = firstSession() + val replacement = original.copy(appPassword = "replacement-password") + var flushCount = 0 + var failFlushOnAttempt: Int? = null + val persistence = persistence(preferences, secrets) { + flushCount += 1 + if (flushCount == failFlushOnAttempt) error("synthetic registry flush failure") + preferences.flush() + } + persistence.saveSession(original) + secrets.failSaveOnAttempt = secrets.saveCount + 3 + failFlushOnAttempt = flushCount + 3 + + assertFailsWith { persistence.saveSession(replacement) } + + assertEquals( + replacement.appPassword, + secrets.load(desktopAccountSecretReference(original.accountId))?.decodeToString(), + ) + assertEquals(original.serverUrl, preferences.get("accountCredentialSaveServer", null)) + + failFlushOnAttempt = null + assertEquals(original, persistence(preferences, secrets).loadActiveSession()) + assertNull(secrets.load(desktopAccountCredentialRollbackReference(original.accountId))) + assertNull(preferences.get("accountCredentialSaveServer", null)) + assertNull(preferences.get("accountCredentialSaveLogin", null)) + } + + @Test + fun crashDuringFailedReauthenticationRollbackDoesNotSelectTheInactiveAccount() = + withStore { preferences, secrets -> + val inactive = firstSession() + val active = secondSession() + var flushCount = 0 + var failFlushOnAttempt: Int? = null + val persistence = persistence(preferences, secrets) { + flushCount += 1 + if (flushCount == failFlushOnAttempt) error("synthetic registry flush failure") + preferences.flush() + } + persistence.saveSession(inactive) + persistence.saveSession(active) + failFlushOnAttempt = flushCount + 3 + secrets.crashSaveOnAttempt = secrets.saveCount + 3 + + assertFailsWith { + persistence.saveSession(inactive.copy(appPassword = "replacement-password")) + } + + assertEquals("rollback", preferences.get("accountCredentialSavePhase", null)) + failFlushOnAttempt = null + val restarted = persistence(preferences, secrets) + assertEquals(active, restarted.loadActiveSession()) + assertEquals(active.accountId, restarted.activeAccountId()) + assertEquals(inactive, restarted.loadSession(inactive.accountId)) + assertNull(secrets.load(desktopAccountCredentialRollbackReference(inactive.accountId))) + assertNull(preferences.get("accountCredentialSavePhase", null)) + } + + @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(), + ) + assertEquals(DesktopAccountOwnership.Present, persistence.accountOwnership(desktopFileCacheAccountId(session))) + assertFailsWith { persistence.saveSession(secondSession()) } + assertEquals(futureRegistry, preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertNull(secrets.load(desktopAccountSecretReference(secondSession().accountId))) + assertEquals(listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }.distinct()) + } + + @Test + fun malformedRegistryWithoutLegacyCredentialRejectsFreshSignInWithoutOrphaningState() = + withStore { preferences, secrets -> + val session = firstSession() + val malformedRegistry = "{not-json" + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, malformedRegistry) + secrets.save( + desktopAccountSecretReference(secondSession().accountId), "bob", "existing-secret".encodeToByteArray(), + ) + val savesBefore = secrets.saveCount + val clearsBefore = secrets.clearCount + val diagnostics = mutableListOf() + val persistence = persistence(preferences, secrets, diagnostics) + + assertEquals(DesktopAccountOwnership.Unknown, persistence.accountOwnership(desktopFileCacheAccountId(session))) + assertFailsWith { persistence.saveSession(session) } + + assertEquals(malformedRegistry, preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertEquals( + "existing-secret", + secrets.load(desktopAccountSecretReference(secondSession().accountId))?.decodeToString(), + ) + assertEquals(savesBefore, secrets.saveCount) + assertEquals(clearsBefore, secrets.clearCount) + assertTrue(diagnostics.any { it.code == "ACCOUNT_REGISTRY_MALFORMED" }) + } + + @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 failedLegacyCleanupForOneAccountDoesNotGetOverwrittenByAnotherRemoval() = + withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + val firstLegacy = desktopSessionSecretReference(first.serverUrl, first.loginName) + val secondLegacy = desktopSessionSecretReference(second.serverUrl, second.loginName) + val persistence = persistence(preferences, secrets) + persistence.saveSession(first) + persistence.saveSession(second) + secrets.save(firstLegacy, first.loginName, first.appPassword.encodeToByteArray()) + secrets.save(secondLegacy, second.loginName, second.appPassword.encodeToByteArray()) + preferences.put("accountLegacyCleanupServer", first.serverUrl) + preferences.put("accountLegacyCleanupLogin", first.loginName) + preferences.flush() + secrets.failClears = true + + assertTrue(persistence.removeAccount(second.accountId)) + + assertEquals(first.serverUrl, preferences.get("accountLegacyCleanupServer", null)) + assertEquals(second.serverUrl, preferences.get("accountLegacyCleanupV2.0.server", null)) + assertNotNull(secrets.load(firstLegacy)) + assertNotNull(secrets.load(secondLegacy)) + + secrets.failClears = false + persistence(preferences, secrets).loadActiveSession() + + assertNull(secrets.load(firstLegacy)) + assertNull(secrets.load(secondLegacy)) + assertNull(preferences.get("accountLegacyCleanupServer", null)) + assertNull(preferences.get("accountLegacyCleanupV2.0.server", null)) + } + + @Test + fun legacyCleanupJournalKeepsDistinctRawSecretReferencesForOneCanonicalAccount() = + withStore { preferences, _ -> + val diagnostics = mutableListOf() + val journal = DesktopLegacyCredentialCleanupJournal(preferences, preferences::flush) { + diagnostics += "malformed" + } + val withoutSlash = DesktopPendingLegacyCredentialCleanup("https://cloud.example.test", "alice") + val withSlash = DesktopPendingLegacyCredentialCleanup("https://cloud.example.test/", "alice") + + journal.prepareAdd(withoutSlash) + journal.prepareAdd(withSlash) + + assertEquals(listOf(withoutSlash, withSlash), journal.pending()) + assertTrue(diagnostics.isEmpty()) + } + + @Test + fun fullLegacyCleanupJournalRejectsAnotherTargetWithoutOverwritingEntries() = + withStore { preferences, _ -> + repeat(MAX_LOCAL_ACCOUNTS) { index -> + preferences.put("accountLegacyCleanupV2.$index.server", "https://cloud$index.example.test") + preferences.put("accountLegacyCleanupV2.$index.login", "user$index") + } + val journal = DesktopLegacyCredentialCleanupJournal(preferences, preferences::flush) {} + + assertFailsWith { + journal.prepareAdd(DesktopPendingLegacyCredentialCleanup("https://overflow.example.test", "alice")) + } + + assertEquals("https://cloud0.example.test", preferences.get("accountLegacyCleanupV2.0.server", null)) + assertEquals( + "https://cloud63.example.test", + preferences.get("accountLegacyCleanupV2.63.server", null), + ) + } + + @Test + fun malformedLegacyCleanupSlotIsPreservedAndDoesNotHideValidTargets() = + withStore { preferences, _ -> + preferences.put("accountLegacyCleanupV2.0.server", "https://malformed.example.test") + var malformedReports = 0 + val journal = DesktopLegacyCredentialCleanupJournal(preferences, preferences::flush) { + malformedReports += 1 + } + val valid = DesktopPendingLegacyCredentialCleanup("https://cloud.example.test", "alice") + + journal.prepareAdd(valid) + + assertEquals(listOf(valid), journal.pending()) + assertEquals(1, malformedReports) + assertEquals( + "https://malformed.example.test", + preferences.get("accountLegacyCleanupV2.0.server", null), + ) + assertEquals(valid.serverUrl, preferences.get("accountLegacyCleanupV2.1.server", null)) + } + + @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 malformedCredentialRemovalEntryDoesNotBlockValidCleanupOrLaterRemoval() = + withStore { preferences, secrets -> + val removed = firstSession() + val retained = secondSession() + val diagnostics = mutableListOf() + val persistence = persistence(preferences, secrets, diagnostics) + persistence.saveSession(removed) + persistence.saveSession(retained) + DesktopAccountRegistryPreferenceStore(preferences).write( + encodeNextcloudAccountRegistry(decodeRegistry(preferences).remove(removed.accountId)), + ) + val malformedJournal = "${removed.accountId.storageKey},truncated" + preferences.put("accountCredentialRemovals", malformedJournal) + preferences.flush() + assertEquals(retained, persistence.loadActiveSession()) + assertNull(secrets.load(desktopAccountSecretReference(removed.accountId))) + assertEquals("truncated", preferences.get("accountCredentialRemovals", null)) + + assertTrue(persistence.removeAccount(retained.accountId)) + + assertNull(persistence.activeAccountId()) + assertNull(secrets.load(desktopAccountSecretReference(retained.accountId))) + assertEquals("truncated", preferences.get("accountCredentialRemovals", null)) + assertNull(preferences.get("server", null)) + assertNull(preferences.get("login", null)) + assertEquals( + listOf("ACCOUNT_CREDENTIAL_REMOVAL_JOURNAL_INVALID"), + diagnostics.mapNotNull { it.code }, + ) + } + + @Test + fun removalMarkersSurviveProcessExitAfterTheRegistryCommit() = withStore { preferences, secrets -> + val first = firstSession() + val removed = secondSession() + var crashDuringRemoval = false + val persistence = persistence(preferences, secrets) { + preferences.flush() + if (crashDuringRemoval && decodeRegistry(preferences).accounts.none { it.id == removed.accountId }) { + throw SimulatedProcessExit() + } + } + persistence.saveSession(first) + persistence.saveSession(removed) + secrets.save( + desktopSessionSecretReference(removed.serverUrl, removed.loginName), + removed.loginName, + removed.appPassword.encodeToByteArray(), + ) + crashDuringRemoval = true + + assertFailsWith { persistence.removeAccount(removed.accountId) } + + assertFalse(decodeRegistry(preferences).accounts.any { it.id == removed.accountId }) + assertEquals(removed.accountId.storageKey, preferences.get("accountCredentialRemovals", null)) + assertEquals(removed.serverUrl, preferences.get("accountLegacyCleanupV2.0.server", null)) + assertNotNull(secrets.load(desktopAccountSecretReference(removed.accountId))) + assertNotNull(secrets.load(desktopSessionSecretReference(removed.serverUrl, removed.loginName))) + assertEquals(removed.serverUrl, preferences.get("server", null)) + assertEquals(removed.loginName, preferences.get("login", null)) + + crashDuringRemoval = false + assertNull(persistence(preferences, secrets).loadActiveSession()) + assertNull(secrets.load(desktopAccountSecretReference(removed.accountId))) + assertNull(secrets.load(desktopSessionSecretReference(removed.serverUrl, removed.loginName))) + assertNull(preferences.get("accountCredentialRemovals", null)) + assertNull(preferences.get("accountLegacyCleanupV2.0.server", null)) + assertNull(preferences.get("server", null)) + assertNull(preferences.get("login", 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 == 14) 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) + } + + @Test + fun missingRollbackCredentialBlocksEveryFollowingCredentialOperation() = + withStore { preferences, secrets -> + val original = firstSession() + val replacement = original.copy(appPassword = "replacement-password") + val persistence = persistence(preferences, secrets) + persistence.saveSession(original) + preparePendingRollback(preferences, secrets, original, replacement, includeRollback = false) + + assertFailsWith { + persistence.loadActiveSession() + } + assertFailsWith { + persistence.saveSession(replacement) + } + assertEquals("rollback", preferences.get("accountCredentialSavePhase", null)) + } + + @Test + fun rollbackCredentialLoadFailureBlocksSelectionUntilRecoveryCanRetry() = + withStore { preferences, secrets -> + val original = firstSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(original) + preparePendingRollback(preferences, secrets, original, original.copy(appPassword = "replacement-password")) + secrets.failLoadTarget = desktopAccountCredentialRollbackReference(original.accountId).targetName + + assertFailsWith { + persistence.loadActiveSession() + } + assertFailsWith { + persistence.selectAccount(original.accountId) + } + assertEquals("rollback", preferences.get("accountCredentialSavePhase", null)) + } + + @Test + fun rollbackCredentialSaveFailureBlocksRemovalUntilRecoveryCanRetry() = + withStore { preferences, secrets -> + val original = firstSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(original) + preparePendingRollback(preferences, secrets, original, original.copy(appPassword = "replacement-password")) + secrets.failSaveTarget = desktopAccountSecretReference(original.accountId).targetName + + assertFailsWith { + persistence.loadActiveSession() + } + assertFailsWith { + persistence.removeAccount(original.accountId) + } + assertEquals("rollback", preferences.get("accountCredentialSavePhase", null)) + } + + @Test + fun rollbackCredentialClearFailureBlocksASubsequentSave() = withStore { preferences, secrets -> + val original = firstSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(original) + preparePendingRollback(preferences, secrets, original, original.copy(appPassword = "replacement-password")) + secrets.failClearTarget = desktopAccountCredentialRollbackReference(original.accountId).targetName + + assertFailsWith { + persistence.loadActiveSession() + } + assertFailsWith { + persistence.saveSession(original) + } + assertEquals(original.appPassword, secrets.load(desktopAccountSecretReference(original.accountId))?.decodeToString()) + assertEquals("rollback-completed", preferences.get("accountCredentialSavePhase", null)) + } + + @Test + fun phaseOnlyRollbackJournalBlocksEveryCredentialOperation() { + assertPhaseOnlyJournalBlocksCredentialOperations("rollback") + } + + @Test + fun phaseOnlyUnknownJournalBlocksEveryCredentialOperation() { + assertPhaseOnlyJournalBlocksCredentialOperations("unexpected-phase") + } + + private fun persistence( + preferences: Preferences, + secrets: MemorySecretStore, + diagnostics: MutableList = mutableListOf(), + flushPreferences: () -> Unit = preferences::flush, + ) = DesktopAccountCredentialPersistence(preferences, secrets, diagnostics::add, flushPreferences) + + private fun preparePendingRollback( + preferences: Preferences, + secrets: MemorySecretStore, + original: NextcloudSession, + replacement: NextcloudSession, + includeRollback: Boolean = true, + ) { + preferences.put("accountCredentialSaveServer", original.serverUrl) + preferences.put("accountCredentialSaveLogin", original.loginName) + preferences.put("accountCredentialSavePhase", "rollback") + secrets.save( + desktopAccountSecretReference(original.accountId), + original.loginName, + replacement.appPassword.encodeToByteArray(), + ) + if (includeRollback) { + secrets.save( + desktopAccountCredentialRollbackReference(original.accountId), + original.loginName, + original.appPassword.encodeToByteArray(), + ) + } + preferences.flush() + } + + 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 assertPhaseOnlyJournalBlocksCredentialOperations(phase: String) = + withStore { preferences, secrets -> + val session = firstSession() + val persistence = persistence(preferences, secrets) + preferences.put("accountCredentialSavePhase", phase) + preferences.flush() + val operations: List<() -> Unit> = listOf( + { persistence.loadActiveSession() }, + { persistence.saveSession(session) }, + { persistence.selectAccount(session.accountId) }, + { persistence.removeAccount(session.accountId) }, + ) + + operations.forEach { operation -> + assertFailsWith { operation() } + assertEquals(phase, preferences.get("accountCredentialSavePhase", null)) + assertNull(preferences.get("accountCredentialSaveServer", null)) + assertNull(preferences.get("accountCredentialSaveLogin", null)) + } + } + + 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 failSaveOnAttempt: Int? = null + var crashSaveOnAttempt: Int? = null + var failClears = false + var loadFailure: RuntimeException? = null + var failLoadTarget: String? = null + var failSaveTarget: String? = null + var failClearTarget: String? = null + var loadCount = 0 + private set + var saveCount = 0 + private set + var clearCount = 0 + private set + + override fun load(reference: DesktopSecretReference): ByteArray? { + loadCount += 1 + loadFailure?.let { throw it } + if (reference.targetName == failLoadTarget) error("synthetic targeted secret load failure") + return values[reference.targetName]?.copyOf() + } + + override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { + saveCount += 1 + if (saveCount == crashSaveOnAttempt) throw SimulatedProcessExit() + if (failSaves || saveCount == failSaveOnAttempt || reference.targetName == failSaveTarget) { + error("private-app-password at cloud.example.test for alice") + } + values[reference.targetName] = secret.copyOf() + } + + override fun clear(reference: DesktopSecretReference) { + clearCount += 1 + if (failClears || reference.targetName == failClearTarget) error("synthetic secret deletion failure") + values.remove(reference.targetName) + } + + fun resetOperationCounts() { + loadCount = 0 + clearCount = 0 + } + } +} + +private class SimulatedProcessExit : Error() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt new file mode 100644 index 000000000..8c74e0738 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt @@ -0,0 +1,112 @@ +package dev.obiente.nextcloudnative.app + +import java.io.IOException +import java.nio.file.Files +import java.util.UUID +import java.util.prefs.Preferences +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopAccountMemoryRetirementTest { + @Test + fun `committed credential removal retires memory before journal commit can fail`() = runBlocking { + val events = mutableListOf() + + val removed = removeDesktopAccountBeforeSyncPairCleanup( + accountId = "a".repeat(64), + accountStorageKey = "b".repeat(64), + prepareCleanup = { _, _, _, _ -> events += "prepare" }, + commitCleanup = { events += "commit"; throw IOException("disk full") }, + clearCleanup = { events += "clear" }, + accountOwnership = { DesktopAccountOwnership.Absent }, + removeCredential = { events += "credential"; true }, + removeSyncPairs = { events += "cleanup" }, + retireCommittedAccount = { events += "retire" }, + recordCleanupFailure = { events += "failure" }, + ) + + assertTrue(removed) + assertEquals(listOf("prepare", "credential", "retire", "commit", "failure"), events) + } + + @Test + fun `post-commit credential throw retires but confirmed presence does not`() = runBlocking { + suspend fun attempt(ownership: DesktopAccountOwnership): Boolean { + var retired = false + runCatching { + removeDesktopAccountBeforeSyncPairCleanup( + accountId = "c".repeat(64), + prepareCleanup = { _, _, _, _ -> }, + commitCleanup = {}, + clearCleanup = {}, + accountOwnership = { ownership }, + removeCredential = { throw IOException("credential result lost") }, + removeSyncPairs = {}, + retireCommittedAccount = { retired = true }, + recordCleanupFailure = {}, + ) + } + return retired + } + + assertTrue(attempt(DesktopAccountOwnership.Absent)) + assertFalse(attempt(DesktopAccountOwnership.Present)) + } + + @Test + fun `committed removal fences file cache before physical cleanup can fail`() = runBlocking { + val root = Files.createTempDirectory("desktop-file-cache-commit-fence-").toFile() + val preferences = Preferences.userRoot().node("desktop-file-cache-commit-fence-${UUID.randomUUID()}") + val accountId = "d".repeat(64) + val cache = DesktopFileReadCache(root, preferences = preferences) + val staleProducer = checkNotNull(cache.producer(accountId)) + val privateContent = NextcloudFileContent( + "private".encodeToByteArray(), + "text/plain", + "etag-private", + ) + try { + assertTrue( + cache.storeContent( + accountId, + "Notes/private.txt", + privateContent, + cacheProducer = staleProducer, + ), + ) + + val removed = removeDesktopAccountBeforeSyncPairCleanup( + accountId = accountId, + prepareCleanup = { _, _, _, _ -> }, + commitCleanup = {}, + clearCleanup = {}, + accountOwnership = { DesktopAccountOwnership.Absent }, + removeCredential = { true }, + removeSyncPairs = { + assertTrue(root.resolve(accountId).isDirectory) + assertFalse( + cache.storeContent( + accountId, + "Notes/late.txt", + privateContent, + cacheProducer = staleProducer, + ), + ) + error("synthetic cleanup failure before physical cache removal") + }, + retireCommittedAccount = { cache.retireAccount(accountId) }, + recordCleanupFailure = {}, + ) + + assertTrue(removed) + assertFalse(root.resolve(accountId).resolve("index-v1.json").readText().contains("late.txt")) + } finally { + runCatching { cache.removeAccount(accountId) } + preferences.removeNode() + root.deleteRecursively() + } + } +} 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..35409227c --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -0,0 +1,1181 @@ +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 accountRemovalWaitsForCrossingDeckDraftSaveThenDeletesIt() = runBlocking { + val guard = DesktopAccountOperationGuard() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val saveEntered = CompletableDeferred() + val releaseSave = CompletableDeferred() + var current: NextcloudSession? = session + var draftExists = false + val save = async { + guard.withAccountPrivateStatePublication( + expectedSession = session, + resolveSession = { current }, + unavailable = { false }, + ) { + saveEntered.complete(Unit) + releaseSave.await() + draftExists = true + true + } + } + saveEntered.await() + val removal = async { + guard.serialize { + current = null + draftExists = false + } + } + yield() + + assertFalse(removal.isCompleted) + releaseSave.complete(Unit) + assertTrue(save.await()) + removal.await() + + assertFalse(draftExists) + } + + @Test + fun accountRemovalCannotOvertakePostSaveDynamicReadActivation() = runBlocking { + val guard = DesktopAccountOperationGuard() + val persistenceEntered = CompletableDeferred() + val finishPersistence = CompletableDeferred() + val events = mutableListOf() + val session = NextcloudSession("https://cloud.example.test", "alice", "saved-password") + val save = async { + guard.persistSessionAndActivateDynamicReads( + persist = { + persistenceEntered.complete(Unit) + finishPersistence.await() + session + }, + activate = { events += "activate" }, + ) + } + persistenceEntered.await() + val removal = async { + guard.serialize { events += "fence" } + } + yield() + + assertFalse(removal.isCompleted) + finishPersistence.complete(Unit) + assertEquals(session, save.await()) + removal.await() + + assertEquals(listOf("activate", "fence"), events) + } + + @Test + fun cancelledSaveFinishesPostCommitActivationBeforeReleasingTheAccountFence() = runBlocking { + val guard = DesktopAccountOperationGuard() + val activationEntered = CompletableDeferred() + val finishActivation = CompletableDeferred() + val events = mutableListOf() + val session = NextcloudSession("https://cloud.example.test", "alice", "saved-password") + val save = async { + guard.persistSessionAndActivateDynamicReads( + persist = { + events += "persist" + session + }, + activate = { + activationEntered.complete(Unit) + finishActivation.await() + events += "activate" + }, + ) + } + activationEntered.await() + save.cancel() + finishActivation.complete(Unit) + + assertFailsWith { save.await() } + assertEquals(listOf("persist", "activate"), events) + } + + @Test + fun lateDurableWriterCannotPublishAfterRemovalAndCredentialReplacement() = runBlocking { + val guard = DesktopAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val replacement = original.copy(appPassword = "replacement-password") + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var current: NextcloudSession? = original + var durablePublished = false + val removal = async { + guard.serialize { + current = replacement + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + removalEntered.await() + + val staleWriter = async { + guard.withAccountPrivateStatePublication( + expectedSession = original, + resolveSession = { current }, + unavailable = { false }, + ) { + durablePublished = true + true + } + } + yield() + assertFalse(staleWriter.isCompleted) + releaseRemoval.complete(Unit) + removal.await() + + assertFalse(staleWriter.await()) + assertFalse(durablePublished) + assertTrue( + guard.withAccountPrivateStatePublication( + expectedSession = replacement, + resolveSession = { current }, + unavailable = { false }, + publish = { true }, + ), + ) + } + + @Test + fun latePendingWriterCannotPublishAfterRemovalAndReadd() = runBlocking { + val guard = DesktopAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val readded = original.copy(appPassword = "new-password") + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var current: NextcloudSession? = original + var pendingPublished = false + val removal = async { + guard.serialize { + current = readded + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + removalEntered.await() + + val staleWriter = async { + guard.withAccountPrivateStatePublication( + expectedSession = original, + resolveSession = { current }, + unavailable = { false }, + ) { + pendingPublished = true + true + } + } + yield() + releaseRemoval.complete(Unit) + removal.await() + + assertFalse(staleWriter.await()) + assertFalse(pendingPublished) + assertTrue( + guard.withAccountPrivateStatePublication( + expectedSession = readded, + resolveSession = { current }, + unavailable = { false }, + publish = { true }, + ), + ) + } + + @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 failedProviderPreferenceClearRestoresThePreviousValue() { + val events = mutableListOf() + + assertFailsWith { + removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = true, + clearProviderPreference = { + events += "clear" + error("synthetic preference flush failure") + }, + restoreProviderPreference = { enabled -> events += "restore:$enabled" }, + removeCredential = { + events += "remove" + true + }, + ) + } + + assertEquals(listOf("clear", "restore: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 committedCredentialRemovalFailureFinishesProviderTeardownWithoutReactivation() { + val events = mutableListOf() + + assertFailsWith { + removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = true, + clearProviderPreference = { events += "cleared" }, + restoreProviderPreference = { enabled -> events += "restored:$enabled" }, + removalCommitted = { true }, + finishCommittedRemoval = { events += "finish" }, + removeCredential = { + events += "remove" + error("synthetic post-commit credential cleanup failure") + }, + ) + } + + assertEquals(listOf("cleared", "remove", "finish"), events) + } + + @Test + fun unknownCredentialCommitStatusNeitherReactivatesNorTearsDownTheProvider() { + val events = mutableListOf() + + val failure = assertFailsWith { + removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = true, + clearProviderPreference = { events += "cleared" }, + restoreProviderPreference = { enabled -> events += "restored:$enabled" }, + removalCommitted = { + events += "probe" + error("synthetic registry read failure") + }, + commitStatusObserved = { events += "status:$it" }, + finishCommittedRemoval = { events += "finish" }, + removeCredential = { + events += "remove" + error("synthetic credential removal failure") + }, + ) + } + + assertEquals(listOf("cleared", "remove", "probe", "status:null"), events) + assertEquals(1, failure.suppressedExceptions.size) + } + + @Test + fun linuxWritesResumeOnlyAfterPositivelyKnownPrecommitFailure() { + assertTrue( + shouldResumeDesktopWritesAfterRemovalFailure( + removalCommitted = false, + remoteRevocationAttempted = false, + credentialRemovalStatus = false, + ), + ) + assertFalse(shouldResumeDesktopWritesAfterRemovalFailure(false, false, null)) + assertFalse(shouldResumeDesktopWritesAfterRemovalFailure(false, true, false)) + assertFalse(shouldResumeDesktopWritesAfterRemovalFailure(true, false, true)) + } + + @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" }, + accountOwnership = { DesktopAccountOwnership.Absent }, + 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" }, + accountOwnership = { DesktopAccountOwnership.Absent }, + 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" }, + accountOwnership = { DesktopAccountOwnership.Absent }, + 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 backgroundSyncContinuesAfterCleanupJournalReadFailure() = runBlocking { + val events = mutableListOf() + + recoverDesktopBackgroundAccountSyncPairCleanups( + retry = { + events += "retry-cleanup" + error("synthetic cleanup journal read failure") + }, + recordFailure = { events += "diagnose-cleanup" }, + ) + events += "continue-background-sync" + + assertEquals( + listOf("retry-cleanup", "diagnose-cleanup", "continue-background-sync"), + 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), + accountOwnership = { DesktopAccountOwnership.Present }, + commitRemoval = { + events += "remove-credential" + error("synthetic credential commit failure") + }, + removeSyncPairs = { events += "remove-pairs-${it.accountId}" }, + recordDiagnostic = { events += "diagnose-cleanup" }, + ) + } + + assertEquals(listOf("remove-credential"), events) + assertTrue(DesktopAccountSyncPairCleanupJournal(preferences).pending().isEmpty()) + } finally { + preferences.removeNode() + } + } + + @Test + fun futureCleanupEntryIsPreservedWithoutHidingValidTombstonesOrBlockingNewRemoval() { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val malformedAccountId = "1".repeat(64) + val validAccountId = "2".repeat(64) + val newAccountId = "3".repeat(64) + var malformedCount = 0 + try { + preferences.put("fsac.$malformedAccountId", "future-phase") + preferences.put("fsac.$validAccountId", "v2|committed|$MUTATION_SCOPE") + val journal = DesktopAccountSyncPairCleanupJournal(preferences) { malformedCount += 1 } + + assertEquals( + listOf( + DesktopAccountSyncPairCleanup( + malformedAccountId, + DesktopAccountSyncPairCleanupPhase.Unknown, + ), + DesktopAccountSyncPairCleanup( + validAccountId, + DesktopAccountSyncPairCleanupPhase.Committed, + MUTATION_SCOPE, + ), + ), + journal.pending(), + ) + assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) + assertEquals("v2|committed|$MUTATION_SCOPE", preferences.get("fsac.$validAccountId", null)) + assertTrue(journal.blocksAccountActivation(malformedAccountId)) + assertFailsWith { requireDesktopAccountActivationAllowed(true) } + assertFalse(journal.blocksAccountActivation(validAccountId)) + assertEquals(1, malformedCount) + + journal.prepare(newAccountId) + + assertEquals( + setOf(malformedAccountId, validAccountId, newAccountId), + journal.pending().mapTo(linkedSetOf(), DesktopAccountSyncPairCleanup::accountId), + ) + assertFalse(journal.blocksAccountActivation(newAccountId)) + assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) + assertFailsWith { journal.prepare(malformedAccountId) } + assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) + assertEquals(1, malformedCount) + } finally { + preferences.removeNode() + } + } + + @Test + fun canonicalAccountStorageIdentityBlocksReactivationUntilPriorCleanupRuns() { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val oldCacheIdentity = "1".repeat(64) + val canonicalCacheIdentity = "2".repeat(64) + try { + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + journal.prepare(oldCacheIdentity, MUTATION_SCOPE, ACCOUNT_STORAGE_KEY) + journal.commit(oldCacheIdentity) + + assertEquals( + listOf( + DesktopAccountSyncPairCleanup( + oldCacheIdentity, + DesktopAccountSyncPairCleanupPhase.Committed, + MUTATION_SCOPE, + ACCOUNT_STORAGE_KEY, + ), + ), + journal.pendingForAccountActivation(canonicalCacheIdentity, ACCOUNT_STORAGE_KEY), + ) + } finally { + preferences.removeNode() + } + } + + @Test + fun futureCleanupPreservesCanonicalIdentityAndBlocksEquivalentReactivation() = runBlocking { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val oldCacheIdentity = "1".repeat(64) + val canonicalCacheIdentity = "2".repeat(64) + val futureValue = "v4|committed|$MUTATION_SCOPE|$ACCOUNT_STORAGE_KEY|$LEGACY_ACCOUNT_SCOPE|future" + preferences.put("fsac.$oldCacheIdentity", futureValue) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + + try { + val cleanup = journal.pendingForAccountActivation(canonicalCacheIdentity, ACCOUNT_STORAGE_KEY).single() + assertEquals(DesktopAccountSyncPairCleanupPhase.Unknown, cleanup.phase) + assertEquals(ACCOUNT_STORAGE_KEY, cleanup.accountStorageKey) + assertTrue(journal.blocksAccountActivation(canonicalCacheIdentity, ACCOUNT_STORAGE_KEY)) + + retryDesktopAccountSyncPairCleanup( + cleanup = cleanup, + accountOwnership = { error("future cleanup ownership must not be queried") }, + removeSyncPairs = { error("future cleanup must not remove state") }, + clearCleanup = { error("future cleanup must not be cleared") }, + ) + + assertEquals(futureValue, preferences.get("fsac.$oldCacheIdentity", null)) + assertTrue(journal.blocksAccountActivation(canonicalCacheIdentity, ACCOUNT_STORAGE_KEY)) + } finally { + preferences.removeNode() + } + } + + @Test + fun futureCleanupBlocksCredentialLoadAndPrivateSessionPublication() { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val session = NextcloudSession("https://cloud.example.test", "alice", "private-password") + val record = session.accountRecord() + val cacheIdentity = desktopFileCacheAccountId(record) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + var loads = 0 + var publications = 0 + + try { + listOf( + "v4|committed|$MUTATION_SCOPE|${record.id.storageKey}|$LEGACY_ACCOUNT_SCOPE|future", + "v99|committed|${"a".repeat(64)}|${"b".repeat(64)}", + ).forEach { futureValue -> + preferences.put("fsac.$cacheIdentity", futureValue) + assertFailsWith { + loadDesktopSessionAfterCleanupGate( + record, + journal, + load = { loads += 1; session }, + publish = { publications += 1 }, + ) + } + } + preferences.put("fsac.$cacheIdentity", "v99|committed|unknown-layout") + assertFailsWith { + loadDesktopSessionAfterCleanupGate( + record = null, + cleanupJournal = journal, + load = { loads += 1; session }, + publish = { publications += 1 }, + ) + } + preferences.remove("fsac.$cacheIdentity") + preferences.put("fsac.not-a-valid-account-id", "committed") + assertFailsWith { + loadDesktopSessionAfterCleanupGate( + record = null, + cleanupJournal = journal, + load = { loads += 1; session }, + publish = { publications += 1 }, + ) + } + assertEquals("committed", preferences.get("fsac.not-a-valid-account-id", null)) + + assertEquals(0, loads) + assertEquals(0, publications) + } 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, + durableMutationAccountScope = MUTATION_SCOPE, + accountStorageKey = ACCOUNT_STORAGE_KEY, + legacyAccountScopeDigest = LEGACY_ACCOUNT_SCOPE, + prepareCleanup = firstJournal::prepare, + commitCleanup = firstJournal::commit, + clearCleanup = firstJournal::clear, + accountOwnership = { DesktopAccountOwnership.Absent }, + 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, + MUTATION_SCOPE, + ACCOUNT_STORAGE_KEY, + LEGACY_ACCOUNT_SCOPE, + ), + ), + restored.pending(), + ) + assertEquals( + "v4|committed|$MUTATION_SCOPE|$ACCOUNT_STORAGE_KEY|$LEGACY_ACCOUNT_SCOPE", + preferences.get("fsac.$CLEANUP_ACCOUNT_ID", null), + ) + + val retryEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = restored.pending().single(), + accountOwnership = { DesktopAccountOwnership.Present }, + removeSyncPairs = { + assertEquals(LEGACY_ACCOUNT_SCOPE, it.legacyAccountScopeDigest) + retryEvents += "remove-pairs-${it.accountId}" + }, + 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, + ), + accountOwnership = { DesktopAccountOwnership.Present }, + removeSyncPairs = { events += "remove-pairs" }, + clearCleanup = { events += "clear-cleanup" }, + ) + + assertEquals(listOf("clear-cleanup"), events) + } + + @Test + fun preparedCleanupPreservesPairsAndJournalWhenCredentialOwnershipIsUnknown() = runBlocking { + val events = mutableListOf() + + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Prepared, + ), + accountOwnership = { DesktopAccountOwnership.Unknown }, + removeSyncPairs = { events += "remove-pairs" }, + clearCleanup = { events += "clear-cleanup" }, + ) + + assertTrue(events.isEmpty()) + } + + @Test + fun futureCleanupFormatRemainsBlockedAndUntouchedWhenCredentialsAreAbsent() = runBlocking { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val futureValue = "v99|committed|future-private-state" + preferences.put("fsac.$CLEANUP_ACCOUNT_ID", futureValue) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + var ownershipChecks = 0 + val events = mutableListOf() + + try { + val cleanup = journal.pending().single() + assertEquals(DesktopAccountSyncPairCleanupPhase.Unknown, cleanup.phase) + assertTrue(journal.blocksAccountActivation(CLEANUP_ACCOUNT_ID)) + + retryDesktopAccountSyncPairCleanup( + cleanup = cleanup, + accountOwnership = { + ownershipChecks += 1 + DesktopAccountOwnership.Absent + }, + removeSyncPairs = { events += "remove-pairs" }, + clearCleanup = { events += "clear-cleanup" }, + ) + + assertEquals(0, ownershipChecks) + assertTrue(events.isEmpty()) + assertEquals(futureValue, preferences.get("fsac.$CLEANUP_ACCOUNT_ID", null)) + assertTrue(journal.blocksAccountActivation(CLEANUP_ACCOUNT_ID)) + assertTrue(journal.blocksAccountActivation("9".repeat(64), ACCOUNT_STORAGE_KEY)) + } finally { + preferences.removeNode() + } + } + + @Test + fun preparedCleanupUsesCredentialFreeOwnershipToRecover() = runBlocking { + val absentEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Prepared, + ), + accountOwnership = { DesktopAccountOwnership.Absent }, + removeSyncPairs = { absentEvents += "remove-pairs" }, + clearCleanup = { absentEvents += "clear-cleanup" }, + ) + assertEquals(listOf("remove-pairs", "clear-cleanup"), absentEvents) + + val presentEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Prepared, + ), + accountOwnership = { DesktopAccountOwnership.Present }, + removeSyncPairs = { presentEvents += "remove-pairs" }, + clearCleanup = { presentEvents += "clear-cleanup" }, + ) + assertEquals(listOf("clear-cleanup"), presentEvents) + } + + private companion object { + const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + const val MUTATION_SCOPE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const val ACCOUNT_STORAGE_KEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + const val LEGACY_ACCOUNT_SCOPE = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } +} 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/DesktopAccountRemovalSessionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemovalSessionTest.kt new file mode 100644 index 000000000..9bb9db759 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemovalSessionTest.kt @@ -0,0 +1,43 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class DesktopAccountRemovalSessionTest { + private val current = NextcloudSession("https://cloud.example.test", "alice", "current-secret") + private val accountId = current.accountId + + @Test + fun localRemovalDoesNotLoadTheRemoteCredential() { + assertNull( + loadDesktopRemoteRevocationSession(accountId, expectedSession = null) { + error("The unavailable secret store must not block local removal.") + }, + ) + } + + @Test + fun remoteRevocationFailsClosedWhenTheCredentialCannotBeLoaded() { + val failure = assertFailsWith { + loadDesktopRemoteRevocationSession(accountId, current) { + throw DesktopSecretStoreUnavailableException("Synthetic unavailable secret store.") + } + } + + assertEquals("Synthetic unavailable secret store.", failure.message) + } + + @Test + fun remoteRevocationRejectsAStaleCredential() { + assertFailsWith { + loadDesktopRemoteRevocationSession(accountId, current.copy(appPassword = "stale-secret")) { current } + } + } + + @Test + fun remoteRevocationUsesTheVerifiedCurrentCredential() { + assertEquals(current, loadDesktopRemoteRevocationSession(accountId, current) { current }) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRevocationCancellationTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRevocationCancellationTest.kt new file mode 100644 index 000000000..7a045e25c --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRevocationCancellationTest.kt @@ -0,0 +1,81 @@ +package dev.obiente.nextcloudnative.app + +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking + +class DesktopAccountRevocationCancellationTest { + @Test + fun ambiguousRemoteRevocationFailureStillCompletesLocalRemovalAndReturnsOriginalFailure() = runBlocking { + val events = mutableListOf() + val revocationFailure = IOException("remote response was lost") + + val thrown = assertFailsWith { + completeDesktopSignOutAfterRemoteRevocation( + session = "account", + revokeRemoteSession = { + events += "remote-revocation-attempted" + throw revocationFailure + }, + completeLocalRemoval = { events += "local-removed" }, + ) + } + + assertTrue(thrown === revocationFailure) + assertEquals(listOf("remote-revocation-attempted", "local-removed"), events) + } + + @Test + fun cancellationReturningFromRemoteRevocationStillCompletesLocalRemoval() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + completeDesktopSignOutAfterRemoteRevocation( + session = "account", + revokeRemoteSession = { + events += "remote-revoked" + throw CancellationException("cancelled while returning from revocation") + }, + completeLocalRemoval = { events += "local-removed" }, + ) + } + + assertEquals(listOf("remote-revoked", "local-removed"), events) + } + + @Test + fun cancellationWhileJoiningHydrationStillCompletesLocalRemoval() = runBlocking { + val hydrationJoinStarted = CompletableDeferred() + val hydrationCanFinish = CompletableDeferred() + val localRemovalFinished = CompletableDeferred() + val events = mutableListOf() + val signOut = async { + completeDesktopSignOutAfterRemoteRevocation( + session = "account", + revokeRemoteSession = { events += "remote-revoked" }, + completeLocalRemoval = { + events += "join-hydration" + hydrationJoinStarted.complete(Unit) + hydrationCanFinish.await() + events += "local-removed" + localRemovalFinished.complete(Unit) + }, + ) + } + hydrationJoinStarted.await() + + signOut.cancel(CancellationException("cancelled while joining hydration")) + hydrationCanFinish.complete(Unit) + localRemovalFinished.await() + + assertFailsWith { signOut.await() } + assertTrue(signOut.isCancelled) + assertEquals(listOf("remote-revoked", "join-hydration", "local-removed"), events) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopCredentialRollbackCompletionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopCredentialRollbackCompletionTest.kt new file mode 100644 index 000000000..47312076b --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopCredentialRollbackCompletionTest.kt @@ -0,0 +1,183 @@ +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.assertNotNull +import kotlin.test.assertNull + +class DesktopCredentialRollbackCompletionTest { + @Test + fun journalClearFailureAfterRestorationCanRetryWithoutTheBackupSecret() = withStore { fixture -> + listOf("rollback", "secret-writing").forEach { phase -> + fixture.preparePendingRollback(phase) + var failClear = true + val persistence = fixture.persistence { + if (failClear && fixture.preferences.get(PHASE_KEY, null) == null) { + failClear = false + error("synthetic journal-clear flush failure") + } + fixture.preferences.flush() + } + + assertFailsWith { + persistence.loadActiveSession() + } + + assertEquals("rollback-completed", fixture.preferences.get(PHASE_KEY, null)) + assertEquals(fixture.original.appPassword, fixture.primarySecret()) + assertNull(fixture.secrets.load(fixture.rollbackReference)) + fixture.assertRestartRecovered() + } + } + + @Test + fun completionMarkerFlushFailureRetainsTheBackupForAnotherRollback() = withStore { fixture -> + fixture.preparePendingRollback("rollback") + var failCompletion = true + val persistence = fixture.persistence { + if (failCompletion && fixture.preferences.get(PHASE_KEY, null) == "rollback-completed") { + failCompletion = false + error("synthetic rollback-completion flush failure") + } + fixture.preferences.flush() + } + + assertFailsWith { + persistence.loadActiveSession() + } + + assertEquals("rollback", fixture.preferences.get(PHASE_KEY, null)) + assertNotNull(fixture.secrets.load(fixture.rollbackReference)) + fixture.assertRestartRecovered() + } + + @Test + fun restartAfterRecoveryDeletesItsBackupKeepsTheOriginalActiveAccount() = withStore { fixture -> + fixture.preparePendingRollback("secret-writing") + fixture.crashAfterBackupDeletion() + + assertFailsWith { fixture.persistence().loadActiveSession() } + + assertEquals("rollback-completed", fixture.preferences.get(PHASE_KEY, null)) + assertNull(fixture.secrets.load(fixture.rollbackReference)) + fixture.assertRestartRecovered() + } + + @Test + fun restartAfterImmediateRollbackDeletesItsBackupKeepsTheOriginalActiveAccount() = withStore { fixture -> + fixture.secrets.failNextSaveTarget = fixture.primaryReference.targetName + fixture.crashAfterBackupDeletion() + + assertFailsWith { + fixture.persistence().saveSession(fixture.original.copy(appPassword = "replacement-password")) + } + + assertEquals("rollback-completed", fixture.preferences.get(PHASE_KEY, null)) + assertEquals(fixture.original.appPassword, fixture.primarySecret()) + assertNull(fixture.secrets.load(fixture.rollbackReference)) + fixture.assertRestartRecovered() + } + + @Test + fun immediateRollbackJournalClearFailureCanRetryWithoutTheBackupSecret() = withStore { fixture -> + fixture.secrets.failNextSaveTarget = fixture.primaryReference.targetName + var failClear = true + val persistence = fixture.persistence { + if (failClear && fixture.preferences.get(PHASE_KEY, null) == null) { + failClear = false + error("synthetic immediate rollback journal-clear failure") + } + fixture.preferences.flush() + } + + assertFailsWith { + persistence.saveSession(fixture.original.copy(appPassword = "replacement-password")) + } + + assertEquals("rollback-completed", fixture.preferences.get(PHASE_KEY, null)) + assertNull(fixture.secrets.load(fixture.rollbackReference)) + fixture.assertRestartRecovered() + } + + private fun withStore(test: (RollbackFixture) -> Unit) { + val preferences = Preferences.userRoot().node("desktop-rollback-completion-test-${UUID.randomUUID()}") + try { + test(RollbackFixture(preferences)) + } finally { + preferences.removeNode() + } + } +} + +private class RollbackFixture(val preferences: Preferences) { + val secrets = RollbackSecretStore() + val original = NextcloudSession("https://cloud.example.test", "alice", "original-password") + private val active = NextcloudSession("https://other.example.test", "bob", "other-password") + val primaryReference = desktopAccountSecretReference(original.accountId) + val rollbackReference = desktopAccountCredentialRollbackReference(original.accountId) + + init { + persistence().saveSession(original) + persistence().saveSession(active) + } + + fun persistence(flush: () -> Unit = preferences::flush) = + DesktopAccountCredentialPersistence(preferences, secrets, recordDiagnostic = {}, flushPreferences = flush) + + fun preparePendingRollback(phase: String) { + secrets.save(primaryReference, original.loginName, "replacement-password".encodeToByteArray()) + secrets.save(rollbackReference, original.loginName, original.appPassword.encodeToByteArray()) + preferences.put("accountCredentialSaveServer", original.serverUrl) + preferences.put("accountCredentialSaveLogin", original.loginName) + preferences.put(PHASE_KEY, phase) + preferences.flush() + } + + fun crashAfterBackupDeletion() { + secrets.afterClear = { reference -> + if (reference == rollbackReference) { + secrets.afterClear = {} + throw SimulatedRollbackProcessExit() + } + } + } + + fun primarySecret(): String? = secrets.load(primaryReference)?.decodeToString() + + fun assertRestartRecovered() { + val restarted = persistence() + assertEquals(active, restarted.loadActiveSession()) + assertEquals(original, restarted.loadSession(original.accountId)) + assertNull(secrets.load(rollbackReference)) + assertNull(preferences.get(PHASE_KEY, null)) + assertNull(preferences.get("accountCredentialSaveServer", null)) + assertNull(preferences.get("accountCredentialSaveLogin", null)) + } +} + +private class RollbackSecretStore : DesktopSecretStore { + private val values = mutableMapOf() + var failNextSaveTarget: String? = null + var afterClear: (DesktopSecretReference) -> Unit = {} + + override fun load(reference: DesktopSecretReference): ByteArray? = values[reference.targetName]?.copyOf() + + override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { + if (reference.targetName == failNextSaveTarget) { + failNextSaveTarget = null + error("synthetic primary credential save failure") + } + values[reference.targetName] = secret.copyOf() + } + + override fun clear(reference: DesktopSecretReference) { + values.remove(reference.targetName) + afterClear(reference) + } +} + +private class SimulatedRollbackProcessExit : Error() +private const val PHASE_KEY = "accountCredentialSavePhase" diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt index b48d5cc6f..b277b382d 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt @@ -3,6 +3,9 @@ package dev.obiente.nextcloudnative.app import java.nio.file.Files import java.util.Base64 import java.util.concurrent.atomic.AtomicLong +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -10,6 +13,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import org.json.JSONObject class DesktopDeckCardDraftStoreTest { @Test @@ -122,8 +126,8 @@ class DesktopDeckCardDraftStoreTest { withStore { root, _, store -> val session = session() repeat(DeckCardDraftRetention.MAX_ENTRIES) { index -> - root.resolve("${DesktopDeckCardDraftStore.FILE_PREFIX}${index.toString(16).padStart(64, '0')}" + - DesktopDeckCardDraftStore.FILE_SUFFIX).writeText("not-an-envelope") + root.resolve(store.storageFileName(session, persisted(cardId = 10_000L + index).key)) + .writeText("not-an-envelope") } assertFailsWith { @@ -159,6 +163,229 @@ class DesktopDeckCardDraftStoreTest { } } + @Test + fun `each account has its own retention budget`() = withStore { root, _, store -> + val alice = session() + val bob = session(login = "bob") + + repeat(DeckCardDraftRetention.MAX_ENTRIES) { index -> + store.save(alice, persisted(cardId = 20_000L + index)) + store.save(bob, persisted(cardId = 30_000L + index)) + } + + assertEquals(DeckCardDraftRetention.MAX_ENTRIES * 2, root.listFiles().orEmpty().size) + assertEquals(persisted(cardId = 20_000L), store.load(alice, persisted(cardId = 20_000L).key)) + assertEquals(persisted(cardId = 30_000L), store.load(bob, persisted(cardId = 30_000L).key)) + } + + @Test + fun `account removal is retryable and preserves another account`() { + val root = Files.createTempDirectory("desktop-deck-drafts-removal").toFile() + val key = ByteArray(DesktopDeckCardDraftStore.AES_KEY_BYTES) { (it + 1).toByte() } + val alice = session() + val bob = session(login = "bob") + val removed = persisted(cardId = 51L) + val retained = persisted(cardId = 52L) + try { + val writer = DesktopDeckCardDraftStore(root, fixedKey(key)) + writer.save(alice, removed) + writer.save(bob, retained) + val aliceDraftName = writer.storageFileName(alice, removed.key) + val aliceMarkerName = aliceDraftName + .replaceFirst(DesktopDeckCardDraftStore.FILE_PREFIX, DesktopDeckCardDraftStore.SUBMITTED_FILE_PREFIX) + .removeSuffix(DesktopDeckCardDraftStore.FILE_SUFFIX) + DesktopDeckCardDraftStore.SUBMITTED_FILE_SUFFIX + root.resolve(aliceMarkerName).writeBytes(DesktopDeckCardDraftStore.SUBMITTED_MARKER_BYTES) + val alicePrefix = "${DesktopDeckCardDraftStore.FILE_PREFIX}${alice.accountId.storageKey}_" + val failing = DesktopDeckCardDraftStore( + root = root, + keyProvider = fixedKey(key), + deleteFile = { file -> + if (file.name.startsWith(alicePrefix)) false + else Files.deleteIfExists(file.toPath()) || !file.exists() + }, + ) + + assertFailsWith { + failing.removeAccount(alice.accountId.storageKey, desktopFileCacheAccountId(alice)) + } + + writer.removeAccount(alice.accountId.storageKey, desktopFileCacheAccountId(alice)) + + assertNull(writer.load(alice, removed.key)) + assertEquals(retained, writer.load(bob, retained.key)) + assertTrue(root.listFiles().orEmpty().none { it.name.contains(alice.accountId.storageKey) }) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `completed legacy migration is not rolled back when deletion must retry`() { + val root = Files.createTempDirectory("desktop-deck-drafts-migration").toFile() + val key = ByteArray(DesktopDeckCardDraftStore.AES_KEY_BYTES) { (it + 1).toByte() } + val session = session() + val original = persisted(title = "Legacy") + try { + val probe = DesktopDeckCardDraftStore(root, fixedKey(key)) + val legacy = root.resolve( + probe.legacyStorageFileName(desktopFileCacheAccountId(session), original.key), + ) + writeLegacyDraft(legacy, key, original) + val failing = DesktopDeckCardDraftStore( + root = root, + keyProvider = fixedKey(key), + deleteFile = { file -> + if (file == legacy) false else Files.deleteIfExists(file.toPath()) || !file.exists() + }, + ) + + failing.migrateLegacyEntries(session) + val updated = original.copy(draft = original.draft.copy(title = "Newer")) + assertFailsWith { failing.save(session, updated) } + assertEquals(original, failing.load(session, original.key)) + assertTrue(legacy.exists()) + + val restarted = DesktopDeckCardDraftStore(root, fixedKey(key)) + restarted.save(session, updated) + + assertEquals(updated, restarted.load(session, original.key)) + assertTrue(!legacy.exists()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `legacy submitted markers must retire before replacement survives restart`() { + listOf("marker-only", "draft-deletion", "marker-deletion").forEach { failureMode -> + withStore { root, key, store -> + val session = session() + val original = persisted(title = "Submitted") + val replacement = persisted(title = "Fresh replacement") + val legacy = root.resolve(store.legacyStorageFileName(desktopFileCacheAccountId(session), original.key)) + val marker = root.resolve( + legacy.name.replaceFirst("draft_", "submitted_").removeSuffix(".json.enc") + ".marker", + ) + if (failureMode != "marker-only") writeLegacyDraft(legacy, key, original) + marker.writeBytes(DesktopDeckCardDraftStore.SUBMITTED_MARKER_BYTES) + val failedTarget = if (failureMode == "draft-deletion") legacy else marker + val failing = DesktopDeckCardDraftStore( + root = root, + keyProvider = fixedKey(key), + deleteFile = { file -> + if (file == failedTarget) false else Files.deleteIfExists(file.toPath()) || !file.exists() + }, + ) + + assertNull(failing.load(session, original.key)) + assertTrue(marker.exists()) + assertFailsWith { failing.save(session, replacement) } + assertTrue(marker.exists()) + + val restarted = DesktopDeckCardDraftStore(root, fixedKey(key)) + restarted.save(session, replacement) + + assertTrue(!legacy.exists()) + assertTrue(!marker.exists()) + repeat(2) { + assertEquals(replacement, DesktopDeckCardDraftStore(root, fixedKey(key)).load(session, original.key)) + } + } + } + } + + @Test + fun `submitted legacy draft cannot return after migration deletion fails`() = withStore { root, key, store -> + val session = session() + val original = persisted(title = "Submitted legacy draft") + val legacy = root.resolve(store.legacyStorageFileName(desktopFileCacheAccountId(session), original.key)) + val marker = root.resolve(legacy.name.replaceFirst("draft_", "submitted_").removeSuffix(".json.enc") + ".marker") + writeLegacyDraft(legacy, key, original) + val originalEnvelope = legacy.readText() + val failing = DesktopDeckCardDraftStore( + root = root, + keyProvider = fixedKey(key), + deleteFile = { file -> + if (file == legacy) false else Files.deleteIfExists(file.toPath()) || !file.exists() + }, + ) + + assertEquals(original, failing.load(session, original.key)) + failing.quarantineAfterSubmit(session, original.key) + + assertEquals(originalEnvelope, legacy.readText()) + assertTrue(marker.exists()) + assertNull(failing.load(session, original.key)) + assertNull(DesktopDeckCardDraftStore(root, fixedKey(key)).load(session, original.key)) + assertTrue(root.listFiles().orEmpty().isEmpty()) + } + + @Test + fun `explicit legacy discard bypasses keyring and preserves unrelated recovery`() = withStore { root, key, store -> + val session = session() + val draftKey = persisted().key + val legacy = root.resolve(store.legacyStorageFileName(desktopFileCacheAccountId(session), draftKey)) + val target = root.resolve(store.storageFileName(session, draftKey)) + val marker = root.resolve(legacy.name.replaceFirst("draft_", "submitted_").removeSuffix(".json.enc") + ".marker") + val targetMarker = root.resolve( + target.name.replaceFirst("draft_v2_", "submitted_v2_").removeSuffix(".json.enc") + ".marker", + ) + val unrelated = listOf( + store.legacyStorageFileName(desktopFileCacheAccountId(session(login = "bob")), draftKey), + store.legacyStorageFileName(desktopFileCacheAccountId(session), persisted(cardId = 91L).key), + store.storageFileName(session(login = "bob"), draftKey), + ).associateWith { "unrelated-unreadable" } + unrelated.forEach { (name, content) -> root.resolve(name).writeText(content) } + listOf(legacy, target, marker, targetMarker).forEach { it.writeText("unreadable") } + var failDeletion = true + val unavailable = DesktopDeckCardDraftStore( + root = root, + keyProvider = DesktopDeckDraftKeyProvider { error("No keyring access during explicit discard") }, + deleteFile = { file -> + if (file == legacy && failDeletion) false else Files.deleteIfExists(file.toPath()) || !file.exists() + }, + ) + + assertFailsWith { store.load(session, draftKey) } + assertFailsWith { store.clear(session, draftKey) } + assertFailsWith { unavailable.clear(session, draftKey, discardUnreadable = true) } + assertEquals("unreadable", legacy.readText()) + failDeletion = false + + unavailable.clear(session, draftKey, discardUnreadable = true) + + assertEquals(unrelated, root.listFiles().orEmpty().associate { it.name to it.readText() }) + val replacement = persisted(title = "Replacement") + store.save(session, replacement) + assertEquals(replacement, DesktopDeckCardDraftStore(root, fixedKey(key)).load(session, draftKey)) + } + + @Test + fun `account removal preserves unreadable and other account legacy drafts`() = + withStore { root, key, store -> + val alice = session() + val bob = session(login = "bob") + val aliceLegacy = root.resolve("draft_${"a".repeat(64)}.json.enc").apply { + writeText("unreadable") + } + val bobDraft = persisted(cardId = 91L) + val bobLegacy = root.resolve( + store.legacyStorageFileName(desktopFileCacheAccountId(bob), bobDraft.key), + ) + writeLegacyDraft(bobLegacy, key, bobDraft) + val aliceDraft = persisted(cardId = 92L) + val attributable = root.resolve( + store.legacyStorageFileName(desktopFileCacheAccountId(alice), aliceDraft.key), + ) + writeLegacyDraft(attributable, key, aliceDraft) + + store.removeAccount(alice.accountId.storageKey, desktopFileCacheAccountId(alice)) + + assertTrue(aliceLegacy.exists()) + assertTrue(bobLegacy.exists()) + assertFalse(attributable.exists()) + } + @Test fun `keyring failure does not delete a valid encrypted draft`() = withStore { root, _, store -> @@ -503,6 +730,42 @@ class DesktopDeckCardDraftStoreTest { ), ) + private fun writeLegacyDraft( + file: java.io.File, + key: ByteArray, + persisted: PersistedDeckCardDraft, + ) { + val plaintext = JSONObject() + .put("version", DesktopDeckCardDraftStore.LEGACY_PLAINTEXT_FORMAT_VERSION) + .put("updatedAtEpochMillis", 100L) + .put("boardId", persisted.key.boardId) + .put("stackId", persisted.key.stackId) + .put("cardId", persisted.key.cardId) + .put("title", persisted.draft.title) + .put("descriptionMarkdown", persisted.draft.descriptionMarkdown) + .put("dueDate", persisted.draft.dueDate) + .put("dueTime", persisted.draft.dueTime) + .put("dueAtBeforeEditing", persisted.draft.dueAtBeforeEditing) + .put("dueFieldsEdited", persisted.draft.dueFieldsEdited) + .toString() + .encodeToByteArray() + val nonce = ByteArray(DesktopDeckCardDraftStore.GCM_NONCE_BYTES) { (it + 7).toByte() } + val cipher = Cipher.getInstance(DesktopDeckCardDraftStore.CIPHER_TRANSFORMATION) + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, DesktopDeckCardDraftStore.AES_ALGORITHM), + GCMParameterSpec(DesktopDeckCardDraftStore.GCM_TAG_BITS, nonce), + ) + cipher.updateAAD(file.name.encodeToByteArray()) + file.writeText( + JSONObject() + .put("version", DesktopDeckCardDraftStore.ENVELOPE_FORMAT_VERSION) + .put("nonce", Base64.getEncoder().encodeToString(nonce)) + .put("ciphertext", Base64.getEncoder().encodeToString(cipher.doFinal(plaintext))) + .toString(), + ) + } + private class ToggleSecretStore : DesktopSecretStore { var secret: ByteArray? = null var failure: RuntimeException? = null diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStoreTest.kt index 2d2eeb6f9..86f304654 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStoreTest.kt @@ -53,6 +53,47 @@ class DesktopDurableMutationRecoveryStoreTest { } } + @Test + fun `account removal purges every recovery kind without touching another account`() { + val root = Files.createTempDirectory("mutation-recovery-account-removal-test").toFile() + try { + val store = DesktopDurableMutationRecoveryStore(root) + val removedScope = "d".repeat(64) + val retainedScope = "e".repeat(64) + DurableMutationRecoveryKind.entries.forEach { kind -> + assertTrue(store.save(removedScope, kind, "removed-${kind.storageKey}")) + assertTrue(store.save(retainedScope, kind, "retained-${kind.storageKey}")) + } + + store.removeAccount(removedScope) + + DurableMutationRecoveryKind.entries.forEach { kind -> + assertNull(store.load(removedScope, kind)) + assertEquals("retained-${kind.storageKey}", store.load(retainedScope, kind)) + } + } finally { + root.deleteRecursively() + } + } + + @Test + fun `account removal fails closed on an unsafe recovery path`() { + val root = Files.createTempDirectory("mutation-recovery-account-removal-unsafe-test").toFile() + try { + val store = DesktopDurableMutationRecoveryStore(root) + val scope = "f".repeat(64) + assertTrue(store.save(scope, DurableMutationRecoveryKind.Calendar, "safe")) + val target = root.resolve("${DurableMutationRecoveryKind.Calendar.storageKey}-$scope.json") + assertTrue(target.delete()) + assertTrue(target.mkdir()) + + assertFailsWith { store.removeAccount(scope) } + assertTrue(target.isDirectory) + } finally { + root.deleteRecursively() + } + } + @Test fun `recovery state fails closed when owner-only permissions drift`() { val root = Files.createTempDirectory("mutation-recovery-permissions-test").toFile() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCacheRetirementTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCacheRetirementTest.kt new file mode 100644 index 000000000..246bcd0c9 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCacheRetirementTest.kt @@ -0,0 +1,91 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopDynamicDiscoveryCacheRetirementTest { + @Test + fun `credential commit fence rejects publication before durable cleanup deletes the file`() { + val root = Files.createTempDirectory("desktop-dynamic-discovery-fence").toFile() + val cache = DesktopDynamicDiscoveryCache(root) + val accountStorageKey = "d".repeat(64) + val cacheAccountId = "5".repeat(64) + val staleProducer = DynamicNativeMemoryCacheProducer(accountStorageKey, 0L) + try { + cache.save(accountStorageKey, cacheAccountId, "deck", "before", staleProducer) + + cache.fenceAccount(accountStorageKey) + cache.save(accountStorageKey, cacheAccountId, "deck", "late", staleProducer) + + assertTrue(root.resolve("$cacheAccountId-deck.json").isFile) + assertNull(cache.load(accountStorageKey, cacheAccountId, "deck")) + + cache.retireAccount(accountStorageKey, cacheAccountId) + assertFalse(root.resolve("$cacheAccountId-deck.json").exists()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `retirement deletes one account prefix and rejects stale publication until activation`() { + val root = Files.createTempDirectory("desktop-dynamic-discovery").toFile() + val first = DesktopDynamicDiscoveryCacheCoordinator.get(root) + val second = DesktopDynamicDiscoveryCacheCoordinator.get(root) + val removedStorageKey = "a".repeat(64) + val removedCacheId = "1".repeat(64) + val retainedStorageKey = "b".repeat(64) + val retainedCacheId = "2".repeat(64) + val removedProducer = DynamicNativeMemoryCacheProducer(removedStorageKey, 0L) + val retainedProducer = DynamicNativeMemoryCacheProducer(retainedStorageKey, 0L) + try { + first.save(removedStorageKey, removedCacheId, "deck", "removed", removedProducer) + first.save(retainedStorageKey, retainedCacheId, "deck", "retained", retainedProducer) + + second.retireAccount(removedStorageKey, removedCacheId) + first.activateAccount(removedStorageKey) + first.save(removedStorageKey, removedCacheId, "deck", "stale", removedProducer) + + assertNull(first.load(removedStorageKey, removedCacheId, "deck")) + assertEquals("retained", first.load(retainedStorageKey, retainedCacheId, "deck")) + assertFalse(root.resolve("$removedCacheId-deck.json").exists()) + assertTrue(root.resolve("$retainedCacheId-deck.json").isFile) + + first.save( + removedStorageKey, + removedCacheId, + "deck", + "current", + DynamicNativeMemoryCacheProducer(removedStorageKey, 1L), + ) + assertEquals("current", first.load(removedStorageKey, removedCacheId, "deck")) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `restart cleanup without a storage identity still removes account files`() { + val root = Files.createTempDirectory("desktop-dynamic-discovery-restart").toFile() + val cache = DesktopDynamicDiscoveryCache(root) + val removedCacheId = "3".repeat(64) + val retainedCacheId = "4".repeat(64) + try { + root.resolve("$removedCacheId-deck.json").writeText("removed") + root.resolve("$removedCacheId-talk.json.part").writeText("partial") + root.resolve("$retainedCacheId-deck.json").writeText("retained") + + cache.retireAccount(accountStorageKey = null, cacheAccountId = removedCacheId) + + assertFalse(root.resolve("$removedCacheId-deck.json").exists()) + assertFalse(root.resolve("$removedCacheId-talk.json.part").exists()) + assertTrue(root.resolve("$retainedCacheId-deck.json").isFile) + } finally { + root.deleteRecursively() + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt index 3047fc4ad..535e6a062 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt @@ -2,6 +2,9 @@ package dev.obiente.nextcloudnative.app import java.io.File import java.nio.file.Files +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -22,6 +25,7 @@ class DesktopExternalFileHandoffTest { }) val result = handoff.launch( + accountId = accountId(), file = file(), action = ExternalFileHandoffAction.OpenWith, capability = capability(), @@ -38,7 +42,8 @@ class DesktopExternalFileHandoffTest { val staged = requireNotNull(launched) assertEquals("report.pdf", staged.name) assertEquals("detached copy", staged.readText()) - assertEquals(root.canonicalFile, staged.parentFile?.parentFile?.canonicalFile) + assertEquals(accountId(), staged.parentFile?.parentFile?.name) + assertEquals(root.canonicalFile, staged.parentFile?.parentFile?.parentFile?.canonicalFile) assertFalse(staged.canWrite()) } finally { root.deleteRecursively() @@ -62,6 +67,7 @@ class DesktopExternalFileHandoffTest { DesktopStagedFileExport.Exported }, ).launch( + accountId = accountId(), file = file(), action = ExternalFileHandoffAction.Share, capability = capability(ExternalFileHandoffAction.Share), @@ -77,7 +83,7 @@ class DesktopExternalFileHandoffTest { assertIs(result) assertEquals("detached copy", exported) assertEquals(0, openCalls) - assertTrue(root.listFiles().orEmpty().isEmpty()) + assertTrue(root.resolve(accountId()).listFiles().orEmpty().isEmpty()) } finally { root.deleteRecursively() } @@ -92,6 +98,7 @@ class DesktopExternalFileHandoffTest { launchCalls += 1 true }).launch( + accountId = accountId(), file = file(), action = ExternalFileHandoffAction.OpenWith, capability = capability(), @@ -124,6 +131,7 @@ class DesktopExternalFileHandoffTest { launched = file true }).launchDetached( + accountId = accountId(), attachment = attachment(byteCount = 13L), action = ExternalFileHandoffAction.OpenWith, capability = capability(), @@ -153,6 +161,7 @@ class DesktopExternalFileHandoffTest { launchCalls += 1 true }).launchDetached( + accountId = accountId(), attachment = attachment(byteCount = null), action = ExternalFileHandoffAction.OpenWith, capability = ExternalFileHandoffCapability( @@ -182,6 +191,7 @@ class DesktopExternalFileHandoffTest { launchCalls += 1 true }).launchDetached( + accountId = accountId(), attachment = attachment(byteCount = 5L), action = ExternalFileHandoffAction.OpenWith, capability = capability(), @@ -193,7 +203,7 @@ class DesktopExternalFileHandoffTest { } assertEquals(0, launchCalls) - assertTrue(root.listFiles().orEmpty().isEmpty()) + assertTrue(root.resolve(accountId()).listFiles().orEmpty().isEmpty()) } finally { root.deleteRecursively() } @@ -203,9 +213,10 @@ class DesktopExternalFileHandoffTest { fun `desktop cache pruning removes expired detached copies`() { val root = Files.createTempDirectory("nextcloud-desktop-handoff-").toFile() try { - val old = root.resolve("old").apply { mkdir() } + val accountRoot = root.resolve(accountId()).apply { mkdir() } + val old = accountRoot.resolve("old").apply { mkdir() } old.resolve("payload.bin").writeBytes(byteArrayOf(1, 2, 3)) - val recent = root.resolve("recent").apply { mkdir() } + val recent = accountRoot.resolve("recent").apply { mkdir() } recent.resolve("payload.bin").writeBytes(byteArrayOf(4, 5, 6)) val now = 2L * 24L * 60L * 60L * 1000L old.setLastModified(1L) @@ -224,12 +235,15 @@ class DesktopExternalFileHandoffTest { fun `desktop cache pressure preserves newly handed off files`() { val root = Files.createTempDirectory("nextcloud-desktop-handoff-").toFile() try { - val recent = root.resolve("recent").apply { mkdir() } + val accountRoot = root.resolve(accountId()).apply { mkdir() } + val recent = accountRoot.resolve("recent").apply { mkdir() } recent.resolve("payload.bin").writeBytes(byteArrayOf(1, 2, 3)) val now = 10L * 60L * 60L * 1000L recent.setLastModified(now) - pruneDesktopExternalFileCache(root, requiredBytes = Long.MAX_VALUE, nowMillis = now) + assertFailsWith { + pruneDesktopExternalFileCache(root, requiredBytes = 2L, nowMillis = now, maximumBytes = 4L) + } assertTrue(recent.exists()) } finally { @@ -237,6 +251,92 @@ class DesktopExternalFileHandoffTest { } } + @Test + fun `desktop cache pressure prunes operation copies across accounts to one global limit`() { + val root = Files.createTempDirectory("nextcloud-desktop-global-handoff-").toFile() + try { + val older = root.resolve("a".repeat(64)).resolve("older").apply { mkdirs() } + val newer = root.resolve("b".repeat(64)).resolve("newer").apply { mkdirs() } + older.resolve("payload.bin").writeBytes(ByteArray(6)) + newer.resolve("payload.bin").writeBytes(ByteArray(6)) + older.setLastModified(1L) + newer.setLastModified(2L) + + val available = pruneDesktopExternalFileCache( + root = root, + requiredBytes = 4L, + nowMillis = 2L * 60L * 60L * 1000L, + maximumBytes = 10L, + ) + + assertFalse(older.exists()) + assertTrue(newer.exists()) + assertEquals(4L, available) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `concurrent account handoffs reserve one shared cache budget`() = runBlocking { + val root = Files.createTempDirectory("nextcloud-desktop-concurrent-handoff-").toFile() + val firstStarted = CompletableDeferred() + val finishFirst = CompletableDeferred() + var secondDownloadStarted = false + try { + val cacheReservations = DesktopExternalFileCacheReservations() + val firstHandoff = DesktopExternalFileHandoff( + root = root, + launchFile = { true }, + cacheReservations = cacheReservations, + maximumCacheBytes = 10L, + ) + val secondHandoff = DesktopExternalFileHandoff( + root = root, + launchFile = { true }, + cacheReservations = cacheReservations, + maximumCacheBytes = 10L, + ) + val sixByteFile = file().copy(size = 6L) + val first = async { + firstHandoff.launchStreamed( + accountId = "a".repeat(64), + file = sixByteFile, + action = ExternalFileHandoffAction.OpenWith, + capability = capability(), + ) { output, maximumBytes -> + assertEquals(6L, maximumBytes) + firstStarted.complete(Unit) + finishFirst.await() + output.write(ByteArray(6)) + DesktopDetachedDownload(6L, "\"v1\"") + } + } + firstStarted.await() + + assertFailsWith { + secondHandoff.launchStreamed( + accountId = "b".repeat(64), + file = sixByteFile, + action = ExternalFileHandoffAction.OpenWith, + capability = capability(), + ) { _, _ -> + secondDownloadStarted = true + error("the second copy must not start") + } + } + assertFalse(secondDownloadStarted) + finishFirst.complete(Unit) + assertIs(first.await()) + assertEquals( + 4L, + pruneDesktopExternalFileCache(root, requiredBytes = 0L, maximumBytes = 10L), + ) + } finally { + deleteDesktopExternalFileTree(root.toPath()) + } + } + @Test fun `same-filesystem export moves the staged copy without requiring duplicate capacity`() { val root = Files.createTempDirectory("nextcloud-desktop-export-").toFile() @@ -257,11 +357,162 @@ class DesktopExternalFileHandoffTest { } } + @Test + fun `account cleanup removes only that accounts detached copies`() = runBlocking { + val root = Files.createTempDirectory("nextcloud-desktop-account-handoff-").toFile() + val removed = "a".repeat(64) + val retained = "b".repeat(64) + val freshLegacy = root.resolve("123e4567-e89b-12d3-a456-426614174000") + try { + val handoff = DesktopExternalFileHandoff(root, launchFile = { true }) + handoff.launch(removed, file(), ExternalFileHandoffAction.OpenWith, capability()) { + NextcloudFileContent("removed".encodeToByteArray(), "application/pdf", "\"v1\"") + } + handoff.launch(retained, file(), ExternalFileHandoffAction.OpenWith, capability()) { + NextcloudFileContent("retained".encodeToByteArray(), "application/pdf", "\"v1\"") + } + freshLegacy.mkdir() + freshLegacy.resolve("payload.bin").writeText("unknown-account-legacy-copy") + + repeat(2) { handoff.removeAccount(removed) } + + assertFalse(root.resolve(removed).exists()) + assertFalse(freshLegacy.exists()) + assertEquals("retained", root.resolve(retained).walkTopDown().first(File::isFile).readText()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `account and legacy cleanup unlink nested symlinks without deleting their targets`() { + val root = Files.createTempDirectory("nextcloud-desktop-handoff-safe-delete-").toFile() + val scopedTarget = Files.createTempDirectory("nextcloud-desktop-handoff-scoped-target-").toFile() + val legacyTarget = Files.createTempDirectory("nextcloud-desktop-handoff-legacy-target-").toFile() + try { + val scoped = root.resolve(accountId()).apply { mkdir() } + val legacy = root.resolve("123e4567-e89b-12d3-a456-426614174000").apply { mkdir() } + scopedTarget.resolve("keep.txt").writeText("scoped-target") + legacyTarget.resolve("keep.txt").writeText("legacy-target") + val linksCreated = runCatching { + Files.createSymbolicLink(scoped.resolve("linked").toPath(), scopedTarget.toPath()) + Files.createSymbolicLink(legacy.resolve("linked").toPath(), legacyTarget.toPath()) + }.isSuccess + if (!linksCreated) return + + DesktopExternalFileHandoff(root).removeAccount(accountId()) + + assertFalse(scoped.exists()) + assertFalse(legacy.exists()) + assertEquals("scoped-target", scopedTarget.resolve("keep.txt").readText()) + assertEquals("legacy-target", legacyTarget.resolve("keep.txt").readText()) + } finally { + deleteDesktopExternalFileTree(root.toPath()) + scopedTarget.deleteRecursively() + legacyTarget.deleteRecursively() + } + } + + @Test + fun `staging rejects an account cache directory replaced by a symlink`() = runBlocking { + val root = Files.createTempDirectory("nextcloud-desktop-handoff-account-link-").toFile() + val target = Files.createTempDirectory("nextcloud-desktop-handoff-account-target-").toFile() + try { + target.resolve("keep.txt").writeText("outside") + val linked = runCatching { + Files.createSymbolicLink(root.resolve(accountId()).toPath(), target.toPath()) + }.isSuccess + if (!linked) return@runBlocking + + assertFailsWith { + DesktopExternalFileHandoff(root).launch( + accountId(), file(), ExternalFileHandoffAction.OpenWith, capability(), + ) { error("download must not start") } + } + + assertEquals("outside", target.resolve("keep.txt").readText()) + } finally { + deleteDesktopExternalFileTree(root.toPath()) + target.deleteRecursively() + } + } + + @Test + fun `legacy cleanup expires old unscoped copies without deleting account directories`() { + val root = Files.createTempDirectory("nextcloud-desktop-legacy-handoff-").toFile() + val expired = root.resolve("123e4567-e89b-12d3-a456-426614174000").apply { mkdir() } + val recent = root.resolve("123e4567-e89b-12d3-a456-426614174001").apply { mkdir() } + val scoped = root.resolve(accountId()).apply { mkdir() } + val now = 2L * DESKTOP_EXTERNAL_FILE_TEST_DAY_MILLIS + try { + expired.resolve("payload.bin").writeText("expired") + recent.resolve("payload.bin").writeText("recent") + scoped.resolve("payload.bin").writeText("scoped") + expired.setLastModified(1L) + recent.setLastModified(now) + scoped.setLastModified(1L) + + pruneLegacyDesktopExternalFileCache(root, now) + + assertFalse(expired.exists()) + assertTrue(recent.isDirectory) + assertTrue(scoped.isDirectory) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `account removal waits for in flight handoff then deletes its copy`() = runBlocking { + val root = Files.createTempDirectory("nextcloud-desktop-handoff-removal-").toFile() + val guard = DesktopAccountOperationGuard() + val session = session() + val scopedAccountId = desktopFileCacheAccountId(session) + val downloadStarted = CompletableDeferred() + val finishDownload = CompletableDeferred() + var removalFinished = false + try { + val handoff = DesktopExternalFileHandoff(root, launchFile = { true }) + val launch = async { + guard.withExternalFileHandoffSession(session, { session }) { + handoff.launch(scopedAccountId, file(), ExternalFileHandoffAction.OpenWith, capability()) { + downloadStarted.complete(Unit) + finishDownload.await() + NextcloudFileContent("detached".encodeToByteArray(), "application/pdf", "\"v1\"") + } + } + } + downloadStarted.await() + val removal = async(start = CoroutineStart.UNDISPATCHED) { + guard.serialize { + handoff.removeAccount(scopedAccountId) + removalFinished = true + } + } + + assertFalse(removalFinished) + finishDownload.complete(Unit) + assertIs(launch.await()) + removal.await() + assertFalse(root.resolve(scopedAccountId).exists()) + } finally { + root.deleteRecursively() + } + } + private fun capability(vararg actions: ExternalFileHandoffAction) = ExternalFileHandoffCapability( supportedActions = actions.toSet().ifEmpty { setOf(ExternalFileHandoffAction.OpenWith) }, maximumInMemoryFileBytes = MAX_IN_MEMORY_EXTERNAL_FILE_HANDOFF_BYTES, ) + private fun accountId() = "0123456789abcdef".repeat(4) + + private fun session() = NextcloudSession( + serverUrl = "https://cloud.invalid", + loginName = "ada", + appPassword = "synthetic-secret", + ) + private fun file() = NextcloudFile( path = "Documents/report.pdf", name = "report.pdf", @@ -287,3 +538,5 @@ class DesktopExternalFileHandoffTest { lastModified = null, ) } + +private const val DESKTOP_EXTERNAL_FILE_TEST_DAY_MILLIS = 24L * 60L * 60L * 1000L diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheTest.kt index 9cff3cc85..04fb06db3 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheTest.kt @@ -17,6 +17,46 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject class DesktopFileReadCacheTest { + @Test + fun `stale producers cannot recreate cache files across retirement and reactivation`() = withCache { root, cache -> + val accountId = desktopFileCacheAccountId(session()) + val staleProducer = checkNotNull(cache.producer(accountId)) + val content = NextcloudFileContent("private".encodeToByteArray(), "text/plain", "\"etag-stale\"") + val listing = listOf(file("Notes/private.txt", "\"etag-stale\"")) + + assertTrue(cache.storeContent(accountId, "Notes/private.txt", content, cacheProducer = staleProducer)) + cache.removeAccount(accountId) + + assertFalse(cache.storeContent(accountId, "Notes/private.txt", content, cacheProducer = staleProducer)) + assertFalse( + cache.storeListingUnlessNewer( + accountId, + "Notes", + listing, + fetchedAtEpochMillis = 10, + cacheProducer = staleProducer, + ), + ) + assertFalse(root.resolve(accountId).exists()) + + cache.activateAccount(accountId) + assertFalse(cache.storeContent(accountId, "Notes/private.txt", content, cacheProducer = staleProducer)) + val currentProducer = checkNotNull(cache.producer(accountId)) + assertTrue(cache.storeContent(accountId, "Notes/private.txt", content, cacheProducer = currentProducer)) + assertTrue( + cache.storeListingUnlessNewer( + accountId, + "Notes", + listing, + fetchedAtEpochMillis = 20, + cacheProducer = currentProducer, + ), + ) + assertFalse(cache.invalidate(accountId, "Notes", staleProducer)) + assertContentEquals(content.bytes, cache.cachedContent(accountId, "Notes/private.txt", 64)?.bytes) + assertEquals(listing, cache.cachedListing(accountId, "Notes")) + } + @Test fun `metadata and content survive a new cache instance without storing credentials`() = withCache { root, cache -> val session = session(password = "first-secret") 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/DesktopHomeWorkspaceLayoutStorageTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorageTest.kt index 7a86e3122..52d054b1a 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorageTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorageTest.kt @@ -9,9 +9,39 @@ import java.util.prefs.Preferences import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class DesktopHomeWorkspaceLayoutStorageTest { + @Test + fun `account removal clears canonical and legacy workspace preferences without touching a peer`() { + val directory = Files.createTempDirectory("nextcloud-native-home-workspace-removal-test").toFile() + val node = "dev/obiente/nextcloudnative/test-home-workspace-${UUID.randomUUID()}" + val preferences = Preferences.userRoot().node(node) + val cleanupPreferences = Preferences.userRoot().node(node) + val storage = DesktopHomeWorkspaceLayoutStorage(cleanupPreferences, directory.resolve("preferences.lock")) + val canonical = "a".repeat(64) + val legacy = "b".repeat(64) + val retained = "c".repeat(64) + val removedKeys = listOf(canonical, legacy).flatMap(::workspacePreferenceKeys) + val retainedKeys = workspacePreferenceKeys(retained) + try { + removedKeys.forEach { key -> preferences.put(key, "removed") } + retainedKeys.forEach { key -> preferences.put(key, "retained") } + preferences.flush() + + storage.removeAccount(canonical, legacy) + preferences.sync() + + removedKeys.forEach { key -> assertNull(preferences.get(key, null), key) } + retainedKeys.forEach { key -> assertEquals("retained", preferences.get(key, null), key) } + } finally { + preferences.removeNode() + Preferences.userRoot().flush() + directory.deleteRecursively() + } + } + @Test fun `concurrent storage instances admit only one conditional promotion`() { val directory = Files.createTempDirectory("nextcloud-native-home-workspace-lock-test").toFile() @@ -70,4 +100,7 @@ class DesktopHomeWorkspaceLayoutStorageTest { home.deleteRecursively() } } + + private fun workspacePreferenceKeys(accountScopeDigest: String): List = + homeWorkspaceAccountPersistenceKeys(accountScopeDigest).toList() } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt new file mode 100644 index 000000000..5cb90aba3 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt @@ -0,0 +1,123 @@ +package dev.obiente.nextcloudnative.app + +import org.junit.Assume.assumeTrue +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue +import ru.serce.jnrfuse.ErrorCodes + +class DesktopLinuxProviderCleanupTest { + @Test + fun `failed unmount aborts fuse and retains the exact quiesced provider`() { + var aborted = false + var abortHandleClosed = false + val fileSystem = RecordingLinuxProviderFileSystem( + abortHandle = object : LinuxFuseAbortHandle { + override fun abortBestEffort() { + aborted = true + } + + override fun close() { + abortHandleClosed = true + } + }, + ) + val provider = DetachedDesktopLinuxProvider(fileSystem, null, "account") + val cleanup = DesktopLinuxProviderCleanupSlot() + + assertFailsWith { cleanup.unmountOrRetain(provider) } + + assertTrue(aborted) + assertTrue(abortHandleClosed) + assertTrue(fileSystem.readsDisabled) + assertSame(provider, cleanup.pendingForTest()) + assertFailsWith { fileSystem.beginMutation() } + } + + @Test + fun `failed unmount without an abort handle still retains a quiesced provider`() { + val fileSystem = RecordingLinuxProviderFileSystem(abortHandle = null) + val provider = DetachedDesktopLinuxProvider(fileSystem, null, "account") + val cleanup = DesktopLinuxProviderCleanupSlot() + + assertFailsWith { cleanup.unmountOrRetain(provider) } + + assertTrue(fileSystem.readsDisabled) + assertSame(provider, cleanup.pendingForTest()) + assertFailsWith { fileSystem.beginMutation() } + } + + @Test + fun `failed unmount rejects retained filesystem reads without reaching its backend`() { + assumeTrue(System.getProperty("os.name").startsWith("Linux", ignoreCase = true)) + val backend = ReadCountingLinuxBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem( + backend = backend, + unmountOperation = { error("synthetic unmount failure") }, + ) + val provider = DetachedDesktopLinuxProvider(fileSystem, null, "account") + val cleanup = DesktopLinuxProviderCleanupSlot() + assertEquals(0, fileSystem.access("/", 0)) + assertEquals(1, backend.resolveCalls) + + assertFailsWith { cleanup.unmountOrRetain(provider) } + + assertEquals(-ErrorCodes.EIO(), fileSystem.access("/", 0)) + assertEquals(1, backend.resolveCalls) + assertSame(provider, cleanup.pendingForTest()) + } +} + +private class RecordingLinuxProviderFileSystem( + private val abortHandle: LinuxFuseAbortHandle?, +) : DesktopLinuxProviderFileSystem { + var readsDisabled = false + private set + private val writeLifecycle = LinuxVirtualWriteLifecycle( + hasOpenWriteHandles = { false }, + hasPendingCreatedFiles = { false }, + ).also { check(it.tryQuiesce()) } + + override fun disableReads() { + readsDisabled = true + } + + override fun unmount() = runLinuxFuseUnmountLifecycle( + abortHandle = abortHandle, + detach = { error("synthetic unmount failure") }, + cleanup = {}, + ) + + fun beginMutation() = writeLifecycle.beginMutation() +} + +private class ReadCountingLinuxBackend : LinuxVirtualFileBackend { + var resolveCalls = 0 + private set + + override fun resolve(path: String): LinuxVirtualFileNode? { + resolveCalls += 1 + return LinuxVirtualFileNode("", "Nextcloud", true, 0L, "root") + } + + override fun list(path: String): List = error("Not used.") + override fun open(node: LinuxVirtualFileNode): LinuxVirtualFileReadHandle = error("Not used.") + + override fun openWrite( + path: String, + existing: LinuxVirtualFileNode?, + truncate: Boolean, + ): LinuxVirtualFileWriteHandle = error("Not used.") + + override fun createDirectory(path: String) = error("Not used.") + override fun delete(node: LinuxVirtualFileNode) = error("Not used.") + override fun move(node: LinuxVirtualFileNode, destinationPath: String) = error("Not used.") + + override fun moveReplacing( + node: LinuxVirtualFileNode, + destination: LinuxVirtualFileNode, + destinationPath: String, + ) = error("Not used.") +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt index 708034082..573bfab13 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt @@ -1,12 +1,21 @@ package dev.obiente.nextcloudnative.app +import dev.obiente.nextcloudnative.contracts.CachedDynamicApiResponse +import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache import java.io.File import java.nio.file.Files import java.nio.file.attribute.PosixFilePermission import kotlin.io.path.createTempDirectory +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue class DesktopPendingDynamicMutationDirectoryTest { @Test @@ -68,4 +77,81 @@ class DesktopPendingDynamicMutationDirectoryTest { root.deleteRecursively() } } + + @Test + fun `account cleanup removes only path confined pending mutation entries`() { + val directory = createTempDirectory("pending-mutation-cleanup-").toFile() + val accountId = "a".repeat(64) + val otherAccountId = "b".repeat(64) + try { + ensurePrivatePendingMutationDirectory(directory) + val owned = directory.resolve("$accountId-deck-${"1".repeat(64)}.json") + val ownedTemporary = directory.resolve("${owned.name}-retry.part") + val retained = directory.resolve("$otherAccountId-deck-${"2".repeat(64)}.json") + listOf(owned, ownedTemporary, retained).forEach { file -> + file.writeText("private") + setPrivatePendingMutationFilePermissions(file) + } + + removeDesktopPendingDynamicMutations(directory, accountId) + + assertFalse(owned.exists()) + assertFalse(ownedTemporary.exists()) + assertTrue(retained.isFile) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `account cleanup fails closed on an unrecognized owned entry`() { + val directory = createTempDirectory("pending-mutation-cleanup-unsafe-").toFile() + val accountId = "c".repeat(64) + try { + ensurePrivatePendingMutationDirectory(directory) + val unsafe = directory.resolve("$accountId-unknown") + unsafe.writeText("private") + setPrivatePendingMutationFilePermissions(unsafe) + + assertFailsWith { + removeDesktopPendingDynamicMutations(directory, accountId) + } + assertTrue(unsafe.isFile) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `desktop account cleanup fences a late GET before deleting its cache`() = runBlocking { + supervisorScope { + val root = createTempDirectory("desktop-dynamic-cache-cleanup-").toFile() + try { + val accountId = "d".repeat(64) + val requestIdentity = "GET /dashboard/widgets" + val cache = DynamicApiResponseCache(root) + val coalescer = DynamicApiRequestCoalescer() + val started = CompletableDeferred() + val release = CompletableDeferred() + val response = CachedDynamicApiResponse(200, "private".encodeToByteArray(), null, null) + cache.store(accountId, requestIdentity, response) + val read = async { + coalescer.execute(accountId, requestIdentity, load = { + started.complete(Unit) + release.await() + response + }, commit = { cache.store(accountId, requestIdentity, it) }) + } + started.await() + + clearDesktopDynamicApiState(accountId, coalescer, cache) + release.complete(Unit) + + assertFailsWith { read.await() } + kotlin.test.assertNull(cache.load(accountId, requestIdentity, 1_024)) + } finally { + root.deleteRecursively() + } + } + } } 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 b551e9af1..cb0ba8e88 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/DesktopVirtualFileProviderPreferencesTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt index 5f268c393..978a369f8 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt @@ -1,8 +1,10 @@ package dev.obiente.nextcloudnative.app import java.util.prefs.Preferences +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertSame @@ -48,4 +50,84 @@ class DesktopVirtualFileProviderPreferencesTest { assertTrue(detached) assertEquals(null, returnedFailure) } + + @Test + fun `remote revocation attempt never restores a pre-disabled provider`() { + var providerEnabled = false + val removed = removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = false, + clearProviderPreference = { providerEnabled = false }, + restoreProviderPreference = { enabled -> providerEnabled = enabled }, + removeCredential = { false }, + ) + + assertFalse(removed) + assertFalse(providerEnabled) + assertFalse(shouldResumeDesktopWritesAfterRemovalFailure(false, true, false)) + } + + @Test + fun `provider restore failure does not prevent in-memory account recovery`() { + val events = mutableListOf() + val restoreFailure = IllegalStateException("synthetic preference flush failure") + + val recoveryFailure = recoverDesktopAccountAfterPrecommitFailure( + restoreProviderPreference = { events += "restore"; throw restoreFailure }, + resumeVirtualFileSystem = { events += "resume-linux" }, + resumeWindowsCloudFiles = { events += "resume-windows" }, + reopenSession = { events += "reopen" }, + restartLifecycle = { events += "restart" }, + ) + + assertSame(restoreFailure, recoveryFailure) + assertEquals(listOf("restore", "resume-linux", "resume-windows", "reopen", "restart"), events) + } + + @Test + fun `aborted account removal leaves virtual file providers attached`() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + commitDesktopAccountRemovalBeforeVirtualFileTeardown( + commitRemoval = { + events += "remove" + error("credential removal failed") + }, + teardownVirtualFiles = { events += "teardown" }, + ) + } + + assertEquals(listOf("remove"), events) + } + + @Test + fun `committed account removal tears down virtual file providers afterward`() = runBlocking { + val events = mutableListOf() + + commitDesktopAccountRemovalBeforeVirtualFileTeardown( + commitRemoval = { events += "remove" }, + teardownVirtualFiles = { events += "teardown" }, + ) + + assertEquals(listOf("remove", "teardown"), events) + } + + @Test + fun `committed removal clears support identities even when provider teardown fails`() { + val events = mutableListOf() + + assertFailsWith { + finishCommittedDesktopAccountRemoval( + markRemovalCommitted = { events += "committed" }, + teardownVirtualFiles = { + events += "teardown" + error("synthetic unmount failure") + }, + clearDiagnosticIdentity = { events += "diagnostics" }, + clearIntakeIdentity = { events += "intake" }, + ) + } + + assertEquals(listOf("committed", "teardown", "diagnostics", "intake"), events) + } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycleTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycleTest.kt new file mode 100644 index 000000000..8529d96d8 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycleTest.kt @@ -0,0 +1,22 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopVirtualFolderHydrationLifecycleTest { + @Test + fun `account resource preflight counts a registered lazy hydration job`() = runBlocking { + val registeredJob = launch(start = CoroutineStart.LAZY) {} + + assertFalse(registeredJob.isActive) + assertTrue(hasLiveVirtualFolderHydrationJobs(listOf(registeredJob))) + + registeredJob.cancelAndJoin() + assertFalse(hasLiveVirtualFolderHydrationJobs(listOf(registeredJob))) + } +} 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/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanupTest.kt new file mode 100644 index 000000000..ee7a133eb --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanupTest.kt @@ -0,0 +1,74 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JvmSupportAccountStorageCleanupTest { + @Test + fun `removal purges only descriptors and archive proven to belong to the account`() { + val root = Files.createTempDirectory("support-retirement").toFile() + val removed = "a".repeat(64) + val retained = "b".repeat(64) + var syncs = 0 + try { + val archive = root.resolve("support-00000000-0000-0000-0000-000000000001.zip") + .apply { writeText("private") } + root.resolve("pending.json").writeText( + """{"originAccountIdentity":"$removed","archiveName":"${archive.name}"}""", + ) + val removedCompleted = root.resolve("completed-00000000-0000-0000-0000-000000000002.json") + .apply { writeText("""{"originAccountIdentity":"$removed"}""") } + val retainedCompleted = root.resolve("completed-00000000-0000-0000-0000-000000000003.json") + .apply { writeText("""{"originAccountIdentity":"$retained"}""") } + + JvmSupportAccountStorageCleanup(root, { syncs += 1 }).removeAccount(removed, archive) + + assertFalse(root.resolve("pending.json").exists()) + assertFalse(archive.exists()) + assertFalse(removedCompleted.exists()) + assertTrue(retainedCompleted.isFile) + assertEquals(1, syncs) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `unreadable ownership fails closed without deleting the descriptor`() { + val root = Files.createTempDirectory("support-retirement-invalid").toFile() + val descriptor = root.resolve("pending.json").apply { writeText("not-json") } + try { + assertFails { + JvmSupportAccountStorageCleanup(root, {}).removeAccount("a".repeat(64), null) + } + assertTrue(descriptor.isFile) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `archive deletion failure preserves the descriptor for cleanup retry`() { + val root = Files.createTempDirectory("support-retirement-delete-failure").toFile() + val account = "c".repeat(64) + val archive = root.resolve("support-00000000-0000-0000-0000-000000000004.zip") + .apply { writeText("private") } + val descriptor = root.resolve("pending.json").apply { + writeText("""{"originAccountIdentity":"$account","archiveName":"${archive.name}"}""") + } + try { + assertFails { + JvmSupportAccountStorageCleanup(root, {}, deleteFile = { false }) + .removeAccount(account, archive) + } + assertTrue(descriptor.isFile) + assertTrue(archive.isFile) + } finally { + root.deleteRecursively() + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt new file mode 100644 index 000000000..0ca9928a8 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt @@ -0,0 +1,103 @@ +package dev.obiente.nextcloudnative.app + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LinuxVirtualMutationGateTest { + @Test + fun `quiescence blocks new mutations and drains an active callback`() { + val gate = LinuxVirtualMutationGate() + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val workers = Executors.newFixedThreadPool(2) + try { + val mutation = workers.submit { + gate.begin() + try { + entered.countDown() + check(release.await(5, TimeUnit.SECONDS)) + } finally { + gate.end() + } + } + check(entered.await(5, TimeUnit.SECONDS)) + val quiescence = workers.submit { gate.tryQuiesce { true } } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (gate.isAcceptingNewOperations() && System.nanoTime() < deadline) Thread.yield() + + assertFalse(gate.isAcceptingNewOperations()) + assertFailsWith { gate.begin() } + assertFalse(quiescence.isDone) + release.countDown() + mutation.get(5, TimeUnit.SECONDS) + assertTrue(quiescence.get(5, TimeUnit.SECONDS)) + + gate.resume() + gate.begin() + gate.end() + } finally { + release.countDown() + workers.shutdownNow() + } + } + + @Test + fun `failed quiescence reopens automatically so an unreleased writer can close`() { + var hasOpenWriteHandle = true + val lifecycle = LinuxVirtualWriteLifecycle( + hasOpenWriteHandles = { hasOpenWriteHandle }, + hasPendingCreatedFiles = { false }, + ) + + assertFalse(lifecycle.tryQuiesce()) + assertTrue(lifecycle.beginRelease()) + hasOpenWriteHandle = false + lifecycle.endOperation() + assertTrue(lifecycle.tryQuiesce()) + assertFailsWith { lifecycle.beginMutation() } + lifecycle.resume() + } + + @Test + fun `quiescence drains final pending file close through a read alias release`() { + val closeStarted = CountDownLatch(1) + val allowClose = CountDownLatch(1) + var hasOpenWriteHandle = true + var hasPendingCreatedFile = true + val lifecycle = LinuxVirtualWriteLifecycle( + hasOpenWriteHandles = { hasOpenWriteHandle }, + hasPendingCreatedFiles = { hasPendingCreatedFile }, + ) + val workers = Executors.newFixedThreadPool(2) + try { + val release = workers.submit { + check(lifecycle.beginRelease()) + try { + closeStarted.countDown() + check(allowClose.await(5, TimeUnit.SECONDS)) + hasOpenWriteHandle = false + hasPendingCreatedFile = false + } finally { + lifecycle.endOperation() + } + } + assertTrue(closeStarted.await(5, TimeUnit.SECONDS)) + val quiescence = workers.submit { lifecycle.tryQuiesce() } + assertFalse(quiescence.isDone) + + allowClose.countDown() + release.get(5, TimeUnit.SECONDS) + assertTrue(quiescence.get(5, TimeUnit.SECONDS)) + assertFailsWith { lifecycle.beginMutation() } + lifecycle.resume() + } finally { + allowClose.countDown() + workers.shutdownNow() + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesAccountRemovalQuiescenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesAccountRemovalQuiescenceTest.kt new file mode 100644 index 000000000..190c6c9d4 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesAccountRemovalQuiescenceTest.kt @@ -0,0 +1,128 @@ +package dev.obiente.nextcloudnative.app + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeBytes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WindowsCloudFilesAccountRemovalQuiescenceTest { + @Test + fun `dirty close crossing the pause is uploaded before account removal continues`() { + val root = createTempDirectory("windows-cloud-account-removal-") + val local = root.resolve("draft.txt") + val bytes = "edit closed while sign-out starts".encodeToByteArray() + val unmanaged = root.resolve("new-note.txt") + val unmanagedBytes = "edit whose watcher debounce crossed sign-out".encodeToByteArray() + val identity = WindowsCloudFileIdentity("account-01", "draft.txt", "etag-1", bytes.size.toLong(), false) + val backend = WindowsCloudFilesProviderTest.FakeBackend(ByteArray(0), expectedUploads = 2) + val api = WindowsCloudFilesProviderTest.FakeApi(expectedConversions = 2) + lateinit var provider: WindowsCloudFilesProvider + try { + provider = WindowsCloudFilesProvider(root, backend, api) + provider.start() + provider.recoverAfterStartup(timeoutSeconds = 5L) + local.writeBytes(bytes) + unmanaged.writeBytes(unmanagedBytes) + api.beforeDisconnect = { + api.seed(local, WindowsCloudPlaceholderState.Dirty, identity) + provider.closed(callbackInfo(local, identity), deleted = false) + provider.localEntryChanged(unmanaged) + } + + assertTrue(provider.quiesceWritesForAccountRemoval(timeoutSeconds = 5L)) + + assertTrue(backend.awaitUploads()) + assertEquals( + setOf(bytes.toList(), unmanagedBytes.toList()), + backend.uploadedBytes.map(ByteArray::toList).toSet(), + ) + assertEquals(listOf(1L), api.disconnectAttempts) + assertEquals(null, api.unregisteredRoot) + provider.removeSyncRoot() + assertEquals(root, api.unregisteredRoot) + } finally { + if (!api.closed) runCatching { provider.close() } + root.toFile().deleteRecursively() + } + } + + @Test + fun `quiescence drains an admitted destructive callback and rejects later callbacks`() { + val root = createTempDirectory("windows-cloud-account-removal-delete-") + val identity = WindowsCloudFileIdentity("account-01", "note.txt", "etag-2", 0L, false) + val backend = WindowsCloudFilesProviderTest.FakeBackend( + ByteArray(0), + listed = listOf(identity), + blockFirstDelete = true, + ) + val api = WindowsCloudFilesProviderTest.FakeApi() + val provider = WindowsCloudFilesProvider(root, backend, api) + try { + provider.start() + provider.recoverAfterStartup(timeoutSeconds = 5L) + val info = callbackInfo(root.resolve(identity.path), identity) + provider.deleteRequested(info) + assertTrue(backend.awaitFirstDeleteStarted()) + val disconnected = CountDownLatch(1) + api.beforeDisconnect = { disconnected.countDown() } + val failure = AtomicReference() + val quiescence = Thread { + try { + provider.quiesceWritesForAccountRemoval(timeoutSeconds = 5L) + } catch (thrown: Throwable) { + failure.set(thrown) + } + } + quiescence.start() + assertFalse(disconnected.await(250L, TimeUnit.MILLISECONDS)) + assertTrue(quiescence.isAlive) + backend.releaseFirstDelete() + assertTrue(disconnected.await(5L, TimeUnit.SECONDS)) + quiescence.join(5_000L) + + assertFalse(quiescence.isAlive) + assertEquals(null, failure.get()) + provider.deleteRequested(info) + assertEquals(listOf("delete:note.txt"), backend.operations) + provider.resumeWritesAfterAccountRemovalFailure() + assertEquals(2, api.lifecycleEvents.count { it == "connect" }) + } finally { + backend.releaseFirstDelete() + provider.close() + root.toFile().deleteRecursively() + } + } + + @Test + fun `unrecoverable writeback reopens callbacks instead of continuing removal`() { + var resumed = false + val quiescence = WindowsCloudFilesRemovalQuiescence( + pauseCallbacks = { true }, + mutationState = { + WindowsCloudFilesMutationState(1, 1, 0, 0, 0) + }, + resumeCallbacks = { resumed = true }, + ) + + assertFailsWith { quiescence.tryQuiesce(timeoutSeconds = 1L) } + + assertTrue(resumed) + } + + private fun callbackInfo(local: java.nio.file.Path, identity: WindowsCloudFileIdentity) = + WindowsCloudCallbackInfo( + connectionKey = 1L, + transferKey = 2L, + requestKey = 3L, + normalizedPath = local.toString(), + fileIdentity = WindowsCloudFileIdentityCodec.encode(identity), + fileSize = identity.size, + priorityHint = 0, + ) +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt index 4add627e0..ccfa13b2b 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt @@ -2894,7 +2894,7 @@ class WindowsCloudFilesProviderTest { return payload + MessageDigest.getInstance("SHA-256").digest(payload) } - private class FakeBackend( + internal class FakeBackend( private val source: ByteArray, private val listed: List = emptyList(), expectedUploads: Int = 0, @@ -2921,7 +2921,6 @@ class WindowsCloudFilesProviderTest { private val remoteIdentities = listed.associateBy { identity -> identity.path }.toMutableMap() private val remoteContents = mutableMapOf() private val scriptedLists = ArrayDeque>() - override fun resolve(path: String): WindowsCloudFileIdentity? = synchronized(this) { resolvedPaths += path remoteIdentities[path] @@ -3019,7 +3018,7 @@ class WindowsCloudFilesProviderTest { } } - private class FakeApi( + internal class FakeApi( expectedTransfers: Int = 0, expectedConversions: Int = 0, expectedRenames: Int = 0, @@ -3048,6 +3047,7 @@ class WindowsCloudFilesProviderTest { val connectFailures = mutableListOf() val disconnectAttempts = mutableListOf() var disconnectFailure: RuntimeException? = null + var beforeDisconnect: (() -> Unit)? = null var createPlaceholdersHook: ((Path, List) -> Unit)? = null var updatePlaceholderFailure: WindowsCloudFilesOperationException? = null var updatePlaceholderFailuresRemaining: Int = Int.MAX_VALUE @@ -3071,6 +3071,7 @@ class WindowsCloudFilesProviderTest { return 1L } override fun disconnect(connectionKey: Long) { + beforeDisconnect?.invoke() disconnectAttempts += connectionKey disconnectFailure?.let { throw it } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt index b5634e97d..ebbf839b1 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt @@ -5,12 +5,51 @@ import java.nio.file.Files import java.nio.file.Path import java.util.UUID import java.util.prefs.Preferences +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class WindowsUninstallCleanupTest { + @Test + fun successfulFallbackCleanupClearsAnEarlierProviderFailure() { + assertEquals( + null, + windowsCloudFilesFailureAfterFallbackCleanup( + providerFailure = "provider removal failed", + fallbackFailure = null, + defaultMessage = "cleanup failed", + ), + ) + } + + @Test + fun failedFallbackCleanupPreservesTheEarlierProviderFailure() { + assertEquals( + "provider removal failed", + windowsCloudFilesFailureAfterFallbackCleanup( + providerFailure = "provider removal failed", + fallbackFailure = IllegalStateException("fallback failed"), + defaultMessage = "cleanup failed", + ), + ) + } + + @Test + fun failedFallbackCleanupPublishesItsOwnFailureWhenTheProviderSucceeded() { + assertEquals( + "fallback failed", + windowsCloudFilesFailureAfterFallbackCleanup( + providerFailure = null, + fallbackFailure = IllegalStateException("fallback failed"), + defaultMessage = "cleanup failed", + ), + ) + } + @Test fun preservedRootRecordSurvivesReloadUntilAcknowledged() { val nodeName = "windows-preserved-root-test-${UUID.randomUUID()}" @@ -241,6 +280,194 @@ class WindowsUninstallCleanupTest { } } + @Test + fun inactiveAccountRemovalUnregistersOnlyThatAccountsCloudFilesRoots() { + val preferences = Preferences.userRoot().node("windows-account-removal-test-${UUID.randomUUID()}") + val home = Files.createTempDirectory("windows-account-removal-home").toFile() + val removedAccountId = "7".repeat(64) + val retainedAccountId = "8".repeat(64) + val removedRoot = desktopWindowsCloudFilesRoot(removedAccountId, home) + val retainedRoot = desktopWindowsCloudFilesRoot(retainedAccountId, home) + val api = RecordingWindowsCloudFilesApi() + try { + preferences.put(windowsCloudFilesRootPreferenceKey(removedAccountId), removedRoot.absolutePath) + preferences.put(windowsCloudFilesRootPreferenceKey(retainedAccountId), retainedRoot.absolutePath) + preferences.put("windows-cloud-files-root", retainedRoot.absolutePath) + + unregisterWindowsCloudFilesRootsForAccountRemoval( + preferences = preferences, + accountId = removedAccountId, + userHome = home, + apiFactory = { api }, + ) + + assertEquals( + listOf( + removedRoot.toPath(), + home.resolve("Nextcloud Native").resolve(removedAccountId).toPath(), + ), + api.unregisteredRoots, + ) + assertEquals(null, preferences.get(windowsCloudFilesRootPreferenceKey(removedAccountId), null)) + assertEquals( + retainedRoot.absolutePath, + preferences.get(windowsCloudFilesRootPreferenceKey(retainedAccountId), null), + ) + assertEquals(retainedRoot.absolutePath, preferences.get("windows-cloud-files-root", null)) + assertTrue(api.closed) + } finally { + preferences.removeNode() + home.deleteRecursively() + } + } + + @Test + fun accountRemovalDeletesHydratedDataOnlyWhenEveryEntryIsInSync() { + val preferences = Preferences.userRoot().node("windows-account-data-removal-${UUID.randomUUID()}") + val home = Files.createTempDirectory("windows-account-data-removal-home").toFile() + val accountId = "d".repeat(64) + val currentRoot = desktopWindowsCloudFilesRoot(accountId, home).toPath() + val legacyRoot = desktopLegacyWindowsCloudFilesRoot(accountId, home).toPath() + val api = RecordingWindowsCloudFilesApi() + try { + Files.createDirectories(currentRoot.resolve("Documents")) + Files.writeString(currentRoot.resolve("Documents/private.txt"), "hydrated private bytes") + Files.createDirectories(legacyRoot) + Files.writeString(legacyRoot.resolve("legacy.txt"), "legacy hydrated bytes") + + unregisterWindowsCloudFilesRootsForAccountRemoval(preferences, accountId, home) { api } + + assertFalse(Files.exists(currentRoot)) + assertFalse(Files.exists(legacyRoot)) + assertNull(persistedWindowsCloudFilesPreservedRoot(preferences, accountId)) + } finally { + preferences.removeNode() + home.deleteRecursively() + } + } + + @Test + fun accountRemovalMovesUncommittedDataIntoTheExplicitRecoveryFolder() { + val preferences = Preferences.userRoot().node("windows-account-data-recovery-${UUID.randomUUID()}") + val home = Files.createTempDirectory("windows-account-data-recovery-home").toFile() + val accountId = "e".repeat(64) + val currentRoot = desktopWindowsCloudFilesRoot(accountId, home).toPath() + val legacyRoot = desktopLegacyWindowsCloudFilesRoot(accountId, home).toPath() + val api = RecordingWindowsCloudFilesApi().apply { + inspectEntry = { path -> + WindowsCloudPlaceholderInspection( + when (path.fileName.toString()) { + "dirty.txt" -> WindowsCloudPlaceholderEntryState.Dirty + "local.txt" -> WindowsCloudPlaceholderEntryState.Local + else -> WindowsCloudPlaceholderEntryState.InSync + }, + ) + } + } + try { + Files.createDirectories(currentRoot) + Files.writeString(currentRoot.resolve("dirty.txt"), "uncommitted edit") + Files.createDirectories(legacyRoot) + Files.writeString(legacyRoot.resolve("local.txt"), "local-only file") + + unregisterWindowsCloudFilesRootsForAccountRemoval(preferences, accountId, home) { api } + + val recoveryRoot = assertNotNull(persistedWindowsCloudFilesPreservedRoot(preferences, accountId)) + assertFalse(Files.exists(currentRoot)) + assertFalse(Files.exists(legacyRoot)) + assertEquals("uncommitted edit", Files.readString(recoveryRoot.resolve("root-0/dirty.txt"))) + assertEquals("local-only file", Files.readString(recoveryRoot.resolve("root-1/local.txt"))) + } finally { + preferences.removeNode() + home.deleteRecursively() + } + } + + @Test + fun accountRemovalRetryClearsAnEmptyRecoveryFolderAfterInterruptedCleanup() { + val preferences = Preferences.userRoot().node("windows-account-data-retry-${UUID.randomUUID()}") + val home = Files.createTempDirectory("windows-account-data-retry-home").toFile() + val accountId = "f".repeat(64) + val currentRoot = desktopWindowsCloudFilesRoot(accountId, home).toPath() + val api = RecordingWindowsCloudFilesApi().apply { + inspectEntry = { WindowsCloudPlaceholderInspection(WindowsCloudPlaceholderEntryState.Dirty) } + } + try { + Files.createDirectories(currentRoot) + Files.writeString(currentRoot.resolve("draft.txt"), "local draft") + unregisterWindowsCloudFilesRootsForAccountRemoval(preferences, accountId, home) { api } + val recoveryRoot = assertNotNull(persistedWindowsCloudFilesPreservedRoot(preferences, accountId)) + recoveryRoot.toFile().listFiles().orEmpty().forEach(File::deleteRecursively) + + unregisterWindowsCloudFilesRootsForAccountRemoval(preferences, accountId, home) { api } + + assertFalse(Files.exists(recoveryRoot)) + assertNull(persistedWindowsCloudFilesPreservedRoot(preferences, accountId)) + } finally { + preferences.removeNode() + home.deleteRecursively() + } + } + + @Test + fun partialAccountRootCleanupRemainsJournaledUntilEveryRootIsUnregistered() = runBlocking { + val preferences = Preferences.userRoot().node("windows-account-cleanup-retry-${UUID.randomUUID()}") + val home = Files.createTempDirectory("windows-account-cleanup-retry-home").toFile() + val accountId = "9".repeat(64) + val currentRoot = desktopWindowsCloudFilesRoot(accountId, home).toPath() + val legacyRoot = desktopLegacyWindowsCloudFilesRoot(accountId, home).toPath() + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + val firstApi = RecordingWindowsCloudFilesApi().apply { failingRoot = legacyRoot } + try { + preferences.put(windowsCloudFilesRootPreferenceKey(accountId), currentRoot.toString()) + val removed = removeDesktopAccountBeforeSyncPairCleanup( + accountId = accountId, + prepareCleanup = journal::prepare, + commitCleanup = journal::commit, + clearCleanup = journal::clear, + accountOwnership = { DesktopAccountOwnership.Absent }, + removeCredential = { true }, + removeSyncPairs = { + unregisterWindowsCloudFilesRootsForAccountRemoval( + preferences = preferences, + accountId = accountId, + userHome = home, + apiFactory = { firstApi }, + ) + }, + recordCleanupFailure = {}, + ) + + assertTrue(removed) + assertEquals(listOf(currentRoot, legacyRoot), firstApi.unregisterAttempts) + assertEquals( + listOf(DesktopAccountSyncPairCleanup(accountId, DesktopAccountSyncPairCleanupPhase.Committed)), + journal.pending(), + ) + + val retryApi = RecordingWindowsCloudFilesApi() + retryDesktopAccountSyncPairCleanup( + cleanup = journal.pending().single(), + accountOwnership = { DesktopAccountOwnership.Absent }, + removeSyncPairs = { + unregisterWindowsCloudFilesRootsForAccountRemoval( + preferences = preferences, + accountId = accountId, + userHome = home, + apiFactory = { retryApi }, + ) + }, + clearCleanup = journal::clear, + ) + + assertEquals(listOf(currentRoot, legacyRoot), retryApi.unregisterAttempts) + assertTrue(journal.pending().isEmpty()) + } finally { + preferences.removeNode() + home.deleteRecursively() + } + } + @Test fun uninstallCanUseThePersistedRootAfterSessionMetadataIsGone() { val preferences = Preferences.userRoot().node("windows-uninstall-root-test-${UUID.randomUUID()}") @@ -410,13 +637,20 @@ class WindowsUninstallCleanupTest { } private class RecordingWindowsCloudFilesApi : WindowsCloudFilesApi { + val unregisterAttempts = mutableListOf() val unregisteredRoots = mutableListOf() val unregisteredRoot: Path? get() = unregisteredRoots.lastOrNull() var prerequisiteRoot: Path? = null var dependentRoot: Path? = null + var failingRoot: Path? = null + var inspectEntry: (Path) -> WindowsCloudPlaceholderInspection = { + WindowsCloudPlaceholderInspection(WindowsCloudPlaceholderEntryState.InSync) + } var closed = false override fun unregisterSyncRoot(root: Path) { + unregisterAttempts.add(root) + if (root == failingRoot) error("Synthetic Cloud Files unregister failure") if (root == dependentRoot && prerequisiteRoot !in unregisteredRoots) { error("The stable registration still points at another candidate root.") } @@ -437,7 +671,8 @@ class WindowsUninstallCleanupTest { override fun failPlaceholderFetch(info: WindowsCloudCallbackInfo) = unsupported() override fun acknowledgeDelete(info: WindowsCloudCallbackInfo, accepted: Boolean) = unsupported() override fun acknowledgeRename(info: WindowsCloudCallbackInfo, accepted: Boolean) = unsupported() - override fun placeholderState(path: Path): WindowsCloudPlaceholderState = unsupported() + override fun placeholderState(path: Path): WindowsCloudPlaceholderState = inspectEntry(path).placeholderState + override fun inspectPlaceholder(path: Path): WindowsCloudPlaceholderInspection = inspectEntry(path) override fun allocatedBytes(path: Path): Long = unsupported() override fun lastAccessedAtEpochMillis(path: Path): Long = unsupported() override fun isPinned(path: Path): Boolean = unsupported() diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.jvm.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.jvm.kt new file mode 100644 index 000000000..24bd319de --- /dev/null +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.jvm.kt @@ -0,0 +1,6 @@ +package dev.obiente.nextcloudnative.app + +internal actual fun dynamicNativeMemoryCacheMonitor(): Any = Any() + +internal actual fun withDynamicNativeMemoryCacheLock(monitor: Any, action: () -> T): T = + synchronized(monitor, action) 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/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanup.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanup.kt new file mode 100644 index 000000000..f0276f008 --- /dev/null +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanup.kt @@ -0,0 +1,76 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.attribute.BasicFileAttributes +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** Deletes only durable support artifacts whose descriptor proves ownership by one account. */ +class JvmSupportAccountStorageCleanup( + private val root: File, + private val directorySync: (File) -> Unit, + private val deleteFile: (File) -> Boolean = File::delete, +) { + private val json = Json { ignoreUnknownKeys = true } + + fun removeAccount(accountIdentity: String, inMemoryArchive: File?) { + require(accountIdentity.matches(ACCOUNT_IDENTITY)) + if (!root.exists()) return + check(root.isDirectory) { "Private support submission storage is unavailable." } + var changed = false + val pending = File(root, "pending.json") + if (pending.exists() && descriptorAccount(pending, MAX_PENDING_DESCRIPTOR_BYTES) == accountIdentity) { + val archiveName = descriptorString(pending, "archiveName") + archiveName?.let { name -> + require(name.matches(SUPPORT_ARCHIVE)) + deletePrivate(File(root, name)) + } + deleteDurably(pending) + changed = true + } + root.listFiles()?.filter { it.name.matches(COMPLETED_DESCRIPTOR) }?.forEach { descriptor -> + if (descriptorAccount(descriptor, MAX_COMPLETED_DESCRIPTOR_BYTES) == accountIdentity) { + deleteDurably(descriptor) + changed = true + } + } ?: throw IOException("Could not inspect private support submission storage.") + inMemoryArchive?.let { archive -> + require(archive.absoluteFile.normalize().parentFile == root.absoluteFile.normalize()) + changed = changed || archive.exists() + deletePrivate(archive) + } + if (changed) directorySync(root) + } + + private fun descriptorAccount(descriptor: File, maximumBytes: Long): String { + val attributes = Files.readAttributes(descriptor.toPath(), BasicFileAttributes::class.java) + require(attributes.isRegularFile && attributes.size() in 1..maximumBytes) + return descriptorString(descriptor, "originAccountIdentity") + ?.takeIf { it.matches(ACCOUNT_IDENTITY) } + ?: error("The private support recovery descriptor is invalid.") + } + + private fun descriptorString(descriptor: File, name: String): String? = + json.parseToJsonElement(descriptor.readText()).jsonObject[name]?.jsonPrimitive?.content + + private fun deleteDurably(file: File) { + Files.deleteIfExists(file.toPath()) + } + + private fun deletePrivate(file: File) { + check(!file.exists() || deleteFile(file) || !file.exists()) { + "Could not clear private support submission storage." + } + } + + private companion object { + val ACCOUNT_IDENTITY = Regex("[0-9a-f]{32}(?:[0-9a-f]{32})?") + val SUPPORT_ARCHIVE = Regex("support-[0-9a-f-]{36}\\.zip") + val COMPLETED_DESCRIPTOR = Regex("completed-[0-9a-f-]{36}\\.json") + const val MAX_PENDING_DESCRIPTOR_BYTES = 4L * 1024L * 1024L + const val MAX_COMPLETED_DESCRIPTOR_BYTES = 64L * 1024L + } +} diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index c89196f92..30681851a 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -52,7 +52,6 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okio.Buffer import okio.BufferedSink -import okio.buffer class JvmSupportIntake( private val diagnostics: AsyncJvmSupportDiagnostics, @@ -121,6 +120,7 @@ class JvmSupportIntake( private var completedSubmissions: List = emptyList() private var completedExpiryJob: Job? = null private var storageUnavailableMessage: String? = null + private val retiredAccountIdentities = mutableSetOf() init { require(descriptorCleanupRetryMillis > 0L) @@ -154,10 +154,41 @@ class JvmSupportIntake( fun setActiveAccountIdentity(accountIdentity: String?) { synchronized(lock) { activeAccountIdentity = accountIdentity?.takeIf(String::isNotBlank) + activeAccountIdentity?.let(retiredAccountIdentities::remove) refreshVisibleStateLocked() } } + suspend fun removeAccount(accountIdentity: String) = withContext(Dispatchers.IO) { + awaitInitialization() + require(accountIdentity.matches(SUPPORT_ACCOUNT_IDENTITY_PATTERN)) + check(synchronized(lock) { storageUnavailableMessage } == null) { + "Private support submission recovery is unavailable." + } + var call: Call? = null + var target: PendingSubmission? = null + synchronized(lock) { + retiredAccountIdentities += accountIdentity + target = pending?.takeIf { it.originAccountIdentity == accountIdentity } + if (target != null || actualStateAccountIdentity == accountIdentity) { + cancellationRequested.set(true) + call = activeCall.getAndSet(null) + } + } + call?.cancel() + synchronized(persistenceLock) { + JvmSupportAccountStorageCleanup(temporaryRoot, directorySync, privateFileDelete) + .removeAccount(accountIdentity, target?.archive) + synchronized(lock) { + if (pending === target) pending = null + completedSubmissions = completedSubmissions.filterNot { it.originAccountIdentity == accountIdentity } + refreshVisibleStateLocked() + } + } + check(synchronized(lock) { !operationActive.get() || actualStateAccountIdentity != accountIdentity }) { + "The private support operation is still stopping." + } + } internal suspend fun awaitInitialization() = initialized.await() suspend fun submit( @@ -1438,6 +1469,10 @@ class JvmSupportIntake( } private fun finishSubmitted(submission: PendingSubmission, receipt: SupportIntakeReceipt) { + if (synchronized(lock) { submission.originAccountIdentity in retiredAccountIdentities }) { + finishTerminal(submission) + return + } validateReceipt(receipt) val existingCompletion = synchronized(lock) { completedSubmissions.firstOrNull { completed -> @@ -1470,9 +1505,17 @@ class JvmSupportIntake( ) return } - synchronized(lock) { - completedSubmissions = completedSubmissions + completedSubmission - scheduleCompletedExpiryLocked() + val retained = synchronized(lock) { + if (submission.originAccountIdentity in retiredAccountIdentities) false else { + completedSubmissions = completedSubmissions + completedSubmission + scheduleCompletedExpiryLocked() + true + } + } + if (!retained) { + deleteCompletedDescriptorSafely(completedDescriptor(completedSubmission.recordId)) + finishTerminal(submission) + return } finishTerminal(submission) publishState(submittedStateFor(submission.originAccountIdentity), submission.originAccountIdentity) @@ -2376,32 +2419,6 @@ private fun syncPosixDirectoryEntry(directory: File) { } } -private fun Long.saturatingAdd(increment: Long): Long = - if (this > Long.MAX_VALUE - increment) Long.MAX_VALUE else this + increment - -private class ProgressRequestBody( - private val delegate: RequestBody, - private val onProgress: (Long, Long) -> Unit, -) : RequestBody() { - override fun contentType() = delegate.contentType() - override fun contentLength(): Long = delegate.contentLength() - - override fun writeTo(sink: BufferedSink) { - val total = contentLength() - val forwarding = object : okio.ForwardingSink(sink) { - var uploaded = 0L - override fun write(source: okio.Buffer, byteCount: Long) { - super.write(source, byteCount) - uploaded += byteCount - onProgress(uploaded, total) - } - } - val buffered = forwarding.buffer() - delegate.writeTo(buffered) - buffered.flush() - } -} - internal class OneShotSupportMessageRequestBody( private val content: ByteArray, ) : RequestBody() { diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/ProgressRequestBody.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/ProgressRequestBody.kt new file mode 100644 index 000000000..9d174b9ba --- /dev/null +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/ProgressRequestBody.kt @@ -0,0 +1,31 @@ +package dev.obiente.nextcloudnative.app + +import okhttp3.RequestBody +import okio.BufferedSink +import okio.buffer + +internal class ProgressRequestBody( + private val delegate: RequestBody, + private val onProgress: (Long, Long) -> Unit, +) : RequestBody() { + override fun contentType() = delegate.contentType() + override fun contentLength(): Long = delegate.contentLength() + + override fun writeTo(sink: BufferedSink) { + val total = contentLength() + val forwarding = object : okio.ForwardingSink(sink) { + var uploaded = 0L + override fun write(source: okio.Buffer, byteCount: Long) { + super.write(source, byteCount) + uploaded += byteCount + onProgress(uploaded, total) + } + } + val buffered = forwarding.buffer() + delegate.writeTo(buffered) + buffered.flush() + } +} + +internal fun Long.saturatingAdd(increment: Long): Long = + if (this > Long.MAX_VALUE - increment) Long.MAX_VALUE else this + increment diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 0df95708e..e3aefd977 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -12,7 +12,10 @@ "settings.gradle.kts", "tools/marketing-capture-inputs.txt", "ui/build.gradle.kts", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityFilters.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityHistoryPresentation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivitySemantics.kt", @@ -42,6 +45,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChoresWorkspaceContext.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardOcsResponse.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusPresentation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardWidgetsAcquisition.kt", @@ -72,8 +76,10 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt", @@ -202,6 +208,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", @@ -427,7 +434,10 @@ "settings.gradle.kts": "0acbe4b907815189abfedb2256c8659558e5a7e6995a3681a2bdfb05e335fd1a", "tools/marketing-capture-inputs.txt": "3c96e83e1ba2d715b1cda9cedf036fc97b78c3ca63b7fc930325ed536940c1f3", "ui/build.gradle.kts": "2ecda1dd8c3ea78d3249c6c562cf8338a9a89f56dbd91d2db1af6b27eee8fb72", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt": "867c3ccb58870957e2c7fc9723b0e44d819194d93dfe806857c3d0e1d71e3662", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt": "36c9267a53d9f6f59fc38862d8a073fca6b6c3c1275730ddc65d138d5e09ce82", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt": "7b93d571553fa8c364681f172edecde3109b45db9a4ff6f9f6fb12f8f6280a0e", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt": "11e6f0eab522b4fc799a67bf6e3881f96a62f458a9fff07648bacb5e3eaba9d5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityFilters.kt": "569265895b9442292c043f5ecbe2cdd55a9da6761a77b19b5f81fff34e999a10", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityHistoryPresentation.kt": "88f25cd079f7d7fc1553f8969818740816e2788542b70377ea11f64201c44474", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivitySemantics.kt": "625e281281f28e5a2d0497626efc882f4fb2b5e778fcbdcac34425c853f83730", @@ -453,12 +463,13 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarScheduleViews.kt": "f37848200d712829405848db3606b5bec49c1421cc00de6144403f0f390ef5ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarWorkspaceNotice.kt": "b5c0cbbd46371eac5835758c836b41c7878d55f2e5da8f29679dde3aa210bf33", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarWorkspacePresentation.kt": "cd4638b118da879acfa711eed9bfcd643df4a847774925e8adae20bdd5c90b3b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "84a6e5ca035ac7796427c25f4b4ed1162832c4febbc44606dc06413cc2e33b58", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "fbefd12e3574060cfc6d7f1abb27f7173e9f8eb2fa8de3c80f85e650757d6d4f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChoresWorkspaceContext.kt": "f22d2bfc408f52eb61f3587e9bcf79ba7cb4c7c6afd1717f04f35731f1906391", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardOcsResponse.kt": "25780cc210c8e2f56fb2e45ab23a29a9030f6350a9a1ea29412650106a0d111f", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt": "427dd6352a5958a5fd31b9b8ed8cd0f8d1eac1b25800ea33b8bad171125e8f5e", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt": "1a0a6b50c2b1f8b528d637520cb95acab42d1a6f4edde4fd47ced7a47bd4ddad", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt": "9ae6444a94a51c711bb69a63705b596569b90f4f728794afe97f18bab6f75c1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusPresentation.kt": "96eb3aa478be8932e695e2dfe2067cc7b8dccf5ffa6cc1370f2db27b8119e0ff", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt": "cf84b77e3d239161c1c84eae7ae949caeb5206c3f09346e61ead6a3ec99533c1", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt": "d257b5d8fe04300bc095a16fedc2cceeb27515795d1156a19d3f3c46d5b215b5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardWidgetsAcquisition.kt": "7b646f0b992dddf9e16abbb497fc3832e284401d65fcc556a771aef00fc88a95", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckAsyncSafety.kt": "f6d939c1d1cf41b2421aad6906e9de7fb64800b146906312210472eebd22a93e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckCardDraftPersistence.kt": "f8aa5c05244022efd2b2c9cf356275f1a96812ebd5d44ec6a501d39c90413a0b", @@ -470,14 +481,14 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckMutationSafety.kt": "224582721737a29428ef2dca64fe489491c158765f5dd7f2eb66b3ce5d2ff5b5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeActions.kt": "5108b8bb8b4573e2711f83ba444418b1d2549e2b58cac1fa941559eb94b2b1fc", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeFoundation.kt": "0ffee64690d6632b8ecd35761f9018205090f9e5256e6083e6a6d9a3e02e34c0", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt": "e1bdd14558456fd38504ae28b0da2b752f29d48ecc2a79fef07cbc987e841edc", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt": "275e63824e802c3df6bc749dfed9a6cd993ffe542ac0814919b186c707a29cac", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCalendarCanvas.kt": "4b87e50bb0133438c3e22c4b7ff39ec1fa0db0c7bb14ed227e82d8f18eeb2c18", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCalendarPanes.kt": "cade401c689544fb987d7bafd4f82ffdeb8fa51693f1c18f7382af2e3eabc252", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCalendarWorkspace.kt": "03c2b85a2a7c86b7506b956c7ab079a251ae862a9d68411fc2d3bb327b48bce5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DocumentPreview.kt": "a9a8743dd7a381504282cc6ddc68034425024ccc1ae51667da9734bbfc7b1a79", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DurableMutationRecoveryDialog.kt": "e720eadb477a347762cd1894285788ac9f6820972431fe0a1953d01955667bfe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt": "2b7ef2d18b4a23615686ced0b7c9c621c58dc5edd0202104d0ca55b1ebf61d81", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "2744c7ce9f156ad7a6f9064e8d04e8790b7b66b599e33925f0288fbef73ecc1d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "cff6ba11283705120375452d6d539c20581f4dd0115dd3d07049eb965242b019", @@ -486,9 +497,11 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt": "468f9bf41ea353dab6f816d333c3a6179d8d8e1f01f98f8eaf0890d5c0d381d6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt": "e0e7b54e02869eabe797c1af6c77a78b0f7eaf7cb609c3cce8d35a8a710e6511", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "21a9dbe66b1ad067c10df887bd97034b8d13b6e9dfe14cfced89b02f53575ff4", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "2cb445b752faa33f051cfd618bfac3c9f39fc20abce3176f782fe032aef4a98c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "09debe6409b538c3944a63b1e2c5022dc3e0584d746ef288183bfe0983fb1f7b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt": "df5c2b3ef90a8a7d0ea02d6587b563ebde8e84d471073f718a7d901f2c0a65fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt": "fc7f8ac5ffb094da9d9a9f05bb2d072b13ff9da9281708f247b546258e0fc2e2", @@ -539,11 +552,11 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceToolbar.kt": "644e576f094b73a78dae541d15621edf86e75c0408553f6319d4fcefe55ed3d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GenericNativeFallback.kt": "768f8704a9999b45d06e39c61529bd9af370315ff1f33d7eb4f8b463aaaedbbf", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarComponents.kt": "84cca2c35c707d0c7915595d3fd7ebc463f40a31b59f44117210cb82b61716c2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "86d8e7b52c78d6008a84fc1ba9c901659f1b657405358c4e5d9f027361373b8a", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "404d55e3cb691609cdbde00eaaddf230ec4e1626c342512b935e7c383630c8da", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactDetailDialog.kt": "ab6826a14ac9fbf62677bed2f6e93ef0fb1a57f1f074bb1a9e79fef212f715b3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "096a154e9d6169da4cc82c0f87d2c572a1a5e97e396cae9eb739d05ce05b0d82", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "1d206c76800e92662b8980a41cd7792684e4c8b8ef94d0119109d5420da343c8", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "c0b42fa450b8748281c7208385f7257d286bc1f842588bc737f3c296dd4ec1d9", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "5ec7d1326c216a1f4af813bd5d1132063654cada621ed5ae54805ac5c63953e1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt": "88f84a03a3c2d95b130601d5aac62fad4b4e1ed559c7851d7d47a3b44d1d2bc9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavMutation.kt": "f32d31bb3564ef2e4a565f840c0db227e2632f6e15251a29151f233c0b25f718", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavResourceStatus.kt": "816453d49fb983cf4eca6c93335d102570f037a6e064188f89f9b734ec1ebea0", @@ -553,12 +566,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksDav.kt": "83fe24e21779ca5a50ab2ae839692bb7d96de63b6c997ee941ca925ac6fe4546", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksLoading.kt": "7ad2ff8b58245db2b678fc79969c70327dccbe07e8b98a5b83fcfb8ec0b45fd1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksRecovery.kt": "475acdbe17f41fa2f1aecbeee73f1b5ba124e22c2fbc75368b8fbf0c575ec43b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt": "ca9e58778d5c68eeb6ce7f105b4e74226b3820135de721994819173e1558cf02", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt": "2a8a18f9a1a933505f775763f8aa1b7dbd2d615eda15c6b2d82ea32385252ca1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksState.kt": "ccd6e1f9b2cf2931f5431380bc2749b5f68cbc5781e7de3d0f34621005caca6e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceActions.kt": "211cc20d9e7f60cc9337591acfad3e63653bff3c97aa6a693b6f647b976f0dfb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceHeader.kt": "0cacd1a4887bc8c830a1667e445bc11339a4c3b888feebaee8625ddc708965ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayout.kt": "f878809aef689f4f47311225e487efe8fb411be1180264b1e69be74bdf1ab5b6", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt": "3c89436210b7c93d5970cf65b97ad5893adf6b12d485ab160831c3946770bfc2", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt": "51a29eb6313c071412229f73a36c44f888c4f1fb7fdce0af1125d449ec45b26d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ImageDecodeBounds.kt": "6a218194682e175396d57b4d4a3fc8aea2c0b39a11212daa61a4d60bc8ed42a5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingFileShare.kt": "2ffac5d12aea372662848799769a9a4f2c887fa31bc2def8647a5773b89d2e35", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingShareUploadScreen.kt": "7730c0b937b79a85cd5b1d9b302783f561a314b7ca022d51bf836a51cefa583d", @@ -611,36 +624,37 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckEditorDialogs.kt": "21bb2800df245b47e59713522f65c8af927869caaa083ccfca60d3333637441d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckInteractionModels.kt": "8d0919f875d94879187a9477a4c8ab7307a6b36572116c2d60daf83a1311a4d5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt": "d0db668ffa824c9ea3e5e08e12764f6138a53866f8e9572df1beee4e3a74e39c", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt": "0f38a3f9873fcfd91ee304d6afbe6a875115eb747591e123388d5c622020a038", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt": "d0200cfa7b9905b3554b71c7c4f982d9b5024246ee5ec08c9b3e7c74d42e0eea", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaCollectionActions.kt": "18f039c4e004fcd267c494c4f50cd467f445d4a9e74338da1f7d56d8e46d8b3d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaCollectionBrowser.kt": "4b2af88cd6ad9282fc7088e42988fc1c80237761cea3d3f5d76c6d10c47ea4a1", "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": "99315e08ee2d9abbdcee0527abd61e614201a2ac81e39c180c34f2ff23480afe", - "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/NextcloudDocumentPreview.kt": "b9b73c66436a686381072162c9656c5158af7ca40c38311ec15193d8a652f145", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileListing.kt": "1f3de4f31d44d3ead139686d3fbcfbd27ccb520a9dab06ccc1d3e582ee25fb71", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt": "0704f87e909bbb9950e02b9ec21e3cab58e8a5438f2b900c94e296527f35c157", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt": "e2d786bc7e80199aad9507edd2a0d93d508ca621e093d7b87a131993ab90ed14", "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": "923cae9286489364664dfbf28bdf7e7c12ce1cc8e3a7d1a1a51a0397950cbcd2", - "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/NextcloudNativeApp.kt": "63a26e6b034604a525a358d8e422a3870d9fc3dc6a88c2cdf97a095259236bbf", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "29b0b80824eeb2d154f52a9eacc10bd4d7068b7eca3bd9427aec67903bf6ee0d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", "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": "3685bbab002ab692fba9c0ad4309ff5da3e81be2a3576ede3185788b540e7f95", "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", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudStatusMessages.kt": "03d7d79e38bdd9ac2e6a90e7172d2e9b7ea361df4d3e3d1a75d64584adc590ec", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesApi.kt": "e3c685b02592ea791b0c9a478a098fc5bf428893aa4764110c793a78040f341e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesFolderOperations.kt": "db6a04cfcd3b25b17c996cdc6dba21847d14f5970e06e6c24a3e2c85139dbe1d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt": "48057ebf53e45a042c4283aaff9a2ca5c3bb46fff3356e962540409f8c7b3b04", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt": "2ddc486b9a948ed1ff5de48d64471df54d55f2bdb055e2446da79b5fb656cdf3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeEditRevalidation.kt": "5f35e2efb61c541546a6c3d206d7d018929fdaca4cc2ba8d7b16c42c174892ae", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt": "2cc0f0f28dee9f74ed88633a571dd298e596e859614dc2b9496bd515d57ccf96", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt": "87714aad1d9cf4f541cb90a2eff5c30a41b696005cc6e9b4c3a60991b1838e75", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspaceLocation.kt": "b6e5e87939a7bf7c87ef82c2d0b11870128035b7efd9d75c784bff16c4e78787", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspaceScreen.kt": "117ae6c25d484bea042da87e1940ba906ed044659fb91297f0e9491868987fe1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PeopleActionPlanning.kt": "d73e2a4557b844e73ad27c43ea38695310d33c59e7dac8c51b140d7f3aa148f8", @@ -666,7 +680,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PlatformEmbeddedWebApp.kt": "a069dce940071b07df4cb3773d0c29a5b8c1be1785206d2ff9a7c17af33d911a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PlatformImage.kt": "5c1ebb3dc168c0a53d6db05c54b7329a571dec808aef3de7fba1a52bd93b2d3d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PlatformVideoPlayback.kt": "8f103ac182fdca3c78f1ffe7a3b15f06f05abb3be173747255fe4a9c84726758", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt": "a30e7fc55f40b13883d2ceb56de0ca72b67e8fb1489d2d261eb66b0a05daef48", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt": "5839b1dfc65ecefde01d9b851219426a734286966c150d6ec8dcce967bedc01e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ProjectNewsAndUpdates.kt": "2ed4cdbd06b23ad2240f5bf245f24ad582d450b716604f6682d4fd785b92f809", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PublicContentDigest.kt": "175cbd645bb72bbf59085e5f749835397f29d6a39a289015fb5b7071d5a0688d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RawPhotoPreview.kt": "73c49576766e266ac5b29e072db6b2e987c2a11107460f7a8019fb8ef4f923ff", @@ -685,7 +699,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportReplyRecovery.kt": "e9eda3fc329c210d25706ee457ad499ac18767417ee974cbb9fd105c33f3ec98", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportRequestsEmptyState.kt": "397b6752a3fb99117574dfa83a861f906e174d50ab9ea7b94fdebc9b9ce624f3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportRuntimeDiagnostics.kt": "02f2a6c2d54335fd166aa9825c1521ec00faa60fda74494ec6728ea9460adf3d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt": "b02224640b3abcac0f37e1dc2c1ab63da5b2d17acffcb5be7113ccebe876ad08", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt": "ec4c0872d30e09602443d0172707fb4251bd92274eb3145f20f99dadc4f15a38", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftState.kt": "deeb8def69d225ba9663e4f712be9e2b461c67bf738a4d391a80b78af218e99e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SystemTags.kt": "452abd9589c627dc7ae9dae03265a3bfdb837809ba0ca82da0698ef73db64332", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TalkAttachmentReader.kt": "aa8c5adab59da93a84fb86ade68e12cb8455a23d7c847aeb8411c68e49551925",