Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -74,7 +73,6 @@ internal class AndroidAccountOwnedStateCleanup(
{ virtualFileCache.clearAccount(accountIdentity) },
{ mutationRecovery.clearDurableRecoveries(durableMutationAccountScope(session)) },
{ mutationRecovery.clearPendingDynamicMutations(cacheIdentity) },
{ AccountPrivateMemoryCleanup.removeAccount(session.accountId.storageKey) },
),
)
}
Expand All @@ -92,7 +90,7 @@ internal class AndroidAccountOwnedStateCleanup(
listOf(
{
if (previewCacheIdentity == null) {
DynamicNativeMemoryAccountLifecycle.retireAccount(session.accountId.storageKey)
AccountPrivateMemoryLifecycle.retireAccount(session.accountId.storageKey)
} else {
fenceAndroidDynamicApiStateForRemoval(
previewCacheIdentity,
Expand Down Expand Up @@ -124,7 +122,6 @@ internal class AndroidAccountOwnedStateCleanup(
{ virtualFileCache.clearAccount(accountIdentity) },
{ durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) },
{ previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) },
{ AccountPrivateMemoryCleanup.removeAccount(session.accountId.storageKey) },
),
)
}
Expand All @@ -142,7 +139,7 @@ internal class AndroidAccountOwnedStateCleanup(
listOf(
{
if (previewCacheIdentity == null) {
DynamicNativeMemoryAccountLifecycle.retireAccount(accountStorageKey)
AccountPrivateMemoryLifecycle.retireAccount(accountStorageKey)
} else {
fenceAndroidDynamicApiStateForRemoval(
previewCacheIdentity,
Expand Down Expand Up @@ -174,19 +171,17 @@ internal class AndroidAccountOwnedStateCleanup(
{ virtualFileCache.clearAccount(accountIdentity) },
{ durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) },
{ previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) },
{ AccountPrivateMemoryCleanup.removeAccount(accountStorageKey) },
),
)
}

}

internal suspend fun <T> clearAndroidDynamicApiState(
accountIdentity: String,
coalescer: DynamicApiRequestCoalescer<T>,
cache: DynamicApiResponseCache,
accountStorageKey: String? = null,
retireMemoryAccount: (String) -> Unit = DynamicNativeMemoryAccountLifecycle::retireAccount,
retireMemoryAccount: (String) -> Unit = AccountPrivateMemoryLifecycle::retireAccount,
) = coalescer.fenceAccount(accountIdentity) {
accountStorageKey?.let(retireMemoryAccount)
cache.invalidateAccount(accountIdentity)
Expand All @@ -197,7 +192,7 @@ internal suspend fun <T> fenceAndroidDynamicApiStateForRemoval(
coalescer: DynamicApiRequestCoalescer<T>,
cache: DynamicApiResponseCache,
accountStorageKey: String? = null,
retireMemoryAccount: (String) -> Unit = DynamicNativeMemoryAccountLifecycle::retireAccount,
retireMemoryAccount: (String) -> Unit = AccountPrivateMemoryLifecycle::retireAccount,
) = withContext(NonCancellable) {
clearAndroidDynamicApiState(accountIdentity, coalescer, cache, accountStorageKey, retireMemoryAccount)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NextcloudApiResponse>,
private val activateMemory: (String) -> Unit = DynamicNativeMemoryAccountLifecycle::activateAccount,
private val activateMemory: (String) -> Unit = AccountPrivateMemoryLifecycle::activateAccount,
) {
suspend fun afterCredentialSave(persistedSession: NextcloudSession) {
activateMemory(persistedSession.accountId.storageKey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1926,6 +1924,7 @@ internal class AndroidNextcloudServices(
monthResolver: PhotoTimelineMonthResolver,
): MemoriesTimelineNavigationSnapshot? = withContext(Dispatchers.IO) {
memoriesTimeline.navigationSnapshot(
accountId = session.accountId,
accountScope = NextcloudDocumentIds.cacheAccountId(session),
monthResolver = monthResolver,
)
Expand Down
7 changes: 7 additions & 0 deletions changes/unreleased/account-private-memory-retirement.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions tools/kotlin-file-size-baseline.txt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
veryCrunchy marked this conversation as resolved.
Comment thread
veryCrunchy marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,38 @@ internal class AccountPrivateMemoryGate {
if (accountStorageKey in closedAccounts) unavailable else action()
}

fun <T> read(
producer: AccountPrivateMemoryProducer?,
unavailable: T,
action: () -> T,
): T = lock.withLock {
val currentProducer = producer ?: return@withLock unavailable
if (!accepts(currentProducer)) unavailable else action()
}

fun <T> 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
}
Expand All @@ -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 <T> withLock(action: () -> T): T = lock.withLock(action)

private fun accepts(producer: AccountPrivateMemoryProducer): Boolean =
producer.accountStorageKey !in closedAccounts &&
(accountIncarnations[producer.accountStorageKey] ?: 0L) == producer.incarnation
Expand All @@ -61,7 +90,8 @@ object AccountPrivateMemoryLifecycle {
}

fun activateAccount(accountStorageKey: String) = sharedAccountPrivateMemoryGate.activateAccount(
accountStorageKey,
accountStorageKey = accountStorageKey,
prepare = { sharedDynamicNativeMemoryCache.activateAccount(accountStorageKey) },
activated = SupportSettingsDraftRegistry::publishAccountActivated,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,9 +38,7 @@ internal fun SettingsScreen(
var selectedSectionName by rememberSaveable(session.serverUrl, session.loginName) {
mutableStateOf<String?>(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<String?>(null) }
Expand Down Expand Up @@ -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<SettingsWorkspaceSection>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map<String, MediaBackupStatus>>(emptyMap())
Expand All @@ -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<String, PhotoTimelineUiState>()

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
Comment on lines +38 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fence retired photo timeline holders against late writes

If an in-flight timeline load completes after account retirement but before the old AuthenticatedApp composition is disposed, this purge only clears the retained PhotoTimelineUiState; it does not revoke writes to that object. Fresh evidence in this revision is that loadTimelinePage still assigns the private page and backup statuses directly at NextcloudNativeApp.kt:8922-8948 without an incarnation producer, so a late completion can repopulate the cleared holder and briefly render or retain the removed account's media metadata. Make these state publications gate-aware rather than relying only on removing and resetting the registry entry.

AGENTS.md reference: AGENTS.md:L327-L328

Useful? React with 👍 / 👎.

}
}
}

Expand All @@ -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<Pair<NextcloudAccountId, String>, CalendarLoadState.Ready>()
Expand Down Expand Up @@ -98,8 +119,7 @@ internal object UserStatusWorkspaceMemoryCache {
private val gate = sharedAccountPrivateMemoryGate
private val entries = linkedMapOf<NextcloudAccountId, UserStatusSurfaceState.Available>()

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) {
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading