From 49d595bb80a1cdf8dfa8283ce74e3c7d3045c967 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 03:49:48 +0200 Subject: [PATCH 01/12] fix(accounts): retire dynamic memory on removal --- .../AndroidAccountOwnedStateCleanup.kt | 46 ++++- .../AndroidDynamicApiCachePolicyTest.kt | 44 ++++- .../app/DesktopAccountRemoval.kt | 3 + .../app/DesktopNextcloudServices.kt | 17 +- .../DesktopPendingDynamicMutationCleanup.kt | 7 +- .../app/DesktopAccountOperationGuardTest.kt | 173 +++++++++++++++++- ...ktopPendingDynamicMutationDirectoryTest.kt | 53 +++++- 7 files changed, 319 insertions(+), 24 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index f81ad074e..db0798228 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -1,8 +1,9 @@ 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.DynamicApiRequestCoalescer +import dev.obiente.nextcloudnative.app.DynamicNativeMemoryAccountLifecycle import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.durableMutationAccountScope import dev.obiente.nextcloudnative.app.removeAndroidHomeWorkspaceAccountPreferences @@ -43,7 +44,14 @@ internal class AndroidAccountOwnedStateCleanup( cacheIdentity, clearPreviewAccount, listOf( - { fenceAndroidDynamicApiStateForRemoval(cacheIdentity, dynamicApiState.coalescer, dynamicApiState.cache) }, + { + fenceAndroidDynamicApiStateForRemoval( + cacheIdentity, + dynamicApiState.coalescer, + dynamicApiState.cache, + session.accountId.storageKey, + ) + }, { dynamicDiscoveryCache.retireAccount(session.accountId.storageKey, cacheIdentity) }, { removeSupportAccount(accountIdentity) }, { @@ -83,8 +91,15 @@ internal class AndroidAccountOwnedStateCleanup( clearPreviewAccount, listOf( { - previewCacheIdentity?.let { identity -> - fenceAndroidDynamicApiStateForRemoval(identity, dynamicApiState.coalescer, dynamicApiState.cache) + if (previewCacheIdentity == null) { + DynamicNativeMemoryAccountLifecycle.retireAccount(session.accountId.storageKey) + } else { + fenceAndroidDynamicApiStateForRemoval( + previewCacheIdentity, + dynamicApiState.coalescer, + dynamicApiState.cache, + session.accountId.storageKey, + ) } }, { dynamicDiscoveryCache.retireAccount(session.accountId.storageKey, previewCacheIdentity) }, @@ -126,8 +141,15 @@ internal class AndroidAccountOwnedStateCleanup( clearPreviewAccount, listOf( { - previewCacheIdentity?.let { identity -> - fenceAndroidDynamicApiStateForRemoval(identity, dynamicApiState.coalescer, dynamicApiState.cache) + if (previewCacheIdentity == null) { + DynamicNativeMemoryAccountLifecycle.retireAccount(accountStorageKey) + } else { + fenceAndroidDynamicApiStateForRemoval( + previewCacheIdentity, + dynamicApiState.coalescer, + dynamicApiState.cache, + accountStorageKey, + ) } }, { dynamicDiscoveryCache.retireAccount(accountStorageKey, previewCacheIdentity) }, @@ -156,20 +178,28 @@ internal class AndroidAccountOwnedStateCleanup( ), ) } + } internal suspend fun clearAndroidDynamicApiState( accountIdentity: String, coalescer: DynamicApiRequestCoalescer, cache: DynamicApiResponseCache, -) = coalescer.fenceAccount(accountIdentity) { cache.invalidateAccount(accountIdentity) } + accountStorageKey: String? = null, + retireMemoryAccount: (String) -> Unit = DynamicNativeMemoryAccountLifecycle::retireAccount, +) = coalescer.fenceAccount(accountIdentity) { + accountStorageKey?.let(retireMemoryAccount) + cache.invalidateAccount(accountIdentity) +} internal suspend fun fenceAndroidDynamicApiStateForRemoval( accountIdentity: String, coalescer: DynamicApiRequestCoalescer, cache: DynamicApiResponseCache, + accountStorageKey: String? = null, + retireMemoryAccount: (String) -> Unit = DynamicNativeMemoryAccountLifecycle::retireAccount, ) = withContext(NonCancellable) { - clearAndroidDynamicApiState(accountIdentity, coalescer, cache) + clearAndroidDynamicApiState(accountIdentity, coalescer, cache, accountStorageKey, retireMemoryAccount) } internal suspend fun runAndroidAccountOwnedStateCleanups( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt index 341f76c41..a2bb16447 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt @@ -3,6 +3,7 @@ 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.app.NextcloudSession import dev.obiente.nextcloudnative.contracts.CachedDynamicApiResponse import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache import java.nio.file.Files @@ -16,6 +17,7 @@ import kotlinx.coroutines.supervisorScope import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails +import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertSame @@ -103,6 +105,8 @@ class AndroidDynamicApiCachePolicyTest { val requestIdentity = "GET /dashboard/widgets" val cache = DynamicApiResponseCache(root) val coalescer = DynamicApiRequestCoalescer() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + var retiredStorageKey: String? = null val started = CompletableDeferred() val release = CompletableDeferred() val response = CachedDynamicApiResponse(200, "private".encodeToByteArray(), null, null) @@ -116,11 +120,18 @@ class AndroidDynamicApiCachePolicyTest { } started.await() - clearAndroidDynamicApiState(accountId, coalescer, cache) + clearAndroidDynamicApiState( + accountId, + coalescer, + cache, + session.accountId.storageKey, + { retiredStorageKey = it }, + ) release.complete(Unit) assertFails { read.await() } assertNull(cache.load(accountId, requestIdentity, 1_024)) + assertEquals(session.accountId.storageKey, retiredStorageKey) } finally { root.deleteRecursively() } @@ -156,6 +167,37 @@ class AndroidDynamicApiCachePolicyTest { } } + @Test + fun `Android memory retirement survives a rejected disk cache purge`() = runBlocking { + val root = Files.createTempDirectory("android-dynamic-cache-rejected-cleanup-").toFile() + try { + val accountId = "e".repeat(64) + val accountDirectory = root.resolve(accountId).apply { mkdirs() } + accountDirectory.resolve("unsafe-entry").mkdir() + val coalescer = DynamicApiRequestCoalescer() + val cache = DynamicApiResponseCache(root) + val accountStorageKey = "f".repeat(64) + var retiredStorageKey: String? = null + + assertFailsWith { + clearAndroidDynamicApiState( + accountId, + coalescer, + cache, + accountStorageKey, + { retiredStorageKey = it }, + ) + } + + assertEquals(accountStorageKey, retiredStorageKey) + assertFailsWith { + coalescer.execute(accountId, "GET /dashboard/widgets", load = { error("must remain fenced") }) + } + } finally { + root.deleteRecursively() + } + } + @Test fun `second Android service cannot commit a GET that crossed account removal`() = runBlocking { supervisorScope { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 9de08d2fe..5f531b2f7 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -188,6 +188,9 @@ internal class DesktopAccountSyncPairCleanupJournal( require(legacyAccountScopeDigest == null || legacyAccountScopeDigest.matches(ACCOUNT_STORAGE_KEY_PATTERN)) { "The desktop legacy workspace cleanup identity is invalid." } + require(accountStorageKey == null || durableMutationAccountScope != null) { + "The desktop account cleanup requires its durable mutation identity." + } val key = cleanupKey(accountId) val current = preferences.get(key, null)?.let { decode(accountId, it) } check(current == null || current.phase != DesktopAccountSyncPairCleanupPhase.Unknown) { 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 b32a2cddc..ba47bb80b 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -826,7 +826,7 @@ class DesktopNextcloudServices( accountId: String, cache: DesktopVirtualRangeCache, ) { - if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId, session.accountId.storageKey)) return if (sessionClearing) return if (synchronized(virtualFileProviderLock) { accountId in virtualFileCacheTierMutations }) return if (cache.hasUnavailableRetainedOverflowRecords(accountId, relativePath)) return @@ -1345,7 +1345,7 @@ class DesktopNextcloudServices( if (!isLinuxDesktop()) return session ?: return val accountId = desktopFileCacheAccountId(session) - if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId, session.accountId.storageKey)) return val cache = virtualRangeCache(accountId) val kept = cache.loadFolderRetention(accountId).rules.filter { rule -> rule.retention == VirtualFolderRetention.KeepOnDevice @@ -1681,7 +1681,9 @@ class DesktopNextcloudServices( } val accountId = desktopFileCacheAccountId(session) val cacheProducer = fileReadCache.producer(accountId) - if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return unknownCleanupStateRejection() + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId, session.accountId.storageKey)) { + return unknownCleanupStateRejection() + } runCatching(linuxProviderCleanup::retry).exceptionOrNull()?.let { return VirtualFileStorageActionResult.Rejected(it.message ?: "The earlier Linux mount is still active.") } @@ -2707,7 +2709,7 @@ class DesktopNextcloudServices( ?: return@syncRun FileSyncCenterActionResult.Rejected("Sign in before syncing folders.") val accountId = desktopFileCacheAccountId(session) diagnosticAccountId = accountId - if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) { + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId, session.accountId.storageKey)) { return@syncRun FileSyncCenterActionResult.Rejected(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE) } val userId = runCatching { loadServerInfo(session).userId }.getOrElse { failure -> @@ -3844,7 +3846,12 @@ class DesktopNextcloudServices( private suspend fun removeDesktopAccountOwnedState(cleanup: DesktopAccountSyncPairCleanup) { val accountId = cleanup.accountId dynamicDiscoveryCache.retireAccount(cleanup.accountStorageKey, accountId) - clearDesktopDynamicApiState(accountId, dynamicApiRequestCoalescer, dynamicApiReadCache) + clearDesktopDynamicApiState( + accountId, + dynamicApiRequestCoalescer, + dynamicApiReadCache, + cleanup.accountStorageKey, + ) supportIntake.removeAccount(accountId) removeDesktopPendingDynamicMutations(pendingDynamicMutationDirectory, accountId) cleanup.durableMutationAccountScope?.let(durableMutationRecovery::removeAccount) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt index 81c06452b..a76825ebe 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt @@ -17,7 +17,12 @@ internal suspend fun clearDesktopDynamicApiState( accountId: String, coalescer: DynamicApiRequestCoalescer, cache: DynamicApiResponseCache, -) = coalescer.fenceAccount(accountId) { cache.invalidateAccount(accountId) } + accountStorageKey: String? = null, + memoryCache: DynamicNativeMemoryCache = sharedDynamicNativeMemoryCache, +) = coalescer.fenceAccount(accountId) { + accountStorageKey?.let(memoryCache::retireAccount) + cache.invalidateAccount(accountId) +} internal fun desktopPendingDynamicMutationDirectory( osName: String = System.getProperty("os.name").orEmpty(), diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 35409227c..ba3b7db14 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -4,6 +4,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred @@ -63,6 +64,10 @@ class DesktopAccountOperationGuardTest { val finishPersistence = CompletableDeferred() val events = mutableListOf() val session = NextcloudSession("https://cloud.example.test", "alice", "saved-password") + val memoryCache = DynamicNativeMemoryCache() + val screenKey = dynamicScreenCacheKey(session, "mail", "messages", null, emptyMap()) + memoryCache.retireAccount(session.accountId.storageKey) + var activatedProducer: DynamicNativeMemoryCacheProducer? = null val save = async { guard.persistSessionAndActivateDynamicReads( persist = { @@ -70,12 +75,19 @@ class DesktopAccountOperationGuardTest { finishPersistence.await() session }, - activate = { events += "activate" }, + activate = { + memoryCache.activateAccount(it.accountId.storageKey) + activatedProducer = memoryCache.producer(screenKey) + events += "activate" + }, ) } persistenceEntered.await() val removal = async { - guard.serialize { events += "fence" } + guard.serialize { + memoryCache.retireAccount(session.accountId.storageKey) + events += "fence" + } } yield() @@ -83,8 +95,14 @@ class DesktopAccountOperationGuardTest { finishPersistence.complete(Unit) assertEquals(session, save.await()) removal.await() + memoryCache.storeScreen( + screenKey, + DynamicScreenSnapshot(emptyList(), emptyMap()), + requireNotNull(activatedProducer), + ) assertEquals(listOf("activate", "fence"), events) + assertNull(memoryCache.screen(screenKey)) } @Test @@ -741,6 +759,7 @@ class DesktopAccountOperationGuardTest { events += "remove-pairs" error("synthetic pair cleanup failure") }, + retireCommittedAccount = { events += "retire-memory" }, recordCleanupFailure = { events += "diagnose-cleanup" }, ) @@ -749,6 +768,7 @@ class DesktopAccountOperationGuardTest { listOf( "prepare-cleanup", "remove-credential", + "retire-memory", "commit-cleanup", "remove-pairs", "diagnose-cleanup", @@ -776,12 +796,13 @@ class DesktopAccountOperationGuardTest { events += "remove-pairs" throw CancellationException("pair cleanup owner stopped") }, + retireCommittedAccount = { events += "retire-memory" }, recordCleanupFailure = { events += "diagnose-cleanup" }, ) } assertEquals( - listOf("prepare-cleanup", "remove-credential", "commit-cleanup", "remove-pairs"), + listOf("prepare-cleanup", "remove-credential", "retire-memory", "commit-cleanup", "remove-pairs"), events, ) } @@ -802,11 +823,61 @@ class DesktopAccountOperationGuardTest { error("synthetic post-commit credential cleanup failure") }, removeSyncPairs = { events += "remove-pairs" }, + retireCommittedAccount = { events += "retire-memory" }, recordCleanupFailure = { events += "diagnose-cleanup" }, ) } - assertEquals(listOf("prepare-cleanup", "remove-credential", "commit-cleanup"), events) + assertEquals(listOf("prepare-cleanup", "remove-credential", "retire-memory", "commit-cleanup"), events) + } + + @Test + fun committedRemovalRetiresMemoryBeforeCleanupJournalCommitFailure() = runBlocking { + val events = mutableListOf() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val memoryCache = DynamicNativeMemoryCache() + val screenKey = dynamicScreenCacheKey(session, "mail", "messages", null, emptyMap()) + memoryCache.storeScreen( + screenKey, + DynamicScreenSnapshot(emptyList(), emptyMap()), + requireNotNull(memoryCache.producer(screenKey)), + ) + + assertTrue( + removeDesktopAccountBeforeSyncPairCleanup( + accountId = CLEANUP_ACCOUNT_ID, + prepareCleanup = { _, _, _ -> events += "prepare-cleanup" }, + commitCleanup = { + events += "commit-cleanup" + error("synthetic cleanup journal commit failure") + }, + clearCleanup = { events += "clear-cleanup" }, + accountOwnership = { DesktopAccountOwnership.Absent }, + removeCredential = { + events += "remove-credential" + true + }, + removeSyncPairs = { events += "remove-pairs" }, + retireCommittedAccount = { + events += "retire-memory" + memoryCache.retireAccount(session.accountId.storageKey) + }, + recordCleanupFailure = { events += "diagnose-cleanup" }, + ), + ) + + assertEquals( + listOf( + "prepare-cleanup", + "remove-credential", + "retire-memory", + "commit-cleanup", + "diagnose-cleanup", + ), + events, + ) + assertNull(memoryCache.screen(screenKey)) + assertNull(memoryCache.producer(screenKey)) } @Test @@ -856,7 +927,7 @@ class DesktopAccountOperationGuardTest { } @Test - fun futureCleanupEntryIsPreservedWithoutHidingValidTombstonesOrBlockingNewRemoval() { + fun futureCleanupEntryFailsActivationClosedWithoutBlockingNewRemoval() { val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") val malformedAccountId = "1".repeat(64) val validAccountId = "2".repeat(64) @@ -885,7 +956,7 @@ class DesktopAccountOperationGuardTest { assertEquals("v2|committed|$MUTATION_SCOPE", preferences.get("fsac.$validAccountId", null)) assertTrue(journal.blocksAccountActivation(malformedAccountId)) assertFailsWith { requireDesktopAccountActivationAllowed(true) } - assertFalse(journal.blocksAccountActivation(validAccountId)) + assertTrue(journal.blocksAccountActivation(validAccountId)) assertEquals(1, malformedCount) journal.prepare(newAccountId) @@ -894,7 +965,7 @@ class DesktopAccountOperationGuardTest { setOf(malformedAccountId, validAccountId, newAccountId), journal.pending().mapTo(linkedSetOf(), DesktopAccountSyncPairCleanup::accountId), ) - assertFalse(journal.blocksAccountActivation(newAccountId)) + assertTrue(journal.blocksAccountActivation(newAccountId)) assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) assertFailsWith { journal.prepare(malformedAccountId) } assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) @@ -954,6 +1025,28 @@ class DesktopAccountOperationGuardTest { assertEquals(futureValue, preferences.get("fsac.$oldCacheIdentity", null)) assertTrue(journal.blocksAccountActivation(canonicalCacheIdentity, ACCOUNT_STORAGE_KEY)) + @Test + fun v3CleanupBlocksCanonicalEquivalentAccountActivationByStorageKey() { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val original = NextcloudSession("https://cloud.example.test", "alice", "password") + val equivalent = original.copy(serverUrl = "https://CLOUD.EXAMPLE.TEST:443/") + val originalProviderId = desktopFileCacheAccountId(original) + val equivalentProviderId = desktopFileCacheAccountId(equivalent) + try { + assertEquals(original.accountId, equivalent.accountId) + assertNotEquals(originalProviderId, equivalentProviderId) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + journal.prepare(originalProviderId, MUTATION_SCOPE, original.accountId.storageKey) + + assertTrue( + journal.blocksAccountActivation( + equivalentProviderId, + equivalent.accountId.storageKey, + ), + ) + assertFailsWith { + journal.requireAccountActivationAllowed(equivalent.accountRecord()) + } } finally { preferences.removeNode() } @@ -1009,6 +1102,32 @@ class DesktopAccountOperationGuardTest { assertEquals(0, publications) } finally { preferences.removeNode() + @Test + fun cleanupWithoutStorageKeyBlocksCanonicalEquivalentActivationUntilRecovery() { + val original = NextcloudSession("https://cloud.example.test", "alice", "password") + val equivalent = original.copy(serverUrl = "https://CLOUD.EXAMPLE.TEST:443/") + val originalProviderId = desktopFileCacheAccountId(original) + val equivalentProviderId = desktopFileCacheAccountId(equivalent) + assertNotEquals(originalProviderId, equivalentProviderId) + + listOf("prepared", "v2|prepared|$MUTATION_SCOPE").forEach { encoded -> + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + try { + preferences.put("fsac.$originalProviderId", encoded) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + + assertTrue( + journal.blocksAccountActivation( + equivalentProviderId, + equivalent.accountId.storageKey, + ), + ) + assertFailsWith { + journal.requireAccountActivationAllowed(equivalent.accountRecord()) + } + } finally { + preferences.removeNode() + } } } @@ -1080,18 +1199,28 @@ class DesktopAccountOperationGuardTest { @Test fun preparedCleanupFromAnAbortedRemovalPreservesExistingPairs() = runBlocking { val events = mutableListOf() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val memoryCache = DynamicNativeMemoryCache() + memoryCache.retireAccount(session.accountId.storageKey) retryDesktopAccountSyncPairCleanup( cleanup = DesktopAccountSyncPairCleanup( CLEANUP_ACCOUNT_ID, DesktopAccountSyncPairCleanupPhase.Prepared, + MUTATION_SCOPE, + session.accountId.storageKey, ), accountOwnership = { DesktopAccountOwnership.Present }, removeSyncPairs = { events += "remove-pairs" }, clearCleanup = { events += "clear-cleanup" }, + reactivatePresentAccount = { + events += "activate-memory" + memoryCache.activateAccount(requireNotNull(it.accountStorageKey)) + }, ) - assertEquals(listOf("clear-cleanup"), events) + assertEquals(listOf("clear-cleanup", "activate-memory"), events) + assertTrue(memoryCache.producer(session) != null) } @Test @@ -1106,6 +1235,7 @@ class DesktopAccountOperationGuardTest { accountOwnership = { DesktopAccountOwnership.Unknown }, removeSyncPairs = { events += "remove-pairs" }, clearCleanup = { events += "clear-cleanup" }, + reactivatePresentAccount = { events += "activate-memory" }, ) assertTrue(events.isEmpty()) @@ -1172,6 +1302,33 @@ class DesktopAccountOperationGuardTest { assertEquals(listOf("clear-cleanup"), presentEvents) } + @Test + fun malformedCleanupRemainsFailClosedRegardlessOfCredentialOwnership() = runBlocking { + val absentEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Unknown, + ), + accountOwnership = { DesktopAccountOwnership.Absent }, + removeSyncPairs = { absentEvents += "remove-pairs" }, + clearCleanup = { absentEvents += "clear-cleanup" }, + ) + assertTrue(absentEvents.isEmpty()) + + val presentEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Unknown, + ), + accountOwnership = { DesktopAccountOwnership.Present }, + removeSyncPairs = { presentEvents += "remove-pairs" }, + clearCleanup = { presentEvents += "clear-cleanup" }, + ) + assertTrue(presentEvents.isEmpty()) + } + private companion object { const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" const val MUTATION_SCOPE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 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 573bfab13..8bf13f8e8 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt @@ -131,10 +131,18 @@ class DesktopPendingDynamicMutationDirectoryTest { val requestIdentity = "GET /dashboard/widgets" val cache = DynamicApiResponseCache(root) val coalescer = DynamicApiRequestCoalescer() + val memoryCache = DynamicNativeMemoryCache() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val screenKey = dynamicScreenCacheKey(session, "dashboard", "widgets", null, emptyMap()) val started = CompletableDeferred() val release = CompletableDeferred() val response = CachedDynamicApiResponse(200, "private".encodeToByteArray(), null, null) cache.store(accountId, requestIdentity, response) + memoryCache.storeScreen( + screenKey, + DynamicScreenSnapshot(emptyList(), emptyMap()), + requireNotNull(memoryCache.producer(screenKey)), + ) val read = async { coalescer.execute(accountId, requestIdentity, load = { started.complete(Unit) @@ -144,14 +152,57 @@ class DesktopPendingDynamicMutationDirectoryTest { } started.await() - clearDesktopDynamicApiState(accountId, coalescer, cache) + clearDesktopDynamicApiState( + accountId, + coalescer, + cache, + session.accountId.storageKey, + memoryCache, + ) release.complete(Unit) assertFailsWith { read.await() } kotlin.test.assertNull(cache.load(accountId, requestIdentity, 1_024)) + kotlin.test.assertNull(memoryCache.screen(screenKey)) } finally { root.deleteRecursively() } } } + + @Test + fun `desktop memory retirement survives a rejected disk cache purge`() = runBlocking { + val root = createTempDirectory("desktop-dynamic-cache-rejected-cleanup-").toFile() + try { + val accountId = "e".repeat(64) + val accountDirectory = root.resolve(accountId).apply { mkdirs() } + accountDirectory.resolve("unsafe-entry").mkdir() + val cache = DynamicApiResponseCache(root) + val coalescer = DynamicApiRequestCoalescer() + val memoryCache = DynamicNativeMemoryCache() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val screenKey = dynamicScreenCacheKey(session, "dashboard", "widgets", null, emptyMap()) + val staleProducer = requireNotNull(memoryCache.producer(screenKey)) + memoryCache.storeScreen(screenKey, DynamicScreenSnapshot(emptyList(), emptyMap()), staleProducer) + + assertFailsWith { + clearDesktopDynamicApiState( + accountId, + coalescer, + cache, + session.accountId.storageKey, + memoryCache, + ) + } + + kotlin.test.assertNull(memoryCache.screen(screenKey)) + memoryCache.storeScreen(screenKey, DynamicScreenSnapshot(emptyList(), emptyMap()), staleProducer) + kotlin.test.assertNull(memoryCache.screen(screenKey)) + assertFailsWith { + coalescer.execute(accountId, "GET /dashboard/widgets", load = { error("must remain fenced") }) + } + } finally { + root.deleteRecursively() + } + } } From d7878ce949a78f1af18048ac17ed8f407aa1023e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 06:08:47 +0200 Subject: [PATCH 02/12] refactor(android): own dynamic account activation --- .../AndroidDynamicAccountActivation.kt | 16 +++++++++ .../AndroidNextcloudServices.kt | 5 ++- .../AndroidDynamicAccountActivationTest.kt | 34 +++++++++++++++++++ .../dynamic-memory-account-retirement.md | 7 ++++ tools/kotlin-file-size-baseline.txt | 2 +- 5 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivation.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivationTest.kt create mode 100644 changes/unreleased/dynamic-memory-account-retirement.md diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivation.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivation.kt new file mode 100644 index 000000000..0c59fec7e --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivation.kt @@ -0,0 +1,16 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer +import dev.obiente.nextcloudnative.app.DynamicNativeMemoryAccountLifecycle +import dev.obiente.nextcloudnative.app.NextcloudApiResponse +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal class AndroidDynamicAccountActivation( + private val coalescer: DynamicApiRequestCoalescer, + private val activateMemory: (String) -> Unit = DynamicNativeMemoryAccountLifecycle::activateAccount, +) { + suspend fun afterCredentialSave(persistedSession: NextcloudSession) { + activateMemory(persistedSession.accountId.storageKey) + coalescer.activateAccount(NextcloudDocumentIds.cacheAccountId(persistedSession)) + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 261e9b25a..3af08d9f5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -14,7 +14,6 @@ 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 @@ -464,6 +463,7 @@ internal class AndroidNextcloudServices( diagnostics = supportDiagnostics, client = httpClient, ) + private val dynamicAccountActivation = AndroidDynamicAccountActivation(dynamicApiRequestCoalescer) private val accountCredentials = AndroidAccountCredentialController( context = appContext, preferences = preferences, @@ -482,8 +482,7 @@ internal class AndroidNextcloudServices( retryQueuedUploadsCleanup = accountOwnedStateCleanup::retry, retryQueuedUploadsCleanupWithoutCredentials = accountOwnedStateCleanup::retryWithoutCredentials, activatePersistedAccount = { session -> - dynamicApiRequestCoalescer.activateAccount(NextcloudDocumentIds.cacheAccountId(session)) - AccountPrivateMemoryLifecycle.activateAccount(session.accountId.storageKey) + dynamicAccountActivation.afterCredentialSave(session) dynamicDiscoveryCache.activateAccount(session.accountId.storageKey) }, ) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivationTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivationTest.kt new file mode 100644 index 000000000..fabd2f591 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivationTest.kt @@ -0,0 +1,34 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer +import dev.obiente.nextcloudnative.app.NextcloudApiResponse +import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.runBlocking + +class AndroidDynamicAccountActivationTest { + @Test + fun currentCredentialSaveReopensBothDynamicCaches() = runBlocking { + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val cacheAccountId = NextcloudDocumentIds.cacheAccountId(session) + val coalescer = DynamicApiRequestCoalescer() + coalescer.fenceAccount(cacheAccountId) {} + var activatedMemoryAccount: String? = null + val activation = AndroidDynamicAccountActivation( + coalescer = coalescer, + activateMemory = { activatedMemoryAccount = it }, + ) + + activation.afterCredentialSave(session) + + assertEquals(session.accountId.storageKey, activatedMemoryAccount) + assertEquals( + 200, + coalescer.execute(cacheAccountId, "GET /status", load = { + NextcloudApiResponse(200, byteArrayOf(), null, null) + }).status, + ) + } + +} diff --git a/changes/unreleased/dynamic-memory-account-retirement.md b/changes/unreleased/dynamic-memory-account-retirement.md new file mode 100644 index 000000000..5a7994df2 --- /dev/null +++ b/changes/unreleased/dynamic-memory-account-retirement.md @@ -0,0 +1,7 @@ +category: fix +issue: 172 +pull: none +platforms: android, desktop +user-facing: yes + +Removing a Nextcloud account now clears its in-memory dynamic app data. Late responses from the removed account cannot restore that data after the account is added again. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index ed786333c..c3b2130c8 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,5 +1,5 @@ androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt|851 -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4230 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4229 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|995 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1224 From 41bac2c4b648da0dcf0eac276f239573bb336779 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 07:07:03 +0200 Subject: [PATCH 03/12] test: enforce Unit memory retirement methods --- .../obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt | 2 +- .../app/DesktopPendingDynamicMutationDirectoryTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt index a2bb16447..50a630fcd 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt @@ -168,7 +168,7 @@ class AndroidDynamicApiCachePolicyTest { } @Test - fun `Android memory retirement survives a rejected disk cache purge`() = runBlocking { + fun `Android memory retirement survives a rejected disk cache purge`(): Unit = runBlocking { val root = Files.createTempDirectory("android-dynamic-cache-rejected-cleanup-").toFile() try { val accountId = "e".repeat(64) 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 8bf13f8e8..c805f1049 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt @@ -171,7 +171,7 @@ class DesktopPendingDynamicMutationDirectoryTest { } @Test - fun `desktop memory retirement survives a rejected disk cache purge`() = runBlocking { + fun `desktop memory retirement survives a rejected disk cache purge`(): Unit = runBlocking { val root = createTempDirectory("desktop-dynamic-cache-rejected-cleanup-").toFile() try { val accountId = "e".repeat(64) From 8fd5d339d8b26983fe1745410af83a96879c7ee1 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 07:20:30 +0200 Subject: [PATCH 04/12] fix(accounts): distinguish uncertain desktop cleanup --- .../app/DesktopAccountRemoval.kt | 71 ++++++++++++++----- .../app/DesktopNextcloudServices.kt | 10 +-- .../app/DesktopAccountMemoryRetirementTest.kt | 54 ++++++++++++++ .../app/DesktopAccountOperationGuardTest.kt | 4 +- 4 files changed, 116 insertions(+), 23 deletions(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 5f531b2f7..f41b03824 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -11,17 +11,24 @@ import kotlinx.coroutines.withContext internal const val DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE = "This account has cleanup state written by a newer app version." +internal const val DESKTOP_PENDING_CLEANUP_STATE_MESSAGE = + "Previous account cleanup must finish before this account can be added again." -internal fun unknownCleanupStateRejection() = - VirtualFileStorageActionResult.Rejected(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE) +internal enum class DesktopAccountActivationBlock(val message: String) { + PendingCleanup(DESKTOP_PENDING_CLEANUP_STATE_MESSAGE), + UnknownJournalData(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE), +} + +internal fun desktopAccountCleanupStateRejection(block: DesktopAccountActivationBlock) = + VirtualFileStorageActionResult.Rejected(block.message) -internal fun requireDesktopAccountActivationAllowed(blockedByUnknownCleanup: Boolean) { - check(!blockedByUnknownCleanup) { DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE } +internal fun requireDesktopAccountActivationAllowed(block: DesktopAccountActivationBlock?) { + check(block == null) { block?.message.orEmpty() } } internal fun DesktopAccountSyncPairCleanupJournal.requireAccountActivationAllowed(record: NextcloudAccountRecord) = requireDesktopAccountActivationAllowed( - blocksAccountActivation(desktopFileCacheAccountId(record), record.id.storageKey), + accountActivationBlock(desktopFileCacheAccountId(record), record.id.storageKey), ) internal fun loadDesktopSessionAfterCleanupGate( @@ -105,25 +112,55 @@ internal class DesktopAccountSyncPairCleanupJournal( preferences.flush() } - fun blocksAccountActivation(accountId: String, accountStorageKey: String? = null): Boolean { + fun blocksAccountActivation(accountId: String, accountStorageKey: String? = null): Boolean = + accountActivationBlock(accountId, accountStorageKey) != null + + fun accountActivationBlock( + accountId: String, + accountStorageKey: String? = null, + ): DesktopAccountActivationBlock? { 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)) + var malformedEntryFound = false + var matchingEntryFound = false + preferences.keys() + .asSequence() + .filter { key -> key.startsWith(KEY_PREFIX) } + .forEach { key -> + val storedAccountId = key.removePrefix(KEY_PREFIX) + val cleanup = runCatching { + validateDesktopSyncPairCleanupAccountId(storedAccountId) + decode(storedAccountId, preferences.get(key, null)) + }.getOrNull() + if (cleanup == null) { + malformedEntryFound = true + } else if ( + cleanup.phase == DesktopAccountSyncPairCleanupPhase.Unknown && + (cleanup.accountStorageKey == null || + accountStorageKey != null && cleanup.matchesAccountActivation(accountId, accountStorageKey) || + accountStorageKey == null && cleanup.accountId == accountId) + ) { + malformedEntryFound = true + } else if ( + cleanup.phase != DesktopAccountSyncPairCleanupPhase.Unknown && + (cleanup.accountStorageKey == null || + cleanup.accountId == accountId || + accountStorageKey != null && cleanup.accountStorageKey == accountStorageKey) + ) { + matchingEntryFound = true + } } + if (malformedEntryFound) recordMalformedOnce() + return when { + malformedEntryFound -> DesktopAccountActivationBlock.UnknownJournalData + matchingEntryFound -> DesktopAccountActivationBlock.PendingCleanup + else -> null } - if (blocked) recordMalformedOnce() - return blocked } - fun blocksAllAccountActivation(): Boolean { + fun blocksAllAccountActivation(): DesktopAccountActivationBlock? { val blocked = preferences.keys().asSequence() .filter { key -> key.startsWith(KEY_PREFIX) } .any { key -> @@ -136,7 +173,7 @@ internal class DesktopAccountSyncPairCleanupJournal( cleanup.phase == DesktopAccountSyncPairCleanupPhase.Unknown && cleanup.accountStorageKey == null } if (blocked) recordMalformedOnce() - return blocked + return DesktopAccountActivationBlock.UnknownJournalData.takeIf { blocked } } fun pending(): List { 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 ba47bb80b..f36e37b19 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1681,8 +1681,8 @@ class DesktopNextcloudServices( } val accountId = desktopFileCacheAccountId(session) val cacheProducer = fileReadCache.producer(accountId) - if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId, session.accountId.storageKey)) { - return unknownCleanupStateRejection() + accountSyncPairCleanupJournal.accountActivationBlock(accountId, session.accountId.storageKey)?.let { + return desktopAccountCleanupStateRejection(it) } runCatching(linuxProviderCleanup::retry).exceptionOrNull()?.let { return VirtualFileStorageActionResult.Rejected(it.message ?: "The earlier Linux mount is still active.") @@ -2709,8 +2709,8 @@ class DesktopNextcloudServices( ?: return@syncRun FileSyncCenterActionResult.Rejected("Sign in before syncing folders.") val accountId = desktopFileCacheAccountId(session) diagnosticAccountId = accountId - if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId, session.accountId.storageKey)) { - return@syncRun FileSyncCenterActionResult.Rejected(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE) + accountSyncPairCleanupJournal.accountActivationBlock(accountId, session.accountId.storageKey)?.let { + return@syncRun FileSyncCenterActionResult.Rejected(it.message) } val userId = runCatching { loadServerInfo(session).userId }.getOrElse { failure -> return@syncRun FileSyncCenterActionResult.Rejected( @@ -3813,7 +3813,7 @@ class DesktopNextcloudServices( ) } requireDesktopAccountActivationAllowed( - accountSyncPairCleanupJournal.blocksAccountActivation(accountId, accountStorageKey), + accountSyncPairCleanupJournal.accountActivationBlock(accountId, accountStorageKey), ) } private suspend fun retryPendingAccountSyncPairCleanups() = diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt index 8c74e0738..8e0583526 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt @@ -7,6 +7,7 @@ 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.assertTrue @@ -109,4 +110,57 @@ class DesktopAccountMemoryRetirementTest { root.deleteRecursively() } } + + @Test + fun unknownCredentialRemovalFailureKeepsMemoryActive() = runBlocking { + var memoryRetired = false + + assertFailsWith { + removeDesktopAccountBeforeSyncPairCleanup( + accountId = ACCOUNT_ID, + prepareCleanup = { _, _, _, _ -> }, + commitCleanup = {}, + clearCleanup = {}, + accountOwnership = { DesktopAccountOwnership.Unknown }, + removeCredential = { error("credential removal outcome is unknown") }, + removeSyncPairs = {}, + retireCommittedAccount = { memoryRetired = true }, + recordCleanupFailure = {}, + ) + } + + assertFalse(memoryRetired) + } + + @Test + fun journalReportsPendingCleanupSeparatelyFromUnknownData() { + val preferences = Preferences.userRoot().node("desktop-memory-cleanup-test-${UUID.randomUUID()}") + try { + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + journal.prepare(ACCOUNT_ID, MUTATION_SCOPE, ACCOUNT_STORAGE_KEY) + + val block = journal.accountActivationBlock(ACCOUNT_ID, ACCOUNT_STORAGE_KEY) + assertEquals(DesktopAccountActivationBlock.PendingCleanup, block) + val failure = assertFailsWith { + requireDesktopAccountActivationAllowed(block) + } + assertEquals(DESKTOP_PENDING_CLEANUP_STATE_MESSAGE, failure.message) + + preferences.put("fsac.$ACCOUNT_ID", "future-phase") + val unknownBlock = journal.accountActivationBlock(ACCOUNT_ID, ACCOUNT_STORAGE_KEY) + assertEquals(DesktopAccountActivationBlock.UnknownJournalData, unknownBlock) + val unknownFailure = assertFailsWith { + requireDesktopAccountActivationAllowed(unknownBlock) + } + assertEquals(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE, unknownFailure.message) + } finally { + preferences.removeNode() + } + } + + private companion object { + const val ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + const val ACCOUNT_STORAGE_KEY = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + const val MUTATION_SCOPE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index ba3b7db14..039b3efa8 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -955,7 +955,9 @@ class DesktopAccountOperationGuardTest { 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) } + assertFailsWith { + requireDesktopAccountActivationAllowed(DesktopAccountActivationBlock.UnknownJournalData) + } assertTrue(journal.blocksAccountActivation(validAccountId)) assertEquals(1, malformedCount) From eb26a771d09c72eafd656792dd32c7c622f4f548 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 07:46:59 +0200 Subject: [PATCH 05/12] refactor(accounts): preserve reduced service boundaries --- tools/kotlin-file-size-baseline.txt | 2 +- .../app/DesktopAccountCacheRemoval.kt | 26 ++++++++++++++++ .../app/DesktopNextcloudServices.kt | 31 +------------------ 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index c3b2130c8..ab3f866c2 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -25,7 +25,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface. ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt|1234 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1883 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12348 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12343 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|1717 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt index 7c6762b25..f678e55ab 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt @@ -84,6 +84,32 @@ internal fun virtualFileCachePreferenceKey(prefix: String, accountId: String): S return desktopAccountPreferenceKey(prefix, accountId) } +internal fun desktopVirtualFileProviderLocation( + preferences: Preferences, + accountId: String, + userHome: File = File(System.getProperty("user.home")), +): VirtualFileProviderLocation { + val stored = preferences.get(virtualFileProviderRootPreferenceKey(accountId), null) + ?.takeIf { path -> path.length <= Preferences.MAX_VALUE_LENGTH } + ?.let(::File) + ?.absoluteFile + ?.normalize() + val folderName = stored?.name?.takeIf(String::isValidVirtualFileProviderFolderName) + val parent = stored?.parentFile + return if (folderName != null && parent != null) { + VirtualFileProviderLocation(parent.absolutePath, folderName) + } else { + VirtualFileProviderLocation(userHome.absolutePath, "Nextcloud Native") + } +} + +internal fun desktopLinuxVirtualFileMountPoint( + preferences: Preferences, + accountId: String, +): File = desktopVirtualFileProviderLocation(preferences, accountId).let { location -> + File(location.parentPath, location.folderName).absoluteFile.normalize() +} + 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) } 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 f36e37b19..73dde109d 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -132,32 +132,6 @@ private const val VIRTUAL_FILE_OVERFLOW_PREFERENCE_VERSION = "v2" private fun isLinuxDesktop(): Boolean = System.getProperty("os.name").orEmpty().lowercase().contains("linux") -private fun desktopVirtualFileProviderLocation( - preferences: Preferences, - accountId: String, - userHome: File = File(System.getProperty("user.home")), -): VirtualFileProviderLocation { - val stored = preferences.get(virtualFileProviderRootPreferenceKey(accountId), null) - ?.takeIf { path -> path.length <= Preferences.MAX_VALUE_LENGTH } - ?.let(::File) - ?.absoluteFile - ?.normalize() - val folderName = stored?.name?.takeIf(String::isValidVirtualFileProviderFolderName) - val parent = stored?.parentFile - return if (folderName != null && parent != null) { - VirtualFileProviderLocation(parent.absolutePath, folderName) - } else { - VirtualFileProviderLocation(userHome.absolutePath, "Nextcloud Native") - } -} - -private fun desktopLinuxVirtualFileMountPoint( - preferences: Preferences, - accountId: String, -): File = desktopVirtualFileProviderLocation(preferences, accountId).let { location -> - File(location.parentPath, location.folderName).absoluteFile.normalize() -} - private data class DesktopVirtualFileCacheTiers( val configuration: VirtualFileCacheTierConfiguration, val primaryIdentity: String?, @@ -3847,10 +3821,7 @@ class DesktopNextcloudServices( val accountId = cleanup.accountId dynamicDiscoveryCache.retireAccount(cleanup.accountStorageKey, accountId) clearDesktopDynamicApiState( - accountId, - dynamicApiRequestCoalescer, - dynamicApiReadCache, - cleanup.accountStorageKey, + accountId, dynamicApiRequestCoalescer, dynamicApiReadCache, cleanup.accountStorageKey, ) supportIntake.removeAccount(accountId) removeDesktopPendingDynamicMutations(pendingDynamicMutationDirectory, accountId) From 4501be31a479536415edef8e40d07723b5c0305f Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 08:27:57 +0200 Subject: [PATCH 06/12] refactor(dynamic): seal cache producer identity --- .../obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6a4bbb87a..7915b22d5 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt @@ -186,9 +186,9 @@ internal class DynamicNativeMemoryCache( } } -data class DynamicNativeMemoryCacheProducer( +internal class DynamicNativeMemoryCacheProducer internal constructor( val accountStorageKey: String, - val incarnation: Long, + internal val incarnation: Long, ) internal data class DynamicDiscoveryCacheKey( From 8bfdd42bd774405b394bdb9d1e702e51254a2883 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 08:59:03 +0200 Subject: [PATCH 07/12] test(dynamic): supply cleanup cache producers --- .../app/AccountPrivateMemoryCleanupTest.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt index cb2430141..5d12cf8b5 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt @@ -20,22 +20,24 @@ class AccountPrivateMemoryCleanupTest { AccountPrivateMemoryLifecycle.activateAccount(retainedKey) val removedPreview = PreviewCacheKey(removedKey, "core", 1L, "etag", 64, 64) val retainedPreview = PreviewCacheKey(retainedKey, "core", 2L, "etag", 64, 64) + val removedDynamicKey = dynamicKey(removed) + val retainedDynamicKey = dynamicKey(retained) 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) + val removedDynamicProducer = requireNotNull(sharedDynamicNativeMemoryCache.producer(removedDynamicKey)) + val retainedDynamicProducer = requireNotNull(sharedDynamicNativeMemoryCache.producer(retainedDynamicKey)) 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, + removedDynamicKey, dynamicSnapshot(1), removedDynamicProducer, ) sharedDynamicNativeMemoryCache.storeScreen( - dynamicKey(retained), dynamicSnapshot(2), retainedDynamicProducer, + retainedDynamicKey, dynamicSnapshot(2), retainedDynamicProducer, ) sharedDashboardStatusMemoryCache.store( removed, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 1L, removedProducer, @@ -76,8 +78,8 @@ class AccountPrivateMemoryCleanupTest { 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(sharedDynamicNativeMemoryCache.screen(removedDynamicKey)) + assertNotNull(sharedDynamicNativeMemoryCache.screen(retainedDynamicKey)) assertNull(sharedDashboardStatusMemoryCache.get(removed, 1L)) assertNotNull(sharedDashboardStatusMemoryCache.get(retained, 1L)) assertNull(ContactsWorkspaceMemoryCache.get(removed, "removed")) From 9d1c1d002b5b21603f7265cec716bb00c9cba1d8 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 09:24:56 +0200 Subject: [PATCH 08/12] fix(dynamic): preserve producer API visibility --- ...roidDynamicDiscoveryCacheRetirementTest.kt | 15 +- tools/kotlin-file-size-baseline.txt | 4 +- .../app/DynamicNativeMemoryCache.kt | 4 +- .../app/DesktopAccountCleanupRecoveryTest.kt | 149 ++++++++++++++ .../app/DesktopAccountMemoryRetirementTest.kt | 47 +++++ .../app/DesktopAccountOperationGuardTest.kt | 190 +----------------- 6 files changed, 218 insertions(+), 191 deletions(-) create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRecoveryTest.kt diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt index 8282725f1..de5477279 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt @@ -17,8 +17,8 @@ class AndroidDynamicDiscoveryCacheRetirementTest { 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) + val removedProducer = producerForTest(removedStorageKey, 0L) + val retainedProducer = producerForTest(retainedStorageKey, 0L) try { cache.save(removedStorageKey, removedCacheId, "deck", "removed", removedProducer) cache.save(retainedStorageKey, retainedCacheId, "deck", "retained", retainedProducer) @@ -34,7 +34,7 @@ class AndroidDynamicDiscoveryCacheRetirementTest { cache.save( removedStorageKey, removedCacheId, "deck", "current", - DynamicNativeMemoryCacheProducer(removedStorageKey, 1L), + producerForTest(removedStorageKey, 1L), ) assertEquals("current", cache.load(removedStorageKey, removedCacheId, "deck")) } finally { @@ -49,11 +49,11 @@ class AndroidDynamicDiscoveryCacheRetirementTest { try { cache.save( "a".repeat(64), "1".repeat(64), "deck", "first", - DynamicNativeMemoryCacheProducer("a".repeat(64), 0L), + producerForTest("a".repeat(64), 0L), ) cache.save( "b".repeat(64), "2".repeat(64), "talk", "second", - DynamicNativeMemoryCacheProducer("b".repeat(64), 0L), + producerForTest("b".repeat(64), 0L), ) cache.retireAccount("a".repeat(64), null) @@ -63,4 +63,9 @@ class AndroidDynamicDiscoveryCacheRetirementTest { root.deleteRecursively() } } + + private fun producerForTest(accountStorageKey: String, incarnation: Long): DynamicNativeMemoryCacheProducer = + DynamicNativeMemoryCacheProducer::class.java + .getDeclaredConstructor(String::class.java, java.lang.Long.TYPE) + .newInstance(accountStorageKey, incarnation) } diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index ab3f866c2..671cd17fd 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -25,7 +25,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface. ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt|1234 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|12343 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12332 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|1717 @@ -53,7 +53,7 @@ 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|6236 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6216 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 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 7915b22d5..32efe56c6 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt @@ -186,9 +186,9 @@ internal class DynamicNativeMemoryCache( } } -internal class DynamicNativeMemoryCacheProducer internal constructor( +class DynamicNativeMemoryCacheProducer internal constructor( val accountStorageKey: String, - internal val incarnation: Long, + val incarnation: Long, ) internal data class DynamicDiscoveryCacheKey( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRecoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRecoveryTest.kt new file mode 100644 index 000000000..3fb22a6b7 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRecoveryTest.kt @@ -0,0 +1,149 @@ +package dev.obiente.nextcloudnative.app + +import java.util.UUID +import java.util.prefs.Preferences +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopAccountCleanupRecoveryTest { + @Test + fun preparedCleanupFromAnAbortedRemovalPreservesExistingPairs() = runBlocking { + val events = mutableListOf() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val memoryCache = DynamicNativeMemoryCache() + memoryCache.retireAccount(session.accountId.storageKey) + + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Prepared, + MUTATION_SCOPE, + session.accountId.storageKey, + ), + accountOwnership = { DesktopAccountOwnership.Present }, + removeSyncPairs = { events += "remove-pairs" }, + clearCleanup = { events += "clear-cleanup" }, + reactivatePresentAccount = { + events += "activate-memory" + memoryCache.activateAccount(requireNotNull(it.accountStorageKey)) + }, + ) + + assertEquals(listOf("clear-cleanup", "activate-memory"), events) + assertTrue(memoryCache.producer(session) != null) + } + + @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" }, + reactivatePresentAccount = { events += "activate-memory" }, + ) + + 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) + } + + @Test + fun malformedCleanupRemainsFailClosedRegardlessOfCredentialOwnership() = runBlocking { + val absentEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Unknown, + ), + accountOwnership = { DesktopAccountOwnership.Absent }, + removeSyncPairs = { absentEvents += "remove-pairs" }, + clearCleanup = { absentEvents += "clear-cleanup" }, + ) + assertTrue(absentEvents.isEmpty()) + + val presentEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Unknown, + ), + accountOwnership = { DesktopAccountOwnership.Present }, + removeSyncPairs = { presentEvents += "remove-pairs" }, + clearCleanup = { presentEvents += "clear-cleanup" }, + ) + assertTrue(presentEvents.isEmpty()) + } + + private companion object { + const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + const val MUTATION_SCOPE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const val ACCOUNT_STORAGE_KEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt index 8e0583526..9077b8ac9 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt @@ -9,6 +9,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotEquals import kotlin.test.assertTrue class DesktopAccountMemoryRetirementTest { @@ -158,6 +159,52 @@ class DesktopAccountMemoryRetirementTest { } } + @Test + fun v3CleanupBlocksCanonicalEquivalentAccountActivationByStorageKey() { + val preferences = Preferences.userRoot().node("desktop-memory-cleanup-test-${UUID.randomUUID()}") + val original = NextcloudSession("https://cloud.example.test", "alice", "password") + val equivalent = original.copy(serverUrl = "https://CLOUD.EXAMPLE.TEST:443/") + val originalProviderId = desktopFileCacheAccountId(original) + val equivalentProviderId = desktopFileCacheAccountId(equivalent) + try { + assertEquals(original.accountId, equivalent.accountId) + assertNotEquals(originalProviderId, equivalentProviderId) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + journal.prepare(originalProviderId, MUTATION_SCOPE, original.accountId.storageKey) + + assertTrue(journal.blocksAccountActivation(equivalentProviderId, equivalent.accountId.storageKey)) + assertFailsWith { + journal.requireAccountActivationAllowed(equivalent.accountRecord()) + } + } finally { + preferences.removeNode() + } + } + + @Test + fun cleanupWithoutStorageKeyBlocksCanonicalEquivalentActivationUntilRecovery() { + val original = NextcloudSession("https://cloud.example.test", "alice", "password") + val equivalent = original.copy(serverUrl = "https://CLOUD.EXAMPLE.TEST:443/") + val originalProviderId = desktopFileCacheAccountId(original) + val equivalentProviderId = desktopFileCacheAccountId(equivalent) + assertNotEquals(originalProviderId, equivalentProviderId) + + listOf("prepared", "v2|prepared|$MUTATION_SCOPE").forEach { encoded -> + val preferences = Preferences.userRoot().node("desktop-memory-cleanup-test-${UUID.randomUUID()}") + try { + preferences.put("fsac.$originalProviderId", encoded) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + + assertTrue(journal.blocksAccountActivation(equivalentProviderId, equivalent.accountId.storageKey)) + assertFailsWith { + journal.requireAccountActivationAllowed(equivalent.accountRecord()) + } + } finally { + preferences.removeNode() + } + } + } + private companion object { const val ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" const val ACCOUNT_STORAGE_KEY = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 039b3efa8..804dfeb01 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -831,55 +831,6 @@ class DesktopAccountOperationGuardTest { assertEquals(listOf("prepare-cleanup", "remove-credential", "retire-memory", "commit-cleanup"), events) } - @Test - fun committedRemovalRetiresMemoryBeforeCleanupJournalCommitFailure() = runBlocking { - val events = mutableListOf() - val session = NextcloudSession("https://cloud.example.test", "alice", "password") - val memoryCache = DynamicNativeMemoryCache() - val screenKey = dynamicScreenCacheKey(session, "mail", "messages", null, emptyMap()) - memoryCache.storeScreen( - screenKey, - DynamicScreenSnapshot(emptyList(), emptyMap()), - requireNotNull(memoryCache.producer(screenKey)), - ) - - assertTrue( - removeDesktopAccountBeforeSyncPairCleanup( - accountId = CLEANUP_ACCOUNT_ID, - prepareCleanup = { _, _, _ -> events += "prepare-cleanup" }, - commitCleanup = { - events += "commit-cleanup" - error("synthetic cleanup journal commit failure") - }, - clearCleanup = { events += "clear-cleanup" }, - accountOwnership = { DesktopAccountOwnership.Absent }, - removeCredential = { - events += "remove-credential" - true - }, - removeSyncPairs = { events += "remove-pairs" }, - retireCommittedAccount = { - events += "retire-memory" - memoryCache.retireAccount(session.accountId.storageKey) - }, - recordCleanupFailure = { events += "diagnose-cleanup" }, - ), - ) - - assertEquals( - listOf( - "prepare-cleanup", - "remove-credential", - "retire-memory", - "commit-cleanup", - "diagnose-cleanup", - ), - events, - ) - assertNull(memoryCache.screen(screenKey)) - assertNull(memoryCache.producer(screenKey)) - } - @Test fun backgroundSyncContinuesAfterCleanupJournalReadFailure() = runBlocking { val events = mutableListOf() @@ -1027,6 +978,11 @@ class DesktopAccountOperationGuardTest { assertEquals(futureValue, preferences.get("fsac.$oldCacheIdentity", null)) assertTrue(journal.blocksAccountActivation(canonicalCacheIdentity, ACCOUNT_STORAGE_KEY)) + } finally { + preferences.removeNode() + } + } + @Test fun v3CleanupBlocksCanonicalEquivalentAccountActivationByStorageKey() { val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") @@ -1104,6 +1060,9 @@ class DesktopAccountOperationGuardTest { assertEquals(0, publications) } finally { preferences.removeNode() + } + } + @Test fun cleanupWithoutStorageKeyBlocksCanonicalEquivalentActivationUntilRecovery() { val original = NextcloudSession("https://cloud.example.test", "alice", "password") @@ -1198,139 +1157,6 @@ class DesktopAccountOperationGuardTest { } } - @Test - fun preparedCleanupFromAnAbortedRemovalPreservesExistingPairs() = runBlocking { - val events = mutableListOf() - val session = NextcloudSession("https://cloud.example.test", "alice", "password") - val memoryCache = DynamicNativeMemoryCache() - memoryCache.retireAccount(session.accountId.storageKey) - - retryDesktopAccountSyncPairCleanup( - cleanup = DesktopAccountSyncPairCleanup( - CLEANUP_ACCOUNT_ID, - DesktopAccountSyncPairCleanupPhase.Prepared, - MUTATION_SCOPE, - session.accountId.storageKey, - ), - accountOwnership = { DesktopAccountOwnership.Present }, - removeSyncPairs = { events += "remove-pairs" }, - clearCleanup = { events += "clear-cleanup" }, - reactivatePresentAccount = { - events += "activate-memory" - memoryCache.activateAccount(requireNotNull(it.accountStorageKey)) - }, - ) - - assertEquals(listOf("clear-cleanup", "activate-memory"), events) - assertTrue(memoryCache.producer(session) != null) - } - - @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" }, - reactivatePresentAccount = { events += "activate-memory" }, - ) - - 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) - } - - @Test - fun malformedCleanupRemainsFailClosedRegardlessOfCredentialOwnership() = runBlocking { - val absentEvents = mutableListOf() - retryDesktopAccountSyncPairCleanup( - cleanup = DesktopAccountSyncPairCleanup( - CLEANUP_ACCOUNT_ID, - DesktopAccountSyncPairCleanupPhase.Unknown, - ), - accountOwnership = { DesktopAccountOwnership.Absent }, - removeSyncPairs = { absentEvents += "remove-pairs" }, - clearCleanup = { absentEvents += "clear-cleanup" }, - ) - assertTrue(absentEvents.isEmpty()) - - val presentEvents = mutableListOf() - retryDesktopAccountSyncPairCleanup( - cleanup = DesktopAccountSyncPairCleanup( - CLEANUP_ACCOUNT_ID, - DesktopAccountSyncPairCleanupPhase.Unknown, - ), - accountOwnership = { DesktopAccountOwnership.Present }, - removeSyncPairs = { presentEvents += "remove-pairs" }, - clearCleanup = { presentEvents += "clear-cleanup" }, - ) - assertTrue(presentEvents.isEmpty()) - } - private companion object { const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" const val MUTATION_SCOPE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" From 7c1b2d9b72546bb2642f1a7401f0ee03b2d23949 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 13:00:19 +0200 Subject: [PATCH 09/12] test(desktop): update account cleanup callback contract From a3776045080afa0142d83dfafad823e0ef8fb932 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:52:19 +0000 Subject: [PATCH 10/12] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 81309996e..b7f2d0776 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -497,7 +497,7 @@ "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": "2cb445b752faa33f051cfd618bfac3c9f39fc20abce3176f782fe032aef4a98c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "ad64293f0aeb944b57766c8bdee8cd56b2923b1ec441358442db883ff3376af1", "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", From 9e6259b488312612eb6c4198f407d81feaebec17 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 06:58:31 +0200 Subject: [PATCH 11/12] fix(desktop): scope legacy cleanup activation blocks --- .../dynamic-memory-account-retirement.md | 2 +- .../app/DesktopAccountRemoval.kt | 3 +- .../app/DesktopAccountMemoryRetirementTest.kt | 19 +++++++---- .../app/DesktopAccountOperationGuardTest.kt | 34 ++++++++++++------- 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/changes/unreleased/dynamic-memory-account-retirement.md b/changes/unreleased/dynamic-memory-account-retirement.md index 5a7994df2..6f1c43632 100644 --- a/changes/unreleased/dynamic-memory-account-retirement.md +++ b/changes/unreleased/dynamic-memory-account-retirement.md @@ -4,4 +4,4 @@ pull: none platforms: android, desktop user-facing: yes -Removing a Nextcloud account now clears its in-memory dynamic app data. Late responses from the removed account cannot restore that data after the account is added again. +Account removal clears dynamic app memory and rejects late responses even after the account is added again. Pending cleanup from older desktop versions no longer prevents unrelated accounts from loading. diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index f41b03824..410e9464a 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -145,8 +145,7 @@ internal class DesktopAccountSyncPairCleanupJournal( malformedEntryFound = true } else if ( cleanup.phase != DesktopAccountSyncPairCleanupPhase.Unknown && - (cleanup.accountStorageKey == null || - cleanup.accountId == accountId || + (cleanup.accountId == accountId || accountStorageKey != null && cleanup.accountStorageKey == accountStorageKey) ) { matchingEntryFound = true diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt index 9077b8ac9..f0611d609 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt @@ -182,23 +182,28 @@ class DesktopAccountMemoryRetirementTest { } @Test - fun cleanupWithoutStorageKeyBlocksCanonicalEquivalentActivationUntilRecovery() { + fun legacyCleanupBlocksOnlyItsKnownAccountIdentity() { val original = NextcloudSession("https://cloud.example.test", "alice", "password") - val equivalent = original.copy(serverUrl = "https://CLOUD.EXAMPLE.TEST:443/") + val unrelated = original.copy(loginName = "bob") val originalProviderId = desktopFileCacheAccountId(original) - val equivalentProviderId = desktopFileCacheAccountId(equivalent) - assertNotEquals(originalProviderId, equivalentProviderId) + val unrelatedProviderId = desktopFileCacheAccountId(unrelated) + assertNotEquals(originalProviderId, unrelatedProviderId) - listOf("prepared", "v2|prepared|$MUTATION_SCOPE").forEach { encoded -> + listOf("prepared", "committed", "v2|prepared|$MUTATION_SCOPE", "v2|committed|$MUTATION_SCOPE").forEach { encoded -> val preferences = Preferences.userRoot().node("desktop-memory-cleanup-test-${UUID.randomUUID()}") try { preferences.put("fsac.$originalProviderId", encoded) val journal = DesktopAccountSyncPairCleanupJournal(preferences) - assertTrue(journal.blocksAccountActivation(equivalentProviderId, equivalent.accountId.storageKey)) + assertTrue(journal.blocksAccountActivation(originalProviderId)) + assertTrue(journal.blocksAccountActivation(originalProviderId, original.accountId.storageKey)) + assertFalse(journal.blocksAccountActivation(unrelatedProviderId)) + assertFalse(journal.blocksAccountActivation(unrelatedProviderId, unrelated.accountId.storageKey)) assertFailsWith { - journal.requireAccountActivationAllowed(equivalent.accountRecord()) + journal.requireAccountActivationAllowed(original.accountRecord()) } + journal.requireAccountActivationAllowed(unrelated.accountRecord()) + assertEquals(encoded, preferences.get("fsac.$originalProviderId", null)) } finally { preferences.removeNode() } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 804dfeb01..3867681b7 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -1064,28 +1064,38 @@ class DesktopAccountOperationGuardTest { } @Test - fun cleanupWithoutStorageKeyBlocksCanonicalEquivalentActivationUntilRecovery() { + fun pendingLegacyCleanupAllowsUnrelatedDesktopSessionToLoad() { val original = NextcloudSession("https://cloud.example.test", "alice", "password") - val equivalent = original.copy(serverUrl = "https://CLOUD.EXAMPLE.TEST:443/") + val unrelated = original.copy(loginName = "bob") val originalProviderId = desktopFileCacheAccountId(original) - val equivalentProviderId = desktopFileCacheAccountId(equivalent) - assertNotEquals(originalProviderId, equivalentProviderId) - listOf("prepared", "v2|prepared|$MUTATION_SCOPE").forEach { encoded -> + listOf("prepared", "committed", "v2|prepared|$MUTATION_SCOPE", "v2|committed|$MUTATION_SCOPE").forEach { encoded -> val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") try { preferences.put("fsac.$originalProviderId", encoded) val journal = DesktopAccountSyncPairCleanupJournal(preferences) - assertTrue( - journal.blocksAccountActivation( - equivalentProviderId, - equivalent.accountId.storageKey, - ), - ) + var originalLoaded = false + var originalPublished = false assertFailsWith { - journal.requireAccountActivationAllowed(equivalent.accountRecord()) + loadDesktopSessionAfterCleanupGate( + original.accountRecord(), journal, + load = { originalLoaded = true; original }, + publish = { originalPublished = true }, + ) } + assertFalse(originalLoaded) + assertFalse(originalPublished) + val publications = mutableListOf() + assertEquals( + unrelated, + loadDesktopSessionAfterCleanupGate( + unrelated.accountRecord(), journal, + load = { unrelated }, publish = { publications += it }, + ), + ) + assertEquals(listOf(unrelated), publications) + assertEquals(encoded, preferences.get("fsac.$originalProviderId", null)) } finally { preferences.removeNode() } From 41120a5181e212e9b7f0a91682b4750c5906af81 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:11:13 +0000 Subject: [PATCH 12/12] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index b7f2d0776..b172b3931 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -488,7 +488,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DocumentPreview.kt": "a9a8743dd7a381504282cc6ddc68034425024ccc1ae51667da9734bbfc7b1a79", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DurableMutationRecoveryDialog.kt": "e720eadb477a347762cd1894285788ac9f6820972431fe0a1953d01955667bfe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt": "2b7ef2d18b4a23615686ced0b7c9c621c58dc5edd0202104d0ca55b1ebf61d81", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "ddf80ca67d954f6e063c9e88c75794fcb81cbd6d42887c45d1a04b1cefe4f2fd", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "cff6ba11283705120375452d6d539c20581f4dd0115dd3d07049eb965242b019",