diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index db0798228..3b1dc85c5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -1,9 +1,8 @@ package dev.obiente.nextcloudnative import android.content.Context -import dev.obiente.nextcloudnative.app.AccountPrivateMemoryCleanup +import dev.obiente.nextcloudnative.app.AccountPrivateMemoryLifecycle 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 @@ -74,7 +73,6 @@ internal class AndroidAccountOwnedStateCleanup( { virtualFileCache.clearAccount(accountIdentity) }, { mutationRecovery.clearDurableRecoveries(durableMutationAccountScope(session)) }, { mutationRecovery.clearPendingDynamicMutations(cacheIdentity) }, - { AccountPrivateMemoryCleanup.removeAccount(session.accountId.storageKey) }, ), ) } @@ -92,7 +90,7 @@ internal class AndroidAccountOwnedStateCleanup( listOf( { if (previewCacheIdentity == null) { - DynamicNativeMemoryAccountLifecycle.retireAccount(session.accountId.storageKey) + AccountPrivateMemoryLifecycle.retireAccount(session.accountId.storageKey) } else { fenceAndroidDynamicApiStateForRemoval( previewCacheIdentity, @@ -124,7 +122,6 @@ internal class AndroidAccountOwnedStateCleanup( { virtualFileCache.clearAccount(accountIdentity) }, { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, { previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) }, - { AccountPrivateMemoryCleanup.removeAccount(session.accountId.storageKey) }, ), ) } @@ -142,7 +139,7 @@ internal class AndroidAccountOwnedStateCleanup( listOf( { if (previewCacheIdentity == null) { - DynamicNativeMemoryAccountLifecycle.retireAccount(accountStorageKey) + AccountPrivateMemoryLifecycle.retireAccount(accountStorageKey) } else { fenceAndroidDynamicApiStateForRemoval( previewCacheIdentity, @@ -174,11 +171,9 @@ internal class AndroidAccountOwnedStateCleanup( { virtualFileCache.clearAccount(accountIdentity) }, { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, { previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) }, - { AccountPrivateMemoryCleanup.removeAccount(accountStorageKey) }, ), ) } - } internal suspend fun clearAndroidDynamicApiState( @@ -186,7 +181,7 @@ internal suspend fun clearAndroidDynamicApiState( coalescer: DynamicApiRequestCoalescer, cache: DynamicApiResponseCache, accountStorageKey: String? = null, - retireMemoryAccount: (String) -> Unit = DynamicNativeMemoryAccountLifecycle::retireAccount, + retireMemoryAccount: (String) -> Unit = AccountPrivateMemoryLifecycle::retireAccount, ) = coalescer.fenceAccount(accountIdentity) { accountStorageKey?.let(retireMemoryAccount) cache.invalidateAccount(accountIdentity) @@ -197,7 +192,7 @@ internal suspend fun fenceAndroidDynamicApiStateForRemoval( coalescer: DynamicApiRequestCoalescer, cache: DynamicApiResponseCache, accountStorageKey: String? = null, - retireMemoryAccount: (String) -> Unit = DynamicNativeMemoryAccountLifecycle::retireAccount, + retireMemoryAccount: (String) -> Unit = AccountPrivateMemoryLifecycle::retireAccount, ) = withContext(NonCancellable) { clearAndroidDynamicApiState(accountIdentity, coalescer, cache, accountStorageKey, retireMemoryAccount) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivation.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivation.kt index 0c59fec7e..74aa840ab 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivation.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicAccountActivation.kt @@ -1,13 +1,13 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer -import dev.obiente.nextcloudnative.app.DynamicNativeMemoryAccountLifecycle +import dev.obiente.nextcloudnative.app.AccountPrivateMemoryLifecycle 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, + private val activateMemory: (String) -> Unit = AccountPrivateMemoryLifecycle::activateAccount, ) { suspend fun afterCredentialSave(persistedSession: NextcloudSession) { activateMemory(persistedSession.accountId.storageKey) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 3af08d9f5..9b4b2d282 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -215,7 +215,6 @@ import dev.obiente.nextcloudnative.app.collectMediaSearchDavPages import dev.obiente.nextcloudnative.app.collectMediaTimelineDavPage import dev.obiente.nextcloudnative.app.mediaSearchDavRequests import dev.obiente.nextcloudnative.app.MediaSearchDavTransportResponse -import dev.obiente.nextcloudnative.app.MediaTimelineDavCarryoverStore import dev.obiente.nextcloudnative.app.MemoriesPreferredTimelineReadService import dev.obiente.nextcloudnative.app.MemoriesTimelineNavigationLoadResult import dev.obiente.nextcloudnative.app.MemoriesTimelineNavigationSnapshot @@ -227,6 +226,7 @@ import dev.obiente.nextcloudnative.app.RawMediaSearchCompatibilityPolicy import dev.obiente.nextcloudnative.app.isRawPhoto import dev.obiente.nextcloudnative.app.mergeMediaSearchResultPages import dev.obiente.nextcloudnative.app.photoMediaCarryoverScope +import dev.obiente.nextcloudnative.app.sharedMediaTimelineDavCarryoverStore import dev.obiente.nextcloudnative.app.toPhotoTimelineEntryOrNull import dev.obiente.nextcloudnative.app.normalizeSystemTagsDavResponse import dev.obiente.nextcloudnative.app.parseNextcloudFileSharingCapabilities @@ -435,12 +435,9 @@ internal class AndroidNextcloudServices( ) } private val nativeMediaPreviewDecodeMutex = Mutex() - private val mediaTimelineCarryoverStore = MediaTimelineDavCarryoverStore() internal fun isDurableUploadAccountResolutionAvailable() = preferences.durableUploadAccountResolutionAvailable() internal fun durableUploadAccountRegistry() = accountCredentials.durableUploadAccountRegistry() - private val memoriesTimeline = MemoriesPreferredTimelineReadService { session, request -> - executeNextcloudApi(session, request) - } + private val memoriesTimeline = MemoriesPreferredTimelineReadService(::executeNextcloudApi) private val fileSyncEngine = AndroidFileSyncEngine(appContext) private val mediaSyncFolderDetector = AndroidMediaSyncFolderDetector(appContext) private val externalFileHandoff = AndroidExternalFileHandoff(appContext) @@ -1894,11 +1891,12 @@ internal class AndroidNextcloudServices( shouldSearchRaw = { files -> rawPreviouslyObserved || files.any(NextcloudFile::isRawPhoto) }, - carryoverStore = mediaTimelineCarryoverStore, + carryoverStore = sharedMediaTimelineDavCarryoverStore, carryoverAccountScope = photoMediaCarryoverScope( accountScope = NextcloudDocumentIds.cacheAccountId(session), owner = queryOwner, ), + carryoverAccountId = session.accountId, ) return PhotoTimelinePage( entries = page.files.mapNotNull(NextcloudFile::toPhotoTimelineEntryOrNull), @@ -1926,6 +1924,7 @@ internal class AndroidNextcloudServices( monthResolver: PhotoTimelineMonthResolver, ): MemoriesTimelineNavigationSnapshot? = withContext(Dispatchers.IO) { memoriesTimeline.navigationSnapshot( + accountId = session.accountId, accountScope = NextcloudDocumentIds.cacheAccountId(session), monthResolver = monthResolver, ) diff --git a/changes/unreleased/account-private-memory-retirement.md b/changes/unreleased/account-private-memory-retirement.md new file mode 100644 index 000000000..b5bc11a2d --- /dev/null +++ b/changes/unreleased/account-private-memory-retirement.md @@ -0,0 +1,7 @@ +category: fix +issue: 172 +pull: none +platforms: android, desktop +user-facing: yes + +Removing an account clears its previews, Notes cache, support drafts, and other private in-memory state. Memories timeline indices keep only four recently used account scopes. diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 671cd17fd..2651022a6 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|4229 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4228 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 @@ -20,7 +20,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt|1293 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt|2646 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingDynamicUiCaptureScenario.kt|909 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt|1244 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt|1172 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|1883 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt index 2c2d3ffe7..84a86baca 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt @@ -6,14 +6,17 @@ object AccountPrivateMemoryCleanup { internal fun purgeRetiredAccount(accountStorageKey: String) { require(accountStorageKey.length == 64 && accountStorageKey.all { it in '0'..'9' || it in 'a'..'f' }) - PreviewMemoryCache.purgeRetiredAccount(accountStorageKey) + sharedPreviewMemoryCache.purgeRetiredAccount(accountStorageKey) sharedNextcloudNotesCache.purgeRetiredAccount(accountStorageKey) sharedDynamicNativeMemoryCache.retireAccount(accountStorageKey) sharedDashboardStatusMemoryCache.purgeRetiredAccount(accountStorageKey) ContactsWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) DeckWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) sharedDocumentEditingCapabilitiesCache.purgeRetiredAccount(accountStorageKey) - SupportSettingsDraftRegistry.removeAccount(accountStorageKey) + SupportSettingsDraftRegistry.purgeRetiredAccount(accountStorageKey) + sharedMediaTimelineDavCarryoverStore.purgeRetiredAccount(accountStorageKey) + sharedMemoriesMainTimelineIndexCache.purgeRetiredAccount(accountStorageKey) + sharedMediaViewerNavigationRepository.purgeRetiredAccount(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 index 9cccb8d17..cdda646af 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt @@ -21,16 +21,38 @@ internal class AccountPrivateMemoryGate { if (accountStorageKey in closedAccounts) unavailable else action() } + fun read( + producer: AccountPrivateMemoryProducer?, + unavailable: T, + action: () -> T, + ): T = lock.withLock { + val currentProducer = producer ?: return@withLock unavailable + if (!accepts(currentProducer)) unavailable else action() + } + + fun read( + accountStorageKey: String, + producer: AccountPrivateMemoryProducer?, + unavailable: T, + action: () -> T, + ): T = lock.withLock { + val currentProducer = producer ?: return@withLock unavailable + require(currentProducer.accountStorageKey == accountStorageKey) { + "The private-memory producer belongs to another account." + } + if (!accepts(currentProducer)) unavailable else action() + } + fun mutate( accountStorageKey: String, producer: AccountPrivateMemoryProducer?, action: () -> Unit, ): Boolean = lock.withLock { - val current = producer ?: return@withLock false - require(current.accountStorageKey == accountStorageKey) { + val currentProducer = producer ?: return@withLock false + require(currentProducer.accountStorageKey == accountStorageKey) { "The private-memory producer belongs to another account." } - if (!accepts(current)) return@withLock false + if (!accepts(currentProducer)) return@withLock false action() true } @@ -42,11 +64,18 @@ internal class AccountPrivateMemoryGate { purge() } - fun activateAccount(accountStorageKey: String, prepare: () -> Unit = {}) = lock.withLock { + fun activateAccount( + accountStorageKey: String, + prepare: () -> Unit = {}, + activated: () -> Unit = {}, + ) = lock.withLock { prepare() closedAccounts.remove(accountStorageKey) + activated() } + fun withLock(action: () -> T): T = lock.withLock(action) + private fun accepts(producer: AccountPrivateMemoryProducer): Boolean = producer.accountStorageKey !in closedAccounts && (accountIncarnations[producer.accountStorageKey] ?: 0L) == producer.incarnation @@ -61,7 +90,8 @@ object AccountPrivateMemoryLifecycle { } fun activateAccount(accountStorageKey: String) = sharedAccountPrivateMemoryGate.activateAccount( - accountStorageKey, + accountStorageKey = accountStorageKey, prepare = { sharedDynamicNativeMemoryCache.activateAccount(accountStorageKey) }, + activated = SupportSettingsDraftRegistry::publishAccountActivated, ) } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt index e5648691c..ef6e2d7ec 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,9 +38,7 @@ internal fun SettingsScreen( var selectedSectionName by rememberSaveable(session.serverUrl, session.loginName) { mutableStateOf(null) } - val supportDrafts = remember(session.serverUrl, session.loginName) { - SupportSettingsDraftRegistry.stateFor(session) - } + val supportDrafts = rememberAccountSupportSettingsDraftState(session) val detailStateHolder = rememberSaveableStateHolder() var loggingOut by remember { mutableStateOf(false) } var logoutError by remember { mutableStateOf(null) } @@ -301,6 +300,17 @@ internal fun SettingsScreen( } } +@Composable +internal fun rememberAccountSupportSettingsDraftState(session: NextcloudSession): SupportSettingsDraftState { + val accountStorageKey = remember(session.serverUrl, session.loginName) { session.accountId.storageKey } + val activationRevision by remember { + SupportSettingsDraftRegistry.activationRevision() + }.collectAsState() + return remember(accountStorageKey, activationRevision) { + SupportSettingsDraftRegistry.stateFor(session) + } +} + internal fun expandedSettingsSection( selectedSection: SettingsWorkspaceSection?, visibleSections: List, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt index 1bb7c3010..18b096bee 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt @@ -2,6 +2,13 @@ package dev.obiente.nextcloudnative.app import androidx.compose.runtime.mutableStateOf +internal enum class StatusExpiryChoice(val label: String, val seconds: Long?) { + Never("No expiry", null), + OneHour("1 hour", 60L * 60L), + FourHours("4 hours", 4L * 60L * 60L), + OneDay("24 hours", 24L * 60L * 60L), +} + internal class PhotoTimelineUiState { val timeline = mutableStateOf(PhotoTimelineState(pageSize = MAX_PHOTO_TIMELINE_PAGE_SIZE)) val backupStatuses = mutableStateOf>(emptyMap()) @@ -10,22 +17,29 @@ internal class PhotoTimelineUiState { internal object PhotoTimelineUiStateRepository { private const val MAXIMUM_ACCOUNT_STATES = 4 + private val gate = sharedAccountPrivateMemoryGate private val accountStates = linkedMapOf() - fun stateFor(session: NextcloudSession): PhotoTimelineUiState { + fun stateFor(session: NextcloudSession): PhotoTimelineUiState = gate.read( + session.accountId.storageKey, null, + ) { val accountKey = previewCacheDigest(session) accountStates.remove(accountKey)?.let { existing -> accountStates[accountKey] = existing - return existing + return@read existing } val created = PhotoTimelineUiState() accountStates[accountKey] = created while (accountStates.size > MAXIMUM_ACCOUNT_STATES) accountStates.remove(accountStates.keys.first()) - return created - } + created + } ?: PhotoTimelineUiState() - fun removeAccount(accountStorageKey: String) { - accountStates.remove(accountStorageKey) + internal fun purgeRetiredAccount(accountStorageKey: String) { + accountStates.remove(accountStorageKey)?.let { retired -> + retired.timeline.value = PhotoTimelineState(pageSize = MAX_PHOTO_TIMELINE_PAGE_SIZE) + retired.backupStatuses.value = emptyMap() + retired.initialLoadCompleted.value = false + } } } @@ -40,6 +54,13 @@ internal sealed interface CalendarLoadState { data class Error(val message: String) : CalendarLoadState } +internal fun calendarReadyMatchesRequest( + readyMonth: CalendarMonth, + readyWindow: GroupwareDavTimeWindow, + requestedMonth: CalendarMonth, + requestedWindow: GroupwareDavTimeWindow, +): Boolean = readyMonth == requestedMonth && readyWindow == requestedWindow + internal object CalendarWorkspaceMemoryCache { private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf, CalendarLoadState.Ready>() @@ -98,8 +119,7 @@ internal object UserStatusWorkspaceMemoryCache { private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf() - fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = - gate.producer(session.accountId.storageKey) + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = gate.producer(session.accountId.storageKey) fun get(session: NextcloudSession): UserStatusSurfaceState.Available? = gate.read(session.accountId.storageKey, null) { @@ -215,7 +235,7 @@ internal fun removeUserStatusWorkspaceMemory(accountStorageKey: String) = UserStatusWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) internal fun removeNextcloudNativeWorkspaceMemory(accountStorageKey: String) { - PhotoTimelineUiStateRepository.removeAccount(accountStorageKey) + PhotoTimelineUiStateRepository.purgeRetiredAccount(accountStorageKey) ActivityWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) TalkWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt index 9396f3362..e03310a95 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt @@ -41,8 +41,8 @@ internal class DashboardStatusMemoryCache( } } - fun invalidate(session: NextcloudSession) { - gate.read(session.accountId.storageKey, Unit) { entries.remove(session.accountId) } + fun invalidate(session: NextcloudSession, producer: AccountPrivateMemoryProducer?) { + gate.mutate(session.accountId.storageKey, producer) { entries.remove(session.accountId) } } internal fun purgeRetiredAccount(accountStorageKey: String) { 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 ded33e7a0..496581fce 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt @@ -1398,13 +1398,6 @@ private fun DashboardFailure(message: String, onRetry: () -> Unit) { } } -private enum class StatusExpiryChoice(val label: String, val seconds: Long?) { - Never("No expiry", null), - OneHour("1 hour", 60L * 60L), - FourHours("4 hours", 4L * 60L * 60L), - OneDay("24 hours", 24L * 60L * 60L), -} - @Composable internal fun NativeUserStatusScreen( services: NextcloudPlatformServices, @@ -1717,6 +1710,7 @@ internal fun NativeUserStatusScreen( onClick = { mutationInProgress = true scope.launch { + val cacheProducer = sharedDashboardStatusMemoryCache.producer(session) runCatching { services.executeNextcloudApi(session, request).also { response -> require(response.status in 200..299) { @@ -1724,7 +1718,7 @@ internal fun NativeUserStatusScreen( } } }.onSuccess { - sharedDashboardStatusMemoryCache.invalidate(session) + sharedDashboardStatusMemoryCache.invalidate(session, cacheProducer) pendingEdit = null mutationInProgress = false mutationError = null 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 0cf769ad8..2db4f09bc 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt @@ -16,11 +16,9 @@ internal object DeckWorkspaceMemoryCache { private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf() - fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = - gate.producer(session.accountId.storageKey) + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = gate.producer(key(session)) - fun get(session: NextcloudSession): DeckWorkspaceMemorySnapshot? = - gate.read(session.accountId.storageKey, null) { + fun get(session: NextcloudSession): DeckWorkspaceMemorySnapshot? = gate.read(key(session), null) { val key = key(session) entries.remove(key)?.also { entries[key] = it } } @@ -30,7 +28,7 @@ internal object DeckWorkspaceMemoryCache { value: DeckWorkspaceMemorySnapshot, producer: AccountPrivateMemoryProducer?, ) { - gate.mutate(session.accountId.storageKey, producer) { + gate.mutate(key(session), producer) { val key = key(session) entries.remove(key) entries[key] = value 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 59f84b9c6..ff8cf8812 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt @@ -792,13 +792,6 @@ fun NativeGroupwareCalendarScreen( } } -internal fun calendarReadyMatchesRequest( - readyMonth: CalendarMonth, - readyWindow: GroupwareDavTimeWindow, - requestedMonth: CalendarMonth, - requestedWindow: GroupwareDavTimeWindow, -): Boolean = readyMonth == requestedMonth && readyWindow == requestedWindow - @Composable private fun CalendarError(message: String, retry: () -> Unit) { Column( 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 8f04dcd29..03c6cd3ad 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt @@ -13,8 +13,7 @@ internal object ContactsWorkspaceMemoryCache { private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf, ContactsLoadState.Ready>() - fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = - gate.producer(session.accountId.storageKey) + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = gate.producer(session.accountId.storageKey) fun get(session: NextcloudSession, userId: String): ContactsLoadState.Ready? = gate.read(session.accountId.storageKey, null) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt index ff8f86d03..a4b29f6a1 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt @@ -1,8 +1,6 @@ package dev.obiente.nextcloudnative.app import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock const val MAXIMUM_MEDIA_SEARCH_RESULTS = DEFAULT_PHOTO_TIMELINE_PAGE_SIZE const val PHOTO_TIMELINE_PARTITION_PAGE_SIZE = DEFAULT_PHOTO_TIMELINE_PAGE_SIZE @@ -10,8 +8,6 @@ const val MAXIMUM_RAW_MEDIA_SEARCH_PATTERNS_PER_REQUEST = 8 const val MAXIMUM_RAW_MEDIA_SEARCH_REQUESTS = 15 const val MAXIMUM_MEDIA_SEARCH_RESULT_PAGES = 2 + MAXIMUM_RAW_MEDIA_SEARCH_REQUESTS private val MEDIA_SEARCH_MIME_PATTERNS = listOf("image/%", "video/%") -private const val DEFAULT_MEDIA_TIMELINE_CARRYOVER_ACCOUNT_LIMIT = 4 -private const val DEFAULT_MEDIA_TIMELINE_CARRYOVER_CURSOR_LIMIT = 4 enum class MediaSearchDavPartition { ImageMime, @@ -45,87 +41,6 @@ data class MediaTimelineDavPage( val optionalRawSearchRetryPending: Boolean = false, ) -/** - * Retains at most one already-fetched server page per active SearchDAV partition. - * - * The store is deliberately runtime-only. Its compact cursor remains sufficient for a stateless - * retry when the process, account LRU, or refresh generation has discarded buffered records. - */ -class MediaTimelineDavCarryoverStore( - private val maximumAccountScopes: Int = DEFAULT_MEDIA_TIMELINE_CARRYOVER_ACCOUNT_LIMIT, - private val maximumCursorsPerAccount: Int = DEFAULT_MEDIA_TIMELINE_CARRYOVER_CURSOR_LIMIT, -) { - private data class AccountState( - val generation: Long, - val continuations: LinkedHashMap, - ) - - private val mutex = Mutex() - private val accounts = linkedMapOf() - private var nextGeneration = 0L - - init { - require(maximumAccountScopes > 0) - require(maximumCursorsPerAccount > 0) - } - - internal suspend fun beginAccountGeneration(accountScope: String): Long = - mutex.withLock { - requireMediaTimelineAccountScope(accountScope) - nextGeneration = if (nextGeneration == Long.MAX_VALUE) 1L else nextGeneration + 1L - accounts.remove(accountScope) - accounts[accountScope] = AccountState(nextGeneration, linkedMapOf()) - while (accounts.size > maximumAccountScopes) { - accounts.remove(accounts.keys.first()) - } - nextGeneration - } - - internal suspend fun take( - accountScope: String, - generation: Long, - cursor: PhotoTimelineCursor, - ): MediaTimelineDavCarryover? = mutex.withLock { - requireMediaTimelineAccountScope(accountScope) - val account = accounts[accountScope]?.takeIf { it.generation == generation } - ?: return@withLock null - val continuation = account.continuations.remove(cursor.value) - accounts.remove(accountScope) - accounts[accountScope] = account - continuation - } - - internal suspend fun put( - accountScope: String, - generation: Long, - cursor: PhotoTimelineCursor, - carryover: MediaTimelineDavCarryover, - ) { - mutex.withLock { - requireMediaTimelineAccountScope(accountScope) - val account = accounts[accountScope]?.takeIf { it.generation == generation } - ?: return@withLock - account.continuations.remove(cursor.value) - account.continuations[cursor.value] = carryover - while (account.continuations.size > maximumCursorsPerAccount) { - account.continuations.remove(account.continuations.keys.first()) - } - accounts.remove(accountScope) - accounts[accountScope] = account - } - } -} - -private fun requireMediaTimelineAccountScope(accountScope: String) { - require( - accountScope.isNotBlank() && - accountScope.length <= 256 && - accountScope.none(Char::isISOControl), - ) { - "The photo timeline carryover scope is invalid." - } -} - fun mediaSearchDavRequestBody( userId: String, maximumResults: Int = MAXIMUM_MEDIA_SEARCH_RESULTS, @@ -247,15 +162,24 @@ suspend fun collectMediaTimelineDavPage( shouldSearchRaw: (List) -> Boolean, carryoverStore: MediaTimelineDavCarryoverStore? = null, carryoverAccountScope: String? = null, + carryoverAccountId: NextcloudAccountId? = null, ): MediaTimelineDavPage { - require((carryoverStore == null) == (carryoverAccountScope == null)) { + require( + (carryoverStore == null && carryoverAccountScope == null && carryoverAccountId == null) || + (carryoverStore != null && carryoverAccountScope != null && carryoverAccountId != null), + ) { "The photo timeline carryover scope is invalid." } carryoverAccountScope?.let(::requireMediaTimelineAccountScope) + val carryoverProducer = carryoverStore?.producer(requireNotNull(carryoverAccountId)) val decodedCursor = cursor?.let(::decodeMediaTimelineDavCursor) val runtimeGeneration = when { carryoverStore == null -> null - cursor == null -> carryoverStore.beginAccountGeneration(requireNotNull(carryoverAccountScope)) + cursor == null -> carryoverStore.beginAccountGeneration( + accountId = requireNotNull(carryoverAccountId), + accountScope = requireNotNull(carryoverAccountScope), + producer = carryoverProducer, + ) else -> decodedCursor?.runtimeGeneration } val runtimeCarryover = if ( @@ -264,9 +188,11 @@ suspend fun collectMediaTimelineDavPage( runtimeGeneration != null ) { carryoverStore.take( + accountId = requireNotNull(carryoverAccountId), accountScope = requireNotNull(carryoverAccountScope), generation = runtimeGeneration, cursor = cursor, + producer = carryoverProducer, ) } else { null @@ -471,10 +397,12 @@ suspend fun collectMediaTimelineDavPage( merged.carryover.partitions.isNotEmpty() ) { carryoverStore.put( + accountId = requireNotNull(carryoverAccountId), accountScope = requireNotNull(carryoverAccountScope), generation = runtimeGeneration, cursor = nextCursor, carryover = merged.carryover, + producer = carryoverProducer, ) } return MediaTimelineDavPage( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaTimelineDavCarryoverStore.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaTimelineDavCarryoverStore.kt new file mode 100644 index 000000000..c64bd41ab --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaTimelineDavCarryoverStore.kt @@ -0,0 +1,127 @@ +package dev.obiente.nextcloudnative.app + +private const val DEFAULT_MEDIA_TIMELINE_CARRYOVER_ACCOUNT_LIMIT = 4 +private const val DEFAULT_MEDIA_TIMELINE_CARRYOVER_CURSOR_LIMIT = 4 + +/** + * Retains at most one already-fetched server page per active SearchDAV partition. + * + * The store is deliberately runtime-only. Its compact cursor remains sufficient for a stateless + * retry when the process, account LRU, or refresh generation has discarded buffered records. + */ +class MediaTimelineDavCarryoverStore private constructor( + private val maximumAccountScopes: Int = DEFAULT_MEDIA_TIMELINE_CARRYOVER_ACCOUNT_LIMIT, + private val maximumCursorsPerAccount: Int = DEFAULT_MEDIA_TIMELINE_CARRYOVER_CURSOR_LIMIT, + private val gate: AccountPrivateMemoryGate, +) { + constructor( + maximumAccountScopes: Int = DEFAULT_MEDIA_TIMELINE_CARRYOVER_ACCOUNT_LIMIT, + maximumCursorsPerAccount: Int = DEFAULT_MEDIA_TIMELINE_CARRYOVER_CURSOR_LIMIT, + ) : this(maximumAccountScopes, maximumCursorsPerAccount, AccountPrivateMemoryGate()) + + internal constructor(gate: AccountPrivateMemoryGate) : this( + DEFAULT_MEDIA_TIMELINE_CARRYOVER_ACCOUNT_LIMIT, + DEFAULT_MEDIA_TIMELINE_CARRYOVER_CURSOR_LIMIT, + gate, + ) + + private data class AccountScope( + val accountId: NextcloudAccountId, + val value: String, + ) + + private data class AccountState( + val generation: Long, + val continuations: LinkedHashMap, + ) + + private val lock = DynamicNativeMemoryCacheLock() + private val accounts = linkedMapOf() + private var nextGeneration = 0L + + init { + require(maximumAccountScopes > 0) + require(maximumCursorsPerAccount > 0) + } + + internal fun producer(accountId: NextcloudAccountId): AccountPrivateMemoryProducer? = + gate.producer(accountId.storageKey) + + internal fun beginAccountGeneration( + accountId: NextcloudAccountId, + accountScope: String, + producer: AccountPrivateMemoryProducer?, + ): Long? = gate.read(accountId.storageKey, producer, null) { + lock.withLock { + requireMediaTimelineAccountScope(accountScope) + val scope = AccountScope(accountId, accountScope) + nextGeneration = if (nextGeneration == Long.MAX_VALUE) 1L else nextGeneration + 1L + accounts.remove(scope) + accounts[scope] = AccountState(nextGeneration, linkedMapOf()) + while (accounts.size > maximumAccountScopes) { + accounts.remove(accounts.keys.first()) + } + nextGeneration + } + } + + internal fun take( + accountId: NextcloudAccountId, + accountScope: String, + generation: Long, + cursor: PhotoTimelineCursor, + producer: AccountPrivateMemoryProducer?, + ): MediaTimelineDavCarryover? = gate.read(accountId.storageKey, producer, null) { + lock.withLock { + requireMediaTimelineAccountScope(accountScope) + val scope = AccountScope(accountId, accountScope) + val account = accounts[scope]?.takeIf { it.generation == generation } + ?: return@withLock null + val continuation = account.continuations.remove(cursor.value) + accounts.remove(scope) + accounts[scope] = account + continuation + } + } + + internal fun put( + accountId: NextcloudAccountId, + accountScope: String, + generation: Long, + cursor: PhotoTimelineCursor, + carryover: MediaTimelineDavCarryover, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(accountId.storageKey, producer) { + lock.withLock { + requireMediaTimelineAccountScope(accountScope) + val scope = AccountScope(accountId, accountScope) + val account = accounts[scope]?.takeIf { it.generation == generation } + ?: return@withLock + account.continuations.remove(cursor.value) + account.continuations[cursor.value] = carryover + while (account.continuations.size > maximumCursorsPerAccount) { + account.continuations.remove(account.continuations.keys.first()) + } + accounts.remove(scope) + accounts[scope] = account + } + } + } + + internal fun purgeRetiredAccount(accountStorageKey: String) = lock.withLock { + accounts.entries.removeAll { (scope, _) -> scope.accountId.storageKey == accountStorageKey } + } +} + +val sharedMediaTimelineDavCarryoverStore = MediaTimelineDavCarryoverStore(sharedAccountPrivateMemoryGate) + +internal fun requireMediaTimelineAccountScope(accountScope: String) { + require( + accountScope.isNotBlank() && + accountScope.length <= 256 && + accountScope.none(Char::isISOControl), + ) { + "The photo timeline carryover scope is invalid." + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt index 49ede8a84..cf0ff893e 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt @@ -39,16 +39,30 @@ data class MediaViewerNavigationSnapshot( * navigation across activity recreation; after process death the caller safely returns to the * source screen, whose timeline repository can reload the account-scoped window. */ -class MediaViewerNavigationRepository( +internal class MediaViewerNavigationRepository private constructor( private val maximumRoutes: Int = 8, private val maximumItemsPerRoute: Int = MAX_PHOTO_TIMELINE_RETAINED_ITEMS, + private val gate: AccountPrivateMemoryGate, ) { private data class RouteContent( + val accountId: NextcloudAccountId, val media: List, val sourceMembers: List, val navigationIdentityBySourceIdentity: Map, ) + constructor( + maximumRoutes: Int = 8, + maximumItemsPerRoute: Int = MAX_PHOTO_TIMELINE_RETAINED_ITEMS, + ) : this(maximumRoutes, maximumItemsPerRoute, AccountPrivateMemoryGate()) + + internal constructor(gate: AccountPrivateMemoryGate) : this( + maximumRoutes = 8, + maximumItemsPerRoute = MAX_PHOTO_TIMELINE_RETAINED_ITEMS, + gate = gate, + ) + + private val lock = DynamicNativeMemoryCacheLock() private val routes = linkedMapOf() private var nextRouteId = 1L @@ -57,12 +71,17 @@ class MediaViewerNavigationRepository( require(maximumItemsPerRoute in 1..MAX_PHOTO_TIMELINE_RETAINED_ITEMS) } + internal fun producer(accountId: NextcloudAccountId): AccountPrivateMemoryProducer? = + gate.producer(accountId.storageKey) + fun register( + accountId: NextcloudAccountId, media: List, selected: NextcloudFile, sourceMembers: List = media, navigationIdentityBySourceIdentity: Map = emptyMap(), - ): MediaViewerNavigationRoute { + producer: AccountPrivateMemoryProducer? = producer(accountId), + ): MediaViewerNavigationRoute? { require(!selected.isDirectory) { "A media viewer route cannot select a directory." } val unique = media .filterNot(NextcloudFile::isDirectory) @@ -85,7 +104,6 @@ class MediaViewerNavigationRepository( mediaViewerFileIdentity(candidate) == selectedIdentity } check(selectedIndex >= 0) { "The selected media item is missing from its viewer route." } - val key = "media-${nextRouteId++}" val retainedIdentities = bounded .mapTo(mutableSetOf(), ::mediaViewerFileIdentity) val boundedSources = (bounded + sourceMembers) @@ -96,27 +114,20 @@ class MediaViewerNavigationRepository( bounded.any { navigationItem -> navigationItem.sharesMediaStackWith(source) } } .take(maximumItemsPerRoute * MAX_MEDIA_VIEWER_SOURCE_MEMBERS_PER_ITEM) - routes[key] = RouteContent( - media = bounded, - sourceMembers = boundedSources, - navigationIdentityBySourceIdentity = buildMap { - boundedSources.forEach { source -> - val sourceIdentity = mediaViewerFileIdentity(source) - val declaredNavigationIdentity = - navigationIdentityBySourceIdentity[sourceIdentity] - val navigationIdentity = declaredNavigationIdentity - ?.takeIf(retainedIdentities::contains) - ?: bounded.firstOrNull { navigationItem -> - navigationItem.sharesMediaStackWith(source) - }?.let(::mediaViewerFileIdentity) - if (navigationIdentity != null) { - put(sourceIdentity, navigationIdentity) - } + val sourceNavigationIdentities = buildMap { + boundedSources.forEach { source -> + val sourceIdentity = mediaViewerFileIdentity(source) + val declaredNavigationIdentity = + navigationIdentityBySourceIdentity[sourceIdentity] + val navigationIdentity = declaredNavigationIdentity + ?.takeIf(retainedIdentities::contains) + ?: bounded.firstOrNull { navigationItem -> + navigationItem.sharesMediaStackWith(source) + }?.let(::mediaViewerFileIdentity) + if (navigationIdentity != null) { + put(sourceIdentity, navigationIdentity) } - }, - ) - while (routes.size > maximumRoutes) { - routes.remove(routes.keys.first()) + } } val selectedSourceIndex = boundedSources.indexOfFirst { candidate -> mediaViewerFileIdentity(candidate) == selectedIdentity @@ -124,51 +135,92 @@ class MediaViewerNavigationRepository( check(selectedSourceIndex >= 0) { "The selected media source is missing from its viewer route." } - return MediaViewerNavigationRoute(key, selectedIndex, selectedSourceIndex) + var route: MediaViewerNavigationRoute? = null + gate.mutate(accountId.storageKey, producer) { + lock.withLock { + val key = "media-${nextRouteId++}" + routes[key] = RouteContent( + accountId = accountId, + media = bounded, + sourceMembers = boundedSources, + navigationIdentityBySourceIdentity = sourceNavigationIdentities, + ) + while (routes.size > maximumRoutes) { + routes.remove(routes.keys.first()) + } + route = MediaViewerNavigationRoute(key, selectedIndex, selectedSourceIndex) + } + } + return route } - fun resolve(route: MediaViewerNavigationRoute): MediaViewerNavigationSnapshot? { - val content = routes[route.key] ?: return null - if (route.selectedIndex !in content.media.indices) return null - if (route.selectedSourceIndex !in content.sourceMembers.indices) return null - return MediaViewerNavigationSnapshot( - media = content.media, - selectedIndex = route.selectedIndex, - sourceMembers = content.sourceMembers, - selectedSourceIndex = route.selectedSourceIndex, - ) + fun resolve( + accountId: NextcloudAccountId, + route: MediaViewerNavigationRoute, + ): MediaViewerNavigationSnapshot? = gate.read(accountId.storageKey, null) { + lock.withLock { + val content = routes[route.key]?.takeIf { it.accountId == accountId } + ?: return@withLock null + if (route.selectedIndex !in content.media.indices) return@withLock null + if (route.selectedSourceIndex !in content.sourceMembers.indices) return@withLock null + MediaViewerNavigationSnapshot( + media = content.media, + selectedIndex = route.selectedIndex, + sourceMembers = content.sourceMembers, + selectedSourceIndex = route.selectedSourceIndex, + ) + } } fun select( + accountId: NextcloudAccountId, route: MediaViewerNavigationRoute, selected: NextcloudFile, - ): MediaViewerNavigationRoute? { - val content = routes[route.key] ?: return null - val selectedIdentity = mediaViewerFileIdentity(selected) - val sourceIndex = content.sourceMembers.indexOfFirst { candidate -> - mediaViewerFileIdentity(candidate) == selectedIdentity + ): MediaViewerNavigationRoute? = gate.read(accountId.storageKey, null) { + lock.withLock { + val content = routes[route.key]?.takeIf { it.accountId == accountId } + ?: return@withLock null + val selectedIdentity = mediaViewerFileIdentity(selected) + val sourceIndex = content.sourceMembers.indexOfFirst { candidate -> + mediaViewerFileIdentity(candidate) == selectedIdentity + } + if (sourceIndex < 0) return@withLock null + val mappedNavigationIdentity = + content.navigationIdentityBySourceIdentity[selectedIdentity] + val navigationIndex = content.media.indexOfFirst { candidate -> + val candidateIdentity = mediaViewerFileIdentity(candidate) + candidateIdentity == selectedIdentity || + candidateIdentity == mappedNavigationIdentity || + candidate.sharesMediaStackWith(selected) + } + if (navigationIndex < 0) return@withLock null + route.copy( + selectedIndex = navigationIndex, + selectedSourceIndex = sourceIndex, + ) } - if (sourceIndex < 0) return null - val mappedNavigationIdentity = - content.navigationIdentityBySourceIdentity[selectedIdentity] - val navigationIndex = content.media.indexOfFirst { candidate -> - val candidateIdentity = mediaViewerFileIdentity(candidate) - candidateIdentity == selectedIdentity || - candidateIdentity == mappedNavigationIdentity || - candidate.sharesMediaStackWith(selected) + } + + fun release(accountId: NextcloudAccountId, routeKey: String) { + gate.read(accountId.storageKey, Unit) { + lock.withLock { + if (routes[routeKey]?.accountId == accountId) { + routes.remove(routeKey) + } + } } - if (navigationIndex < 0) return null - return route.copy( - selectedIndex = navigationIndex, - selectedSourceIndex = sourceIndex, - ) } - fun release(routeKey: String) { - routes.remove(routeKey) + internal fun purgeRetiredAccount(accountStorageKey: String) = lock.withLock { + routes.entries.removeAll { (_, content) -> + content.accountId.storageKey == accountStorageKey + } } } +internal val sharedMediaViewerNavigationRepository = + MediaViewerNavigationRepository(sharedAccountPrivateMemoryGate) + internal fun mediaViewerFileIdentity(file: NextcloudFile): String = file.fileId?.takeIf { it > 0L }?.let { "file:$it" } ?: "path:${file.path.trim('/')}" diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimeline.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimeline.kt index 7c8f24cb2..ed9cf2454 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimeline.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimeline.kt @@ -1,8 +1,6 @@ package dev.obiente.nextcloudnative.app import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -429,11 +427,20 @@ class MemoriesMainTimelineReadService internal constructor( } } -class MemoriesPreferredTimelineReadService( +class MemoriesPreferredTimelineReadService internal constructor( execute: suspend (NextcloudSession, NextcloudApiRequest) -> NextcloudApiResponse, + private val indexCache: MemoriesMainTimelineIndexCache, ) { private val memories = MemoriesMainTimelineReadService(execute) - private val indexCache = MemoriesMainTimelineIndexCache() + + constructor( + execute: suspend (NextcloudSession, NextcloudApiRequest) -> NextcloudApiResponse, + ) : this(execute, sharedMemoriesMainTimelineIndexCache) + + internal constructor( + execute: suspend (NextcloudSession, NextcloudApiRequest) -> NextcloudApiResponse, + gate: AccountPrivateMemoryGate, + ) : this(execute, MemoriesMainTimelineIndexCache(gate)) constructor(services: NextcloudPlatformServices) : this(services::executeNextcloudApi) @@ -445,13 +452,18 @@ class MemoriesPreferredTimelineReadService( fallback: suspend (PhotoTimelineCursor?) -> PhotoTimelinePage, ): PhotoTimelinePage { require(accountScope.isNotBlank()) { "The Memories timeline account scope is missing." } + val producer = checkNotNull(indexCache.producer(session.accountId)) { + "The Memories timeline account is no longer available." + } if (cursor != null && !cursor.isMemoriesMainTimelineCursor()) { - indexCache.markFallbackActive(accountScope) + indexCache.markFallbackActive(session.accountId, accountScope, producer) return fallback(cursor) } val indexResult = indexCache.load( + accountId = session.accountId, accountScope = accountScope, forceRefresh = cursor == null, + producer = producer, ) { memories.loadDayIndex(session) } @@ -461,7 +473,7 @@ class MemoriesPreferredTimelineReadService( require(!cursor.isMemoriesMainTimelineCursor()) { "The Memories timeline became unavailable; refresh the timeline." } - indexCache.markFallbackActive(accountScope) + indexCache.markFallbackActive(session.accountId, accountScope, producer) return fallback(cursor) } } @@ -476,8 +488,10 @@ class MemoriesPreferredTimelineReadService( is MemoriesMainTimelineLoadResult.Loaded -> { check( indexCache.markMemoriesActive( + accountId = session.accountId, accountScope = accountScope, sourceGeneration = cachedIndex.sourceGeneration, + producer = producer, ), ) { "The Memories timeline index changed while loading a page; refresh the timeline." @@ -489,18 +503,19 @@ class MemoriesPreferredTimelineReadService( require(cursor == null) { "The Memories timeline paging contract changed; refresh the timeline." } - indexCache.markFallbackActive(accountScope) + indexCache.markFallbackActive(session.accountId, accountScope, producer) fallback(null) } } } suspend fun navigationSnapshot( + accountId: NextcloudAccountId, accountScope: String, monthResolver: PhotoTimelineMonthResolver = UtcPhotoTimelineMonthResolver, ): MemoriesTimelineNavigationSnapshot? { require(accountScope.isNotBlank()) { "The Memories timeline account scope is missing." } - val cached = indexCache.activeMemoriesIndex(accountScope) ?: return null + val cached = indexCache.activeMemoriesIndex(accountId, accountScope) ?: return null val geometry = buildMemoriesTimelinePlaceholderGeometry( index = cached.index, monthResolver = monthResolver, @@ -522,9 +537,12 @@ class MemoriesPreferredTimelineReadService( require(sourceGeneration > 0L) { "The Memories timeline navigation generation is invalid." } + val producer = indexCache.producer(session.accountId) val cached = indexCache.activeMemoriesIndex( + accountId = session.accountId, accountScope = accountScope, sourceGeneration = sourceGeneration, + producer = producer, ) ?: return MemoriesTimelineNavigationLoadResult.Stale val advertisedNewerItems = cached.index.advertisedItemsBefore(targetDayId) ?: return MemoriesTimelineNavigationLoadResult.Unavailable( @@ -543,8 +561,10 @@ class MemoriesPreferredTimelineReadService( ) if ( !indexCache.matchesActiveMemoriesIndex( + accountId = session.accountId, accountScope = accountScope, sourceGeneration = sourceGeneration, + producer = producer, ) ) { return MemoriesTimelineNavigationLoadResult.Stale @@ -573,97 +593,6 @@ class MemoriesPreferredTimelineReadService( } } -private class MemoriesMainTimelineIndexCache { - private val mutex = Mutex() - private var accountScope: String? = null - private var index: MemoriesMainTimelineDayIndex? = null - private var sourceGeneration = 0L - private var memoriesActive = false - - suspend fun load( - accountScope: String, - forceRefresh: Boolean, - fetch: suspend () -> MemoriesMainTimelineLoadResult, - ): MemoriesMainTimelineLoadResult = mutex.withLock { - if (!forceRefresh && accountScope == this.accountScope) { - index?.let { - return@withLock MemoriesMainTimelineLoadResult.Loaded( - MemoriesMainTimelineCachedIndex(it, sourceGeneration), - ) - } - } - val loaded = fetch() - when (loaded) { - is MemoriesMainTimelineLoadResult.Loaded -> { - sourceGeneration = nextMemoriesTimelineSourceGeneration(sourceGeneration) - this.accountScope = accountScope - index = loaded.value - memoriesActive = false - MemoriesMainTimelineLoadResult.Loaded( - MemoriesMainTimelineCachedIndex(loaded.value, sourceGeneration), - ) - } - - is MemoriesMainTimelineLoadResult.UseFallback -> { - sourceGeneration = nextMemoriesTimelineSourceGeneration(sourceGeneration) - this.accountScope = null - index = null - memoriesActive = false - loaded - } - } - } - - suspend fun markMemoriesActive( - accountScope: String, - sourceGeneration: Long, - ): Boolean = mutex.withLock { - val matches = - accountScope == this.accountScope && - sourceGeneration == this.sourceGeneration && - index != null - if (matches) memoriesActive = true - matches - } - - suspend fun markFallbackActive(accountScope: String) = mutex.withLock { - if (accountScope == this.accountScope) memoriesActive = false - } - - suspend fun activeMemoriesIndex( - accountScope: String, - sourceGeneration: Long? = null, - ): MemoriesMainTimelineCachedIndex? = mutex.withLock { - val currentIndex = index ?: return@withLock null - if ( - !memoriesActive || - accountScope != this.accountScope || - (sourceGeneration != null && sourceGeneration != this.sourceGeneration) - ) { - return@withLock null - } - MemoriesMainTimelineCachedIndex(currentIndex, this.sourceGeneration) - } - - suspend fun matchesActiveMemoriesIndex( - accountScope: String, - sourceGeneration: Long, - ): Boolean = mutex.withLock { - memoriesActive && - index != null && - accountScope == this.accountScope && - sourceGeneration == this.sourceGeneration - } -} - -private data class MemoriesMainTimelineCachedIndex( - val index: MemoriesMainTimelineDayIndex, - val sourceGeneration: Long, -) - -private fun nextMemoriesTimelineSourceGeneration(current: Long): Long = - if (current == Long.MAX_VALUE) 1L else current + 1L - private fun PhotoTimelineCursor?.isMemoriesMainTimelineCursor(): Boolean = this?.value?.startsWith(MEMORIES_MAIN_TIMELINE_CURSOR_PREFIX) == true diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimelineIndexCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimelineIndexCache.kt new file mode 100644 index 000000000..4b3569414 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimelineIndexCache.kt @@ -0,0 +1,184 @@ +package dev.obiente.nextcloudnative.app + +private const val MAXIMUM_RETAINED_MEMORIES_ACCOUNT_SCOPES = 4 + +internal class MemoriesMainTimelineIndexCache( + private val gate: AccountPrivateMemoryGate, +) { + private data class AccountScope( + val accountId: NextcloudAccountId, + val value: String, + ) + + private data class AccountState( + val index: MemoriesMainTimelineDayIndex, + val sourceGeneration: Long, + var memoriesActive: Boolean, + ) + + private val lock = DynamicNativeMemoryCacheLock() + private val accounts = linkedMapOf() + private val pendingLoads = mutableMapOf() + private var sourceGeneration = 0L + private var nextLoadId = 0L + + internal fun producer(accountId: NextcloudAccountId): AccountPrivateMemoryProducer? = + gate.producer(accountId.storageKey) + + suspend fun load( + accountId: NextcloudAccountId, + accountScope: String, + forceRefresh: Boolean, + producer: AccountPrivateMemoryProducer?, + fetch: suspend () -> MemoriesMainTimelineLoadResult, + ): MemoriesMainTimelineLoadResult { + val scope = AccountScope(accountId, accountScope) + var cached: MemoriesMainTimelineCachedIndex? = null + var loadId: Long? = null + val started = gate.mutate(accountId.storageKey, producer) { + lock.withLock { + if (!forceRefresh) { + accounts[scope]?.let { state -> + accounts.remove(scope) + accounts[scope] = state + cached = MemoriesMainTimelineCachedIndex( + state.index, + state.sourceGeneration, + ) + } + } + if (cached == null) { + nextLoadId = nextMemoriesTimelineSourceGeneration(nextLoadId) + loadId = nextLoadId + pendingLoads[scope] = nextLoadId + } + } + } + check(started) { "The Memories timeline account is no longer available." } + cached?.let { + return MemoriesMainTimelineLoadResult.Loaded(it) + } + val loaded = try { + fetch() + } catch (failure: Throwable) { + gate.mutate(accountId.storageKey, producer) { + lock.withLock { + if (pendingLoads[scope] == loadId) pendingLoads.remove(scope) + } + } + throw failure + } + var published: MemoriesMainTimelineLoadResult? = null + val accepted = gate.mutate(accountId.storageKey, producer) { + lock.withLock { + if (pendingLoads[scope] != loadId) return@withLock + pendingLoads.remove(scope) + sourceGeneration = nextMemoriesTimelineSourceGeneration(sourceGeneration) + when (loaded) { + is MemoriesMainTimelineLoadResult.Loaded -> { + val state = AccountState( + index = loaded.value, + sourceGeneration = sourceGeneration, + memoriesActive = false, + ) + accounts.remove(scope) + accounts[scope] = state + while (accounts.size > MAXIMUM_RETAINED_MEMORIES_ACCOUNT_SCOPES) { + accounts.remove(accounts.keys.first()) + } + published = MemoriesMainTimelineLoadResult.Loaded( + MemoriesMainTimelineCachedIndex(state.index, state.sourceGeneration), + ) + } + + is MemoriesMainTimelineLoadResult.UseFallback -> { + accounts.remove(scope) + published = loaded + } + } + } + } + check(accepted) { + "The Memories timeline account changed while loading the index; refresh the timeline." + } + return checkNotNull(published) { + "The Memories timeline index changed while loading; refresh the timeline." + } + } + + fun markMemoriesActive( + accountId: NextcloudAccountId, + accountScope: String, + sourceGeneration: Long, + producer: AccountPrivateMemoryProducer?, + ): Boolean = gate.read(accountId.storageKey, producer, false) { + lock.withLock { + val scope = AccountScope(accountId, accountScope) + val state = accounts[scope] + ?.takeIf { it.sourceGeneration == sourceGeneration } + ?: return@withLock false + state.memoriesActive = true + accounts.remove(scope) + accounts[scope] = state + true + } + } + + fun markFallbackActive( + accountId: NextcloudAccountId, + accountScope: String, + producer: AccountPrivateMemoryProducer?, + ) = gate.read(accountId.storageKey, producer, Unit) { + lock.withLock { + accounts[AccountScope(accountId, accountScope)]?.memoriesActive = false + } + } + + fun activeMemoriesIndex( + accountId: NextcloudAccountId, + accountScope: String, + sourceGeneration: Long? = null, + producer: AccountPrivateMemoryProducer? = producer(accountId), + ): MemoriesMainTimelineCachedIndex? = gate.read(accountId.storageKey, producer, null) { + lock.withLock { + val scope = AccountScope(accountId, accountScope) + val state = accounts[scope] + ?.takeIf { current -> + current.memoriesActive && + (sourceGeneration == null || sourceGeneration == current.sourceGeneration) + } + ?: return@withLock null + accounts.remove(scope) + accounts[scope] = state + MemoriesMainTimelineCachedIndex(state.index, state.sourceGeneration) + } + } + + fun matchesActiveMemoriesIndex( + accountId: NextcloudAccountId, + accountScope: String, + sourceGeneration: Long, + producer: AccountPrivateMemoryProducer?, + ): Boolean = activeMemoriesIndex( + accountId = accountId, + accountScope = accountScope, + sourceGeneration = sourceGeneration, + producer = producer, + ) != null + + internal fun purgeRetiredAccount(accountStorageKey: String) = lock.withLock { + accounts.entries.removeAll { (scope, _) -> scope.accountId.storageKey == accountStorageKey } + pendingLoads.keys.removeAll { scope -> scope.accountId.storageKey == accountStorageKey } + } +} + +internal val sharedMemoriesMainTimelineIndexCache = + MemoriesMainTimelineIndexCache(sharedAccountPrivateMemoryGate) + +internal data class MemoriesMainTimelineCachedIndex( + val index: MemoriesMainTimelineDayIndex, + val sourceGeneration: Long, +) + +private fun nextMemoriesTimelineSourceGeneration(current: Long): Long = + if (current == Long.MAX_VALUE) 1L else current + 1L 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 01ecfc93d..dba39e2e4 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt @@ -30,6 +30,7 @@ fun NativeDeckScreen( modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() + val workspaceProducer = remember(session) { DeckWorkspaceMemoryCache.producer(session) } val retainedWorkspace = remember(session) { DeckWorkspaceMemoryCache.get(session) } var state by remember(session) { mutableStateOf(retainedWorkspace?.state ?: DeckWorkspaceState.Loading) @@ -82,7 +83,6 @@ fun NativeDeckScreen( requestedBoardId, requestedCardId, ) { - val cacheProducer = DeckWorkspaceMemoryCache.producer(session) DeckWorkspaceMemoryCache.store( session, DeckWorkspaceMemorySnapshot( @@ -94,7 +94,7 @@ fun NativeDeckScreen( requestedBoardId = requestedBoardId, requestedCardId = requestedCardId, ), - cacheProducer, + workspaceProducer, ) } 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 1670d485a..384e8b1d2 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -304,7 +304,7 @@ internal fun NativeAppSchema.forDynamicContractVersion( private const val DYNAMIC_MUTATION_AUTHORITATIVE_READ_DELAY_MILLIS = 500L -private val mediaViewerNavigationRepository = MediaViewerNavigationRepository() +private val mediaViewerNavigationRepository = sharedMediaViewerNavigationRepository private inline fun > enumSaver() = Saver( save = { value -> value.name }, @@ -1588,13 +1588,13 @@ private fun AuthenticatedApp( returnTo: Screen, sourceMembers: List = media, navigationIdentityBySourceIdentity: Map = emptyMap(), - ): Screen.MediaViewer { + ): Screen { val route = mediaViewerNavigationRepository.register( - media = media, + accountId = session.accountId, media = media, selected = selected, sourceMembers = sourceMembers, navigationIdentityBySourceIdentity = navigationIdentityBySourceIdentity, - ) + ) ?: return returnTo return Screen.MediaViewer( navigationKey = route.key, selectedIndex = route.selectedIndex, @@ -1979,7 +1979,7 @@ private fun AuthenticatedApp( is Screen.Chat -> screen = Screen.Talk is Screen.NoteEditor -> screen = Screen.Notes is Screen.MediaViewer -> { - mediaViewerNavigationRepository.release(current.navigationKey) + mediaViewerNavigationRepository.release(session.accountId, current.navigationKey) screen = current.returnTo } is Screen.FileInfo -> screen = Screen.Files(current.parentPath) @@ -2463,7 +2463,7 @@ private fun AuthenticatedApp( selectedIndex = current.selectedIndex, selectedSourceIndex = current.selectedSourceIndex, ) - val snapshot = mediaViewerNavigationRepository.resolve(route) + val snapshot = mediaViewerNavigationRepository.resolve(session.accountId, route) if (snapshot == null) { LaunchedEffect(current.navigationKey) { screen = current.returnTo @@ -2481,7 +2481,7 @@ private fun AuthenticatedApp( sharingCapabilities = serverInfo?.fileSharing ?: NextcloudFileSharingCapabilities.Unavailable, onSelect = { selected -> - mediaViewerNavigationRepository.select(route, selected)?.let { next -> + mediaViewerNavigationRepository.select(session.accountId, route, selected)?.let { next -> screen = current.copy( selectedIndex = next.selectedIndex, selectedSourceIndex = next.selectedSourceIndex, @@ -2489,11 +2489,11 @@ private fun AuthenticatedApp( } }, onSourceRemoved = { - mediaViewerNavigationRepository.release(current.navigationKey) + mediaViewerNavigationRepository.release(session.accountId, current.navigationKey) screen = current.returnTo }, onClose = { - mediaViewerNavigationRepository.release(current.navigationKey) + mediaViewerNavigationRepository.release(session.accountId, current.navigationKey) screen = current.returnTo }, navigationRequest = pendingEditorNavigationRequest, 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 fc0d14a69..627d0b8c5 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt @@ -7,40 +7,55 @@ import kotlinx.coroutines.CancellationException * between grids and viewers. Persistent encrypted/offline caching remains a separate repository * concern because it needs account lifecycle, quotas, and platform storage policies. */ -internal object PreviewMemoryCache { - private const val MAX_BYTES = 24 * 1024 * 1024 - private val gate = sharedAccountPrivateMemoryGate +internal class PreviewMemoryCache( + private val gate: AccountPrivateMemoryGate = AccountPrivateMemoryGate(), + private val maximumBytes: Int = MAX_PREVIEW_MEMORY_CACHE_BYTES, +) { private val entries = linkedMapOf() private var bytes = 0 - fun producer(accountStorageKey: String): AccountPrivateMemoryProducer? = gate.producer(accountStorageKey) + init { + require(maximumBytes > 0) + } + + fun producer(key: PreviewCacheKey): AccountPrivateMemoryProducer? = gate.producer(key.account) fun get(key: PreviewCacheKey): ByteArray? = gate.read(key.account, null) { - val value = entries.remove(key) ?: return@read null - entries[key] = value + val value = entries.remove(key) + if (value != null) entries[key] = value value } fun put(key: PreviewCacheKey, value: ByteArray, producer: AccountPrivateMemoryProducer?) { - if (value.size > MAX_BYTES) return + if (value.size > maximumBytes) return gate.mutate(key.account, producer) { entries.remove(key)?.let { bytes -= it.size } entries[key] = value bytes += value.size - while (bytes > MAX_BYTES && entries.isNotEmpty()) { + while (bytes > maximumBytes && entries.isNotEmpty()) { val oldestKey = entries.keys.first() bytes -= entries.remove(oldestKey)?.size ?: 0 } } } + fun retireAccount(accountStorageKey: String) = gate.retireAccount(accountStorageKey) { + purgeRetiredAccount(accountStorageKey) + } + + fun activateAccount(accountStorageKey: String) = gate.activateAccount(accountStorageKey) + internal fun purgeRetiredAccount(accountStorageKey: String) { - entries.keys.filter { key -> key.account == accountStorageKey }.forEach { key -> - bytes -= entries.remove(key)?.size ?: 0 + val retired = entries.filterKeys { key -> key.account == accountStorageKey } + retired.forEach { (key, value) -> + entries.remove(key) + bytes -= value.size } } } +internal val sharedPreviewMemoryCache = PreviewMemoryCache(sharedAccountPrivateMemoryGate) + internal data class PreviewCacheKey( val account: String, val variant: String, @@ -71,11 +86,12 @@ internal fun previewCacheKeyOrNull( internal suspend fun loadPreviewMemoryCached( key: PreviewCacheKey?, + cache: PreviewMemoryCache = sharedPreviewMemoryCache, load: suspend () -> ByteArray, ): ByteArray { if (key == null) return load() - val producer = PreviewMemoryCache.producer(key.account) - return PreviewMemoryCache.get(key) ?: load().also { PreviewMemoryCache.put(key, it, producer) } + val producer = cache.producer(key) + return cache.get(key) ?: load().also { cache.put(key, it, producer) } } internal suspend fun NextcloudPlatformServices.loadPreviewCached( @@ -217,3 +233,5 @@ internal fun accountPersistenceScopeDigests(session: NextcloudSession): AccountP } internal expect fun legacyPreviewCacheDigest(session: NextcloudSession): String + +private const val MAX_PREVIEW_MEMORY_CACHE_BYTES = 24 * 1024 * 1024 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 8e785bdd7..385e7d9c5 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt @@ -1,8 +1,17 @@ package dev.obiente.nextcloudnative.app +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + internal object SupportSettingsDraftRegistry { + private val gate = sharedAccountPrivateMemoryGate private val loginDraftState = SupportSettingsDraftState() private val states = linkedMapOf() + private val mutableActivationRevision = MutableStateFlow(0L) + private val activationRevision = mutableActivationRevision.asStateFlow() + private val inactiveState = SupportSettingsDraftState.inactive() fun loginState(): SupportSettingsDraftState = loginDraftState @@ -10,24 +19,46 @@ internal object SupportSettingsDraftRegistry { internal fun stateFor(accountScopeDigest: String): SupportSettingsDraftState { require(accountScopeDigest.length == 64 && accountScopeDigest.all { it in '0'..'9' || it in 'a'..'f' }) - states.remove(accountScopeDigest)?.let { retained -> - states[accountScopeDigest] = retained - return retained - } - return SupportSettingsDraftState().also { created -> - states[accountScopeDigest] = created - while (states.size > MAX_RETAINED_SUPPORT_DRAFT_ACCOUNTS) { - val evictable = states.entries.firstOrNull { (digest, state) -> - digest != accountScopeDigest && !state.hasDraftContent() - }?.key ?: break - states.remove(evictable) + val producer = gate.producer(accountScopeDigest) ?: return inactiveState + return gate.read(producer, inactiveState) { + val retained = states.remove(accountScopeDigest) + if (retained != null) { + states[accountScopeDigest] = retained + retained + } else { + SupportSettingsDraftState.account(gate, producer).also { created -> + states[accountScopeDigest] = created + while (states.size > MAX_RETAINED_SUPPORT_DRAFT_ACCOUNTS) { + val evictable = states.entries.firstOrNull { (digest, state) -> + digest != accountScopeDigest && !state.hasDraftContent() + }?.key ?: break + states.remove(evictable) + } + } } } } - fun removeAccount(accountStorageKey: String) { - states.remove(accountStorageKey)?.clearDrafts() + internal fun activationRevision(): StateFlow = activationRevision + + internal fun publishAccountActivated() = + mutableActivationRevision.update { revision -> revision + 1L } + + internal fun purgeRetiredAccount(accountStorageKey: String) { + states[accountStorageKey]?.let { retired -> + retired.purgeRetiredAccount() + states.remove(accountStorageKey) + } + } + + internal fun retireAccount(accountStorageKey: String) = gate.retireAccount(accountStorageKey) { + purgeRetiredAccount(accountStorageKey) } + + internal fun activateAccount(accountStorageKey: String) = gate.activateAccount( + accountStorageKey = accountStorageKey, + activated = ::publishAccountActivated, + ) } private const val MAX_RETAINED_SUPPORT_DRAFT_ACCOUNTS = 4 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftState.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftState.kt index 7852aacd6..9665df545 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftState.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftState.kt @@ -7,32 +7,98 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @Stable -internal class SupportSettingsDraftState { - var reportDraft by mutableStateOf("") - private set +internal class SupportSettingsDraftState private constructor( + private val access: SupportSettingsDraftAccess, +) { + constructor() : this(StandaloneSupportSettingsDraftAccess()) + + private var storedReportDraft by mutableStateOf("") private val replyDrafts = mutableStateMapOf() + val reportDraft: String + get() = access.read("") { storedReportDraft } + fun updateReportDraft(value: String) { - reportDraft = value.take(MAX_SUPPORT_REPRODUCTION_STEPS_LENGTH) + access.mutate { storedReportDraft = value.take(MAX_SUPPORT_REPRODUCTION_STEPS_LENGTH) } } - fun replyDraft(recordId: String): String = replyDrafts[recordId].orEmpty() + fun replyDraft(recordId: String): String = access.read("") { replyDrafts[recordId].orEmpty() } fun updateReplyDraft(recordId: String, value: String) { val bounded = value.take(MAX_SUPPORT_CONVERSATION_MESSAGE_LENGTH) - if (bounded.isEmpty()) replyDrafts.remove(recordId) else replyDrafts[recordId] = bounded + access.mutate { + if (bounded.isEmpty()) replyDrafts.remove(recordId) else replyDrafts[recordId] = bounded + } } fun retainReplyDrafts(recordIds: Set) { - replyDrafts.keys.toList().filterNot(recordIds::contains).forEach(replyDrafts::remove) + access.mutate { + replyDrafts.keys.toList().filterNot(recordIds::contains).forEach(replyDrafts::remove) + } } fun clearDrafts() { - reportDraft = "" + access.mutate(::clearDraftsUnchecked) + } + + fun hasDraftContent(): Boolean = access.read(false) { + storedReportDraft.isNotEmpty() || replyDrafts.isNotEmpty() + } + + internal fun purgeRetiredAccount() = access.purge(::clearDraftsUnchecked) + + private fun clearDraftsUnchecked() { + storedReportDraft = "" replyDrafts.clear() } - fun hasDraftContent(): Boolean = reportDraft.isNotEmpty() || replyDrafts.isNotEmpty() + internal companion object { + fun account( + gate: AccountPrivateMemoryGate, + producer: AccountPrivateMemoryProducer, + ) = SupportSettingsDraftState(AccountSupportSettingsDraftAccess(gate, producer)) + + fun inactive() = SupportSettingsDraftState(InactiveSupportSettingsDraftAccess) + } +} + +private interface SupportSettingsDraftAccess { + fun read(unavailable: T, action: () -> T): T + + fun mutate(action: () -> Unit) + + fun purge(action: () -> Unit) +} + +private class StandaloneSupportSettingsDraftAccess : SupportSettingsDraftAccess { + private val lock = DynamicNativeMemoryCacheLock() + + override fun read(unavailable: T, action: () -> T): T = lock.withLock(action) + + override fun mutate(action: () -> Unit) = lock.withLock(action) + + override fun purge(action: () -> Unit) = lock.withLock(action) +} + +private class AccountSupportSettingsDraftAccess( + private val gate: AccountPrivateMemoryGate, + private val producer: AccountPrivateMemoryProducer, +) : SupportSettingsDraftAccess { + override fun read(unavailable: T, action: () -> T): T = gate.read(producer, unavailable, action) + + override fun mutate(action: () -> Unit) { + gate.mutate(producer.accountStorageKey, producer, action) + } + + override fun purge(action: () -> Unit) = gate.withLock(action) +} + +private object InactiveSupportSettingsDraftAccess : SupportSettingsDraftAccess { + override fun read(unavailable: T, action: () -> T): T = unavailable + + override fun mutate(action: () -> Unit) = Unit + + override fun purge(action: () -> Unit) = Unit } internal fun supportReplyMessageByteCount(value: String): Int = value.encodeToByteArray().size 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 5d12cf8b5..ba4faf0d7 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt @@ -4,14 +4,16 @@ import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNotSame import kotlin.test.assertNull import kotlin.test.assertSame +import kotlin.test.assertTrue class AccountPrivateMemoryCleanupTest { @Test - fun `removal purges one account from shared workspace memory`() { + fun `removal purges one account from shared workspace memory`(): Unit = kotlinx.coroutines.runBlocking { val removed = session("removed") val retained = session("retained") val removedKey = removed.accountId.storageKey @@ -22,15 +24,34 @@ class AccountPrivateMemoryCleanupTest { 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 removedProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(removedKey)) + val retainedProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(retainedKey)) val removedDynamicProducer = requireNotNull(sharedDynamicNativeMemoryCache.producer(removedDynamicKey)) val retainedDynamicProducer = requireNotNull(sharedDynamicNativeMemoryCache.producer(retainedDynamicKey)) + val removedCarryoverScope = "removed|photos:timeline" + val retainedCarryoverScope = "retained|photos:timeline" + val removedCarryoverGeneration = requireNotNull( + sharedMediaTimelineDavCarryoverStore.beginAccountGeneration( + removed.accountId, + removedCarryoverScope, + removedProducer, + ), + ) + val retainedCarryoverGeneration = requireNotNull( + sharedMediaTimelineDavCarryoverStore.beginAccountGeneration( + retained.accountId, + retainedCarryoverScope, + retainedProducer, + ), + ) + val carryoverCursor = PhotoTimelineCursor("private-memory-carryover") + val carryover = mediaCarryover() + val removedPhotoState = PhotoTimelineUiStateRepository.stateFor(removed) + val retainedPhotoState = PhotoTimelineUiStateRepository.stateFor(retained) + removedPhotoState.initialLoadCompleted.value = true try { - PreviewMemoryCache.put(removedPreview, byteArrayOf(1), removedProducer) - PreviewMemoryCache.put(retainedPreview, byteArrayOf(2), retainedProducer) + sharedPreviewMemoryCache.put(removedPreview, byteArrayOf(1), removedProducer) + sharedPreviewMemoryCache.put(retainedPreview, byteArrayOf(2), retainedProducer) sharedNextcloudNotesCache.storeDetail(removed, note(1L, "Removed"), removedProducer) sharedNextcloudNotesCache.storeDetail(retained, note(2L, "Retained"), retainedProducer) sharedDynamicNativeMemoryCache.storeScreen( @@ -51,6 +72,10 @@ class AccountPrivateMemoryCleanupTest { ContactsWorkspaceMemoryCache.store( retained, "retained", ContactsLoadState.Ready(emptyList(), emptyList()), retainedProducer, ) + CalendarWorkspaceMemoryCache.store(removed, "removed", calendarSnapshot(), removedProducer) + CalendarWorkspaceMemoryCache.store(retained, "retained", calendarSnapshot(), retainedProducer) + UserStatusWorkspaceMemoryCache.store(removed, statusSnapshot("removed"), removedProducer) + UserStatusWorkspaceMemoryCache.store(retained, statusSnapshot("retained"), retainedProducer) DeckWorkspaceMemoryCache.store(removed, deckSnapshot(), removedProducer) DeckWorkspaceMemoryCache.store(retained, deckSnapshot(), retainedProducer) sharedDocumentEditingCapabilitiesCache.store( @@ -71,11 +96,89 @@ class AccountPrivateMemoryCleanupTest { TalkWorkspaceMemoryCache.storeRooms( retained, listOf(TalkRoom("retained", "Retained", null, 0)), retainedProducer, ) + TalkWorkspaceMemoryCache.storeMessages(removed, "removed", emptyList(), removedProducer) + TalkWorkspaceMemoryCache.storeMessages(retained, "retained", emptyList(), retainedProducer) + sharedMediaTimelineDavCarryoverStore.put( + removed.accountId, + removedCarryoverScope, + removedCarryoverGeneration, + carryoverCursor, + carryover, + removedProducer, + ) + sharedMediaTimelineDavCarryoverStore.put( + retained.accountId, + retainedCarryoverScope, + retainedCarryoverGeneration, + carryoverCursor, + carryover, + retainedProducer, + ) + val removedMemoriesIndex = assertIs< + MemoriesMainTimelineLoadResult.Loaded + >( + sharedMemoriesMainTimelineIndexCache.load( + removed.accountId, + removedCarryoverScope, + true, + removedProducer, + ) { + MemoriesMainTimelineLoadResult.Loaded( + MemoriesMainTimelineDayIndex(listOf(NativeMediaDay(30L, 1))), + ) + }, + ).value + val retainedMemoriesIndex = assertIs< + MemoriesMainTimelineLoadResult.Loaded + >( + sharedMemoriesMainTimelineIndexCache.load( + retained.accountId, + retainedCarryoverScope, + true, + retainedProducer, + ) { + MemoriesMainTimelineLoadResult.Loaded( + MemoriesMainTimelineDayIndex(listOf(NativeMediaDay(29L, 1))), + ) + }, + ).value + assertTrue( + sharedMemoriesMainTimelineIndexCache.markMemoriesActive( + removed.accountId, + removedCarryoverScope, + removedMemoriesIndex.sourceGeneration, + removedProducer, + ), + ) + assertTrue( + sharedMemoriesMainTimelineIndexCache.markMemoriesActive( + retained.accountId, + retainedCarryoverScope, + retainedMemoriesIndex.sourceGeneration, + retainedProducer, + ), + ) + val removedViewerRoute = assertNotNull( + sharedMediaViewerNavigationRepository.register( + removed.accountId, + listOf(mediaFile(30L)), + mediaFile(30L), + producer = removedProducer, + ), + ) + val retainedViewerRoute = assertNotNull( + sharedMediaViewerNavigationRepository.register( + retained.accountId, + listOf(mediaFile(29L)), + mediaFile(29L), + producer = retainedProducer, + ), + ) AccountPrivateMemoryCleanup.removeAccount(removedKey) - assertNull(PreviewMemoryCache.get(removedPreview)) - assertContentEquals(byteArrayOf(2), PreviewMemoryCache.get(retainedPreview)) + assertNull(sharedPreviewMemoryCache.get(removedPreview)) + assertContentEquals(byteArrayOf(2), sharedPreviewMemoryCache.get(retainedPreview)) assertNull(sharedNextcloudNotesCache.detail(removed, 1L)) assertEquals("Retained", sharedNextcloudNotesCache.detail(retained, 2L)?.title) assertNull(sharedDynamicNativeMemoryCache.screen(removedDynamicKey)) @@ -84,6 +187,10 @@ class AccountPrivateMemoryCleanupTest { assertNotNull(sharedDashboardStatusMemoryCache.get(retained, 1L)) assertNull(ContactsWorkspaceMemoryCache.get(removed, "removed")) assertNotNull(ContactsWorkspaceMemoryCache.get(retained, "retained")) + assertNull(CalendarWorkspaceMemoryCache.get(removed, "removed", testMonth, testWindow)) + assertNotNull(CalendarWorkspaceMemoryCache.get(retained, "retained", testMonth, testWindow)) + assertNull(UserStatusWorkspaceMemoryCache.get(removed)) + assertEquals("retained", UserStatusWorkspaceMemoryCache.get(retained)?.status?.userId) assertNull(DeckWorkspaceMemoryCache.get(removed)) assertNotNull(DeckWorkspaceMemoryCache.get(retained)) assertNull(sharedDocumentEditingCapabilitiesCache.get(removed)) @@ -92,8 +199,95 @@ class AccountPrivateMemoryCleanupTest { assertNotNull(ActivityWorkspaceMemoryCache.get(retained, "all")) assertNull(TalkWorkspaceMemoryCache.rooms(removed)) assertEquals("retained", TalkWorkspaceMemoryCache.rooms(retained)?.single()?.token) + assertNull(TalkWorkspaceMemoryCache.messages(removed, "removed")) + assertNotNull(TalkWorkspaceMemoryCache.messages(retained, "retained")) + assertNull( + sharedMemoriesMainTimelineIndexCache.activeMemoriesIndex( + removed.accountId, + removedCarryoverScope, + ), + ) + assertEquals( + listOf(29L), + assertNotNull( + sharedMemoriesMainTimelineIndexCache.activeMemoriesIndex( + retained.accountId, + retainedCarryoverScope, + ), + ).index.days.map(NativeMediaDay::id), + ) + assertNull( + sharedMediaViewerNavigationRepository.resolve( + removed.accountId, + removedViewerRoute, + ), + ) + assertEquals( + 29L, + assertNotNull( + sharedMediaViewerNavigationRepository.resolve( + retained.accountId, + retainedViewerRoute, + ), + ).selected.fileId, + ) + assertFalse(removedPhotoState.initialLoadCompleted.value) assertNotSame(removedPhotoState, PhotoTimelineUiStateRepository.stateFor(removed)) assertSame(retainedPhotoState, PhotoTimelineUiStateRepository.stateFor(retained)) + + AccountPrivateMemoryLifecycle.activateAccount(removedKey) + val currentProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(removedKey)) + assertNull( + sharedMediaTimelineDavCarryoverStore.take( + removed.accountId, + removedCarryoverScope, + removedCarryoverGeneration, + carryoverCursor, + currentProducer, + ), + ) + assertEquals( + carryover, + sharedMediaTimelineDavCarryoverStore.take( + retained.accountId, + retainedCarryoverScope, + retainedCarryoverGeneration, + carryoverCursor, + retainedProducer, + ), + ) + sharedDashboardStatusMemoryCache.store( + removed, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 2L, removedProducer, + ) + ContactsWorkspaceMemoryCache.store( + removed, "removed", ContactsLoadState.Ready(emptyList(), emptyList()), removedProducer, + ) + CalendarWorkspaceMemoryCache.store(removed, "removed", calendarSnapshot(), removedProducer) + UserStatusWorkspaceMemoryCache.store(removed, statusSnapshot("removed"), removedProducer) + DeckWorkspaceMemoryCache.store(removed, deckSnapshot(), removedProducer) + sharedDocumentEditingCapabilitiesCache.store( + removed, NextcloudDocumentEditingCapabilities.Unavailable, null, removedProducer, + ) + ActivityWorkspaceMemoryCache.store( + removed, "all", ActivityTimelineState(initialized = true), removedProducer, + ) + TalkWorkspaceMemoryCache.storeRooms( + removed, listOf(TalkRoom("removed", "Removed", null, 0)), removedProducer, + ) + + assertNull(sharedDashboardStatusMemoryCache.get(removed, 2L)) + assertNull(ContactsWorkspaceMemoryCache.get(removed, "removed")) + assertNull(CalendarWorkspaceMemoryCache.get(removed, "removed", testMonth, testWindow)) + assertNull(UserStatusWorkspaceMemoryCache.get(removed)) + assertNull(DeckWorkspaceMemoryCache.get(removed)) + assertNull(sharedDocumentEditingCapabilitiesCache.get(removed)) + assertNull(ActivityWorkspaceMemoryCache.get(removed, "all")) + assertNull(TalkWorkspaceMemoryCache.rooms(removed)) + + ContactsWorkspaceMemoryCache.store( + removed, "removed", ContactsLoadState.Ready(emptyList(), emptyList()), currentProducer, + ) + assertNotNull(ContactsWorkspaceMemoryCache.get(removed, "removed")) } finally { AccountPrivateMemoryCleanup.removeAccount(removedKey) AccountPrivateMemoryCleanup.removeAccount(retainedKey) @@ -144,7 +338,7 @@ class AccountPrivateMemoryCleanupTest { account, NextcloudDocumentEditingCapabilities.Unavailable, null, staleProducer, ) DeckWorkspaceMemoryCache.store(account, deckSnapshot(), staleProducer) - PreviewMemoryCache.put( + sharedPreviewMemoryCache.put( PreviewCacheKey(accountKey, "core", 1L, "etag", 64, 64), byteArrayOf(1), staleProducer, ) @@ -153,7 +347,7 @@ class AccountPrivateMemoryCleanupTest { assertNull(UserStatusWorkspaceMemoryCache.get(account)) assertNull(sharedDocumentEditingCapabilitiesCache.get(account)) assertNull(DeckWorkspaceMemoryCache.get(account)) - assertNull(PreviewMemoryCache.get(PreviewCacheKey(accountKey, "core", 1L, "etag", 64, 64))) + assertNull(sharedPreviewMemoryCache.get(PreviewCacheKey(accountKey, "core", 1L, "etag", 64, 64))) val currentProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(accountKey)) sharedDashboardStatusMemoryCache.store(account, dashboard, null, 2L, currentProducer) @@ -214,6 +408,17 @@ class AccountPrivateMemoryCleanupTest { predefined = emptyList(), ) + private fun calendarSnapshot() = CalendarLoadState.Ready(testMonth, testWindow, emptyList(), emptyList()) + + private fun statusSnapshot(userId: String) = UserStatusSurfaceState.Available( + capabilities = NativeUserStatusCapabilities(false, false, false, false), + status = NativeUserStatus( + userId, NativeUserPresence.Offline, null, null, null, null, + messageIsPredefined = false, statusIsUserDefined = false, + ), + predefined = emptyList(), + ) + private fun deckSnapshot() = DeckWorkspaceMemorySnapshot( state = DeckWorkspaceState.Loading, loadedBoards = emptyList(), @@ -223,4 +428,41 @@ class AccountPrivateMemoryCleanupTest { requestedBoardId = null, requestedCardId = null, ) + + private fun mediaCarryover() = MediaTimelineDavCarryover( + mapOf( + MediaTimelinePartitionKey.Mime(MediaSearchDavPartition.ImageMime) to + MediaTimelinePartitionCarryover( + files = listOf( + NextcloudFile( + path = "Photos/private.jpg", + name = "private.jpg", + isDirectory = false, + mimeType = "image/jpeg", + size = 1L, + lastModified = "private", + fileId = 1L, + hasPreview = true, + ), + ), + remoteCursorAfterFetched = null, + ), + ), + ) + + private fun mediaFile(id: Long) = NextcloudFile( + path = "Photos/$id.jpg", + name = "$id.jpg", + isDirectory = false, + mimeType = "image/jpeg", + size = 1L, + lastModified = "private", + fileId = id, + hasPreview = true, + ) + + private companion object { + val testMonth = CalendarMonth(2026, 9) + val testWindow = GroupwareDavTimeWindow("20260901T000000Z", "20261001T000000Z") + } } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycleTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycleTest.kt new file mode 100644 index 000000000..c4ea5d07a --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycleTest.kt @@ -0,0 +1,92 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertNull + +class AccountPrivateMemoryLifecycleTest { + @Test + fun `aggregate retirement purges private stores and rejects their old producers`() { + val session = NextcloudSession( + serverUrl = "https://private-memory-lifecycle.example.test", + loginName = "private-memory-lifecycle-test", + appPassword = "password", + ) + val account = session.accountId.storageKey + val dynamicKey = dynamicScreenCacheKey(session, "dashboard", "widgets", null, emptyMap()) + val previewKey = PreviewCacheKey(account, "core-preview", 701L, "preview-v1", 320, 320) + val dynamicProducer = requireNotNull(sharedDynamicNativeMemoryCache.producer(dynamicKey)) + val previewProducer = requireNotNull(sharedPreviewMemoryCache.producer(previewKey)) + val notesProducer = requireNotNull(sharedNextcloudNotesCache.producer(session)) + val drafts = SupportSettingsDraftRegistry.stateFor(session) + try { + sharedDynamicNativeMemoryCache.storeScreen( + dynamicKey, + dynamicSnapshot(1), + dynamicProducer, + ) + sharedPreviewMemoryCache.put(previewKey, byteArrayOf(1), previewProducer) + sharedNextcloudNotesCache.storeDetail(session, note(1, "Before"), notesProducer) + drafts.updateReportDraft("Private report") + + AccountPrivateMemoryLifecycle.retireAccount(account) + + assertNull(sharedDynamicNativeMemoryCache.screen(dynamicKey)) + assertNull(sharedPreviewMemoryCache.get(previewKey)) + assertNull(sharedNextcloudNotesCache.detail(session, 1L)) + assertFalse(drafts.hasDraftContent()) + + AccountPrivateMemoryLifecycle.activateAccount(account) + val currentDynamicProducer = requireNotNull(sharedDynamicNativeMemoryCache.producer(dynamicKey)) + val currentPreviewProducer = requireNotNull(sharedPreviewMemoryCache.producer(previewKey)) + val currentNotesProducer = requireNotNull(sharedNextcloudNotesCache.producer(session)) + val currentDrafts = SupportSettingsDraftRegistry.stateFor(session) + assertNotSame(drafts, currentDrafts) + + sharedDynamicNativeMemoryCache.storeScreen( + dynamicKey, + dynamicSnapshot(2), + currentDynamicProducer, + ) + sharedPreviewMemoryCache.put(previewKey, byteArrayOf(2), currentPreviewProducer) + sharedNextcloudNotesCache.storeDetail(session, note(1, "Current"), currentNotesProducer) + currentDrafts.updateReportDraft("Current report") + + sharedDynamicNativeMemoryCache.storeScreen(dynamicKey, dynamicSnapshot(3), dynamicProducer) + sharedPreviewMemoryCache.put(previewKey, byteArrayOf(3), previewProducer) + sharedNextcloudNotesCache.storeDetail(session, note(1, "Late"), notesProducer) + drafts.updateReportDraft("Late report") + + assertEquals(2, sharedDynamicNativeMemoryCache.screen(dynamicKey)?.pagination?.nextPageNumber) + assertContentEquals(byteArrayOf(2), sharedPreviewMemoryCache.get(previewKey)) + assertEquals("Current", sharedNextcloudNotesCache.detail(session, 1L)?.title) + assertEquals("Current report", currentDrafts.reportDraft) + } finally { + AccountPrivateMemoryLifecycle.retireAccount(account) + AccountPrivateMemoryLifecycle.activateAccount(account) + } + } + + private fun dynamicSnapshot(page: Int) = DynamicScreenSnapshot( + records = emptyList(), + relatedRecords = emptyMap(), + pagination = DynamicPaginationCheckpoint( + nextPageNumber = page, + nextRequestValue = "page-$page", + ), + ) + + private fun note(id: Long, title: String) = NextcloudNote( + id = id, + title = title, + modified = 1L, + category = "Personal", + favorite = false, + readOnly = false, + content = "private body", + etag = "etag-$id", + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaTimelineDavCarryoverTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaTimelineDavCarryoverTest.kt index 3e8a9db97..df077d0f8 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaTimelineDavCarryoverTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaTimelineDavCarryoverTest.kt @@ -99,6 +99,7 @@ class MediaTimelineDavCarryoverTest { shouldSearchRaw = { false }, carryoverStore = store, carryoverAccountScope = "account-a", + carryoverAccountId = account('a'), ) val first = load(null) @@ -173,7 +174,11 @@ class MediaTimelineDavCarryoverTest { ) val firstCursor = PhotoTimelineCursor("first") val secondCursor = PhotoTimelineCursor("second") - val firstGeneration = store.beginAccountGeneration("account-a") + val firstAccount = account('a') + val firstProducer = requireNotNull(store.producer(firstAccount)) + val firstGeneration = requireNotNull( + store.beginAccountGeneration(firstAccount, "account-a", firstProducer), + ) val carryover = MediaTimelineDavCarryover( mapOf( MediaTimelinePartitionKey.Mime(MediaSearchDavPartition.VideoMime) to @@ -184,15 +189,22 @@ class MediaTimelineDavCarryoverTest { ), ) - store.put("account-a", firstGeneration, firstCursor, carryover) - store.put("account-a", firstGeneration, secondCursor, carryover) + store.put(firstAccount, "account-a", firstGeneration, firstCursor, carryover, firstProducer) + store.put(firstAccount, "account-a", firstGeneration, secondCursor, carryover, firstProducer) - assertNull(store.take("account-a", firstGeneration, firstCursor)) - assertEquals(carryover, store.take("account-a", firstGeneration, secondCursor)) + assertNull(store.take(firstAccount, "account-a", firstGeneration, firstCursor, firstProducer)) + assertEquals( + carryover, + store.take(firstAccount, "account-a", firstGeneration, secondCursor, firstProducer), + ) - val secondGeneration = store.beginAccountGeneration("account-b") + val secondAccount = account('b') + val secondProducer = requireNotNull(store.producer(secondAccount)) + val secondGeneration = requireNotNull( + store.beginAccountGeneration(secondAccount, "account-b", secondProducer), + ) assertTrue(secondGeneration > firstGeneration) - assertNull(store.take("account-a", firstGeneration, secondCursor)) + assertNull(store.take(firstAccount, "account-a", firstGeneration, secondCursor, firstProducer)) assertFailsWith { MediaTimelinePartitionCarryover( files = List(PHOTO_TIMELINE_PARTITION_PAGE_SIZE + 1) { index -> @@ -204,6 +216,72 @@ class MediaTimelineDavCarryoverTest { Unit } + @Test + fun `retirement purges every scope for one account and preserves another`() { + val store = MediaTimelineDavCarryoverStore() + val removed = account('c') + val retained = account('d') + val removedProducer = requireNotNull(store.producer(removed)) + val retainedProducer = requireNotNull(store.producer(retained)) + val timelineScope = "removed|photos:timeline" + val folderScope = "removed|photos:folder-inventory" + val retainedScope = "retained|photos:timeline" + val timelineGeneration = requireNotNull( + store.beginAccountGeneration(removed, timelineScope, removedProducer), + ) + val folderGeneration = requireNotNull( + store.beginAccountGeneration(removed, folderScope, removedProducer), + ) + val retainedGeneration = requireNotNull( + store.beginAccountGeneration(retained, retainedScope, retainedProducer), + ) + val cursor = PhotoTimelineCursor("cursor") + val carryover = carryover() + assertFailsWith { + store.beginAccountGeneration(removed, timelineScope, retainedProducer) + } + store.put(removed, timelineScope, timelineGeneration, cursor, carryover, removedProducer) + store.put(removed, folderScope, folderGeneration, cursor, carryover, removedProducer) + store.put(retained, retainedScope, retainedGeneration, cursor, carryover, retainedProducer) + + store.purgeRetiredAccount(removed.storageKey) + + assertNull(store.take(removed, timelineScope, timelineGeneration, cursor, removedProducer)) + assertNull(store.take(removed, folderScope, folderGeneration, cursor, removedProducer)) + assertEquals( + carryover, + store.take(retained, retainedScope, retainedGeneration, cursor, retainedProducer), + ) + } + + @Test + fun `retirement and reactivation reject late carryover completions from the old incarnation`() { + val gate = AccountPrivateMemoryGate() + val store = MediaTimelineDavCarryoverStore(gate) + val account = account('e') + val scope = "account-e" + val cursor = PhotoTimelineCursor("cursor") + val carryover = carryover() + val oldProducer = requireNotNull(store.producer(account)) + requireNotNull(store.beginAccountGeneration(account, scope, oldProducer)) + + gate.retireAccount(account.storageKey) { store.purgeRetiredAccount(account.storageKey) } + + assertNull(store.producer(account)) + assertNull(store.beginAccountGeneration(account, scope, oldProducer)) + + gate.activateAccount(account.storageKey) + val currentProducer = requireNotNull(store.producer(account)) + val currentGeneration = requireNotNull( + store.beginAccountGeneration(account, scope, currentProducer), + ) + store.put(account, scope, currentGeneration, cursor, carryover, oldProducer) + assertNull(store.take(account, scope, currentGeneration, cursor, currentProducer)) + + store.put(account, scope, currentGeneration, cursor, carryover, currentProducer) + assertEquals(carryover, store.take(account, scope, currentGeneration, cursor, currentProducer)) + } + private class TimelineFixture { val requestBodies = mutableListOf() private val images = (1L..PHOTO_TIMELINE_PARTITION_PAGE_SIZE.toLong()).map { id -> @@ -229,6 +307,7 @@ class MediaTimelineDavCarryoverTest { cursor: PhotoTimelineCursor?, carryoverStore: MediaTimelineDavCarryoverStore? = null, accountScope: String? = null, + accountId: NextcloudAccountId? = carryoverStore?.let { account('a') }, ): MediaTimelineDavPage = collectMediaTimelineDavPage( userId = "account", cursor = cursor, @@ -254,10 +333,23 @@ class MediaTimelineDavCarryoverTest { shouldSearchRaw = { false }, carryoverStore = carryoverStore, carryoverAccountScope = accountScope, + carryoverAccountId = accountId, ) } companion object { + private fun account(marker: Char) = NextcloudAccountId(marker.toString().repeat(64)) + + private fun carryover() = MediaTimelineDavCarryover( + mapOf( + MediaTimelinePartitionKey.Mime(MediaSearchDavPartition.VideoMime) to + MediaTimelinePartitionCarryover( + files = listOf(file(50L, "Photos/carried.mp4", 100L, "video/mp4")), + remoteCursorAfterFetched = null, + ), + ), + ) + private fun file( id: Long, path: String, diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigationTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigationTest.kt index 6ea56687b..918b8f4c3 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigationTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigationTest.kt @@ -11,36 +11,39 @@ class MediaViewerNavigationTest { @Test fun routeCarriesOnlyOpaqueKeyAndIndexWhileRepositoryOwnsTheFiles() { val repository = MediaViewerNavigationRepository(maximumRoutes = 2, maximumItemsPerRoute = 3) + val accountId = session("route").accountId val files = (1L..3L).map(::file) - val route = repository.register(files, files[1]) - val snapshot = assertNotNull(repository.resolve(route)) + val route = assertNotNull(repository.register(accountId, files, files[1])) + val snapshot = assertNotNull(repository.resolve(accountId, route)) assertEquals(1, route.selectedIndex) assertEquals(files, snapshot.media) assertEquals(files[1], snapshot.selected) - val next = assertNotNull(repository.select(route, files[2])) - assertEquals(files[2], assertNotNull(repository.resolve(next)).selected) + val next = assertNotNull(repository.select(accountId, route, files[2])) + assertEquals(files[2], assertNotNull(repository.resolve(accountId, next)).selected) } @Test fun routesAndItemsAreBoundedAndReleasedRoutesCannotResolve() { val repository = MediaViewerNavigationRepository(maximumRoutes = 1, maximumItemsPerRoute = 2) - val first = repository.register(listOf(file(1L)), file(1L)) + val accountId = session("bounded").accountId + val first = assertNotNull(repository.register(accountId, listOf(file(1L)), file(1L))) val secondFiles = listOf(file(2L), file(3L), file(4L)) - val second = repository.register(secondFiles, secondFiles.last()) + val second = assertNotNull(repository.register(accountId, secondFiles, secondFiles.last())) - assertNull(repository.resolve(first)) - val snapshot = assertNotNull(repository.resolve(second)) + assertNull(repository.resolve(accountId, first)) + val snapshot = assertNotNull(repository.resolve(accountId, second)) assertEquals(2, snapshot.media.size) assertEquals(secondFiles.last(), snapshot.selected) - repository.release(second.key) - assertNull(repository.resolve(second)) + repository.release(accountId, second.key) + assertNull(repository.resolve(accountId, second)) } @Test fun routeSelectsRawStackSourcesWithoutMakingThemSeparateNavigationItems() { val repository = MediaViewerNavigationRepository(maximumRoutes = 1, maximumItemsPerRoute = 2) + val accountId = session("raw-stack").accountId val rendered = file(2L) val nextRendered = file(3L) val rawSibling = rendered.copy( @@ -56,35 +59,90 @@ class MediaViewerNavigationTest { fileId = 99L, ) - val route = repository.register( + val route = assertNotNull(repository.register( + accountId = accountId, media = listOf(rendered, nextRendered), selected = rendered, sourceMembers = listOf(rendered, rawSibling, nextRendered, unrelatedRaw), navigationIdentityBySourceIdentity = mapOf( mediaViewerFileIdentity(rawSibling) to mediaViewerFileIdentity(rendered), ), - ) - val snapshot = assertNotNull(repository.resolve(route)) + )) + val snapshot = assertNotNull(repository.resolve(accountId, route)) assertEquals(listOf(rendered, nextRendered), snapshot.media) assertEquals(listOf(rendered, nextRendered, rawSibling), snapshot.sourceMembers) assertEquals(rendered, snapshot.selected) - val rawRoute = assertNotNull(repository.select(route, rawSibling)) - val rawSnapshot = assertNotNull(repository.resolve(rawRoute)) + val rawRoute = assertNotNull(repository.select(accountId, route, rawSibling)) + val rawSnapshot = assertNotNull(repository.resolve(accountId, rawRoute)) assertEquals(0, rawSnapshot.selectedIndex) assertEquals(rawSibling, rawSnapshot.selected) assertEquals(listOf(rendered, nextRendered), rawSnapshot.media) - val nextRoute = assertNotNull(repository.select(rawRoute, nextRendered)) - val nextSnapshot = assertNotNull(repository.resolve(nextRoute)) + val nextRoute = assertNotNull(repository.select(accountId, rawRoute, nextRendered)) + val nextSnapshot = assertNotNull(repository.resolve(accountId, nextRoute)) assertEquals(1, nextSnapshot.selectedIndex) assertEquals(nextRendered, nextSnapshot.selected) - val renderedRoute = assertNotNull(repository.select(nextRoute, rendered)) + val renderedRoute = assertNotNull(repository.select(accountId, nextRoute, rendered)) assertEquals(0, renderedRoute.selectedIndex) - assertEquals(rendered, assertNotNull(repository.resolve(renderedRoute)).selected) - assertNull(repository.select(route, unrelatedRaw)) + assertEquals(rendered, assertNotNull(repository.resolve(accountId, renderedRoute)).selected) + assertNull(repository.select(accountId, route, unrelatedRaw)) + } + + @Test + fun retirementPurgesOnlyTheRemovedAccountRoutes() { + val gate = AccountPrivateMemoryGate() + val repository = MediaViewerNavigationRepository(gate) + val removed = session("removed") + val retained = session("retained") + val removedRoute = assertNotNull( + repository.register(removed.accountId, listOf(file(1L)), file(1L)), + ) + val retainedRoute = assertNotNull( + repository.register(retained.accountId, listOf(file(2L)), file(2L)), + ) + + gate.retireAccount(removed.accountId.storageKey) { + repository.purgeRetiredAccount(removed.accountId.storageKey) + } + gate.activateAccount(removed.accountId.storageKey) + + assertNull(repository.resolve(removed.accountId, removedRoute)) + assertNotNull(repository.resolve(retained.accountId, retainedRoute)) + assertNull(repository.resolve(retained.accountId, removedRoute)) + } + + @Test + fun staleProducerCannotRegisterRoutesAfterAccountReactivation() { + val gate = AccountPrivateMemoryGate() + val repository = MediaViewerNavigationRepository(gate) + val account = session("reactivated") + val staleProducer = assertNotNull(repository.producer(account.accountId)) + + gate.retireAccount(account.accountId.storageKey) { + repository.purgeRetiredAccount(account.accountId.storageKey) + } + gate.activateAccount(account.accountId.storageKey) + + assertNull( + repository.register( + account.accountId, + listOf(file(1L)), + file(1L), + producer = staleProducer, + ), + ) + val currentProducer = assertNotNull(repository.producer(account.accountId)) + assertNotNull( + repository.register( + account.accountId, + listOf(file(2L)), + file(2L), + producer = currentProducer, + ), + ) } @Test @@ -129,4 +187,10 @@ class MediaViewerNavigationTest { fileId = id, hasPreview = true, ) + + private fun session(name: String) = NextcloudSession( + serverUrl = "https://$name.media-viewer.example.test", + loginName = name, + appPassword = "password", + ) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimelineIndexCacheTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimelineIndexCacheTest.kt new file mode 100644 index 000000000..0db46370a --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimelineIndexCacheTest.kt @@ -0,0 +1,75 @@ +package dev.obiente.nextcloudnative.app + +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.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MemoriesMainTimelineIndexCacheTest { + @Test + fun cacheHitsKeepRecentlyUsedIndicesWithinTheFourScopeLimit() = runBlocking { + val cache = MemoriesMainTimelineIndexCache(AccountPrivateMemoryGate()) + (1..4).forEach { publish(cache, it) } + + val cached = cache.load(account(1), scope(1), false, cache.producer(account(1))) { + error("The retained index must be reused without another request") + } + val retained = assertIs>(cached).value + assertEquals(1L, retained.index.days.single().id) + publish(cache, 5) + + assertNull(cache.activeMemoriesIndex(account(2), scope(2))) + assertNotNull(cache.activeMemoriesIndex(account(1), scope(1))) + publish(cache, 6) + + assertNull(cache.activeMemoriesIndex(account(3), scope(3))) + listOf(1, 4, 5, 6).forEach { id -> + assertNotNull(cache.activeMemoriesIndex(account(id), scope(id))) + } + } + + @Test + fun inFlightRefreshMayPublishAfterEvictionWithoutExceedingTheLimit() = runBlocking { + val cache = MemoriesMainTimelineIndexCache(AccountPrivateMemoryGate()) + (1..4).forEach { publish(cache, it) } + val releaseRefresh = CompletableDeferred() + val producer = cache.producer(account(1)) + val refresh = async(start = CoroutineStart.UNDISPATCHED) { + cache.load(account(1), scope(1), true, producer) { + releaseRefresh.await() + MemoriesMainTimelineLoadResult.Loaded(MemoriesMainTimelineDayIndex(listOf(NativeMediaDay(99L, 1)))) + } + } + + publish(cache, 5) + assertNull(cache.activeMemoriesIndex(account(1), scope(1))) + releaseRefresh.complete(Unit) + val refreshed = assertIs>(refresh.await()) + .value + assertTrue(cache.markMemoriesActive(account(1), scope(1), refreshed.sourceGeneration, producer)) + + assertEquals(99L, assertNotNull(cache.activeMemoriesIndex(account(1), scope(1))).index.days.single().id) + assertNull(cache.activeMemoriesIndex(account(2), scope(2))) + (3..5).forEach { id -> assertNotNull(cache.activeMemoriesIndex(account(id), scope(id))) } + } + + private suspend fun publish(cache: MemoriesMainTimelineIndexCache, id: Int) { + val account = account(id) + val producer = cache.producer(account) + val result = cache.load(account, scope(id), true, producer) { + MemoriesMainTimelineLoadResult.Loaded(MemoriesMainTimelineDayIndex(listOf(NativeMediaDay(id.toLong(), 1)))) + } + val loaded = assertIs>(result).value + assertTrue(cache.markMemoriesActive(account, scope(id), loaded.sourceGeneration, producer)) + } + + private fun account(id: Int) = NextcloudAccountId(id.toString().repeat(64)) + + private fun scope(id: Int) = "synthetic-scope-$id" +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimelineTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimelineTest.kt index efb672acb..2035bbc3f 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimelineTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimelineTest.kt @@ -1,12 +1,16 @@ package dev.obiente.nextcloudnative.app import kotlinx.coroutines.CancellationException +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.assertIs +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -446,6 +450,91 @@ class MemoriesMainTimelineTest { } } + @Test + fun retiredAccountIndexIsPurgedWithoutRemovingAnotherAccount() = runBlocking { + val gate = AccountPrivateMemoryGate() + val cache = MemoriesMainTimelineIndexCache(gate) + val removed = session("removed") + val retained = session("retained") + val removedProducer = assertNotNull(cache.producer(removed.accountId)) + val retainedProducer = assertNotNull(cache.producer(retained.accountId)) + + val removedIndex = assertIs>( + cache.load(removed.accountId, "removed-scope", true, removedProducer) { + MemoriesMainTimelineLoadResult.Loaded( + MemoriesMainTimelineDayIndex(listOf(NativeMediaDay(30L, 1))), + ) + }, + ).value + val retainedIndex = assertIs>( + cache.load(retained.accountId, "retained-scope", true, retainedProducer) { + MemoriesMainTimelineLoadResult.Loaded( + MemoriesMainTimelineDayIndex(listOf(NativeMediaDay(29L, 2))), + ) + }, + ).value + assertTrue( + cache.markMemoriesActive( + removed.accountId, + "removed-scope", + removedIndex.sourceGeneration, + removedProducer, + ), + ) + assertTrue( + cache.markMemoriesActive( + retained.accountId, + "retained-scope", + retainedIndex.sourceGeneration, + retainedProducer, + ), + ) + + gate.retireAccount(removed.accountId.storageKey) { + cache.purgeRetiredAccount(removed.accountId.storageKey) + } + gate.activateAccount(removed.accountId.storageKey) + + assertNull(cache.activeMemoriesIndex(removed.accountId, "removed-scope")) + assertEquals( + listOf(29L), + assertNotNull( + cache.activeMemoriesIndex(retained.accountId, "retained-scope"), + ).index.days.map(NativeMediaDay::id), + ) + } + + @Test + fun indexLoadCrossingRetirementCannotPublishIntoReactivatedAccount() = runBlocking { + val gate = AccountPrivateMemoryGate() + val cache = MemoriesMainTimelineIndexCache(gate) + val account = session("crossing") + val staleProducer = assertNotNull(cache.producer(account.accountId)) + val loadStarted = CompletableDeferred() + val releaseLoad = CompletableDeferred() + val staleLoad = async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + cache.load(account.accountId, "account-scope", true, staleProducer) { + loadStarted.complete(Unit) + releaseLoad.await() + MemoriesMainTimelineLoadResult.Loaded( + MemoriesMainTimelineDayIndex(listOf(NativeMediaDay(30L, 1))), + ) + } + } + } + loadStarted.await() + + gate.retireAccount(account.accountId.storageKey) { + cache.purgeRetiredAccount(account.accountId.storageKey) + } + gate.activateAccount(account.accountId.storageKey) + releaseLoad.complete(Unit) + + assertIs(staleLoad.await().exceptionOrNull()) + assertNull(cache.activeMemoriesIndex(account.accountId, "account-scope")) + } + @Test fun cursorFromAnotherTimelineSourceIsRejected() { val index = MemoriesMainTimelineDayIndex(listOf(NativeMediaDay(30L, 1))) @@ -643,7 +732,7 @@ class MemoriesMainTimelineTest { } val snapshot = requireNotNull( runBlocking { - service.navigationSnapshot("account-a") + service.navigationSnapshot(session().accountId, "account-a") }, ) val target = runBlocking { @@ -686,7 +775,7 @@ class MemoriesMainTimelineTest { assertNull( runBlocking { - service.navigationSnapshot("account-a") + service.navigationSnapshot(session().accountId, "account-a") }, ) } @@ -712,7 +801,7 @@ class MemoriesMainTimelineTest { } } val firstSnapshot = requireNotNull( - runBlocking { service.navigationSnapshot("account-a") }, + runBlocking { service.navigationSnapshot(session().accountId, "account-a") }, ) runBlocking { service.loadPage(session(), "account-a", cursor = null) { @@ -768,4 +857,10 @@ class MemoriesMainTimelineTest { loginName = "fixture", appPassword = "fixture-secret", ) + + private fun session(name: String): NextcloudSession = NextcloudSession( + serverUrl = "https://$name.cloud.example.test", + loginName = name, + appPassword = "fixture-secret", + ) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PhotoMediaQueryOwnershipTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PhotoMediaQueryOwnershipTest.kt index c1436a790..371d9ddaf 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PhotoMediaQueryOwnershipTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PhotoMediaQueryOwnershipTest.kt @@ -56,23 +56,25 @@ class PhotoMediaQueryOwnershipTest { accountScope = "account-a", owner = PhotoMediaQueryOwner.FolderInventory, ) - val timelineGeneration = store.beginAccountGeneration(timelineScope) - val folderGeneration = store.beginAccountGeneration(folderScope) + val account = NextcloudAccountId("a".repeat(64)) + val producer = requireNotNull(store.producer(account)) + val timelineGeneration = requireNotNull(store.beginAccountGeneration(account, timelineScope, producer)) + val folderGeneration = requireNotNull(store.beginAccountGeneration(account, folderScope, producer)) val timelineCursor = PhotoTimelineCursor("timeline-cursor") val folderCursor = PhotoTimelineCursor("folder-cursor") val timelineCarryover = carryover("Photos/timeline.jpg", 1L) val folderCarryover = carryover("Photos/Trips/folder.jpg", 2L) - store.put(timelineScope, timelineGeneration, timelineCursor, timelineCarryover) - store.put(folderScope, folderGeneration, folderCursor, folderCarryover) + store.put(account, timelineScope, timelineGeneration, timelineCursor, timelineCarryover, producer) + store.put(account, folderScope, folderGeneration, folderCursor, folderCarryover, producer) assertEquals( timelineCarryover, - store.take(timelineScope, timelineGeneration, timelineCursor), + store.take(account, timelineScope, timelineGeneration, timelineCursor, producer), ) assertEquals( folderCarryover, - store.take(folderScope, folderGeneration, folderCursor), + store.take(account, folderScope, folderGeneration, folderCursor, producer), ) } 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 b7233b0dd..68bf73d03 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,7 @@ 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 @@ -15,6 +16,7 @@ import kotlin.test.assertNull class PreviewMemoryCacheTest { @Test fun missingGenerationBypassesMemoryCache() = runBlocking { + val cache = PreviewMemoryCache() var loads = 0 val key = previewCacheKeyOrNull( account = "no-generation-account", @@ -26,7 +28,7 @@ class PreviewMemoryCacheTest { ) repeat(2) { - loadPreviewMemoryCached(key) { + loadPreviewMemoryCached(key, cache) { loads += 1 byteArrayOf(loads.toByte()) } @@ -38,6 +40,7 @@ class PreviewMemoryCacheTest { @Test fun authoritativeGenerationCachesAndSeparatesRevisions() = runBlocking { + val cache = PreviewMemoryCache() var firstLoads = 0 val firstKey = previewCacheKeyOrNull( account = "generation-account", @@ -56,15 +59,15 @@ class PreviewMemoryCacheTest { height = 320, ) - val first = loadPreviewMemoryCached(firstKey) { + val first = loadPreviewMemoryCached(firstKey, cache) { firstLoads += 1 byteArrayOf(1) } - val repeated = loadPreviewMemoryCached(firstKey) { + val repeated = loadPreviewMemoryCached(firstKey, cache) { firstLoads += 1 byteArrayOf(2) } - val second = loadPreviewMemoryCached(secondKey) { byteArrayOf(3) } + val second = loadPreviewMemoryCached(secondKey, cache) { byteArrayOf(3) } assertEquals(1, firstLoads) assertContentEquals(byteArrayOf(1), first) @@ -95,10 +98,10 @@ class PreviewMemoryCacheTest { allowLoadToFinish.complete(Unit) assertContentEquals(byteArrayOf(4), pending.await()) - assertNull(PreviewMemoryCache.get(key)) + assertNull(sharedPreviewMemoryCache.get(key)) assertContentEquals(byteArrayOf(5), loadPreviewMemoryCached(key) { byteArrayOf(5) }) - assertContentEquals(byteArrayOf(5), PreviewMemoryCache.get(key)) + assertContentEquals(byteArrayOf(5), sharedPreviewMemoryCache.get(key)) } finally { allowLoadToFinish.complete(Unit) AccountPrivateMemoryCleanup.removeAccount(accountKey) @@ -121,7 +124,7 @@ class PreviewMemoryCacheTest { accountKey, "core", publisher.toLong(), "etag-$revision", 64, 64, ) loadPreviewMemoryCached(key) { byteArrayOf(revision.toByte()) } - PreviewMemoryCache.get(key) + sharedPreviewMemoryCache.get(key) } } } @@ -139,4 +142,99 @@ class PreviewMemoryCacheTest { AccountPrivateMemoryLifecycle.activateAccount(accountKey) } } + + @Test + fun `retirement purges only the exact preview account`() { + val cache = PreviewMemoryCache() + val target = key("target-account", 201L) + val other = key("other-account", 202L) + cache.put(target, byteArrayOf(1), requireNotNull(cache.producer(target))) + cache.put(other, byteArrayOf(2), requireNotNull(cache.producer(other))) + + cache.retireAccount(target.account) + + assertNull(cache.get(target)) + assertContentEquals(byteArrayOf(2), cache.get(other)) + cache.activateAccount(target.account) + assertNull(cache.get(target)) + } + + @Test + fun `synchronized preview cache retains its byte bound and lru order`() { + val cache = PreviewMemoryCache(maximumBytes = 2) + val first = key("bounded-account", 301L) + val second = key("bounded-account", 302L) + val third = key("bounded-account", 303L) + val producer = requireNotNull(cache.producer(first)) + cache.put(first, byteArrayOf(1), producer) + cache.put(second, byteArrayOf(2), producer) + assertContentEquals(byteArrayOf(1), cache.get(first)) + + cache.put(third, byteArrayOf(3), producer) + + assertNull(cache.get(second)) + assertContentEquals(byteArrayOf(1), cache.get(first)) + assertContentEquals(byteArrayOf(3), cache.get(third)) + } + + @Test + fun `completion crossing retirement and reactivation cannot repopulate previews`() = runBlocking { + val cache = PreviewMemoryCache() + val key = key("recreated-account", 203L) + val started = CompletableDeferred() + val release = CompletableDeferred() + var loads = 0 + val staleLoad = async(start = CoroutineStart.UNDISPATCHED) { + loadPreviewMemoryCached(key, cache) { + loads += 1 + started.complete(Unit) + release.await() + byteArrayOf(3) + } + } + started.await() + + cache.retireAccount(key.account) + cache.activateAccount(key.account) + release.complete(Unit) + + assertContentEquals(byteArrayOf(3), staleLoad.await()) + val current = loadPreviewMemoryCached(key, cache) { + loads += 1 + byteArrayOf(4) + } + assertEquals(2, loads) + assertContentEquals(byteArrayOf(4), current) + assertContentEquals(byteArrayOf(4), cache.get(key)) + } + + @Test + fun `concurrent preview access remains safe across retirement`() = runBlocking { + val cache = PreviewMemoryCache(maximumBytes = 1_024) + val account = "concurrent-account" + val producer = requireNotNull(cache.producer(key(account, 0L))) + + val writers = List(8) { worker -> + async(Dispatchers.Default) { + repeat(100) { iteration -> + val key = key(account, (worker * 100 + iteration).toLong()) + cache.put(key, byteArrayOf(iteration.toByte()), producer) + cache.get(key) + } + } + } + val retirement = async(Dispatchers.Default) { cache.retireAccount(account) } + (writers + retirement).awaitAll() + + repeat(800) { fileId -> assertNull(cache.get(key(account, fileId.toLong()))) } + } + + private fun key(account: String, fileId: Long) = PreviewCacheKey( + account = account, + variant = "core-preview", + fileId = fileId, + etag = "etag-$fileId", + width = 320, + height = 320, + ) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistryTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistryTest.kt index 71343ff7e..26c38d634 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistryTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistryTest.kt @@ -1,5 +1,9 @@ package dev.obiente.nextcloudnative.app +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.assertFalse @@ -77,4 +81,69 @@ class SupportSettingsDraftRegistryTest { assertSame(retained, SupportSettingsDraftRegistry.stateFor(retainedAccount)) assertEquals("Unsaved private report", retained.reportDraft) } + + @Test + fun `retirement clears the old state and preserves other accounts and login`() { + val targetAccount = "6".repeat(64) + val otherAccount = "7".repeat(64) + val target = SupportSettingsDraftRegistry.stateFor(targetAccount) + val other = SupportSettingsDraftRegistry.stateFor(otherAccount) + val login = SupportSettingsDraftRegistry.loginState() + try { + target.updateReportDraft("Removed account report") + target.updateReplyDraft("removed", "Removed account reply") + other.updateReportDraft("Other account report") + login.updateReportDraft("Login report") + + SupportSettingsDraftRegistry.retireAccount(targetAccount) + + assertFalse(target.hasDraftContent()) + assertEquals("Other account report", other.reportDraft) + assertEquals("Login report", login.reportDraft) + val whileClosed = SupportSettingsDraftRegistry.stateFor(targetAccount) + whileClosed.updateReportDraft("Must not survive") + assertFalse(whileClosed.hasDraftContent()) + + SupportSettingsDraftRegistry.activateAccount(targetAccount) + val current = SupportSettingsDraftRegistry.stateFor(targetAccount) + assertNotSame(target, current) + assertFalse(current.hasDraftContent()) + target.updateReportDraft("Late old report") + whileClosed.updateReplyDraft("late", "Late closed reply") + assertFalse(current.hasDraftContent()) + } finally { + SupportSettingsDraftRegistry.retireAccount(targetAccount) + SupportSettingsDraftRegistry.activateAccount(targetAccount) + SupportSettingsDraftRegistry.retireAccount(otherAccount) + SupportSettingsDraftRegistry.activateAccount(otherAccount) + login.clearDrafts() + } + } + + @Test + fun `concurrent draft writes cannot survive account retirement`() = runBlocking { + val account = "8".repeat(64) + val state = SupportSettingsDraftRegistry.stateFor(account) + try { + val writers = List(8) { worker -> + async(Dispatchers.Default) { + repeat(100) { iteration -> + state.updateReportDraft("report-$worker-$iteration") + state.updateReplyDraft("reply-$worker", "reply-$iteration") + } + } + } + val retirement = async(Dispatchers.Default) { + SupportSettingsDraftRegistry.retireAccount(account) + } + (writers + retirement).awaitAll() + + assertFalse(state.hasDraftContent()) + assertFalse(SupportSettingsDraftRegistry.stateFor(account).hasDraftContent()) + } finally { + SupportSettingsDraftRegistry.retireAccount(account) + SupportSettingsDraftRegistry.activateAccount(account) + } + } + } 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 73dde109d..adcca2fd8 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -758,7 +758,6 @@ class DesktopNextcloudServices( desktopContractCacheDirectory("responses"), ) private val dynamicApiRequestCoalescer = DynamicApiRequestCoalescer() - private val mediaTimelineCarryoverStore = MediaTimelineDavCarryoverStore() private val memoriesTimeline = MemoriesPreferredTimelineReadService { session, request -> executeNextcloudApi(session, request) } @@ -4264,11 +4263,12 @@ class DesktopNextcloudServices( shouldSearchRaw = { files -> rawPreviouslyObserved || files.any(NextcloudFile::isRawPhoto) }, - carryoverStore = mediaTimelineCarryoverStore, + carryoverStore = sharedMediaTimelineDavCarryoverStore, carryoverAccountScope = photoMediaCarryoverScope( accountScope = desktopFileCacheAccountId(session), owner = queryOwner, ), + carryoverAccountId = session.accountId, ) return PhotoTimelinePage( entries = page.files.mapNotNull(NextcloudFile::toPhotoTimelineEntryOrNull), @@ -4296,6 +4296,7 @@ class DesktopNextcloudServices( monthResolver: PhotoTimelineMonthResolver, ): MemoriesTimelineNavigationSnapshot? = withContext(Dispatchers.IO) { memoriesTimeline.navigationSnapshot( + accountId = session.accountId, accountScope = desktopFileCacheAccountId(session), monthResolver = monthResolver, ) 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 a76825ebe..36d1f13d3 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt @@ -18,9 +18,9 @@ internal suspend fun clearDesktopDynamicApiState( coalescer: DynamicApiRequestCoalescer, cache: DynamicApiResponseCache, accountStorageKey: String? = null, - memoryCache: DynamicNativeMemoryCache = sharedDynamicNativeMemoryCache, + retireMemoryAccount: (String) -> Unit = AccountPrivateMemoryLifecycle::retireAccount, ) = coalescer.fenceAccount(accountId) { - accountStorageKey?.let(memoryCache::retireAccount) + accountStorageKey?.let(retireMemoryAccount) cache.invalidateAccount(accountId) } 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 c805f1049..45e8412a0 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt @@ -157,7 +157,7 @@ class DesktopPendingDynamicMutationDirectoryTest { coalescer, cache, session.accountId.storageKey, - memoryCache, + memoryCache::retireAccount, ) release.complete(Unit) @@ -191,7 +191,7 @@ class DesktopPendingDynamicMutationDirectoryTest { coalescer, cache, session.accountId.storageKey, - memoryCache, + memoryCache::retireAccount, ) } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftReactivationTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftReactivationTest.kt new file mode 100644 index 000000000..b21e5adae --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftReactivationTest.kt @@ -0,0 +1,82 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.AbstractApplier +import androidx.compose.runtime.BroadcastFrameClock +import androidx.compose.runtime.Composition +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame + +class SupportSettingsDraftReactivationTest { + @Test + fun `live settings composition acquires a new holder after safe reactivation`() = runBlocking { + val session = NextcloudSession( + serverUrl = "https://support-reactivation.example.test", + loginName = "alice", + appPassword = "password", + ) + val account = session.accountId.storageKey + val frameClock = BroadcastFrameClock() + val recomposer = Recomposer(coroutineContext + frameClock) + val recomposerJob = launch(frameClock, start = CoroutineStart.UNDISPATCHED) { + recomposer.runRecomposeAndApplyChanges() + } + val composition = Composition(EmptyUnitApplier(), recomposer) + var frameTime = 0L + suspend fun advanceComposition() { + yield() + Snapshot.sendApplyNotifications() + yield() + frameClock.sendFrame(frameTime++) + recomposer.awaitIdle() + } + var rendered: SupportSettingsDraftState? = null + try { + composition.setContent { + rendered = rememberAccountSupportSettingsDraftState(session) + } + advanceComposition() + val retiredHolder = requireNotNull(rendered) + val staleCallback = retiredHolder::updateReportDraft + retiredHolder.updateReportDraft("Private text before removal") + + AccountPrivateMemoryLifecycle.retireAccount(account) + + assertFalse(retiredHolder.hasDraftContent()) + AccountPrivateMemoryLifecycle.activateAccount(account) + withTimeout(5_000L) { + while (rendered === retiredHolder) advanceComposition() + } + val currentHolder = requireNotNull(rendered) + assertNotSame(retiredHolder, currentHolder) + + staleCallback("Late callback from the retired screen") + currentHolder.updateReportDraft("Fresh text after recovery") + + assertEquals("", retiredHolder.reportDraft) + assertEquals("Fresh text after recovery", currentHolder.reportDraft) + } finally { + composition.dispose() + recomposer.close() + recomposerJob.join() + AccountPrivateMemoryLifecycle.retireAccount(account) + AccountPrivateMemoryLifecycle.activateAccount(account) + } + } + + private class EmptyUnitApplier : AbstractApplier(Unit) { + override fun insertTopDown(index: Int, instance: Unit) = Unit + override fun insertBottomUp(index: Int, instance: Unit) = Unit + override fun remove(index: Int, count: Int) = Unit + override fun move(from: Int, to: Int, count: Int) = Unit + override fun onClear() = Unit + } +} diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index b7f2d0776..e634767ed 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -183,12 +183,14 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaInformation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaStacking.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaTimelineDavCarryoverStore.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaTransferCenterScreen.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerFeedback.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewportTransform.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesFolderBrowsing.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimeline.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimelineIndexCache.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesTimelinePlaceholderGeometry.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MobileCalendarWorkspace.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeAppCatalogSurface.kt", @@ -434,10 +436,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/AccountPrivateMemoryCleanup.kt": "35c13216a14662f9bc2b47cee36b29f9d7d8602373b937536fcc5cc01da67e4f", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt": "09053be8a8739289395cd079d9eff473df53f5eb87305bb6609f4dabefe86bb2", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt": "f59c5befeabd5986b2a12cdaa62bbe71faaebd52c007616fc7a1e7d43046c5ea", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt": "bfb8ab56800e5c34f3fc42cc423e8aa8114c7fc1605fa6afda7ac963d03c8dbd", "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", @@ -467,9 +469,9 @@ "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": "1a0a6b50c2b1f8b528d637520cb95acab42d1a6f4edde4fd47ced7a47bd4ddad", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt": "9ae6444a94a51c711bb69a63705b596569b90f4f728794afe97f18bab6f75c1d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt": "f0d6ad6cbfd32a65c3d8dde6c6fd1ebead4fa1584816103d7616f1cd1453d6c9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusPresentation.kt": "96eb3aa478be8932e695e2dfe2067cc7b8dccf5ffa6cc1370f2db27b8119e0ff", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt": "d257b5d8fe04300bc095a16fedc2cceeb27515795d1156a19d3f3c46d5b215b5", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt": "6ba8ff771df03275e430257a328e58ca9cc2a175826143e8b7760cc179dcb180", "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", @@ -481,14 +483,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": "275e63824e802c3df6bc749dfed9a6cd993ffe542ac0814919b186c707a29cac", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt": "02e5dbcbd680fe377e22db137f7c0075856e6a5b64a35c4a26e6195805b23521", "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": "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", @@ -552,11 +554,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": "404d55e3cb691609cdbde00eaaddf230ec4e1626c342512b935e7c383630c8da", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "5497ca9474453a41661834bfa44c2e3be87171e2f3bc37d4db373a3614ecb775", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactDetailDialog.kt": "ab6826a14ac9fbf62677bed2f6e93ef0fb1a57f1f074bb1a9e79fef212f715b3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "c0b42fa450b8748281c7208385f7257d286bc1f842588bc737f3c296dd4ec1d9", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "5ec7d1326c216a1f4af813bd5d1132063654cada621ed5ae54805ac5c63953e1", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "1310216fe02f19005a355bd05420f252bbf9e3e37b11d46b90cc71844f56c2d6", "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", @@ -603,14 +605,16 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaBackupStatusIndicator.kt": "98342ae941670e4b30463335669e941ffa74d50bb36d6dee18fad9aabcd4a492", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaImageCanvas.kt": "decaeffd96fbb0488937713b93d12f2936ebe19e7c335509e98e2a61de11fe16", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaInformation.kt": "3570f3cbd671c5218ed20979fa943d8f49a75db36ff2d7f29ac89f9b345c4565", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt": "694fc02979217b82978c316acc503a8fb09bda7ea04965dbc9a9f04bf127f9b4", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt": "9ff45eaf8a433eb3f9c9ebdc75d16c7a57c79982cc7fd01d106950f28429d392", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaStacking.kt": "8d81df18797ce2c3299cb36f1a3be0f7a23c30f66ea09447ae78d44aa61ac5d9", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaTimelineDavCarryoverStore.kt": "70e080f249cd7d3d47e993afcbda55f10e67888f1413014f9f97bd308341d0d5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaTransferCenterScreen.kt": "b40873ea125fc4d67c016201b7bba0db26cf9131d1d7ad661f02d4639b407580", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerFeedback.kt": "698e9d0b12915264e6a7293c37408faee70e46364625fc7b574fd05e7f6170a5", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt": "b5e0ce2c504df592fb034a8ca917eb7fb0839c07e752575a00560a6179b40ca8", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewerNavigation.kt": "9b46db810cff7082e30ba11c6cd2a6956dfae40ce5f5b12b19ce6a00768f9dc4", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaViewportTransform.kt": "b5d75403f3be594ea5aefcb631c985a452c5a6792dfb16e097068e93ee3c79ef", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesFolderBrowsing.kt": "a68c6cf314182178d90805f3ad96b778f7330db7b20003a81aeb7e03ea979c6d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimeline.kt": "76c732fc9e271b3b8a9d6027f233af99c4af2d16a6323cabe0dfd94d8174d088", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimeline.kt": "4ebdf54b62d91d5c37f72c652abf22bceb6c78fc05e72c258c35e60201e98369", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesMainTimelineIndexCache.kt": "542cd7db33dd362aa24b595987c635589a23ae4e0ca343fafd7705a92ebe864a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MemoriesTimelinePlaceholderGeometry.kt": "7492d1b49c6bcdf9954aeeeae0e43ee250007f058bcad7ad57145460e612b812", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MobileCalendarWorkspace.kt": "fcee8b5fd26a3d636703012495e9f92e4daa22199261b96f5d0bfc0c98f1f70f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeAppCatalogSurface.kt": "faccc930eb077fb38b594b7a5209c36494167fdddb2d2cc2611c8e7ca3e2614a", @@ -624,7 +628,7 @@ "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": "d0200cfa7b9905b3554b71c7c4f982d9b5024246ee5ec08c9b3e7c74d42e0eea", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt": "5452117c7e9d8338691bfd280c6274f96018251024ac9e004bf5fde8c15bb982", "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", @@ -640,7 +644,7 @@ "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": "63a26e6b034604a525a358d8e422a3870d9fc3dc6a88c2cdf97a095259236bbf", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "af03ba05bc18e03191dad58e523f348574061a9e42332bbfe74e9117a80421e0", "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", @@ -680,7 +684,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": "5839b1dfc65ecefde01d9b851219426a734286966c150d6ec8dcce967bedc01e", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt": "bf249c798709c5964aa10cb6e009db514c560b551e6d433f8c155ed19a8a5fe2", "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", @@ -699,8 +703,8 @@ "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": "ec4c0872d30e09602443d0172707fb4251bd92274eb3145f20f99dadc4f15a38", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftState.kt": "deeb8def69d225ba9663e4f712be9e2b461c67bf738a4d391a80b78af218e99e", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt": "657b648fe2f99bc138a7d3b10d4e05ebe4d05e9298b2c84402f1bfc52d596c3c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftState.kt": "7c9bc2dc5024ebcfcfc21ee7e14e1c7e0ea5c7f32ff92afcf50acafbd9a61531", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SystemTags.kt": "452abd9589c627dc7ae9dae03265a3bfdb837809ba0ca82da0698ef73db64332", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TalkAttachmentReader.kt": "aa8c5adab59da93a84fb86ade68e12cb8455a23d7c847aeb8411c68e49551925", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TalkMessageCards.kt": "53dca071750fd5399ca627d80cafcc6a96ab3926984d6d1ead4d97a55d3df874",