From 93631d65d197f02aa60d69a34b2131af03c0cdc8 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 22:42:08 +0200 Subject: [PATCH 001/119] feat(accounts): add platform credential slots --- .../AndroidAccountCredentialController.kt | 176 +++++++++++ .../AndroidNextcloudServices.kt | 138 ++------ .../AndroidPersistedSession.kt | 245 ++++++++++++--- .../AndroidPersistedSessionTest.kt | 296 +++++++++++++----- .../172-account-credential-slots.md | 7 + .../app/NextcloudAccountCredentialServices.kt | 31 ++ .../nextcloudnative/app/NextcloudPlatform.kt | 8 +- .../DesktopAccountCredentialPersistence.kt | 258 +++++++++++++++ .../app/DesktopNextcloudServices.kt | 150 ++++----- .../nextcloudnative/app/DesktopSecretStore.kt | 12 + ...DesktopAccountCredentialPersistenceTest.kt | 236 ++++++++++++++ .../app/DesktopSecretStoreTest.kt | 17 + 12 files changed, 1250 insertions(+), 324 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt create mode 100644 changes/unreleased/172-account-credential-slots.md create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt new file mode 100644 index 000000000..8e8df50f9 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -0,0 +1,176 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudAccountId +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent +import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft +import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +internal class AndroidAccountCredentialController( + context: Context, + private val preferences: SharedPreferences, + private val sessionCipher: SessionCipher, + private val registerSessionPrivateValues: (NextcloudSession) -> Unit, + private val recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, + private val publishAccountIdentity: (String?) -> Unit, + private val clearPreviewAccount: (String) -> Unit, + private val notifyDocumentRootsChanged: () -> Unit, +) { + private val appContext = context.applicationContext + private val mutationMutex = Mutex() + + fun loadSession(): NextcloudSession? = ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.restorePersistedSession( + load = { loadState()?.activeSession }, + accountIdOf = NextcloudDocumentIds::accountKey, + publishAccount = { session, accountIdentity -> + session?.let(registerSessionPrivateValues) + publishAccountIdentity(accountIdentity) + }, + ) + + fun listAccounts(): List = loadState()?.registry?.accounts.orEmpty() + + fun activeAccountId(): NextcloudAccountId? = loadState()?.registry?.activeAccountId + + fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = + loadState()?.sessions?.get(accountId)?.also(registerSessionPrivateValues) + + suspend fun saveSession(session: NextcloudSession) = mutationMutex.withLock { + registerSessionPrivateValues(session) + val current = requireValidState() + replaceActiveState(current.upsertAndSelect(session), current.activeSession) + } + + suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = mutationMutex.withLock { + val current = requireValidState() + val selected = current.select(accountId) ?: return@withLock null + val session = requireNotNull(selected.activeSession) + registerSessionPrivateValues(session) + replaceActiveState(selected, current.activeSession) + session + } + + suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = mutationMutex.withLock { + val current = requireValidState() + if (accountId !in current.sessions) return@withLock false + if (current.registry.activeAccountId == accountId) { + clearSession(current) + } else { + persistState(current.remove(accountId)) + } + true + } + + suspend fun clearSession() = mutationMutex.withLock { + clearSession(requireValidState()) + } + + private suspend fun clearSession(current: AndroidAccountCredentialState) { + val activeSession = current.activeSession ?: return + val replacement = current.remove(activeSession.accountId) + val encodedReplacement = replacement.takeUnless { state -> + state.registry.accounts.isEmpty() && state.sessions.isEmpty() + }?.let(::encryptState) + withContext(Dispatchers.IO) { AndroidExternalFileHandoffRegistry.clear() } + val scheduler = AndroidFileSyncScheduler(appContext) + ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.clearSession( + persist = { + preferences.edit().apply { + if (encodedReplacement == null) remove(KEY_SESSION) else putString(KEY_SESSION, encodedReplacement) + remove(KEY_TEST_READ_ONLY) + }.apply() + }, + cancelAll = scheduler::cancelAll, + clearPublishedAccount = { publishAccountIdentity(null) }, + ) + clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(activeSession)) + notifyDocumentRootsChanged() + } + + private suspend fun replaceActiveState( + replacement: AndroidAccountCredentialState, + previousSession: NextcloudSession?, + ) { + val session = requireNotNull(replacement.activeSession) + val encrypted = encryptState(replacement) + withContext(Dispatchers.IO) { AndroidExternalFileHandoffRegistry.clear() } + val scheduler = AndroidFileSyncScheduler(appContext) + ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.replaceSession( + replacementAccountId = NextcloudDocumentIds.accountKey(session), + persist = { + preferences.edit() + .putString(KEY_SESSION, encrypted) + .remove(KEY_TEST_READ_ONLY) + .apply() + }, + cancelAll = scheduler::cancelAll, + publishAccount = publishAccountIdentity, + ) + if (previousSession != null && previousSession.accountId != session.accountId) { + clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(previousSession)) + } + notifyDocumentRootsChanged() + } + + private fun requireValidState(): AndroidAccountCredentialState = requireNotNull(loadState()) { + "The account credential store is invalid." + } + + private fun loadState(): AndroidAccountCredentialState? { + val encrypted = preferences.getString(KEY_SESSION, null) ?: return AndroidAccountCredentialState.Empty + val encoded = try { + sessionCipher.decrypt(encrypted) + } catch (_: Exception) { + recordCredentialFailure( + code = "ACCOUNT_CREDENTIAL_STORE_READ_FAILED", + operation = "account-credentials.restore", + ) + return null + } + return restoreAndroidAccountCredentialState( + encoded = encoded, + persistMigrated = { migrated -> + preferences.edit().putString(KEY_SESSION, sessionCipher.encrypt(migrated)).apply() + }, + recordDiagnostic = recordDiagnostic, + ) + } + + private fun persistState(state: AndroidAccountCredentialState) { + preferences.edit().putString(KEY_SESSION, encryptState(state)).apply() + } + + private fun encryptState(state: AndroidAccountCredentialState): String = try { + sessionCipher.encrypt(encodeAndroidAccountCredentialState(state)) + } catch (failure: Exception) { + recordCredentialFailure( + code = "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", + operation = "account-credentials.persist", + ) + throw failure + } + + private fun recordCredentialFailure(code: String, operation: String) { + recordDiagnostic( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.Authentication, + operation = operation, + outcome = "failed", + code = code, + ), + ) + } + + private companion object { + const val KEY_SESSION = "encrypted_session" + const val KEY_TEST_READ_ONLY = "emulator_test_read_only" + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index a78d3032e..22a2fbcd7 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -55,6 +55,7 @@ import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest import dev.obiente.nextcloudnative.app.GroupwareDavRequest import dev.obiente.nextcloudnative.app.NextcloudAppEntry import dev.obiente.nextcloudnative.app.NextcloudActivity +import dev.obiente.nextcloudnative.app.NextcloudAccountId import dev.obiente.nextcloudnative.app.NextcloudConditionalRead import dev.obiente.nextcloudnative.app.NextcloudDocumentEditingCapabilities import dev.obiente.nextcloudnative.app.NextcloudDocumentEditSession @@ -409,7 +410,6 @@ internal class AndroidNextcloudServices( private val appContext = context.applicationContext private val activity = context as? Activity private val preferences = appContext.getSharedPreferences("nextcloud_native", Context.MODE_PRIVATE) - private val sessionCipher = SessionCipher() private val httpClient = OkHttpClient.Builder() .useAndroidNextcloudCertificateTrust(appContext) .trackJvmNetworkFailures() @@ -481,6 +481,19 @@ internal class AndroidNextcloudServices( diagnostics = supportDiagnostics, client = httpClient, ) + private val accountCredentials = AndroidAccountCredentialController( + context = appContext, + preferences = preferences, + sessionCipher = SessionCipher(), + registerSessionPrivateValues = ::registerSessionPrivateValues, + recordDiagnostic = ::recordSupportDiagnostic, + publishAccountIdentity = { accountIdentity -> + supportDiagnostics.setActiveAccountIdentity(accountIdentity) + supportIntake.setActiveAccountIdentity(accountIdentity) + }, + clearPreviewAccount = nativeMediaPreviewCache::clearAccount, + notifyDocumentRootsChanged = ::notifyDocumentsRootsChanged, + ) init { supportDiagnostics.registerPrivateValue(System.getProperty("user.home")) @@ -918,85 +931,24 @@ internal class AndroidNextcloudServices( ) } - override fun loadSession(): NextcloudSession? { - return ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.restorePersistedSession( - load = { - val encrypted = preferences.getString(KEY_SESSION, null) - ?: return@restorePersistedSession null - runCatching { - restoreAndroidPersistedSession( - encoded = sessionCipher.decrypt(encrypted), - persistMigrated = { migrated -> - preferences.edit().putString(KEY_SESSION, sessionCipher.encrypt(migrated)).commit() - }, - recordDiagnostic = ::recordSupportDiagnostic, - ) - }.onFailure { failure -> - recordSupportDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Authentication, - operation = "session.load", - outcome = "failed", - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - }.getOrNull() - }, - accountIdOf = NextcloudDocumentIds::accountKey, - publishAccount = { session, accountIdentity -> - session?.let(::registerSessionPrivateValues) - supportDiagnostics.setActiveAccountIdentity(accountIdentity) - supportIntake.setActiveAccountIdentity(accountIdentity) - }, - ) - } + override fun loadSession(): NextcloudSession? = accountCredentials.loadSession() override suspend fun prepareDeckCardDraftRecovery(session: NextcloudSession) = withContext(Dispatchers.IO) { deckCardDrafts.migrateLegacyEntries(session) } - override suspend fun saveSession(session: NextcloudSession) { - registerSessionPrivateValues(session) - val previousAccountId = loadSession()?.let(NextcloudDocumentIds::cacheAccountId) - val replacementAccountId = NextcloudDocumentIds.cacheAccountId(session) - val encrypted = runCatching { sessionCipher.encrypt(encodeAndroidPersistedSession(session)) } - .onFailure { failure -> - recordSupportDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Authentication, - operation = "session.save", - outcome = "failed", - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - } - .getOrThrow() - withContext(Dispatchers.IO) { - AndroidExternalFileHandoffRegistry.clear() - } - val scheduler = AndroidFileSyncScheduler(appContext) - ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.replaceSession( - replacementAccountId = NextcloudDocumentIds.accountKey(session), - persist = { - preferences.edit() - .putString(KEY_SESSION, encrypted) - .remove(KEY_TEST_READ_ONLY) - .apply() - }, - cancelAll = scheduler::cancelAll, - publishAccount = { accountIdentity -> - supportDiagnostics.setActiveAccountIdentity(accountIdentity) - supportIntake.setActiveAccountIdentity(accountIdentity) - }, - ) - if (previousAccountId != null && previousAccountId != replacementAccountId) { - nativeMediaPreviewCache.clearAccount(previousAccountId) - } - notifyDocumentsRootsChanged() - } + override suspend fun saveSession(session: NextcloudSession) = accountCredentials.saveSession(session) + + override fun listAccounts() = accountCredentials.listAccounts() + + override fun activeAccountId() = accountCredentials.activeAccountId() + + override fun loadSession(accountId: NextcloudAccountId) = accountCredentials.loadSession(accountId) + + override suspend fun selectAccount(accountId: NextcloudAccountId) = accountCredentials.selectAccount(accountId) + + override suspend fun removeAccount(accountId: NextcloudAccountId) = accountCredentials.removeAccount(accountId) override suspend fun loadDeckCardDraft( session: NextcloudSession, @@ -1031,41 +983,7 @@ internal class AndroidNextcloudServices( deckCardDrafts.discardAll() } - override suspend fun clearSession() { - try { - val accountId = loadSession()?.let(NextcloudDocumentIds::cacheAccountId) - withContext(Dispatchers.IO) { - AndroidExternalFileHandoffRegistry.clear() - } - val scheduler = AndroidFileSyncScheduler(appContext) - ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.clearSession( - persist = { - preferences.edit() - .remove(KEY_SESSION) - .remove(KEY_TEST_READ_ONLY) - .apply() - }, - cancelAll = scheduler::cancelAll, - clearPublishedAccount = { - supportDiagnostics.setActiveAccountIdentity(null) - supportIntake.setActiveAccountIdentity(null) - }, - ) - accountId?.let(nativeMediaPreviewCache::clearAccount) - notifyDocumentsRootsChanged() - } catch (failure: Throwable) { - recordSupportDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Authentication, - operation = "session.clear", - outcome = "failed", - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - throw failure - } - } + override suspend fun clearSession() = accountCredentials.clearSession() override fun openExternalUrl(url: String) { appContext.startActivity( @@ -3887,8 +3805,6 @@ internal class AndroidNextcloudServices( private companion object { const val KEY_THEME = "theme_preference" const val KEY_LAST_OPENED_APP = "last_opened_app" - const val KEY_SESSION = "encrypted_session" - const val KEY_TEST_READ_ONLY = "emulator_test_read_only" const val USER_AGENT = "Nextcloud-Native/0.1.0 (Android)" const val DAV_NAMESPACE = "DAV:" const val OWNCLOUD_NAMESPACE = "http://owncloud.org/ns" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index e577ad0ca..e0f367df3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -1,72 +1,225 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.NextcloudAccountId +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistryRecoveryReason import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity +import dev.obiente.nextcloudnative.app.accountRecord +import dev.obiente.nextcloudnative.app.decodeNextcloudAccountRegistry import dev.obiente.nextcloudnative.app.encodeNextcloudAccountRegistry import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry import dev.obiente.nextcloudnative.app.singleAccountRegistry import dev.obiente.nextcloudnative.app.toNonSecretSupportDiagnosticExceptionDraft +import org.json.JSONArray import org.json.JSONObject +internal data class AndroidAccountCredentialState( + val registry: NextcloudAccountRegistry, + val sessions: Map, +) { + init { + require(sessions.size <= MAX_ANDROID_ACCOUNT_CREDENTIALS) + require(sessions.size == registry.accounts.size) + require(sessions.all { (id, session) -> + id == session.accountId && registry.accounts.any { account -> account == session.accountRecord() } + }) + require(registry.activeAccountId == null || registry.activeAccountId in sessions) + } + + val activeSession: NextcloudSession? + get() = registry.activeAccountId?.let(sessions::get) + + fun upsertAndSelect(session: NextcloudSession): AndroidAccountCredentialState = copy( + registry = registry.upsertAndSelect(session.accountRecord()), + sessions = sessions + (session.accountId to session), + ) + + fun select(accountId: NextcloudAccountId): AndroidAccountCredentialState? { + if (accountId !in sessions) return null + return copy(registry = requireNotNull(registry.select(accountId))) + } + + fun remove(accountId: NextcloudAccountId): AndroidAccountCredentialState = copy( + registry = registry.remove(accountId), + sessions = sessions - accountId, + ) + + companion object { + val Empty = AndroidAccountCredentialState(NextcloudAccountRegistry.Empty, emptyMap()) + } +} + +internal data class RestoredAndroidAccountCredentialState( + val state: AndroidAccountCredentialState?, + val needsPersistence: Boolean = false, + val diagnosticCode: String? = null, +) + +internal fun restoreAndroidAccountCredentialState( + encoded: String, + persistMigrated: (String) -> Unit, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, +): AndroidAccountCredentialState? { + val restored = decodeAndroidAccountCredentialState(encoded) + restored.diagnosticCode?.let { code -> recordAccountCredentialDiagnostic(code, recordDiagnostic) } + if (restored.needsPersistence && restored.state != null) { + runCatching { persistMigrated(encodeAndroidAccountCredentialState(restored.state)) } + .onFailure { failure -> + recordAccountCredentialDiagnostic( + code = "ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED", + recordDiagnostic = recordDiagnostic, + failure = failure, + ) + } + } + return restored.state +} + +internal fun decodeAndroidAccountCredentialState(encoded: String): RestoredAndroidAccountCredentialState { + if (encoded.encodeToByteArray().size > MAX_ANDROID_ACCOUNT_CREDENTIAL_STORE_BYTES) { + return malformedAndroidAccountCredentialState() + } + return try { + val json = JSONObject(encoded) + if (!json.has(KEY_VERSION)) { + restoreLegacyAndroidAccountCredentialState(json) + } else { + require(json.getInt(KEY_VERSION) == ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION) + val registry = requireNotNull(decodeNextcloudAccountRegistry(json.getString(KEY_ACCOUNT_REGISTRY))) + val encodedSessions = json.getJSONArray(KEY_CREDENTIALS) + require(encodedSessions.length() <= MAX_ANDROID_ACCOUNT_CREDENTIALS) + val sessions = linkedMapOf() + repeat(encodedSessions.length()) { index -> + val encodedSession = encodedSessions.getJSONObject(index) + val session = NextcloudSession( + serverUrl = encodedSession.getString(KEY_SERVER_URL), + loginName = encodedSession.getString(KEY_LOGIN_NAME), + appPassword = encodedSession.getString(KEY_APP_PASSWORD), + ) + val claimedAccountId = encodedSession.getString(KEY_ACCOUNT_ID) + if (claimedAccountId != session.accountId.storageKey) throw AndroidCredentialMismatchException() + if (sessions.put(session.accountId, session) != null) throw AndroidCredentialMismatchException() + } + if (sessions.size != registry.accounts.size || sessions.any { (_, session) -> + registry.accounts.none { account -> account == session.accountRecord() } + } + ) { + throw AndroidCredentialMismatchException() + } + if (registry.activeAccountId != null && registry.activeAccountId !in sessions) { + throw AndroidCredentialMismatchException() + } + RestoredAndroidAccountCredentialState(AndroidAccountCredentialState(registry, sessions)) + } + } catch (_: AndroidCredentialMismatchException) { + RestoredAndroidAccountCredentialState( + state = null, + diagnosticCode = "ACCOUNT_CREDENTIAL_SLOT_MISMATCH", + ) + } catch (_: Exception) { + malformedAndroidAccountCredentialState() + } +} + +internal fun encodeAndroidAccountCredentialState(state: AndroidAccountCredentialState): String = JSONObject() + .put(KEY_VERSION, ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION) + .put(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(state.registry)) + .put( + KEY_CREDENTIALS, + JSONArray().also { credentials -> + state.sessions.values.sortedBy { session -> session.accountId.storageKey }.forEach { session -> + credentials.put( + JSONObject() + .put(KEY_ACCOUNT_ID, session.accountId.storageKey) + .put(KEY_SERVER_URL, session.serverUrl) + .put(KEY_LOGIN_NAME, session.loginName) + .put(KEY_APP_PASSWORD, session.appPassword), + ) + } + }, + ) + .toString() + .also { encoded -> + require(encoded.encodeToByteArray().size <= MAX_ANDROID_ACCOUNT_CREDENTIAL_STORE_BYTES) + } + internal fun restoreAndroidPersistedSession( encoded: String, - persistMigrated: (String) -> Boolean, + persistMigrated: (String) -> Unit, recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, -): NextcloudSession { - val json = JSONObject(encoded) +): NextcloudSession = requireNotNull( + restoreAndroidAccountCredentialState(encoded, persistMigrated, recordDiagnostic)?.activeSession, +) { "The active account credential is unavailable." } + +internal fun encodeAndroidPersistedSession(session: NextcloudSession): String = + encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(session)) + +private fun restoreLegacyAndroidAccountCredentialState( + json: JSONObject, +): RestoredAndroidAccountCredentialState { val session = NextcloudSession( - serverUrl = json.getString("serverUrl"), - loginName = json.getString("loginName"), - appPassword = json.getString("appPassword"), + serverUrl = json.getString(KEY_SERVER_URL), + loginName = json.getString(KEY_LOGIN_NAME), + appPassword = json.getString(KEY_APP_PASSWORD), ) val encodedRegistry = when (val registry = json.opt(KEY_ACCOUNT_REGISTRY)) { null -> null is String -> registry else -> "" } - val restored = restoreNextcloudAccountRegistry(encodedRegistry, session) - restored.recoveryReason?.let { reason -> - recordDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Warning, - component = SupportDiagnosticComponent.Authentication, - operation = "account-registry.restore", - outcome = "recovered", - code = reason.diagnosticCode, - ), - ) - } - if (restored.needsPersistence) { - runCatching { - val migrated = json - .put(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(restored.registry)) - .toString() - check(persistMigrated(migrated)) { - "Could not persist the migrated account registry." - } - }.onFailure { failure -> - recordDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Warning, - component = SupportDiagnosticComponent.Authentication, - operation = "account-registry.migrate", - outcome = "failed", - code = "ACCOUNT_REGISTRY_MIGRATION_FAILED", - exception = failure.toNonSecretSupportDiagnosticExceptionDraft(), - ), - ) - } - } - return session + val restoredRegistry = restoreNextcloudAccountRegistry(encodedRegistry, session) + val credentialRegistry = singleAccountRegistry(session) + return RestoredAndroidAccountCredentialState( + state = AndroidAccountCredentialState( + registry = credentialRegistry, + sessions = mapOf(session.accountId to session), + ), + needsPersistence = restoredRegistry.recoveryReason != + NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion, + diagnosticCode = restoredRegistry.recoveryReason?.diagnosticCode ?: if ( + restoredRegistry.registry != credentialRegistry + ) { + "ACCOUNT_CREDENTIAL_SLOT_MISMATCH" + } else { + null + }, + ) } -internal fun encodeAndroidPersistedSession(session: NextcloudSession): String = JSONObject() - .put("serverUrl", session.serverUrl) - .put("loginName", session.loginName) - .put("appPassword", session.appPassword) - .put(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(singleAccountRegistry(session))) - .toString() +private fun malformedAndroidAccountCredentialState() = RestoredAndroidAccountCredentialState( + state = null, + diagnosticCode = "ACCOUNT_CREDENTIAL_STORE_MALFORMED", +) + +private fun recordAccountCredentialDiagnostic( + code: String, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, + failure: Throwable? = null, +) { + recordDiagnostic( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Authentication, + operation = "account-credentials.restore", + outcome = "recovered", + code = code, + exception = failure?.toNonSecretSupportDiagnosticExceptionDraft(), + ), + ) +} + +private class AndroidCredentialMismatchException : IllegalArgumentException() +private const val ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION = 2 +private const val MAX_ANDROID_ACCOUNT_CREDENTIALS = 64 +private const val MAX_ANDROID_ACCOUNT_CREDENTIAL_STORE_BYTES = 512 * 1024 +private const val KEY_VERSION = "version" private const val KEY_ACCOUNT_REGISTRY = "account_registry_v1" +private const val KEY_CREDENTIALS = "credentials" +private const val KEY_ACCOUNT_ID = "accountId" +private const val KEY_SERVER_URL = "serverUrl" +private const val KEY_LOGIN_NAME = "loginName" +private const val KEY_APP_PASSWORD = "appPassword" diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 57c5e0b72..a9a80f957 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -1,9 +1,10 @@ package dev.obiente.nextcloudnative -import dev.obiente.nextcloudnative.app.NextcloudAccountRegistrySource +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft -import dev.obiente.nextcloudnative.app.decodeNextcloudAccountRegistry -import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.accountRecord +import dev.obiente.nextcloudnative.app.encodeNextcloudAccountRegistry import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -18,147 +19,270 @@ class AndroidPersistedSessionTest { val diagnostics = mutableListOf() var migrated: String? = null - val first = restoreAndroidPersistedSession( - encoded = legacyPayload(), - persistMigrated = { encoded -> - migrated = encoded - true - }, + val first = restoreAndroidAccountCredentialState( + encoded = legacyPayload(firstSession()), + persistMigrated = { encoded -> migrated = encoded }, recordDiagnostic = diagnostics::add, ) val migratedPayload = requireNotNull(migrated) - val registry = decodeNextcloudAccountRegistry( - JSONObject(migratedPayload).getString(ACCOUNT_REGISTRY_KEY), - ) - assertEquals(first.accountId, requireNotNull(registry).activeAccountId) + assertEquals(firstSession(), requireNotNull(first).activeSession) + assertEquals(2, JSONObject(migratedPayload).getInt("version")) assertTrue(diagnostics.isEmpty()) var unexpectedSecondMigration = false - val restarted = restoreAndroidPersistedSession( + val restarted = restoreAndroidAccountCredentialState( encoded = migratedPayload, - persistMigrated = { - unexpectedSecondMigration = true - true - }, + persistMigrated = { unexpectedSecondMigration = true }, recordDiagnostic = diagnostics::add, ) + assertEquals(first, restarted) assertFalse(unexpectedSecondMigration) assertTrue(diagnostics.isEmpty()) } @Test - fun malformedRegistryFallsBackWithoutDiscardingTheLegacySession() { + fun versionlessRegistryPayloadFromAccountFoundationMigratesToCredentialSlots() { + val session = firstSession() + val versionless = JSONObject(legacyPayload(session)) + .put( + "account_registry_v1", + encodeNextcloudAccountRegistry(NextcloudAccountRegistry.Empty.upsertAndSelect(session.accountRecord())), + ) + .toString() + var migrated: String? = null + + val restored = restoreAndroidAccountCredentialState( + encoded = versionless, + persistMigrated = { migrated = it }, + recordDiagnostic = {}, + ) + + assertEquals(session, requireNotNull(restored).activeSession) + assertEquals(2, JSONObject(requireNotNull(migrated)).getInt("version")) + } + + @Test + fun twoCredentialSlotsRestartAndSelectTheExactAccount() { + val first = firstSession() + val second = secondSession() + val state = AndroidAccountCredentialState.Empty + .upsertAndSelect(first) + .upsertAndSelect(second) + val restarted = requireNotNull( + decodeAndroidAccountCredentialState(encodeAndroidAccountCredentialState(state)).state, + ) + + assertEquals( + listOf(first.accountId, second.accountId).sortedBy { it.storageKey }, + restarted.sessions.keys.sortedBy { it.storageKey }, + ) + assertEquals(second, restarted.activeSession) + assertEquals(first, requireNotNull(restarted.select(first.accountId)).activeSession) + } + + @Test + fun encodingIsDeterministicAcrossCredentialInsertionOrder() { + val firstThenSecond = AndroidAccountCredentialState.Empty + .upsertAndSelect(firstSession()) + .upsertAndSelect(secondSession()) + .select(firstSession().accountId) + val secondThenFirst = AndroidAccountCredentialState.Empty + .upsertAndSelect(secondSession()) + .upsertAndSelect(firstSession()) + + assertEquals( + encodeAndroidAccountCredentialState(requireNotNull(firstThenSecond)), + encodeAndroidAccountCredentialState(secondThenFirst), + ) + } + + @Test + fun malformedStoreDoesNotExposeOrOverwriteCredentialValues() { + val diagnostics = mutableListOf() + var persisted = false + val malformed = "{\"appPassword\":\"private-app-password\",\"version\":2" + + val restored = restoreAndroidAccountCredentialState( + encoded = malformed, + persistMigrated = { persisted = true }, + recordDiagnostic = diagnostics::add, + ) + + assertNull(restored) + assertFalse(persisted) + assertEquals(listOf("ACCOUNT_CREDENTIAL_STORE_MALFORMED"), diagnostics.mapNotNull { it.code }) + assertDiagnosticsExcludePrivateValues(diagnostics) + } + + @Test + fun claimedAccountMismatchRejectsTheWholeCredentialStore() { + val encoded = JSONObject( + encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(firstSession())), + ) + encoded.getJSONArray("credentials").getJSONObject(0) + .put("accountId", secondSession().accountId.storageKey) + val diagnostics = mutableListOf() + + val restored = restoreAndroidAccountCredentialState( + encoded = encoded.toString(), + persistMigrated = {}, + recordDiagnostic = diagnostics::add, + ) + + assertNull(restored) + assertEquals(listOf("ACCOUNT_CREDENTIAL_SLOT_MISMATCH"), diagnostics.mapNotNull { it.code }) + assertDiagnosticsExcludePrivateValues(diagnostics) + } + + @Test + fun duplicateCredentialIdentityIsRejected() { + val encoded = JSONObject( + encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(firstSession())), + ) + val credentials = encoded.getJSONArray("credentials") + credentials.put(JSONObject(credentials.getJSONObject(0).toString())) + + val restored = decodeAndroidAccountCredentialState(encoded.toString()) + + assertNull(restored.state) + assertEquals("ACCOUNT_CREDENTIAL_SLOT_MISMATCH", restored.diagnosticCode) + } + + @Test + fun registryEntryWithoutCredentialIsRejected() { + val first = firstSession() + val second = secondSession() + val state = AndroidAccountCredentialState.Empty.upsertAndSelect(first) + val encoded = JSONObject(encodeAndroidAccountCredentialState(state)) + .put( + "account_registry_v1", + encodeNextcloudAccountRegistry( + NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()), + ), + ) + + val restored = decodeAndroidAccountCredentialState(encoded.toString()) + + assertNull(restored.state) + assertEquals("ACCOUNT_CREDENTIAL_SLOT_MISMATCH", restored.diagnosticCode) + } + + @Test + fun removingTheActiveSlotRetainsOtherCredentialsWithoutSelectingOne() { + val first = firstSession() + val second = secondSession() + val state = AndroidAccountCredentialState.Empty + .upsertAndSelect(first) + .upsertAndSelect(second) + .remove(second.accountId) + val restarted = requireNotNull( + decodeAndroidAccountCredentialState(encodeAndroidAccountCredentialState(state)).state, + ) + + assertNull(restarted.activeSession) + assertEquals(setOf(first.accountId), restarted.sessions.keys) + assertNull(restarted.registry.activeAccountId) + assertFalse(restarted.sessions.values.any { session -> session.appPassword == second.appPassword }) + } + + @Test + fun malformedLegacyRegistryFallsBackWithoutDiscardingTheValidSession() { val diagnostics = mutableListOf() var migrated: String? = null - val malformed = JSONObject(legacyPayload()) - .put(ACCOUNT_REGISTRY_KEY, "{not-json") + val malformed = JSONObject(legacyPayload(firstSession())) + .put("account_registry_v1", "{not-json") .toString() - val session = restoreAndroidPersistedSession( + val restored = restoreAndroidAccountCredentialState( encoded = malformed, - persistMigrated = { encoded -> - migrated = encoded - true - }, + persistMigrated = { migrated = it }, recordDiagnostic = diagnostics::add, ) - val restoredRegistry = restoreNextcloudAccountRegistry( - JSONObject(requireNotNull(migrated)).getString(ACCOUNT_REGISTRY_KEY), - session, - ) - assertEquals(NextcloudAccountRegistrySource.Persisted, restoredRegistry.source) - assertEquals(session.accountId, restoredRegistry.registry.activeAccountId) + assertEquals(firstSession(), requireNotNull(restored).activeSession) + assertNotNull(migrated) assertEquals(listOf("ACCOUNT_REGISTRY_MALFORMED"), diagnostics.mapNotNull { it.code }) - val renderedDiagnostics = diagnostics.joinToString() - assertFalse(renderedDiagnostics.contains("private-app-password")) - assertFalse(renderedDiagnostics.contains("alice")) - assertFalse(renderedDiagnostics.contains("cloud.example.test")) + assertDiagnosticsExcludePrivateValues(diagnostics) } @Test - fun unsupportedFutureRegistryIsNotPersistedOver() { + fun unsupportedFutureLegacyRegistryIsNotPersistedOver() { val diagnostics = mutableListOf() var migrated = false val futureRegistry = """{"version":2,"futureAccounts":[]}""" - val payload = JSONObject(legacyPayload()) - .put(ACCOUNT_REGISTRY_KEY, futureRegistry) + val payload = JSONObject(legacyPayload(firstSession())) + .put("account_registry_v1", futureRegistry) .toString() - val session = restoreAndroidPersistedSession( + val restored = restoreAndroidAccountCredentialState( encoded = payload, - persistMigrated = { - migrated = true - true - }, + persistMigrated = { migrated = true }, recordDiagnostic = diagnostics::add, ) - assertEquals("alice", session.loginName) + assertEquals(firstSession(), requireNotNull(restored).activeSession) assertFalse(migrated) assertEquals(listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }) } @Test - fun savedPayloadKeepsCredentialsOutsideTheRegistry() { - val session = restoreAndroidPersistedSession( - encoded = legacyPayload(), - persistMigrated = { true }, - recordDiagnostic = {}, - ) - - val payload = JSONObject(encodeAndroidPersistedSession(session)) - val encodedRegistry = payload.getString(ACCOUNT_REGISTRY_KEY) - - assertEquals("private-app-password", payload.getString("appPassword")) - assertFalse(encodedRegistry.contains("private-app-password")) - assertFalse(encodedRegistry.contains("appPassword")) - } - - @Test - fun migrationFailureAttachesABoundedCauseWithoutPrivateValues() { + fun migrationFailureUsesABoundedCauseWithoutPrivateValues() { val diagnostics = mutableListOf() - val session = restoreAndroidPersistedSession( - encoded = legacyPayload(), + val restored = restoreAndroidAccountCredentialState( + encoded = legacyPayload(firstSession()), persistMigrated = { error("private-app-password at cloud.example.test for alice") }, recordDiagnostic = diagnostics::add, ) - assertEquals("alice", session.loginName) + assertEquals(firstSession(), requireNotNull(restored).activeSession) val diagnostic = diagnostics.single() - assertEquals("ACCOUNT_REGISTRY_MIGRATION_FAILED", diagnostic.code) + assertEquals("ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED", diagnostic.code) val exception = assertNotNull(diagnostic.exception) assertNull(exception.message) - val rendered = diagnostic.toString() - assertFalse(rendered.contains("private-app-password")) - assertFalse(rendered.contains("cloud.example.test")) - assertFalse(rendered.contains("alice")) + assertDiagnosticsExcludePrivateValues(diagnostics) } @Test - fun rejectedMigrationCommitIsReportedWithoutDiscardingTheLegacySession() { - val diagnostics = mutableListOf() - - val session = restoreAndroidPersistedSession( - encoded = legacyPayload(), - persistMigrated = { false }, - recordDiagnostic = diagnostics::add, + fun accountRegistryInsideTheStoreContainsNoCredential() { + val session = firstSession() + val payload = JSONObject( + encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(session)), ) + val registry = payload.getString("account_registry_v1") - assertEquals("alice", session.loginName) - assertEquals(listOf("ACCOUNT_REGISTRY_MIGRATION_FAILED"), diagnostics.mapNotNull { it.code }) + assertFalse(registry.contains(session.appPassword)) + assertFalse(registry.contains("appPassword")) + assertEquals(1, payload.getJSONArray("credentials").length()) } - private fun legacyPayload(): String = JSONObject() - .put("serverUrl", "https://cloud.example.test") - .put("loginName", "alice") - .put("appPassword", "private-app-password") + private fun assertDiagnosticsExcludePrivateValues(diagnostics: List) { + val rendered = diagnostics.joinToString() + assertFalse(rendered.contains("private-app-password")) + assertFalse(rendered.contains("second-private-password")) + assertFalse(rendered.contains("alice")) + assertFalse(rendered.contains("cloud.example.test")) + } + + private fun legacyPayload(session: NextcloudSession): String = JSONObject() + .put("serverUrl", session.serverUrl) + .put("loginName", session.loginName) + .put("appPassword", session.appPassword) .toString() - private companion object { - const val ACCOUNT_REGISTRY_KEY = "account_registry_v1" - } + private fun firstSession() = NextcloudSession( + serverUrl = "https://cloud.example.test", + loginName = "alice", + appPassword = "private-app-password", + ) + + private fun secondSession() = NextcloudSession( + serverUrl = "https://second.example.test/nextcloud", + loginName = "bob", + appPassword = "second-private-password", + ) } diff --git a/changes/unreleased/172-account-credential-slots.md b/changes/unreleased/172-account-credential-slots.md new file mode 100644 index 000000000..33ee7bc61 --- /dev/null +++ b/changes/unreleased/172-account-credential-slots.md @@ -0,0 +1,7 @@ +category: internal +issue: 172 +pull: none +platforms: android, desktop +user-facing: no + +Store bounded credentials for each local account, migrate existing Android and desktop sessions, and keep account selection and removal aligned with sync lifecycle cleanup. diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt new file mode 100644 index 000000000..2c3641a6f --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt @@ -0,0 +1,31 @@ +package dev.obiente.nextcloudnative.app + +interface NextcloudAccountCredentialServices { + fun loadSession(): NextcloudSession? + + suspend fun saveSession(session: NextcloudSession) + + suspend fun clearSession() + + /** Lists credential-free local account records without loading their secrets. */ + fun listAccounts(): List = loadSession()?.let { session -> + listOf(session.accountRecord()) + }.orEmpty() + + /** Returns the selected local account identity, or null when no account is selected. */ + fun activeAccountId(): NextcloudAccountId? = loadSession()?.accountId + + /** Loads one account's credentials without changing the active selection. */ + fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = + loadSession()?.takeIf { session -> session.accountId == accountId } + + /** Selects a stored account and returns its session after the selection is durable. */ + suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = loadSession(accountId) + + /** Removes one stored account. The compatibility default supports only the active account. */ + suspend fun removeAccount(accountId: NextcloudAccountId): Boolean { + if (activeAccountId() != accountId) return false + clearSession() + return true + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index 7ba660ad3..d27eb4971 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -429,7 +429,7 @@ data class NextcloudPerson( val backend: String, ) -interface NextcloudPlatformServices : DeckCardDraftPlatformServices { +interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCardDraftPlatformServices { /** Loads public project news from the fixed Obiente feed, with a bounded platform cache. */ suspend fun loadProjectNews(forceRefresh: Boolean = false): ProjectNewsResult = error("Project news is unavailable on this platform.") @@ -675,12 +675,6 @@ interface NextcloudPlatformServices : DeckCardDraftPlatformServices { targetRecordId: String, ) = Unit - fun loadSession(): NextcloudSession? - - suspend fun saveSession(session: NextcloudSession) - - suspend fun clearSession() - fun openExternalUrl(url: String) /** Opens the one-time browser login URL without blocking the UI dispatcher. */ diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt new file mode 100644 index 000000000..3867ef307 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -0,0 +1,258 @@ +package dev.obiente.nextcloudnative.app + +import java.util.prefs.Preferences + +internal class DesktopAccountCredentialPersistence( + private val preferences: Preferences, + private val secretStore: DesktopSecretStore, + private val recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, +) { + fun loadActiveSession(): NextcloudSession? { + val read = readRegistry() + if (read.registry == null) { + return restoreLegacySession(read.encoded != null) + } + val active = read.registry.activeAccount ?: return null + return loadSession(active.id) + } + + fun listAccounts(): List { + val read = readRegistry() + if (read.registry != null) return read.registry.accounts + restoreLegacySession(read.encoded != null) + return readRegistry().registry?.accounts.orEmpty() + } + + fun activeAccountId(): NextcloudAccountId? { + val read = readRegistry() + if (read.registry != null) return read.registry.activeAccountId + return restoreLegacySession(read.encoded != null)?.accountId + } + + fun loadSession(accountId: NextcloudAccountId): NextcloudSession? { + val registry = readRegistry().registry ?: return null + val record = registry.accounts.firstOrNull { account -> account.id == accountId } ?: return null + val secret = loadSecret(desktopAccountSecretReference(accountId)) + if (secret != null) return record.toSession(secret) + + val legacy = loadLegacySession() ?: return null + if (legacy.accountId != accountId || legacy.accountRecord() != record) { + recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_ACTIVE_MISMATCH", "account-credentials.restore") + return null + } + migrateLegacyCredential(legacy) + return legacy + } + + fun saveSession(session: NextcloudSession) { + val read = readRegistry() + val registry = read.registry + ?: restoreLegacySession(read.encoded != null)?.let { requireNotNull(readRegistry().registry) } + ?: if (read.encoded == null) NextcloudAccountRegistry.Empty else throw invalidRegistryForMutation() + val updatedRegistry = registry.upsertAndSelect(session.accountRecord()) + val encodedRegistry = prepareRegistry(updatedRegistry) + saveSecret(session) + persistRegistry(encodedRegistry) + persistLegacyActiveMetadata(session.accountRecord()) + } + + fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? { + val registry = readRegistry().registry ?: return null + val session = loadSession(accountId) ?: return null + persistRegistry(requireNotNull(registry.select(accountId))) + persistLegacyActiveMetadata(session.accountRecord()) + return session + } + + fun removeAccount(accountId: NextcloudAccountId): Boolean { + val registry = readRegistry().registry ?: return false + val record = registry.accounts.firstOrNull { account -> account.id == accountId } ?: return false + clearSecret(desktopAccountSecretReference(accountId)) + if (legacyMetadataMatches(record)) { + clearSecret(desktopSessionSecretReference(record.serverUrl, record.loginName)) + preferences.remove(KEY_SERVER) + preferences.remove(KEY_LOGIN) + } + persistRegistry(registry.remove(accountId)) + return true + } + + private fun restoreLegacySession(malformedRegistry: Boolean): NextcloudSession? { + val legacy = loadLegacySession() ?: run { + if (malformedRegistry) { + recordCredentialDiagnostic("ACCOUNT_REGISTRY_MALFORMED", "account-registry.restore") + } + return null + } + val restored = restoreNextcloudAccountRegistry( + encoded = preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null), + legacySession = legacy, + ) + restored.recoveryReason?.diagnosticCode?.let { code -> + recordCredentialDiagnostic(code, "account-registry.restore") + } + if (!restored.needsPersistence) return legacy + try { + val encodedRegistry = prepareRegistry(restored.registry) + saveSecret(legacy) + persistRegistry(encodedRegistry) + } catch (failure: Exception) { + recordCredentialDiagnostic( + code = "ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED", + operation = "account-credentials.migrate", + failure = failure, + ) + return legacy + } + persistLegacyActiveMetadata(legacy.accountRecord()) + clearLegacyCredentialAfterMigration(legacy) + return legacy + } + + private fun loadLegacySession(): NextcloudSession? { + val server = preferences.get(KEY_SERVER, null) ?: return null + val login = preferences.get(KEY_LOGIN, null) ?: return null + val password = loadSecret(desktopSessionSecretReference(server, login)) ?: return null + return NextcloudSession(server, login, password) + } + + private fun migrateLegacyCredential(session: NextcloudSession) { + saveSecret(session) + clearLegacyCredentialAfterMigration(session) + } + + private fun clearLegacyCredentialAfterMigration(session: NextcloudSession) { + try { + secretStore.clear(desktopSessionSecretReference(session.serverUrl, session.loginName)) + } catch (_: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", + "account-credentials.migrate", + ) + } + } + + private fun saveSecret(session: NextcloudSession) { + try { + secretStore.save( + reference = desktopAccountSecretReference(session.accountId), + username = session.loginName, + secret = session.appPassword.encodeToByteArray(), + ) + } catch (failure: Exception) { + recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", "account-credentials.persist") + throw failure + } + } + + private fun loadSecret(reference: DesktopSecretReference): String? = try { + secretStore.load(reference) + ?.decodeToString() + ?.takeIf(String::isNotBlank) + } catch (_: Exception) { + recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_STORE_READ_FAILED", "account-credentials.restore") + null + } + + private fun clearSecret(reference: DesktopSecretReference) { + try { + secretStore.clear(reference) + } catch (failure: Exception) { + recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_STORE_CLEAR_FAILED", "account-credentials.remove") + throw failure + } + } + + private fun readRegistry(): DesktopRegistryRead { + val encoded = preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null) + return DesktopRegistryRead(encoded, encoded?.let(::decodeNextcloudAccountRegistry)) + } + + private fun prepareRegistry(registry: NextcloudAccountRegistry): String = + encodeNextcloudAccountRegistry(registry).also { encoded -> + require(encoded.length <= Preferences.MAX_VALUE_LENGTH) { + "The account registry exceeds the desktop preference value limit." + } + } + + private fun persistRegistry(registry: NextcloudAccountRegistry) { + persistRegistry(prepareRegistry(registry)) + } + + private fun persistRegistry(encodedRegistry: String) { + require(encodedRegistry.length <= Preferences.MAX_VALUE_LENGTH) + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodedRegistry) + } + + private fun persistLegacyActiveMetadata(record: NextcloudAccountRecord) { + preferences.put(KEY_SERVER, record.serverUrl) + preferences.put(KEY_LOGIN, record.loginName) + } + + private fun legacyMetadataMatches(record: NextcloudAccountRecord): Boolean = + preferences.get(KEY_SERVER, null) == record.serverUrl && + preferences.get(KEY_LOGIN, null) == record.loginName + + private fun invalidRegistryForMutation(): IllegalStateException { + recordCredentialDiagnostic("ACCOUNT_REGISTRY_MALFORMED", "account-registry.persist") + return IllegalStateException("The local account registry is invalid.") + } + + private fun recordCredentialDiagnostic( + code: String, + operation: String, + failure: Throwable? = null, + ) { + recordDiagnostic( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Authentication, + operation = operation, + outcome = "failed", + code = code, + exception = failure?.toNonSecretSupportDiagnosticExceptionDraft(), + ), + ) + } + + private data class DesktopRegistryRead( + val encoded: String?, + val registry: NextcloudAccountRegistry?, + ) + + private companion object { + const val KEY_SERVER = "server" + const val KEY_LOGIN = "login" + } +} + +private fun NextcloudAccountRecord.toSession(appPassword: String) = NextcloudSession( + serverUrl = serverUrl, + loginName = loginName, + appPassword = appPassword, +) + +internal fun desktopFileCacheAccountId(account: NextcloudAccountRecord): String = + desktopFileCacheAccountId(account.toSession(appPassword = "")) + +internal class DesktopAccountSessionPublication( + private val registerPrivateValue: (String) -> Unit, + private val publishAccountIdentity: (String) -> Unit, +) { + fun register(session: NextcloudSession) { + listOf(session.serverUrl, session.loginName, session.appPassword).forEach(registerPrivateValue) + } + + fun publish(session: NextcloudSession) { + register(session) + publishAccountIdentity(desktopFileCacheAccountId(session)) + } +} + +internal fun desktopAccountSelectionBlockedDiagnostic() = SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Authentication, + operation = "account.select", + outcome = "blocked", + code = "ACCOUNT_SELECTION_ACTIVE_RESOURCES", +) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 694516d1d..e7d04687a 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1013,6 +1013,13 @@ class DesktopNextcloudServices( ?: resolvedSupportDiagnosticsRoot?.resolve("support-submissions") ?: Files.createTempDirectory("nextcloud-native-test-support-intake").toFile() private val secretStore = defaultDesktopSecretStore() + private val accountCredentials = DesktopAccountCredentialPersistence(preferences, secretStore, supportDiagnostics::record) + private val accountSessionPublication = DesktopAccountSessionPublication( + supportDiagnostics::registerPrivateValue, + ) { identity -> + supportDiagnostics.setActiveAccountIdentity(identity) + supportIntake.setActiveAccountIdentity(identity) + } private val sessionPublicationGuard = DesktopSessionPublicationGuard() private val appUpdater = DesktopAppUpdater( preferences = preferences.node("app-updates-v1"), @@ -3660,68 +3667,76 @@ class DesktopNextcloudServices( } override fun loadSession(): NextcloudSession? = sessionPublicationGuard.serialize { - val server = preferences.get(KEY_SERVER, null) - val login = preferences.get(KEY_LOGIN, null) - if (server == null || login == null) { - supportDiagnostics.setActiveAccountIdentity(null) - supportIntake.setActiveAccountIdentity(null) - return@serialize null - } - val password = secretStore.load(desktopSessionSecretReference(server, login)) - ?.decodeToString() - ?.takeIf(String::isNotBlank) - if (password == null) { + val session = accountCredentials.loadActiveSession() + if (session == null) { supportDiagnostics.setActiveAccountIdentity(null) supportIntake.setActiveAccountIdentity(null) - return@serialize null - } - listOf(server, login, password).forEach(supportDiagnostics::registerPrivateValue) - NextcloudSession(server, login, password).also { session -> - restoreDesktopAccountRegistry(preferences, session, supportDiagnostics::record) - val accountIdentity = desktopFileCacheAccountId(session) - supportDiagnostics.setActiveAccountIdentity(accountIdentity) - supportIntake.setActiveAccountIdentity(accountIdentity) + } else { + accountSessionPublication.publish(session) } + session } override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { sessionPublicationGuard.serialize { - val encodedRegistry = prepareDesktopAccountRegistry(session) - listOf(session.serverUrl, session.loginName, session.appPassword) - .forEach(supportDiagnostics::registerPrivateValue) - try { - secretStore.save( - reference = desktopSessionSecretReference(session.serverUrl, session.loginName), - username = session.loginName, - secret = session.appPassword.encodeToByteArray(), - ) - } catch (failure: Throwable) { - recordSupportDiagnostic( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Authentication, - operation = "credentials.save", - outcome = "failed", - code = if (failure is DesktopSecretStoreUnavailableException) { - "DESKTOP_SECRET_STORE_UNAVAILABLE" - } else { - "DESKTOP_SECRET_STORE_FAILED" - }, - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - throw failure - } - persistDesktopAccountRegistry(preferences, encodedRegistry) - preferences.put(KEY_SERVER, session.serverUrl) - preferences.put(KEY_LOGIN, session.loginName) - val accountIdentity = desktopFileCacheAccountId(session) - supportDiagnostics.setActiveAccountIdentity(accountIdentity) - supportIntake.setActiveAccountIdentity(accountIdentity) + accountCredentials.saveSession(session) + accountSessionPublication.publish(session) } synchronized(fileRangeSessionLock) { sessionClearing = false } startDesktopSyncLifecycle() } + + override fun listAccounts() = sessionPublicationGuard.serialize(accountCredentials::listAccounts) + + override fun activeAccountId() = sessionPublicationGuard.serialize(accountCredentials::activeAccountId) + + override fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = + sessionPublicationGuard.serialize { + accountCredentials.loadSession(accountId)?.also { session -> + accountSessionPublication.register(session) + } + } + + override suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = + withContext(Dispatchers.IO) { + if (activeAccountId() == accountId) return@withContext loadSession(accountId) + val hasLiveAccountResources = synchronized(fileRangeSessionLock) { + activeFileRangeSessions.isNotEmpty() + } || synchronized(virtualFolderHydrationJobs) { + virtualFolderHydrationJobs.values.any { job -> job.isActive } + } || synchronized(virtualFileProviderLock) { + linuxVirtualFileSystem != null || + windowsCloudFilesProvider != null || + virtualFileCacheTierMutations.isNotEmpty() + } + if (hasLiveAccountResources) { + recordSupportDiagnostic(desktopAccountSelectionBlockedDiagnostic()) + return@withContext null + } + val syncJob = synchronized(this@DesktopNextcloudServices) { + backgroundFileSyncJob.also { backgroundFileSyncJob = null } + } + syncJob?.cancel() + syncJob?.join() + val selected = sessionPublicationGuard.serialize { + accountCredentials.selectAccount(accountId)?.also { session -> + accountSessionPublication.publish(session) + } + } + startDesktopSyncLifecycle() + selected + } + + override suspend fun removeAccount(accountId: NextcloudAccountId): Boolean { + if (activeAccountId() == accountId) { + clearSession() + return true + } + return withContext(Dispatchers.IO) { + sessionPublicationGuard.serialize { accountCredentials.removeAccount(accountId) } + } + } + override suspend fun clearSession() = withContext(Dispatchers.IO) { val userHome = File(System.getProperty("user.home")) val rangeSessions = synchronized(fileRangeSessionLock) { @@ -3730,7 +3745,13 @@ class DesktopNextcloudServices( } var cleared = false try { - val accountId = desktopStoredSessionAccountId(preferences) + val activeAccountId = activeAccountId() + val activeSession = activeAccountId?.let(::loadSession) + val activeRecord = activeAccountId?.let { id -> + listAccounts().firstOrNull { account -> account.id == id } + } + val accountId = activeSession?.let(::desktopFileCacheAccountId) + ?: activeRecord?.let(::desktopFileCacheAccountId) val syncJob = synchronized(this) { val active = backgroundFileSyncJob backgroundFileSyncJob = null @@ -3834,27 +3855,10 @@ class DesktopNextcloudServices( mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot( phase = DesktopFileSyncTrayPhase.Idle, ) - val server = preferences.get(KEY_SERVER, null) - val login = preferences.get(KEY_LOGIN, null) - runCatching { - if (server != null && login != null) secretStore.clear(desktopSessionSecretReference(server, login)) - }.onFailure { failure -> - supportDiagnostics.record( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Authentication, - operation = "credentials.clear", - outcome = "failed", - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - if (failure is DesktopSecretDeletionRecoveryUnavailableException || - failure is DesktopSecretLegacyCleanupUnavailableException) throw failure - } sessionPublicationGuard.serialize { - preferences.remove(KEY_SERVER) - preferences.remove(KEY_LOGIN) - clearDesktopAccountRegistry(preferences) + if (activeAccountId != null) { + check(accountCredentials.removeAccount(activeAccountId)) + } supportDiagnostics.setActiveAccountIdentity(null) supportIntake.setActiveAccountIdentity(null) } @@ -5935,8 +5939,6 @@ class DesktopNextcloudServices( const val APP_ID = "dev.obiente.nextcloudnative" const val KEY_THEME = "theme" const val KEY_LAST_OPENED_APP = "last_opened_app" - const val KEY_SERVER = "server" - const val KEY_LOGIN = "login" const val KEY_FILE_SYNC_PAUSED = "file_sync_paused" const val KEY_START_ON_LOGIN = "start_on_login" const val KEY_KEEP_RUNNING_IN_BACKGROUND = "keep_running_in_background" diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStore.kt index 3a1ac2267..dbea1c32c 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStore.kt @@ -262,6 +262,18 @@ internal fun desktopSessionSecretReference(serverUrl: String, loginName: String) ) } +internal fun desktopAccountSecretReference(accountId: NextcloudAccountId): DesktopSecretReference = + DesktopSecretReference( + targetName = "$WINDOWS_CREDENTIAL_PREFIX/session/v2/${accountId.storageKey}", + label = "Nextcloud Native account credential", + attributes = linkedMapOf( + "application" to DESKTOP_APPLICATION_ID, + "purpose" to "account-session", + "account" to accountId.storageKey, + "schema" to "2", + ), + ) + internal fun desktopDeckDraftSecretReference(): DesktopSecretReference = DesktopSecretReference( targetName = "$WINDOWS_CREDENTIAL_PREFIX/deck-card-drafts/v1", label = "nati.ve Deck draft encryption", diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt new file mode 100644 index 000000000..1db605a3b --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -0,0 +1,236 @@ +package dev.obiente.nextcloudnative.app + +import java.util.UUID +import java.util.prefs.Preferences +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopAccountCredentialPersistenceTest { + @Test + fun legacyCredentialMigratesAndRestartsWithTheExactActiveAccount() = withStore { preferences, secrets -> + val session = firstSession() + putLegacySession(preferences, secrets, session) + val persistence = persistence(preferences, secrets) + + assertEquals(session, persistence.loadActiveSession()) + assertNull(secrets.load(desktopSessionSecretReference(session.serverUrl, session.loginName))) + assertEquals(session.appPassword, secrets.load(desktopAccountSecretReference(session.accountId))?.decodeToString()) + + val restarted = persistence(preferences, secrets) + assertEquals(session, restarted.loadActiveSession()) + assertEquals(session.accountId, restarted.activeAccountId()) + } + + @Test + fun twoCredentialSlotsRestartAndSelectTheRequestedAccount() = withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(first) + persistence.saveSession(second) + + val restarted = persistence(preferences, secrets) + + assertEquals(setOf(first.accountRecord(), second.accountRecord()), restarted.listAccounts().toSet()) + assertEquals(second.accountId, restarted.activeAccountId()) + assertEquals(first, restarted.selectAccount(first.accountId)) + assertEquals(first, persistence(preferences, secrets).loadActiveSession()) + } + + @Test + fun unsupportedFutureRegistryUsesLegacyCredentialWithoutOverwritingIt() = withStore { preferences, secrets -> + val session = firstSession() + val futureRegistry = """{"version":2,"futureAccounts":[{"id":"future"}]}""" + putLegacySession(preferences, secrets, session) + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, futureRegistry) + val diagnostics = mutableListOf() + + val restored = persistence(preferences, secrets, diagnostics).loadActiveSession() + + assertEquals(session, restored) + assertEquals(futureRegistry, preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertEquals(listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }) + } + + @Test + fun malformedRegistryFallsBackWithoutDiscardingTheLegacyCredential() = withStore { preferences, secrets -> + val session = firstSession() + putLegacySession(preferences, secrets, session) + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, "{not-json") + val diagnostics = mutableListOf() + + val restored = persistence(preferences, secrets, diagnostics).loadActiveSession() + + assertEquals(session, restored) + assertEquals(session.accountId, decodeRegistry(preferences).activeAccountId) + assertEquals(listOf("ACCOUNT_REGISTRY_MALFORMED"), diagnostics.mapNotNull { it.code }) + assertDiagnosticsExcludePrivateValues(diagnostics) + } + + @Test + fun activeRegistryMismatchNeverBindsTheLegacyPasswordToAnotherAccount() = withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + putLegacySession(preferences, secrets, first) + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodeNextcloudAccountRegistry(registry)) + val diagnostics = mutableListOf() + + val restored = persistence(preferences, secrets, diagnostics).loadActiveSession() + + assertNull(restored) + assertEquals(registry, decodeRegistry(preferences)) + assertNull(secrets.load(desktopAccountSecretReference(second.accountId))) + assertEquals(listOf("ACCOUNT_CREDENTIAL_ACTIVE_MISMATCH"), diagnostics.mapNotNull { it.code }) + assertDiagnosticsExcludePrivateValues(diagnostics) + } + + @Test + fun removingTheActiveAccountRetainsOtherCredentialsWithoutSelectingOne() = withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(first) + persistence.saveSession(second) + + assertTrue(persistence.removeAccount(second.accountId)) + + val restarted = persistence(preferences, secrets) + assertNull(restarted.loadActiveSession()) + assertNull(restarted.activeAccountId()) + assertEquals(listOf(first.accountRecord()), restarted.listAccounts()) + assertNotNull(secrets.load(desktopAccountSecretReference(first.accountId))) + assertNull(secrets.load(desktopAccountSecretReference(second.accountId))) + } + + @Test + fun activeAccountWithMissingCredentialCanStillBeRemoved() = withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(first) + persistence.saveSession(second) + secrets.clear(desktopAccountSecretReference(second.accountId)) + + assertNull(persistence.loadActiveSession()) + assertTrue(persistence.removeAccount(second.accountId)) + + val restarted = persistence(preferences, secrets) + assertNull(restarted.activeAccountId()) + assertEquals(listOf(first.accountRecord()), restarted.listAccounts()) + } + + @Test + fun oversizedRegistryFailsBeforeCredentialOrMetadataWrites() = withStore { preferences, secrets -> + val session = NextcloudSession( + serverUrl = "https://cloud.example.test/" + "a".repeat(8_050), + loginName = "alice", + appPassword = "private-app-password", + ) + + assertFailsWith { + persistence(preferences, secrets).saveSession(session) + } + + assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertNull(preferences.get("server", null)) + assertNull(preferences.get("login", null)) + assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + } + + @Test + fun migrationFailureAttachesABoundedCauseWithoutPrivateValues() = withStore { preferences, secrets -> + val session = firstSession() + putLegacySession(preferences, secrets, session) + secrets.failSaves = true + val diagnostics = mutableListOf() + + val restored = persistence(preferences, secrets, diagnostics).loadActiveSession() + + assertEquals(session, restored) + assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + val diagnostic = diagnostics.single { it.code == "ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED" } + assertNotNull(diagnostic.exception) + assertNull(diagnostic.exception.message) + assertDiagnosticsExcludePrivateValues(diagnostics) + } + + private fun persistence( + preferences: Preferences, + secrets: MemorySecretStore, + diagnostics: MutableList = mutableListOf(), + ) = DesktopAccountCredentialPersistence(preferences, secrets, diagnostics::add) + + private fun putLegacySession( + preferences: Preferences, + secrets: MemorySecretStore, + session: NextcloudSession, + ) { + preferences.put("server", session.serverUrl) + preferences.put("login", session.loginName) + secrets.save( + desktopSessionSecretReference(session.serverUrl, session.loginName), + session.loginName, + session.appPassword.encodeToByteArray(), + ) + } + + private fun decodeRegistry(preferences: Preferences): NextcloudAccountRegistry = requireNotNull( + decodeNextcloudAccountRegistry(requireNotNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null))), + ) + + private fun assertDiagnosticsExcludePrivateValues(diagnostics: List) { + val rendered = diagnostics.joinToString() + assertFalse(rendered.contains("private-app-password")) + assertFalse(rendered.contains("second-private-password")) + assertFalse(rendered.contains("alice")) + assertFalse(rendered.contains("cloud.example.test")) + } + + private fun firstSession() = NextcloudSession( + serverUrl = "https://cloud.example.test", + loginName = "alice", + appPassword = "private-app-password", + ) + + private fun secondSession() = NextcloudSession( + serverUrl = "https://second.example.test/nextcloud", + loginName = "bob", + appPassword = "second-private-password", + ) + + private fun withStore(block: (Preferences, MemorySecretStore) -> Unit) { + val preferences = Preferences.userRoot().node( + "dev/obiente/nextcloudnative/tests/account-credentials/${UUID.randomUUID()}", + ) + try { + block(preferences, MemorySecretStore()) + } finally { + preferences.removeNode() + } + } + + private class MemorySecretStore : DesktopSecretStore { + private val values = mutableMapOf() + var failSaves = false + + override fun load(reference: DesktopSecretReference): ByteArray? = values[reference.targetName]?.copyOf() + + override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { + if (failSaves) error("private-app-password at cloud.example.test for alice") + values[reference.targetName] = secret.copyOf() + } + + override fun clear(reference: DesktopSecretReference) { + values.remove(reference.targetName) + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt index b551e9af1..cb0ba8e88 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt @@ -842,6 +842,23 @@ class DesktopSecretStoreTest { assertEquals("alice", first.attributes.getValue("login")) } + @Test + fun accountCredentialReferenceContainsOnlyTheOpaqueAccountIdentity() { + val session = NextcloudSession( + serverUrl = "https://cloud.invalid", + loginName = "alice", + appPassword = "private-app-password", + ) + + val reference = desktopAccountSecretReference(session.accountId) + val rendered = listOf(reference.targetName, reference.label, reference.attributes.toString()).joinToString() + + assertTrue(rendered.contains(session.accountId.storageKey)) + assertFalse(rendered.contains(session.serverUrl)) + assertFalse(rendered.contains(session.loginName)) + assertFalse(rendered.contains(session.appPassword)) + } + @Test fun windowsCredentialManagerRoundTripUsesCurrentUserCredentialSet() { if (desktopSecretStoreKind() != DesktopSecretStoreKind.WindowsCredentialManager) return From cf90d81d13a7113eedd2adbf06620ddbae2a081a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 22:43:01 +0200 Subject: [PATCH 002/119] chore(changelog): link pull request --- changes/unreleased/172-account-credential-slots.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/unreleased/172-account-credential-slots.md b/changes/unreleased/172-account-credential-slots.md index 33ee7bc61..d1d12dbc1 100644 --- a/changes/unreleased/172-account-credential-slots.md +++ b/changes/unreleased/172-account-credential-slots.md @@ -1,6 +1,6 @@ category: internal issue: 172 -pull: none +pull: 436 platforms: android, desktop user-facing: no From 32f6c798cc73d87c03b6af2f658cf59198f2a2f7 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 23:48:47 +0200 Subject: [PATCH 003/119] fix(accounts): serialize credential lifecycle state --- .../AndroidAccountCredentialController.kt | 77 +++-- .../AndroidFileSyncScheduler.kt | 26 ++ .../AndroidPersistedSession.kt | 31 +- .../NextcloudFileSyncWorker.kt | 34 +++ .../AndroidFileSyncEngineInvariantTest.kt | 28 +- .../AndroidPersistedSessionTest.kt | 38 ++- .../172-account-credential-slots.md | 2 +- .../DesktopAccountCredentialPersistence.kt | 70 +++-- .../app/DesktopAccountOperationGuard.kt | 25 ++ .../app/DesktopNextcloudServices.kt | 283 +++++++++--------- ...DesktopAccountCredentialPersistenceTest.kt | 55 +++- .../app/DesktopAccountOperationGuardTest.kt | 67 +++++ 12 files changed, 536 insertions(+), 200 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 8e8df50f9..b585d8027 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -80,16 +80,23 @@ internal class AndroidAccountCredentialController( }?.let(::encryptState) withContext(Dispatchers.IO) { AndroidExternalFileHandoffRegistry.clear() } val scheduler = AndroidFileSyncScheduler(appContext) - ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.clearSession( - persist = { - preferences.edit().apply { - if (encodedReplacement == null) remove(KEY_SESSION) else putString(KEY_SESSION, encodedReplacement) - remove(KEY_TEST_READ_ONLY) - }.apply() - }, - cancelAll = scheduler::cancelAll, - clearPublishedAccount = { publishAccountIdentity(null) }, - ) + withContext(Dispatchers.IO) { + ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.clearSession( + persist = { + val editor = preferences.edit().apply { + if (encodedReplacement == null) { + remove(KEY_SESSION) + } else { + putString(KEY_SESSION, encodedReplacement) + } + remove(KEY_TEST_READ_ONLY) + } + commitPreferences(editor) + }, + cancelAll = scheduler::cancelAll, + clearPublishedAccount = { publishAccountIdentity(null) }, + ) + } clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(activeSession)) notifyDocumentRootsChanged() } @@ -102,17 +109,21 @@ internal class AndroidAccountCredentialController( val encrypted = encryptState(replacement) withContext(Dispatchers.IO) { AndroidExternalFileHandoffRegistry.clear() } val scheduler = AndroidFileSyncScheduler(appContext) - ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.replaceSession( - replacementAccountId = NextcloudDocumentIds.accountKey(session), - persist = { - preferences.edit() - .putString(KEY_SESSION, encrypted) - .remove(KEY_TEST_READ_ONLY) - .apply() - }, - cancelAll = scheduler::cancelAll, - publishAccount = publishAccountIdentity, - ) + withContext(Dispatchers.IO) { + ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.replaceSession( + replacementAccountId = NextcloudDocumentIds.accountKey(session), + persist = { + commitPreferences( + preferences.edit() + .putString(KEY_SESSION, encrypted) + .remove(KEY_TEST_READ_ONLY), + ) + }, + cancelAll = scheduler::cancelAll, + publishAccount = publishAccountIdentity, + restoreSchedules = scheduler::restorePersistedPairSchedules, + ) + } if (previousSession != null && previousSession.accountId != session.accountId) { clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(previousSession)) } @@ -137,14 +148,28 @@ internal class AndroidAccountCredentialController( return restoreAndroidAccountCredentialState( encoded = encoded, persistMigrated = { migrated -> - preferences.edit().putString(KEY_SESSION, sessionCipher.encrypt(migrated)).apply() + commitPreferences( + preferences.edit().putString(KEY_SESSION, sessionCipher.encrypt(migrated)), + ) }, recordDiagnostic = recordDiagnostic, ) } - private fun persistState(state: AndroidAccountCredentialState) { - preferences.edit().putString(KEY_SESSION, encryptState(state)).apply() + private suspend fun persistState(state: AndroidAccountCredentialState) = withContext(Dispatchers.IO) { + commitPreferences(preferences.edit().putString(KEY_SESSION, encryptState(state))) + } + + private fun commitPreferences(editor: SharedPreferences.Editor) { + try { + requireCommittedAndroidAccountCredentialEdit(editor) + } catch (failure: Exception) { + recordCredentialFailure( + code = "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", + operation = "account-credentials.persist", + ) + throw failure + } } private fun encryptState(state: AndroidAccountCredentialState): String = try { @@ -174,3 +199,7 @@ internal class AndroidAccountCredentialController( const val KEY_TEST_READ_ONLY = "emulator_test_read_only" } } + +internal fun requireCommittedAndroidAccountCredentialEdit(editor: SharedPreferences.Editor) { + check(editor.commit()) { "The account credential store could not be committed." } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt index f3973a8cb..bef39201e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt @@ -5,7 +5,9 @@ import androidx.work.BackoffPolicy import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager @@ -53,6 +55,7 @@ internal class AndroidFileSyncSessionSchedulingGuard { persist: () -> Unit, cancelAll: () -> Unit, publishAccount: (String) -> Unit = {}, + restoreSchedules: (String) -> Unit = {}, ) { synchronized(monitor) { val accountChanged = accountId != replacementAccountId @@ -65,6 +68,7 @@ internal class AndroidFileSyncSessionSchedulingGuard { } finally { if (accountChanged) cancelAll() } + restoreSchedules(replacementAccountId) } } @@ -165,6 +169,28 @@ internal class AndroidFileSyncScheduler(context: Context) { ) } + fun restorePersistedPairSchedules(accountId: String) { + val request = OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putString(AndroidFileSyncScheduleRestorationWorker.KEY_ACCOUNT_ID, accountId) + .build(), + ) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .addTag(TAG) + .build() + workManager.enqueueUniqueWork( + "file-sync-restore-$accountId", + ExistingWorkPolicy.REPLACE, + request, + ) + } + suspend fun cancel(pairId: String) { workManager.cancelUniqueWork(workName(pairId)).await() } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index e0f367df3..927ac960c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -19,6 +19,7 @@ import org.json.JSONObject internal data class AndroidAccountCredentialState( val registry: NextcloudAccountRegistry, val sessions: Map, + val mutationsAllowed: Boolean = true, ) { init { require(sessions.size <= MAX_ANDROID_ACCOUNT_CREDENTIALS) @@ -32,20 +33,31 @@ internal data class AndroidAccountCredentialState( val activeSession: NextcloudSession? get() = registry.activeAccountId?.let(sessions::get) - fun upsertAndSelect(session: NextcloudSession): AndroidAccountCredentialState = copy( - registry = registry.upsertAndSelect(session.accountRecord()), - sessions = sessions + (session.accountId to session), - ) + fun upsertAndSelect(session: NextcloudSession): AndroidAccountCredentialState { + requireMutationsAllowed() + return copy( + registry = registry.upsertAndSelect(session.accountRecord()), + sessions = sessions + (session.accountId to session), + ) + } fun select(accountId: NextcloudAccountId): AndroidAccountCredentialState? { + requireMutationsAllowed() if (accountId !in sessions) return null return copy(registry = requireNotNull(registry.select(accountId))) } - fun remove(accountId: NextcloudAccountId): AndroidAccountCredentialState = copy( - registry = registry.remove(accountId), - sessions = sessions - accountId, - ) + fun remove(accountId: NextcloudAccountId): AndroidAccountCredentialState { + requireMutationsAllowed() + return copy( + registry = registry.remove(accountId), + sessions = sessions - accountId, + ) + } + + private fun requireMutationsAllowed() { + check(mutationsAllowed) { "The account credential store version is unsupported." } + } companion object { val Empty = AndroidAccountCredentialState(NextcloudAccountRegistry.Empty, emptyMap()) @@ -125,6 +137,7 @@ internal fun decodeAndroidAccountCredentialState(encoded: String): RestoredAndro } internal fun encodeAndroidAccountCredentialState(state: AndroidAccountCredentialState): String = JSONObject() + .also { check(state.mutationsAllowed) { "The account credential store version is unsupported." } } .put(KEY_VERSION, ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION) .put(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(state.registry)) .put( @@ -176,6 +189,8 @@ private fun restoreLegacyAndroidAccountCredentialState( state = AndroidAccountCredentialState( registry = credentialRegistry, sessions = mapOf(session.accountId to session), + mutationsAllowed = restoredRegistry.recoveryReason != + NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion, ), needsPersistence = restoredRegistry.recoveryReason != NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt index 0cb0acd5c..35bc0d6c3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt @@ -12,6 +12,7 @@ import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import dev.obiente.nextcloudnative.app.FileSyncCenterActionResult import dev.obiente.nextcloudnative.app.FileSyncRejectionScope +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticFieldDraft @@ -168,6 +169,39 @@ internal class NextcloudFileSyncWorker( } } +internal class AndroidFileSyncScheduleRestorationWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val expectedAccountId = inputData.getString(KEY_ACCOUNT_ID)?.takeIf(String::isNotBlank) + ?: return@withContext Result.failure() + val services = AndroidNextcloudServices(applicationContext) + val session = services.loadSession() + ?.takeIf { restored -> isAndroidFileSyncScheduleRestorationCurrent(expectedAccountId, restored) } + ?: return@withContext Result.success() + runCatching { + val userId = services.loadServerInfo(session).userId + services.loadFileSyncCenter(session, userId) + }.fold( + onSuccess = { Result.success() }, + onFailure = { failure -> + rethrowAndroidFileSyncCancellation(failure) + Result.retry() + }, + ) + } + + internal companion object { + const val KEY_ACCOUNT_ID = "account_id" + } +} + +internal fun isAndroidFileSyncScheduleRestorationCurrent( + expectedAccountId: String, + session: NextcloudSession, +): Boolean = NextcloudDocumentIds.accountKey(session) == expectedAccountId + internal fun syncConflictNotificationDetail(conflictCount: Int): String { require(conflictCount > 0) return "$conflictCount sync conflict" + diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index ccf2c2a5c..1e5bcdaa7 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -7,6 +7,7 @@ import dev.obiente.nextcloudnative.app.FileSyncDirection import dev.obiente.nextcloudnative.app.FileSyncOperation import dev.obiente.nextcloudnative.app.FileSyncPair import dev.obiente.nextcloudnative.app.LocalSyncEntry +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.RemoteSyncEntry import dev.obiente.nextcloudnative.app.SyncEntryKind import dev.obiente.nextcloudnative.app.scanFileSyncPair @@ -58,6 +59,25 @@ class AndroidFileSyncEngineInvariantTest { assertEquals(scanHashes.getValue(unverified.relativePath), reconciled[1].contentHash) } + @Test + fun scheduleRestorationRejectsAStaleAccountSwitch() { + val selected = NextcloudSession("https://cloud.example.test/nextcloud", "alice", "secret") + val other = NextcloudSession("https://cloud.example.test/nextcloud", "bob", "other-secret") + + assertTrue( + isAndroidFileSyncScheduleRestorationCurrent( + NextcloudDocumentIds.accountKey(selected), + selected, + ), + ) + assertFalse( + isAndroidFileSyncScheduleRestorationCurrent( + NextcloudDocumentIds.accountKey(selected), + other, + ), + ) + } + @Test fun largeFileDirectoryReplacementKeepsTheDirectoryUntilProtectedPublication() { val directory = RemoteSyncEntry("archive.bin", SyncEntryKind.Directory, "directory-etag") @@ -720,13 +740,19 @@ class AndroidFileSyncEngineInvariantTest { replacementAccountId = "account-new", persist = { events += "save-new-session" }, cancelAll = { events += "cancel-old-work" }, + restoreSchedules = { events += "restore-$it-work" }, ) val newToken = requireNotNull(guard.capture("account-new")) assertFalse(guard.runIfCurrent(oldToken) { events += "schedule-old-account" }) assertTrue(guard.runIfCurrent(newToken) { events += "schedule-new-account" }) assertEquals( - listOf("save-new-session", "cancel-old-work", "schedule-new-account"), + listOf( + "save-new-session", + "cancel-old-work", + "restore-account-new-work", + "schedule-new-account", + ), events, ) } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index a9a80f957..9a1c4f4e2 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import android.content.SharedPreferences import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft @@ -7,13 +8,28 @@ import dev.obiente.nextcloudnative.app.accountRecord import dev.obiente.nextcloudnative.app.encodeNextcloudAccountRegistry import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import org.json.JSONObject +import java.lang.reflect.Proxy class AndroidPersistedSessionTest { + @Test + fun accountCredentialEditsUseCheckedSynchronousCommit() { + val successfulCalls = mutableListOf() + requireCommittedAndroidAccountCredentialEdit(recordingEditor(commitResult = true, successfulCalls)) + assertEquals(listOf("commit"), successfulCalls) + + val failedCalls = mutableListOf() + assertFailsWith { + requireCommittedAndroidAccountCredentialEdit(recordingEditor(commitResult = false, failedCalls)) + } + assertEquals(listOf("commit"), failedCalls) + } + @Test fun legacyPayloadMigratesOnceAndRestartsWithTheSameActiveAccount() { val diagnostics = mutableListOf() @@ -224,9 +240,14 @@ class AndroidPersistedSessionTest { recordDiagnostic = diagnostics::add, ) - assertEquals(firstSession(), requireNotNull(restored).activeSession) + val readOnly = requireNotNull(restored) + assertEquals(firstSession(), readOnly.activeSession) assertFalse(migrated) assertEquals(listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }) + assertFailsWith { readOnly.upsertAndSelect(secondSession()) } + assertFailsWith { readOnly.select(firstSession().accountId) } + assertFailsWith { readOnly.remove(firstSession().accountId) } + assertFailsWith { encodeAndroidAccountCredentialState(readOnly) } } @Test @@ -268,6 +289,21 @@ class AndroidPersistedSessionTest { assertFalse(rendered.contains("cloud.example.test")) } + private fun recordingEditor( + commitResult: Boolean, + calls: MutableList, + ): SharedPreferences.Editor = Proxy.newProxyInstance( + SharedPreferences.Editor::class.java.classLoader, + arrayOf(SharedPreferences.Editor::class.java), + ) { proxy, method, _ -> + calls += method.name + when (method.name) { + "commit" -> commitResult + "apply" -> Unit + else -> proxy + } + } as SharedPreferences.Editor + private fun legacyPayload(session: NextcloudSession): String = JSONObject() .put("serverUrl", session.serverUrl) .put("loginName", session.loginName) diff --git a/changes/unreleased/172-account-credential-slots.md b/changes/unreleased/172-account-credential-slots.md index d1d12dbc1..9c6ca4370 100644 --- a/changes/unreleased/172-account-credential-slots.md +++ b/changes/unreleased/172-account-credential-slots.md @@ -4,4 +4,4 @@ pull: 436 platforms: android, desktop user-facing: no -Store bounded credentials for each local account, migrate existing Android and desktop sessions, and keep account selection and removal aligned with sync lifecycle cleanup. +Store bounded credentials for each local account, migrate existing Android and desktop sessions durably, and keep account selection, removal, and background sync aligned across account switches. diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 3867ef307..5200274db 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -6,6 +6,7 @@ internal class DesktopAccountCredentialPersistence( private val preferences: Preferences, private val secretStore: DesktopSecretStore, private val recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, + private val flushPreferences: () -> Unit = preferences::flush, ) { fun loadActiveSession(): NextcloudSession? { val read = readRegistry() @@ -52,28 +53,27 @@ internal class DesktopAccountCredentialPersistence( val updatedRegistry = registry.upsertAndSelect(session.accountRecord()) val encodedRegistry = prepareRegistry(updatedRegistry) saveSecret(session) - persistRegistry(encodedRegistry) - persistLegacyActiveMetadata(session.accountRecord()) + persistAccountState(encodedRegistry, updatedRegistry.activeAccount) } fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? { val registry = readRegistry().registry ?: return null val session = loadSession(accountId) ?: return null - persistRegistry(requireNotNull(registry.select(accountId))) - persistLegacyActiveMetadata(session.accountRecord()) + val selected = requireNotNull(registry.select(accountId)) + persistAccountState(prepareRegistry(selected), selected.activeAccount) return session } fun removeAccount(accountId: NextcloudAccountId): Boolean { val registry = readRegistry().registry ?: return false val record = registry.accounts.firstOrNull { account -> account.id == accountId } ?: return false + val clearLegacyCredential = legacyMetadataMatches(record) + val updated = registry.remove(accountId) + persistAccountState(prepareRegistry(updated), updated.activeAccount) clearSecret(desktopAccountSecretReference(accountId)) - if (legacyMetadataMatches(record)) { + if (clearLegacyCredential) { clearSecret(desktopSessionSecretReference(record.serverUrl, record.loginName)) - preferences.remove(KEY_SERVER) - preferences.remove(KEY_LOGIN) } - persistRegistry(registry.remove(accountId)) return true } @@ -95,7 +95,7 @@ internal class DesktopAccountCredentialPersistence( try { val encodedRegistry = prepareRegistry(restored.registry) saveSecret(legacy) - persistRegistry(encodedRegistry) + persistAccountState(encodedRegistry, restored.registry.activeAccount) } catch (failure: Exception) { recordCredentialDiagnostic( code = "ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED", @@ -104,7 +104,6 @@ internal class DesktopAccountCredentialPersistence( ) return legacy } - persistLegacyActiveMetadata(legacy.accountRecord()) clearLegacyCredentialAfterMigration(legacy) return legacy } @@ -175,18 +174,31 @@ internal class DesktopAccountCredentialPersistence( } } - private fun persistRegistry(registry: NextcloudAccountRegistry) { - persistRegistry(prepareRegistry(registry)) - } - - private fun persistRegistry(encodedRegistry: String) { + private fun persistAccountState( + encodedRegistry: String, + activeAccount: NextcloudAccountRecord?, + ) { require(encodedRegistry.length <= Preferences.MAX_VALUE_LENGTH) - preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodedRegistry) - } - - private fun persistLegacyActiveMetadata(record: NextcloudAccountRecord) { - preferences.put(KEY_SERVER, record.serverUrl) - preferences.put(KEY_LOGIN, record.loginName) + val previous = DesktopAccountPreferenceSnapshot( + registry = preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null), + server = preferences.get(KEY_SERVER, null), + login = preferences.get(KEY_LOGIN, null), + ) + try { + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodedRegistry) + preferences.putOrRemove(KEY_SERVER, activeAccount?.serverUrl) + preferences.putOrRemove(KEY_LOGIN, activeAccount?.loginName) + flushPreferences() + } catch (failure: Exception) { + previous.restore(preferences) + runCatching(flushPreferences) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", + "account-credentials.persist", + failure, + ) + throw failure + } } private fun legacyMetadataMatches(record: NextcloudAccountRecord): Boolean = @@ -220,12 +232,28 @@ internal class DesktopAccountCredentialPersistence( val registry: NextcloudAccountRegistry?, ) + private data class DesktopAccountPreferenceSnapshot( + val registry: String?, + val server: String?, + val login: String?, + ) { + fun restore(preferences: Preferences) { + preferences.putOrRemove(DESKTOP_ACCOUNT_REGISTRY_KEY, registry) + preferences.putOrRemove(KEY_SERVER, server) + preferences.putOrRemove(KEY_LOGIN, login) + } + } + private companion object { const val KEY_SERVER = "server" const val KEY_LOGIN = "login" } } +private fun Preferences.putOrRemove(key: String, value: String?) { + if (value == null) remove(key) else put(key, value) +} + private fun NextcloudAccountRecord.toSession(appPassword: String) = NextcloudSession( serverUrl = serverUrl, loginName = loginName, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt new file mode 100644 index 000000000..0d3c990cd --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -0,0 +1,25 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class DesktopAccountOperationGuard { + private val accountMutationMutex = Mutex() + private val syncRunMutex = Mutex() + + suspend fun serialize(action: suspend () -> Result): Result = + accountMutationMutex.withLock { action() } + + suspend fun serializeWhenSyncIdle(action: suspend () -> Result): Result = serialize { + withSyncRunLock(action) + } + + suspend fun withSyncRunLock(action: suspend () -> Result): Result = syncRunMutex.withLock { action() } +} + +internal fun desktopAccountDiagnosticFields(accountId: String?): List = + accountId?.let { + listOf( + SupportDiagnosticFieldDraft("account", it, SupportDiagnosticValuePrivacy.Identifier), + ) + }.orEmpty() 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 e7d04687a..1938d4baf 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1021,6 +1021,7 @@ class DesktopNextcloudServices( supportIntake.setActiveAccountIdentity(identity) } private val sessionPublicationGuard = DesktopSessionPublicationGuard() + private val accountOperationGuard = DesktopAccountOperationGuard() private val appUpdater = DesktopAppUpdater( preferences = preferences.node("app-updates-v1"), onInstallerConfirmationOpened = { target -> onDesktopUpdateInstallerOpened(target.platform) }, @@ -1652,7 +1653,6 @@ class DesktopNextcloudServices( }, ) private val startOnLoginController = DesktopStartOnLoginController() - private val fileSyncRunLock = Mutex() private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var backgroundFileSyncJob: Job? = null private val mutableFileSyncTraySnapshot = MutableStateFlow( @@ -2703,9 +2703,9 @@ class DesktopNextcloudServices( SupportDiagnosticFieldDraft("pair", pairId, SupportDiagnosticValuePrivacy.Identifier), ) diagnoseDesktopSupportFailure(accountId, "sync.pair-run", diagnosticFields) { - fileSyncRunLock.withLock { + accountOperationGuard.withSyncRunLock syncRun@{ if (isFileSyncPaused()) { - return@withLock FileSyncCenterActionResult.Rejected( + return@syncRun FileSyncCenterActionResult.Rejected( "Desktop syncing is paused. Resume it from the system tray first.", ) } @@ -2753,9 +2753,9 @@ class DesktopNextcloudServices( SupportDiagnosticFieldDraft("choice", choice.name.lowercase()), ) diagnoseDesktopSupportFailure(accountId, "sync.conflict-resolve", diagnosticFields) { - fileSyncRunLock.withLock { + accountOperationGuard.withSyncRunLock syncRun@{ if (isFileSyncPaused()) { - return@withLock FileSyncCenterActionResult.Rejected( + return@syncRun FileSyncCenterActionResult.Rejected( "Desktop syncing is paused. Resume it from the system tray first.", ) } @@ -2800,9 +2800,9 @@ class DesktopNextcloudServices( SupportDiagnosticFieldDraft("conflict_count", resolutions.size.toString()), ) diagnoseDesktopSupportFailure(accountId, "sync.conflict-resolve-batch", diagnosticFields) { - fileSyncRunLock.withLock { + accountOperationGuard.withSyncRunLock syncRun@{ if (isFileSyncPaused()) { - return@withLock FileSyncCenterActionResult.Rejected( + return@syncRun FileSyncCenterActionResult.Rejected( "Desktop syncing is paused. Resume it from the system tray first.", ) } @@ -2925,23 +2925,23 @@ class DesktopNextcloudServices( ): FileSyncCenterActionResult = withContext(Dispatchers.IO) { var diagnosticAccountId: String? = null try { - fileSyncRunLock.withLock { + accountOperationGuard.withSyncRunLock syncRun@{ if (isFileSyncPaused()) { - return@withLock FileSyncCenterActionResult.Rejected("Desktop syncing is paused.") + return@syncRun FileSyncCenterActionResult.Rejected("Desktop syncing is paused.") } val session = loadSession() - ?: return@withLock FileSyncCenterActionResult.Rejected("Sign in before syncing folders.") + ?: return@syncRun FileSyncCenterActionResult.Rejected("Sign in before syncing folders.") val accountId = desktopFileCacheAccountId(session) diagnosticAccountId = accountId val userId = runCatching { loadServerInfo(session).userId }.getOrElse { failure -> - return@withLock FileSyncCenterActionResult.Rejected( + return@syncRun FileSyncCenterActionResult.Rejected( failure.message ?: "Could not load the signed-in account.", ) } val initial = loadDesktopFileSyncCenter(session) if (initial.pairs.isEmpty()) { publishFileSyncTraySnapshot(initial, emptyList()) - return@withLock FileSyncCenterActionResult.Completed("No desktop sync folders are configured.") + return@syncRun FileSyncCenterActionResult.Completed("No desktop sync folders are configured.") } mutableFileSyncTraySnapshot.value = mutableFileSyncTraySnapshot.value.copy( phase = DesktopFileSyncTrayPhase.Syncing, @@ -3678,12 +3678,14 @@ class DesktopNextcloudServices( } override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { - sessionPublicationGuard.serialize { - accountCredentials.saveSession(session) - accountSessionPublication.publish(session) + accountOperationGuard.serializeWhenSyncIdle { + sessionPublicationGuard.serialize { + accountCredentials.saveSession(session) + accountSessionPublication.publish(session) + } + synchronized(fileRangeSessionLock) { sessionClearing = false } + startDesktopSyncLifecycle() } - synchronized(fileRangeSessionLock) { sessionClearing = false } - startDesktopSyncLifecycle() } override fun listAccounts() = sessionPublicationGuard.serialize(accountCredentials::listAccounts) @@ -3699,45 +3701,54 @@ class DesktopNextcloudServices( override suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = withContext(Dispatchers.IO) { - if (activeAccountId() == accountId) return@withContext loadSession(accountId) - val hasLiveAccountResources = synchronized(fileRangeSessionLock) { - activeFileRangeSessions.isNotEmpty() - } || synchronized(virtualFolderHydrationJobs) { - virtualFolderHydrationJobs.values.any { job -> job.isActive } - } || synchronized(virtualFileProviderLock) { - linuxVirtualFileSystem != null || - windowsCloudFilesProvider != null || - virtualFileCacheTierMutations.isNotEmpty() - } - if (hasLiveAccountResources) { - recordSupportDiagnostic(desktopAccountSelectionBlockedDiagnostic()) - return@withContext null - } - val syncJob = synchronized(this@DesktopNextcloudServices) { - backgroundFileSyncJob.also { backgroundFileSyncJob = null } - } - syncJob?.cancel() - syncJob?.join() - val selected = sessionPublicationGuard.serialize { - accountCredentials.selectAccount(accountId)?.also { session -> - accountSessionPublication.publish(session) + accountOperationGuard.serialize operation@{ + if (activeAccountId() == accountId) return@operation loadSession(accountId) + val hasLiveAccountResources = synchronized(fileRangeSessionLock) { + activeFileRangeSessions.isNotEmpty() + } || synchronized(virtualFolderHydrationJobs) { + virtualFolderHydrationJobs.values.any { job -> job.isActive } + } || synchronized(virtualFileProviderLock) { + linuxVirtualFileSystem != null || + windowsCloudFilesProvider != null || + virtualFileCacheTierMutations.isNotEmpty() + } + if (hasLiveAccountResources) { + recordSupportDiagnostic(desktopAccountSelectionBlockedDiagnostic()) + return@operation null + } + val syncJob = synchronized(this@DesktopNextcloudServices) { + backgroundFileSyncJob.also { backgroundFileSyncJob = null } } + syncJob?.cancel() + syncJob?.join() + val selected = accountOperationGuard.withSyncRunLock { + sessionPublicationGuard.serialize { + accountCredentials.selectAccount(accountId)?.also { session -> + accountSessionPublication.publish(session) + } + } + } + startDesktopSyncLifecycle() + selected } - startDesktopSyncLifecycle() - selected } - override suspend fun removeAccount(accountId: NextcloudAccountId): Boolean { - if (activeAccountId() == accountId) { - clearSession() - return true - } - return withContext(Dispatchers.IO) { - sessionPublicationGuard.serialize { accountCredentials.removeAccount(accountId) } + override suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = withContext(Dispatchers.IO) { + accountOperationGuard.serialize { + if (activeAccountId() == accountId) { + clearSessionForAccountOperation() + true + } else { + sessionPublicationGuard.serialize { accountCredentials.removeAccount(accountId) } + } } } override suspend fun clearSession() = withContext(Dispatchers.IO) { + accountOperationGuard.serialize { clearSessionForAccountOperation() } + } + + private suspend fun clearSessionForAccountOperation() { val userHome = File(System.getProperty("user.home")) val rangeSessions = synchronized(fileRangeSessionLock) { sessionClearing = true @@ -3758,111 +3769,97 @@ class DesktopNextcloudServices( active } syncJob?.cancel() - val hydrationJobs = accountId?.let(::cancelAllVirtualFolderHydration).orEmpty() - rangeSessions.forEach { source -> runCatching(source::close) } - hydrationJobs.forEach { job -> job.join() } - accountId?.let { clearedAccountId -> - val prefix = "$clearedAccountId\u0000" - synchronized(virtualFolderMutationLock) { - virtualFolderMutationGenerationsByJob.keys.removeIf { key -> key.startsWith(prefix) } - virtualFolderCompletedGenerations.keys.removeIf { key -> key.startsWith(prefix) } - virtualFolderRetryAtEpochMillis.keys.removeIf { key -> key.startsWith(prefix) } - } - } syncJob?.join() - synchronized(virtualFileProviderLock) { - linuxVirtualFileSystem?.unmount() - linuxVirtualFileSystem = null - linuxVirtualMetadataBackend = null - linuxVirtualFileMountIdentity = null - linuxVirtualFileFailure = null - val windowsCloudFilesFailureMessage = "Could not remove the Windows Cloud Files root." - val provider = windowsCloudFilesProvider - try { - if (provider != null) { - provider.removeSyncRoot() - } else if (isWindowsDesktop()) { - unregisterWindowsCloudFilesRootForUninstall(preferences) + accountOperationGuard.withSyncRunLock { + val hydrationJobs = accountId?.let(::cancelAllVirtualFolderHydration).orEmpty() + rangeSessions.forEach { source -> runCatching(source::close) } + hydrationJobs.forEach { job -> job.join() } + accountId?.let { clearedAccountId -> + val prefix = "$clearedAccountId\u0000" + synchronized(virtualFolderMutationLock) { + virtualFolderMutationGenerationsByJob.keys.removeIf { key -> key.startsWith(prefix) } + virtualFolderCompletedGenerations.keys.removeIf { key -> key.startsWith(prefix) } + virtualFolderRetryAtEpochMillis.keys.removeIf { key -> key.startsWith(prefix) } } - windowsCloudFilesFailure = null - } catch (failure: Throwable) { - windowsCloudFilesFailure = failure.message ?: windowsCloudFilesFailureMessage - supportDiagnostics.record( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.VirtualFiles, - operation = "cloud-files.signout-cleanup", - outcome = "failed", - fields = accountId?.let { - listOf( - SupportDiagnosticFieldDraft( - "account", - it, - SupportDiagnosticValuePrivacy.Identifier, - ), - ) - }.orEmpty(), - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - } finally { - runCatching { provider?.close() } - windowsCloudFilesProvider = null - windowsCloudFilesIdentity = null - preferences.remove(KEY_WINDOWS_CLOUD_FILES_ROOT) - accountId?.let { - clearWindowsCloudFilesRootPreferences( - preferences, - it, - desktopWindowsCloudFilesRoot(it, userHome).toPath(), - ) - clearWindowsCloudFilesRootPreferences( - preferences, - it, - desktopLegacyWindowsCloudFilesRoot(it, userHome).toPath(), + } + synchronized(virtualFileProviderLock) { + linuxVirtualFileSystem?.unmount() + linuxVirtualFileSystem = null + linuxVirtualMetadataBackend = null + linuxVirtualFileMountIdentity = null + linuxVirtualFileFailure = null + val windowsCloudFilesFailureMessage = "Could not remove the Windows Cloud Files root." + val provider = windowsCloudFilesProvider + try { + if (provider != null) { + provider.removeSyncRoot() + } else if (isWindowsDesktop()) { + unregisterWindowsCloudFilesRootForUninstall(preferences) + } + windowsCloudFilesFailure = null + } catch (failure: Throwable) { + windowsCloudFilesFailure = failure.message ?: windowsCloudFilesFailureMessage + supportDiagnostics.record( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.VirtualFiles, + operation = "cloud-files.signout-cleanup", + outcome = "failed", + fields = desktopAccountDiagnosticFields(accountId), + exception = failure.toSupportDiagnosticExceptionDraft(), + ), ) - } - if (isWindowsDesktop()) { - val uninstallFailure = runCatching { - unregisterWindowsCloudFilesRootForUninstall(preferences, userHome = userHome) - }.exceptionOrNull() - if (uninstallFailure != null) { - windowsCloudFilesFailure = windowsCloudFilesFailure ?: ( - uninstallFailure.message ?: windowsCloudFilesFailureMessage + } finally { + runCatching { provider?.close() } + windowsCloudFilesProvider = null + windowsCloudFilesIdentity = null + preferences.remove(KEY_WINDOWS_CLOUD_FILES_ROOT) + accountId?.let { + clearWindowsCloudFilesRootPreferences( + preferences, + it, + desktopWindowsCloudFilesRoot(it, userHome).toPath(), ) - supportDiagnostics.record( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.VirtualFiles, - operation = "cloud-files.signout-cleanup-retry", - outcome = "failed", - fields = accountId?.let { - listOf( - SupportDiagnosticFieldDraft( - "account", - it, - SupportDiagnosticValuePrivacy.Identifier, - ), - ) - }.orEmpty(), - exception = uninstallFailure.toSupportDiagnosticExceptionDraft(), - ), + clearWindowsCloudFilesRootPreferences( + preferences, + it, + desktopLegacyWindowsCloudFilesRoot(it, userHome).toPath(), ) } + if (isWindowsDesktop()) { + val uninstallFailure = runCatching { + unregisterWindowsCloudFilesRootForUninstall(preferences, userHome = userHome) + }.exceptionOrNull() + if (uninstallFailure != null) { + windowsCloudFilesFailure = windowsCloudFilesFailure ?: ( + uninstallFailure.message ?: windowsCloudFilesFailureMessage + ) + supportDiagnostics.record( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.VirtualFiles, + operation = "cloud-files.signout-cleanup-retry", + outcome = "failed", + fields = desktopAccountDiagnosticFields(accountId), + exception = uninstallFailure.toSupportDiagnosticExceptionDraft(), + ), + ) + } + } } } - } - mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot( - phase = DesktopFileSyncTrayPhase.Idle, - ) - sessionPublicationGuard.serialize { - if (activeAccountId != null) { - check(accountCredentials.removeAccount(activeAccountId)) + mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot( + phase = DesktopFileSyncTrayPhase.Idle, + ) + sessionPublicationGuard.serialize { + if (activeAccountId != null) { + check(accountCredentials.removeAccount(activeAccountId)) + } + supportDiagnostics.setActiveAccountIdentity(null) + supportIntake.setActiveAccountIdentity(null) } - supportDiagnostics.setActiveAccountIdentity(null) - supportIntake.setActiveAccountIdentity(null) + cleared = true } - cleared = true } finally { if (!cleared) { synchronized(fileRangeSessionLock) { sessionClearing = false } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index 1db605a3b..d3d399671 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -42,6 +42,19 @@ class DesktopAccountCredentialPersistenceTest { assertEquals(first, persistence(preferences, secrets).loadActiveSession()) } + @Test + fun selectionFlushesRegistryAndLegacyMetadataBeforeReturning() = withStore { preferences, secrets -> + var flushCount = 0 + val persistence = persistence(preferences, secrets) { flushCount += 1 } + persistence.saveSession(firstSession()) + persistence.saveSession(secondSession()) + + assertEquals(firstSession(), persistence.selectAccount(firstSession().accountId)) + assertEquals(3, flushCount) + assertEquals(firstSession().serverUrl, preferences.get("server", null)) + assertEquals(firstSession().loginName, preferences.get("login", null)) + } + @Test fun unsupportedFutureRegistryUsesLegacyCredentialWithoutOverwritingIt() = withStore { preferences, secrets -> val session = firstSession() @@ -73,6 +86,45 @@ class DesktopAccountCredentialPersistenceTest { assertDiagnosticsExcludePrivateValues(diagnostics) } + @Test + fun legacyMigrationFlushesBeforeDeletingTheOnlyLegacyCredential() = withStore { preferences, secrets -> + val session = firstSession() + val legacyReference = desktopSessionSecretReference(session.serverUrl, session.loginName) + putLegacySession(preferences, secrets, session) + var legacyPresentAtFlush = false + + val restored = persistence(preferences, secrets) { + legacyPresentAtFlush = secrets.load(legacyReference) != null + }.loadActiveSession() + + assertEquals(session, restored) + assertTrue(legacyPresentAtFlush) + assertNull(secrets.load(legacyReference)) + } + + @Test + fun failedMigrationFlushKeepsLegacyCredentialAndRollsBackCachedMetadata() = + withStore { preferences, secrets -> + val session = firstSession() + val legacyReference = desktopSessionSecretReference(session.serverUrl, session.loginName) + putLegacySession(preferences, secrets, session) + val diagnostics = mutableListOf() + var flushAttempts = 0 + + val restored = persistence(preferences, secrets, diagnostics) { + flushAttempts += 1 + if (flushAttempts == 1) error("synthetic flush failure") + }.loadActiveSession() + + assertEquals(session, restored) + assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertNotNull(secrets.load(legacyReference)) + assertEquals( + listOf("ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", "ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED"), + diagnostics.mapNotNull { it.code }, + ) + } + @Test fun activeRegistryMismatchNeverBindsTheLegacyPasswordToAnotherAccount() = withStore { preferences, secrets -> val first = firstSession() @@ -167,7 +219,8 @@ class DesktopAccountCredentialPersistenceTest { preferences: Preferences, secrets: MemorySecretStore, diagnostics: MutableList = mutableListOf(), - ) = DesktopAccountCredentialPersistence(preferences, secrets, diagnostics::add) + flushPreferences: () -> Unit = preferences::flush, + ) = DesktopAccountCredentialPersistence(preferences, secrets, diagnostics::add, flushPreferences) private fun putLegacySession( preferences: Preferences, diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt new file mode 100644 index 000000000..8cf27de5d --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -0,0 +1,67 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield + +class DesktopAccountOperationGuardTest { + @Test + fun removalCannotPassAConcurrentSelection() = runBlocking { + val guard = DesktopAccountOperationGuard() + val selectionStarted = CompletableDeferred() + val releaseSelection = CompletableDeferred() + val events = mutableListOf() + val selection = async { + guard.serialize { + events += "selection-started" + selectionStarted.complete(Unit) + releaseSelection.await() + events += "selection-finished" + } + } + selectionStarted.await() + + val removal = async { + guard.serialize { events += "removal" } + } + yield() + + assertFalse(removal.isCompleted) + releaseSelection.complete(Unit) + selection.await() + removal.await() + assertEquals(listOf("selection-started", "selection-finished", "removal"), events) + } + + @Test + fun accountMutationWaitsForAnIndependentSyncRun() = runBlocking { + val guard = DesktopAccountOperationGuard() + val releaseSync = CompletableDeferred() + val syncStarted = CompletableDeferred() + val events = mutableListOf() + val sync = async { + guard.withSyncRunLock { + syncStarted.complete(Unit) + releaseSync.await() + } + } + syncStarted.await() + val mutation = async { + guard.serializeWhenSyncIdle { + events += "account-mutated" + } + } + yield() + + assertFalse(mutation.isCompleted) + assertEquals(emptyList(), events) + releaseSync.complete(Unit) + sync.await() + mutation.await() + assertEquals(listOf("account-mutated"), events) + } +} From eee6c83e020a5eb409c741df935dc1a3acc8024f Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 3 Sep 2026 22:37:29 +0200 Subject: [PATCH 004/119] fix(accounts): preserve retained account recovery --- .../AndroidAccountCredentialController.kt | 15 ++++++ .../AndroidFileOfflineRepository.kt | 9 +++- .../AndroidFileSyncScheduler.kt | 18 ++++--- .../AndroidIncomingShareRecovery.kt | 10 +++- .../AndroidIncomingShareUploadWorker.kt | 11 +++- .../AndroidFileSyncEngineInvariantTest.kt | 47 +++++++++++++++++ .../AndroidPersistedSessionTest.kt | 28 +++++++++++ .../DesktopAccountCredentialPersistence.kt | 2 +- .../app/DesktopAccountOperationGuard.kt | 19 +++++++ .../app/DesktopNextcloudServices.kt | 41 ++++++++------- ...DesktopAccountCredentialPersistenceTest.kt | 50 +++++++++++++++++++ .../app/DesktopAccountOperationGuardTest.kt | 33 ++++++++++++ 12 files changed, 251 insertions(+), 32 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index b585d8027..267800417 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -203,3 +203,18 @@ internal class AndroidAccountCredentialController( internal fun requireCommittedAndroidAccountCredentialEdit(editor: SharedPreferences.Editor) { check(editor.commit()) { "The account credential store could not be committed." } } + +internal fun resolveStoredAndroidAccountSession( + accountIdentity: String, + listAccounts: () -> List, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): NextcloudSession? { + val accountId = listAccounts().firstOrNull { account -> + NextcloudDocumentIds.accountKey( + NextcloudSession(account.serverUrl, account.loginName, appPassword = ""), + ) == accountIdentity + }?.id ?: return null + return loadSession(accountId)?.takeIf { session -> + NextcloudDocumentIds.accountKey(session) == accountIdentity + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt index 604390636..223957f70 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt @@ -391,8 +391,13 @@ internal class AndroidFileOfflineRepository(context: Context) { jobId: Long, cancellation: DocumentRequestCancellation, ): AndroidOfflineExecutionOutcome { - val session = AndroidNextcloudServices(appContext).loadSession() - if (session == null || NextcloudDocumentIds.accountKey(session) != expectedAccountId) { + val services = AndroidNextcloudServices(appContext) + val session = resolveStoredAndroidAccountSession( + accountIdentity = expectedAccountId, + listAccounts = services::listAccounts, + loadSession = { accountId -> services.loadSession(accountId) }, + ) + if (session == null) { finish( jobId, FileOfflineJobResult.PermanentFailure("Sign in to this account to finish the offline download."), diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt index bef39201e..7ad0562f4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt @@ -59,16 +59,22 @@ internal class AndroidFileSyncSessionSchedulingGuard { ) { synchronized(monitor) { val accountChanged = accountId != replacementAccountId + persist() generation += 1 - accountId = null + accountId = replacementAccountId try { - persist() - accountId = replacementAccountId publishAccount(replacementAccountId) } finally { - if (accountChanged) cancelAll() + if (accountChanged) { + try { + cancelAll() + } finally { + restoreSchedules(replacementAccountId) + } + } else { + restoreSchedules(replacementAccountId) + } } - restoreSchedules(replacementAccountId) } } @@ -78,10 +84,10 @@ internal class AndroidFileSyncSessionSchedulingGuard { clearPublishedAccount: () -> Unit = {}, ) { synchronized(monitor) { + persist() generation += 1 accountId = null try { - persist() clearPublishedAccount() } finally { cancelAll() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt index 32084b713..5adc536cb 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt @@ -101,7 +101,14 @@ internal class AndroidIncomingShareChunkCleanupWorker( val chunk = request.chunkSession ?: return@withContext Result.success() val claimed = store.claimChunkSessionForCleanup(requestId, chunk.uploadId) ?: return@withContext Result.success() - val session = AndroidNextcloudServices(applicationContext).loadSession() + val services = AndroidNextcloudServices(applicationContext) + val session = request.accountId?.let { accountIdentity -> + resolveStoredAndroidAccountSession( + accountIdentity = accountIdentity, + listAccounts = services::listAccounts, + loadSession = { accountId -> services.loadSession(accountId) }, + ) + } if (session == null) { return@withContext retryOrReleaseIncomingShareChunkCleanup( store, @@ -111,7 +118,6 @@ internal class AndroidIncomingShareChunkCleanupWorker( ) } if ( - request.accountId != NextcloudDocumentIds.accountKey(session) || request.userId.isNullOrBlank() ) { return@withContext retryOrReleaseIncomingShareChunkCleanup( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt index b5ca68704..f616e6f23 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt @@ -80,8 +80,15 @@ internal class AndroidIncomingShareUploadWorker( scheduleIncomingShareRetry(applicationContext, request) return@withContext Result.success() } - val session = AndroidNextcloudServices(applicationContext).loadSession() - if (session == null || NextcloudDocumentIds.accountKey(session) != request.accountId) { + val services = AndroidNextcloudServices(applicationContext) + val session = request.accountId?.let { accountIdentity -> + resolveStoredAndroidAccountSession( + accountIdentity = accountIdentity, + listAccounts = services::listAccounts, + loadSession = { accountId -> services.loadSession(accountId) }, + ) + } + if (session == null) { val failed = store.transition( id = requestId, expected = setOf(AndroidIncomingShareState.Queued), diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index 1e5bcdaa7..2215705ca 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -757,6 +757,53 @@ class AndroidFileSyncEngineInvariantTest { ) } + @Test + fun failedSessionReplacementPreservesOldAuthorityAndSchedules() { + val guard = AndroidFileSyncSessionSchedulingGuard() + guard.restorePersistedSession(load = { "account-old" }, accountIdOf = { it }) + val oldToken = requireNotNull(guard.capture("account-old")) + val events = mutableListOf() + + assertFailsWith { + guard.replaceSession( + replacementAccountId = "account-new", + persist = { + events += "save-new-session" + error("synthetic persistence failure") + }, + cancelAll = { events += "cancel-old-work" }, + publishAccount = { events += "publish-$it" }, + restoreSchedules = { events += "restore-$it-work" }, + ) + } + + assertTrue(guard.runIfCurrent(oldToken) { events += "old-account-still-current" }) + assertEquals(listOf("save-new-session", "old-account-still-current"), events) + assertEquals(null, guard.capture("account-new")) + } + + @Test + fun failedSessionClearPreservesOldAuthorityAndSchedules() { + val guard = AndroidFileSyncSessionSchedulingGuard() + guard.restorePersistedSession(load = { "account-old" }, accountIdOf = { it }) + val oldToken = requireNotNull(guard.capture("account-old")) + val events = mutableListOf() + + assertFailsWith { + guard.clearSession( + persist = { + events += "clear-session" + error("synthetic persistence failure") + }, + cancelAll = { events += "cancel-all" }, + clearPublishedAccount = { events += "publish-none" }, + ) + } + + assertTrue(guard.runIfCurrent(oldToken) { events += "old-account-still-current" }) + assertEquals(listOf("clear-session", "old-account-still-current"), events) + } + @Test fun newSessionGenerationCanScheduleWhileOldDedupeEntryFinishes() { val guard = AndroidFileSyncSessionSchedulingGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 9a1c4f4e2..b4eed1b70 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -17,6 +17,34 @@ import org.json.JSONObject import java.lang.reflect.Proxy class AndroidPersistedSessionTest { + @Test + fun retainedAccountSessionResolvesWithoutSelectingIt() { + val first = firstSession() + val second = secondSession() + val sessions = mapOf(first.accountId to first, second.accountId to second) + + val resolved = resolveStoredAndroidAccountSession( + accountIdentity = NextcloudDocumentIds.accountKey(second), + listAccounts = { listOf(first.accountRecord(), second.accountRecord()) }, + loadSession = sessions::get, + ) + + assertEquals(second, resolved) + } + + @Test + fun retainedAccountResolutionRejectsMismatchedCredential() { + val first = firstSession() + val second = secondSession() + + val resolved = resolveStoredAndroidAccountSession( + accountIdentity = NextcloudDocumentIds.accountKey(second), + listAccounts = { listOf(second.accountRecord()) }, + loadSession = { first }, + ) + + assertNull(resolved) + } @Test fun accountCredentialEditsUseCheckedSynchronousCommit() { val successfulCalls = mutableListOf() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 5200274db..4be98b361 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -69,11 +69,11 @@ internal class DesktopAccountCredentialPersistence( val record = registry.accounts.firstOrNull { account -> account.id == accountId } ?: return false val clearLegacyCredential = legacyMetadataMatches(record) val updated = registry.remove(accountId) - persistAccountState(prepareRegistry(updated), updated.activeAccount) clearSecret(desktopAccountSecretReference(accountId)) if (clearLegacyCredential) { clearSecret(desktopSessionSecretReference(record.serverUrl, record.loginName)) } + persistAccountState(prepareRegistry(updated), updated.activeAccount) return true } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 0d3c990cd..9603ded0d 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -23,3 +23,22 @@ internal fun desktopAccountDiagnosticFields(accountId: String?): List Unit, +) { + if (allowed) return + recordBlocked(desktopAccountSelectionBlockedDiagnostic()) + error("Close files and virtual folders before switching accounts.") +} + +internal inline fun reopenDesktopSessionAfterSelection( + selected: Session?, + reopen: () -> Unit, +): Session? = selected.also { if (it != null) reopen() } 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 1938d4baf..5ccc1523f 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3679,6 +3679,10 @@ class DesktopNextcloudServices( override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { accountOperationGuard.serializeWhenSyncIdle { + requireDesktopSessionSaveAllowed( + !desktopSessionSaveSwitchesAccount(activeAccountId(), session.accountId) || !hasLiveAccountResources(), + ::recordSupportDiagnostic, + ) sessionPublicationGuard.serialize { accountCredentials.saveSession(session) accountSessionPublication.publish(session) @@ -3687,11 +3691,8 @@ class DesktopNextcloudServices( startDesktopSyncLifecycle() } } - override fun listAccounts() = sessionPublicationGuard.serialize(accountCredentials::listAccounts) - override fun activeAccountId() = sessionPublicationGuard.serialize(accountCredentials::activeAccountId) - override fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = sessionPublicationGuard.serialize { accountCredentials.loadSession(accountId)?.also { session -> @@ -3703,16 +3704,7 @@ class DesktopNextcloudServices( withContext(Dispatchers.IO) { accountOperationGuard.serialize operation@{ if (activeAccountId() == accountId) return@operation loadSession(accountId) - val hasLiveAccountResources = synchronized(fileRangeSessionLock) { - activeFileRangeSessions.isNotEmpty() - } || synchronized(virtualFolderHydrationJobs) { - virtualFolderHydrationJobs.values.any { job -> job.isActive } - } || synchronized(virtualFileProviderLock) { - linuxVirtualFileSystem != null || - windowsCloudFilesProvider != null || - virtualFileCacheTierMutations.isNotEmpty() - } - if (hasLiveAccountResources) { + if (hasLiveAccountResources()) { recordSupportDiagnostic(desktopAccountSelectionBlockedDiagnostic()) return@operation null } @@ -3721,18 +3713,29 @@ class DesktopNextcloudServices( } syncJob?.cancel() syncJob?.join() - val selected = accountOperationGuard.withSyncRunLock { - sessionPublicationGuard.serialize { - accountCredentials.selectAccount(accountId)?.also { session -> - accountSessionPublication.publish(session) + val selected = reopenDesktopSessionAfterSelection( + selected = accountOperationGuard.withSyncRunLock { + sessionPublicationGuard.serialize { + accountCredentials.selectAccount(accountId)?.also { session -> + accountSessionPublication.publish(session) + } } - } - } + }, + reopen = { synchronized(fileRangeSessionLock) { sessionClearing = false } }, + ) startDesktopSyncLifecycle() selected } } + private fun hasLiveAccountResources(): Boolean = + synchronized(fileRangeSessionLock) { activeFileRangeSessions.isNotEmpty() } || + synchronized(virtualFolderHydrationJobs) { virtualFolderHydrationJobs.values.any { it.isActive } } || + synchronized(virtualFileProviderLock) { + linuxVirtualFileSystem != null || windowsCloudFilesProvider != null || + virtualFileCacheTierMutations.isNotEmpty() + } + override suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = withContext(Dispatchers.IO) { accountOperationGuard.serialize { if (activeAccountId() == accountId) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index d3d399671..95bbc3ae5 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -180,6 +180,54 @@ class DesktopAccountCredentialPersistenceTest { assertEquals(listOf(first.accountRecord()), restarted.listAccounts()) } + @Test + fun failedCredentialDeletionKeepsTheAccountRegisteredForRetry() = withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(first) + persistence.saveSession(second) + secrets.failClears = true + + assertFailsWith { + persistence.removeAccount(second.accountId) + } + + assertEquals(second.accountId, persistence.activeAccountId()) + assertEquals(setOf(first.accountRecord(), second.accountRecord()), persistence.listAccounts().toSet()) + assertNotNull(secrets.load(desktopAccountSecretReference(second.accountId))) + + secrets.failClears = false + assertTrue(persistence.removeAccount(second.accountId)) + assertNull(persistence(preferences, secrets).activeAccountId()) + assertNull(secrets.load(desktopAccountSecretReference(second.accountId))) + } + + @Test + fun failedRegistryFlushAfterCredentialDeletionKeepsADeletionRetryPath() = + withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + var flushAttempts = 0 + val persistence = persistence(preferences, secrets) { + flushAttempts += 1 + if (flushAttempts == 3) error("synthetic removal flush failure") + preferences.flush() + } + persistence.saveSession(first) + persistence.saveSession(second) + + assertFailsWith { + persistence.removeAccount(second.accountId) + } + + assertEquals(second.accountId, persistence.activeAccountId()) + assertEquals(setOf(first.accountRecord(), second.accountRecord()), persistence.listAccounts().toSet()) + assertNull(secrets.load(desktopAccountSecretReference(second.accountId))) + assertTrue(persistence.removeAccount(second.accountId)) + assertNull(persistence(preferences, secrets).activeAccountId()) + } + @Test fun oversizedRegistryFailsBeforeCredentialOrMetadataWrites() = withStore { preferences, secrets -> val session = NextcloudSession( @@ -274,6 +322,7 @@ class DesktopAccountCredentialPersistenceTest { private class MemorySecretStore : DesktopSecretStore { private val values = mutableMapOf() var failSaves = false + var failClears = false override fun load(reference: DesktopSecretReference): ByteArray? = values[reference.targetName]?.copyOf() @@ -283,6 +332,7 @@ class DesktopAccountCredentialPersistenceTest { } override fun clear(reference: DesktopSecretReference) { + if (failClears) error("synthetic secret deletion failure") values.remove(reference.targetName) } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 8cf27de5d..36d76eafd 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -2,13 +2,46 @@ package dev.obiente.nextcloudnative.app import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield class DesktopAccountOperationGuardTest { + @Test + fun differentAccountSaveRequiresTheSelectionTransition() { + val first = NextcloudSession("https://first.example.test", "alice", "one") + val second = NextcloudSession("https://second.example.test", "bob", "two") + + assertFalse(desktopSessionSaveSwitchesAccount(null, first.accountId)) + assertFalse(desktopSessionSaveSwitchesAccount(first.accountId, first.accountId)) + assertTrue(desktopSessionSaveSwitchesAccount(first.accountId, second.accountId)) + } + + @Test + fun blockedAccountSaveRecordsTheSelectionDiagnosticBeforeFailing() { + val diagnostics = mutableListOf() + + assertFailsWith { + requireDesktopSessionSaveAllowed(allowed = false, recordBlocked = diagnostics::add) + } + + assertEquals(listOf("ACCOUNT_SELECTION_ACTIVE_RESOURCES"), diagnostics.map { it.code }) + } + + @Test + fun retainedSelectionReopensTheDesktopSessionOnlyAfterSuccess() { + var reopenCount = 0 + val session = NextcloudSession("https://first.example.test", "alice", "one") + + assertNull(reopenDesktopSessionAfterSelection(null) { reopenCount += 1 }) + assertEquals(session, reopenDesktopSessionAfterSelection(session) { reopenCount += 1 }) + assertEquals(1, reopenCount) + } @Test fun removalCannotPassAConcurrentSelection() = runBlocking { val guard = DesktopAccountOperationGuard() From d62cea8f5445ed887119f044b4880ae48f6d1819 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 3 Sep 2026 22:50:55 +0200 Subject: [PATCH 005/119] fix(android): defer retained-account uploads --- .../AndroidDurableMultipartUploads.kt | 34 ++++++++++++++++++- ...AndroidDurableMultipartUploadPolicyTest.kt | 27 +++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index d53c82dec..68d7ab41b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -151,8 +151,25 @@ internal class DeckAttachmentUploadWorker( } if (initial.state != DurableUploadState.Queued) return@withContext Result.success() - val session = AndroidNextcloudServices(applicationContext).loadSession() + val accountServices = AndroidNextcloudServices(applicationContext) + val session = accountServices.loadSession() if (session == null || NextcloudDocumentIds.accountKey(session) != initial.accountId) { + val retainedSession = resolveStoredAndroidAccountSession( + accountIdentity = initial.accountId, + listAccounts = accountServices::listAccounts, + loadSession = { accountId -> accountServices.loadSession(accountId) }, + ) + if (durableUploadAccountMismatchOutcome(initial.accountId, retainedSession) == + DurableUploadAccountMismatchOutcome.RetryRetainedAccount + ) { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-deferred", + accountId = initial.accountId, + jobId = jobId, + ) + return@withContext Result.retry() + } store.transition( jobId, expected = DurableUploadState.Queued, @@ -284,6 +301,21 @@ internal class DeckAttachmentUploadWorker( } } +internal enum class DurableUploadAccountMismatchOutcome { + RetryRetainedAccount, + AccountUnavailable, +} + +internal fun durableUploadAccountMismatchOutcome( + expectedAccountId: String, + retainedSession: NextcloudSession?, +): DurableUploadAccountMismatchOutcome = + if (retainedSession != null && NextcloudDocumentIds.accountKey(retainedSession) == expectedAccountId) { + DurableUploadAccountMismatchOutcome.RetryRetainedAccount + } else { + DurableUploadAccountMismatchOutcome.AccountUnavailable + } + internal data class AndroidDurableMultipartUploadJob( val id: String, val accountId: String, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 8960cc28a..3def10fb3 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -4,6 +4,7 @@ import dev.obiente.nextcloudnative.app.DurableUploadScope import dev.obiente.nextcloudnative.app.DurableUploadState import dev.obiente.nextcloudnative.app.NextcloudApiMethod import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.afterProcessRecovery import dev.obiente.nextcloudnative.app.localUploadFile import java.io.IOException @@ -268,6 +269,32 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals(DurableUploadState.OutcomeUnknown, durableUploadStateForHttpResponse(500)) } + @Test + fun `retained background account retries instead of becoming unavailable`() { + val retainedSession = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "alice", + appPassword = "fixture-password", + ) + val accountId = NextcloudDocumentIds.accountKey(retainedSession) + + assertEquals( + DurableUploadAccountMismatchOutcome.RetryRetainedAccount, + durableUploadAccountMismatchOutcome(accountId, retainedSession), + ) + assertEquals( + DurableUploadAccountMismatchOutcome.AccountUnavailable, + durableUploadAccountMismatchOutcome(accountId, null), + ) + assertEquals( + DurableUploadAccountMismatchOutcome.AccountUnavailable, + durableUploadAccountMismatchOutcome( + accountId, + retainedSession.copy(loginName = "another-account"), + ), + ) + } + private fun fixtureJob( index: Int, account: String, From 5f17d3f085c812888a91dc96b11a47ce3f9a337f Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 3 Sep 2026 23:06:15 +0200 Subject: [PATCH 006/119] fix(accounts): harden credential recovery races --- .../AndroidAccountCredentialController.kt | 119 +++++++++++++----- .../NextcloudFileSyncWorker.kt | 5 +- .../AndroidFileSyncEngineInvariantTest.kt | 7 ++ .../AndroidPersistedSessionTest.kt | 52 ++++++++ .../DesktopAccountCredentialPersistence.kt | 4 +- .../app/DesktopAccountOperationGuard.kt | 7 ++ .../app/DesktopFileVersionDav.kt | 81 ++++++++++++ .../app/DesktopNextcloudServices.kt | 101 +++------------ ...DesktopAccountCredentialPersistenceTest.kt | 10 +- .../app/DesktopAccountOperationGuardTest.kt | 36 ++++++ 10 files changed, 307 insertions(+), 115 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileVersionDav.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 267800417..cafb9d75b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -27,7 +27,7 @@ internal class AndroidAccountCredentialController( private val mutationMutex = Mutex() fun loadSession(): NextcloudSession? = ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.restorePersistedSession( - load = { loadState()?.activeSession }, + load = { (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state?.activeSession }, accountIdOf = NextcloudDocumentIds::accountKey, publishAccount = { session, accountIdentity -> session?.let(registerSessionPrivateValues) @@ -35,17 +35,31 @@ internal class AndroidAccountCredentialController( }, ) - fun listAccounts(): List = loadState()?.registry?.accounts.orEmpty() + fun listAccounts(): List = + (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state?.registry?.accounts.orEmpty() - fun activeAccountId(): NextcloudAccountId? = loadState()?.registry?.activeAccountId + fun activeAccountId(): NextcloudAccountId? = + (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state?.registry?.activeAccountId fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = - loadState()?.sessions?.get(accountId)?.also(registerSessionPrivateValues) + (readStore() as? AndroidAccountCredentialStoreRead.Available) + ?.state + ?.sessions + ?.get(accountId) + ?.also(registerSessionPrivateValues) suspend fun saveSession(session: NextcloudSession) = mutationMutex.withLock { registerSessionPrivateValues(session) - val current = requireValidState() - replaceActiveState(current.upsertAndSelect(session), current.activeSession) + when (val read = readStore()) { + is AndroidAccountCredentialStoreRead.Available -> + replaceActiveState(read.state.upsertAndSelect(session), read.state.activeSession) + is AndroidAccountCredentialStoreRead.Invalid -> + replaceActiveState( + replacement = AndroidAccountCredentialState.Empty.upsertAndSelect(session), + previousSession = null, + suspectEncrypted = read.encrypted, + ) + } } suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = mutationMutex.withLock { @@ -69,7 +83,10 @@ internal class AndroidAccountCredentialController( } suspend fun clearSession() = mutationMutex.withLock { - clearSession(requireValidState()) + when (val read = readStore()) { + is AndroidAccountCredentialStoreRead.Available -> clearSession(read.state) + is AndroidAccountCredentialStoreRead.Invalid -> clearInvalidStore(read.encrypted) + } } private suspend fun clearSession(current: AndroidAccountCredentialState) { @@ -78,18 +95,38 @@ internal class AndroidAccountCredentialController( val encodedReplacement = replacement.takeUnless { state -> state.registry.accounts.isEmpty() && state.sessions.isEmpty() }?.let(::encryptState) + clearPersistedSession(encodedReplacement) + clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(activeSession)) + notifyDocumentRootsChanged() + } + + private suspend fun clearInvalidStore(suspectEncrypted: String) { + clearPersistedSession(encodedReplacement = null, suspectEncrypted = suspectEncrypted) + notifyDocumentRootsChanged() + } + + private suspend fun clearPersistedSession( + encodedReplacement: String?, + suspectEncrypted: String? = null, + ) { withContext(Dispatchers.IO) { AndroidExternalFileHandoffRegistry.clear() } val scheduler = AndroidFileSyncScheduler(appContext) withContext(Dispatchers.IO) { ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.clearSession( persist = { - val editor = preferences.edit().apply { - if (encodedReplacement == null) { - remove(KEY_SESSION) - } else { - putString(KEY_SESSION, encodedReplacement) + val editor = if (suspectEncrypted == null) { + preferences.edit().apply { + if (encodedReplacement == null) remove(KEY_SESSION) + else putString(KEY_SESSION, encodedReplacement) + remove(KEY_TEST_READ_ONLY) } - remove(KEY_TEST_READ_ONLY) + } else { + prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor = preferences.edit(), + suspectEncrypted = suspectEncrypted, + replacementEncrypted = encodedReplacement, + hasExistingQuarantine = preferences.contains(KEY_QUARANTINED_SESSION), + ) } commitPreferences(editor) }, @@ -97,13 +134,12 @@ internal class AndroidAccountCredentialController( clearPublishedAccount = { publishAccountIdentity(null) }, ) } - clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(activeSession)) - notifyDocumentRootsChanged() } private suspend fun replaceActiveState( replacement: AndroidAccountCredentialState, previousSession: NextcloudSession?, + suspectEncrypted: String? = null, ) { val session = requireNotNull(replacement.activeSession) val encrypted = encryptState(replacement) @@ -113,11 +149,19 @@ internal class AndroidAccountCredentialController( ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.replaceSession( replacementAccountId = NextcloudDocumentIds.accountKey(session), persist = { - commitPreferences( + val editor = if (suspectEncrypted == null) { preferences.edit() .putString(KEY_SESSION, encrypted) - .remove(KEY_TEST_READ_ONLY), - ) + .remove(KEY_TEST_READ_ONLY) + } else { + prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor = preferences.edit(), + suspectEncrypted = suspectEncrypted, + replacementEncrypted = encrypted, + hasExistingQuarantine = preferences.contains(KEY_QUARANTINED_SESSION), + ) + } + commitPreferences(editor) }, cancelAll = scheduler::cancelAll, publishAccount = publishAccountIdentity, @@ -130,12 +174,14 @@ internal class AndroidAccountCredentialController( notifyDocumentRootsChanged() } - private fun requireValidState(): AndroidAccountCredentialState = requireNotNull(loadState()) { - "The account credential store is invalid." + private fun requireValidState(): AndroidAccountCredentialState = when (val read = readStore()) { + is AndroidAccountCredentialStoreRead.Available -> read.state + is AndroidAccountCredentialStoreRead.Invalid -> error("The account credential store is invalid.") } - private fun loadState(): AndroidAccountCredentialState? { - val encrypted = preferences.getString(KEY_SESSION, null) ?: return AndroidAccountCredentialState.Empty + private fun readStore(): AndroidAccountCredentialStoreRead { + val encrypted = preferences.getString(KEY_SESSION, null) + ?: return AndroidAccountCredentialStoreRead.Available(AndroidAccountCredentialState.Empty) val encoded = try { sessionCipher.decrypt(encrypted) } catch (_: Exception) { @@ -143,9 +189,9 @@ internal class AndroidAccountCredentialController( code = "ACCOUNT_CREDENTIAL_STORE_READ_FAILED", operation = "account-credentials.restore", ) - return null + return AndroidAccountCredentialStoreRead.Invalid(encrypted) } - return restoreAndroidAccountCredentialState( + val state = restoreAndroidAccountCredentialState( encoded = encoded, persistMigrated = { migrated -> commitPreferences( @@ -154,6 +200,8 @@ internal class AndroidAccountCredentialController( }, recordDiagnostic = recordDiagnostic, ) + return state?.let { AndroidAccountCredentialStoreRead.Available(it) } + ?: AndroidAccountCredentialStoreRead.Invalid(encrypted) } private suspend fun persistState(state: AndroidAccountCredentialState) = withContext(Dispatchers.IO) { @@ -194,10 +242,22 @@ internal class AndroidAccountCredentialController( ) } - private companion object { - const val KEY_SESSION = "encrypted_session" - const val KEY_TEST_READ_ONLY = "emulator_test_read_only" - } +} + +internal sealed interface AndroidAccountCredentialStoreRead { + data class Available(val state: AndroidAccountCredentialState) : AndroidAccountCredentialStoreRead + data class Invalid(val encrypted: String) : AndroidAccountCredentialStoreRead +} + +internal fun prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor: SharedPreferences.Editor, + suspectEncrypted: String, + replacementEncrypted: String?, + hasExistingQuarantine: Boolean, +): SharedPreferences.Editor = editor.apply { + if (!hasExistingQuarantine) putString(KEY_QUARANTINED_SESSION, suspectEncrypted) + if (replacementEncrypted == null) remove(KEY_SESSION) else putString(KEY_SESSION, replacementEncrypted) + remove(KEY_TEST_READ_ONLY) } internal fun requireCommittedAndroidAccountCredentialEdit(editor: SharedPreferences.Editor) { @@ -218,3 +278,6 @@ internal fun resolveStoredAndroidAccountSession( NextcloudDocumentIds.accountKey(session) == accountIdentity } } + +private const val KEY_SESSION = "encrypted_session" +private const val KEY_QUARANTINED_SESSION = "encrypted_session_quarantine" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt index 35bc0d6c3..a78507bb1 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt @@ -187,7 +187,7 @@ internal class AndroidFileSyncScheduleRestorationWorker( onSuccess = { Result.success() }, onFailure = { failure -> rethrowAndroidFileSyncCancellation(failure) - Result.retry() + scheduleRestorationFailureDisposition(runAttemptCount).toWorkerResult() }, ) } @@ -202,6 +202,9 @@ internal fun isAndroidFileSyncScheduleRestorationCurrent( session: NextcloudSession, ): Boolean = NextcloudDocumentIds.accountKey(session) == expectedAccountId +internal fun scheduleRestorationFailureDisposition(runAttemptCount: Int): BackgroundSyncWorkerDisposition = + backgroundSyncFailureDisposition(runAttemptCount) + internal fun syncConflictNotificationDetail(conflictCount: Int): String { require(conflictCount > 0) return "$conflictCount sync conflict" + diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index 2215705ca..1a5ee4c3f 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -78,6 +78,13 @@ class AndroidFileSyncEngineInvariantTest { ) } + @Test + fun scheduleRestorationStopsImmediateRetriesAfterTheBoundedBudget() { + assertEquals(BackgroundSyncWorkerDisposition.Retry, scheduleRestorationFailureDisposition(0)) + assertEquals(BackgroundSyncWorkerDisposition.Retry, scheduleRestorationFailureDisposition(1)) + assertEquals(BackgroundSyncWorkerDisposition.WaitForNextPeriod, scheduleRestorationFailureDisposition(2)) + } + @Test fun largeFileDirectoryReplacementKeepsTheDirectoryUntilProtectedPublication() { val directory = RemoteSyncEntry("archive.bin", SyncEntryKind.Directory, "directory-etag") diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index b4eed1b70..dfcd5f91e 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -17,6 +17,35 @@ import org.json.JSONObject import java.lang.reflect.Proxy class AndroidPersistedSessionTest { + @Test + fun invalidCredentialStoreCanBeQuarantinedForLoginOrResetRecovery() { + val replacementWrites = linkedMapOf() + val replacementRemovals = linkedSetOf() + prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor = recoveryRecordingEditor(replacementWrites, replacementRemovals), + suspectEncrypted = "suspect-encrypted-store", + replacementEncrypted = "new-encrypted-session", + hasExistingQuarantine = false, + ) + + assertEquals("suspect-encrypted-store", replacementWrites["encrypted_session_quarantine"]) + assertEquals("new-encrypted-session", replacementWrites["encrypted_session"]) + assertTrue("emulator_test_read_only" in replacementRemovals) + + val resetWrites = linkedMapOf() + val resetRemovals = linkedSetOf() + prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor = recoveryRecordingEditor(resetWrites, resetRemovals), + suspectEncrypted = "newer-suspect-store", + replacementEncrypted = null, + hasExistingQuarantine = true, + ) + + assertFalse("encrypted_session_quarantine" in resetWrites) + assertTrue("encrypted_session" in resetRemovals) + assertTrue("emulator_test_read_only" in resetRemovals) + } + @Test fun retainedAccountSessionResolvesWithoutSelectingIt() { val first = firstSession() @@ -332,6 +361,29 @@ class AndroidPersistedSessionTest { } } as SharedPreferences.Editor + private fun recoveryRecordingEditor( + writes: MutableMap, + removals: MutableSet, + ): SharedPreferences.Editor = Proxy.newProxyInstance( + SharedPreferences.Editor::class.java.classLoader, + arrayOf(SharedPreferences.Editor::class.java), + ) { proxy, method, arguments -> + val callArguments = arguments.orEmpty() + when (method.name) { + "putString" -> { + writes[callArguments[0] as String] = callArguments[1] as String + proxy + } + "remove" -> { + removals += callArguments[0] as String + proxy + } + "commit" -> true + "apply" -> Unit + else -> proxy + } + } as SharedPreferences.Editor + private fun legacyPayload(session: NextcloudSession): String = JSONObject() .put("serverUrl", session.serverUrl) .put("loginName", session.loginName) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 4be98b361..a17d1ab33 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -20,8 +20,8 @@ internal class DesktopAccountCredentialPersistence( fun listAccounts(): List { val read = readRegistry() if (read.registry != null) return read.registry.accounts - restoreLegacySession(read.encoded != null) - return readRegistry().registry?.accounts.orEmpty() + val legacy = restoreLegacySession(read.encoded != null) + return readRegistry().registry?.accounts ?: legacy?.let { listOf(it.accountRecord()) }.orEmpty() } fun activeAccountId(): NextcloudAccountId? { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 9603ded0d..1a09d2243 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -14,6 +14,8 @@ internal class DesktopAccountOperationGuard { withSyncRunLock(action) } + suspend fun serializeResourceActivation(action: suspend () -> Result): Result = serialize(action) + suspend fun withSyncRunLock(action: suspend () -> Result): Result = syncRunMutex.withLock { action() } } @@ -29,6 +31,11 @@ internal fun desktopSessionSaveSwitchesAccount( savedAccountId: NextcloudAccountId, ): Boolean = activeAccountId != null && activeAccountId != savedAccountId +internal fun desktopResourceActivationMatchesActiveAccount( + activeAccountId: NextcloudAccountId?, + requestedAccountId: NextcloudAccountId, +): Boolean = activeAccountId == requestedAccountId + internal fun requireDesktopSessionSaveAllowed( allowed: Boolean, recordBlocked: (SupportDiagnosticEventDraft) -> Unit, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileVersionDav.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileVersionDav.kt new file mode 100644 index 000000000..b68646405 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileVersionDav.kt @@ -0,0 +1,81 @@ +package dev.obiente.nextcloudnative.app + +import java.io.ByteArrayInputStream +import javax.xml.parsers.DocumentBuilderFactory + +internal fun handleDesktopFileVersionRestoreStatus(status: Int, onRestored: () -> Unit) { + when (status) { + in 200..299 -> onRestored() + 403 -> error("You do not have permission to restore this file version.") + 404 -> error("This historical version no longer exists.") + 409 -> error("The server could not restore this version to the current file.") + else -> error("Restoring the file version failed (HTTP $status).") + } +} + +internal fun parseDesktopFileVersionDavRecords(xml: ByteArray): List { + val factory = DocumentBuilderFactory.newInstance().apply { + isNamespaceAware = true + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + setFeature("http://xml.org/sax/features/external-general-entities", false) + setFeature("http://xml.org/sax/features/external-parameter-entities", false) + } + val responses = factory.newDocumentBuilder().parse(ByteArrayInputStream(xml)) + .getElementsByTagNameNS(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "response") + return buildList { + for (index in 0 until responses.length) { + val response = responses.item(index) + val properties = response.successfulFileVersionPropertyRoot() ?: continue + add( + FileVersionDavRecord( + href = response.fileVersionFirstText(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "href").orEmpty(), + contentLength = properties.fileVersionFirstText( + FILE_VERSION_DESKTOP_DAV_NAMESPACE, + "getcontentlength", + ), + lastModified = properties.fileVersionFirstText( + FILE_VERSION_DESKTOP_DAV_NAMESPACE, + "getlastmodified", + ), + etag = properties.fileVersionFirstText(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "getetag"), + author = properties.fileVersionFirstText(FILE_VERSION_DESKTOP_NC_NAMESPACE, "version-author"), + label = properties.fileVersionFirstText(FILE_VERSION_DESKTOP_NC_NAMESPACE, "version-label"), + ), + ) + } + } +} + +private fun org.w3c.dom.Node.successfulFileVersionPropertyRoot(): org.w3c.dom.Node? { + val element = this as? org.w3c.dom.Element ?: return null + val propstats = element.getElementsByTagNameNS(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "propstat") + if (propstats.length > 0) { + for (index in 0 until propstats.length) { + val propstat = propstats.item(index) + val status = propstat.fileVersionFirstText(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "status").orEmpty() + if (status.isFileVersionDavSuccessStatus()) return propstat + } + return null + } + return if ( + element.getElementsByTagNameNS(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "status") + .item(0)?.textContent.orEmpty().isFileVersionDavSuccessStatus() + ) { + element + } else { + null + } +} + +private fun String.isFileVersionDavSuccessStatus(): Boolean = + trim().split(' ').any { token -> token.toIntOrNull()?.let { it in 200..299 } == true } + +private fun org.w3c.dom.Node.fileVersionFirstText(namespace: String, localName: String): String? = + (this as? org.w3c.dom.Element) + ?.getElementsByTagNameNS(namespace, localName) + ?.item(0) + ?.textContent + ?.takeIf(String::isNotBlank) + +private const val FILE_VERSION_DESKTOP_DAV_NAMESPACE = "DAV:" +private const val FILE_VERSION_DESKTOP_NC_NAMESPACE = "http://nextcloud.org/ns" diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 5ccc1523f..62f259c7f 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1942,8 +1942,22 @@ class DesktopNextcloudServices( session: NextcloudSession, userId: String, ): VirtualFileStorageActionResult = withContext(Dispatchers.IO) { + accountOperationGuard.serializeResourceActivation { + activateVirtualFileProviderForCurrentAccount(session, userId) + } + } + + private fun activateVirtualFileProviderForCurrentAccount( + session: NextcloudSession, + userId: String, + ): VirtualFileStorageActionResult { + if (!desktopResourceActivationMatchesActiveAccount(activeAccountId(), session.accountId)) { + return VirtualFileStorageActionResult.Rejected( + "The account changed before virtual file storage could be activated.", + ) + } if (!isLinuxDesktop() && !isWindowsDesktop()) { - return@withContext VirtualFileStorageActionResult.Unsupported( + return VirtualFileStorageActionResult.Unsupported( "This desktop build does not have a system virtual-file adapter for the current operating system.", ) } @@ -1962,7 +1976,7 @@ class DesktopNextcloudServices( windowsCloudFilesProvider != null && windowsCloudFilesIdentity == accountId && windowsCloudFilesFailure == null && windowsCloudFilesProvider?.runtimeRecoveryFailure() == null ) { - return@withContext VirtualFileStorageActionResult.Completed( + return VirtualFileStorageActionResult.Completed( "Windows Cloud Files are already connected at ${desktopWindowsCloudFilesRoot(accountId).absolutePath}.", ) } @@ -2088,13 +2102,13 @@ class DesktopNextcloudServices( ) throw failure } - return@withContext VirtualFileStorageActionResult.Completed( + return VirtualFileStorageActionResult.Completed( windowsCloudFilesRecoveryNotice ?: "Windows Cloud Files connected at ${desktopWindowsCloudFilesRoot(accountId).absolutePath}.", ) } if (linuxVirtualFileSystem != null && linuxVirtualFileMountIdentity == accountId) { - return@withContext VirtualFileStorageActionResult.Completed( + return VirtualFileStorageActionResult.Completed( "Virtual files are already mounted at ${desktopLinuxVirtualFileMountPoint(preferences, accountId).absolutePath}.", ) } @@ -2180,7 +2194,7 @@ class DesktopNextcloudServices( throw failure } } - VirtualFileStorageActionResult.Completed( + return VirtualFileStorageActionResult.Completed( "Virtual files mounted at ${desktopLinuxVirtualFileMountPoint(preferences, accountId).absolutePath}.", ) } @@ -6111,16 +6125,6 @@ internal fun advanceAffectedVirtualFolderGenerations( } } -internal fun handleDesktopFileVersionRestoreStatus(status: Int, onRestored: () -> Unit) { - when (status) { - in 200..299 -> onRestored() - 403 -> error("You do not have permission to restore this file version.") - 404 -> error("This historical version no longer exists.") - 409 -> error("The server could not restore this version to the current file.") - else -> error("Restoring the file version failed (HTTP $status).") - } -} - private data class VirtualFolderListingGeneration( val path: String, val directory: Boolean, @@ -6163,73 +6167,6 @@ internal fun publishDesktopLinuxFallbackMetadataBestEffort( } } -internal fun parseDesktopFileVersionDavRecords(xml: ByteArray): List { - val factory = DocumentBuilderFactory.newInstance().apply { - isNamespaceAware = true - setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) - setFeature("http://xml.org/sax/features/external-general-entities", false) - setFeature("http://xml.org/sax/features/external-parameter-entities", false) - } - val responses = factory.newDocumentBuilder().parse(ByteArrayInputStream(xml)) - .getElementsByTagNameNS(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "response") - return buildList { - for (index in 0 until responses.length) { - val response = responses.item(index) - val properties = response.successfulFileVersionPropertyRoot() ?: continue - add( - FileVersionDavRecord( - href = response.fileVersionFirstText(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "href").orEmpty(), - contentLength = properties.fileVersionFirstText( - FILE_VERSION_DESKTOP_DAV_NAMESPACE, - "getcontentlength", - ), - lastModified = properties.fileVersionFirstText( - FILE_VERSION_DESKTOP_DAV_NAMESPACE, - "getlastmodified", - ), - etag = properties.fileVersionFirstText(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "getetag"), - author = properties.fileVersionFirstText(FILE_VERSION_DESKTOP_NC_NAMESPACE, "version-author"), - label = properties.fileVersionFirstText(FILE_VERSION_DESKTOP_NC_NAMESPACE, "version-label"), - ), - ) - } - } -} - -private fun org.w3c.dom.Node.successfulFileVersionPropertyRoot(): org.w3c.dom.Node? { - val element = this as? org.w3c.dom.Element ?: return null - val propstats = element.getElementsByTagNameNS(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "propstat") - if (propstats.length > 0) { - for (index in 0 until propstats.length) { - val propstat = propstats.item(index) - val status = propstat.fileVersionFirstText(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "status").orEmpty() - if (status.isFileVersionDavSuccessStatus()) return propstat - } - return null - } - return if ( - element.getElementsByTagNameNS(FILE_VERSION_DESKTOP_DAV_NAMESPACE, "status") - .item(0)?.textContent.orEmpty().isFileVersionDavSuccessStatus() - ) { - element - } else { - null - } -} - -private fun String.isFileVersionDavSuccessStatus(): Boolean = - trim().split(' ').any { token -> token.toIntOrNull()?.let { it in 200..299 } == true } - -private fun org.w3c.dom.Node.fileVersionFirstText(namespace: String, localName: String): String? = - (this as? org.w3c.dom.Element) - ?.getElementsByTagNameNS(namespace, localName) - ?.item(0) - ?.textContent - ?.takeIf(String::isNotBlank) - -private const val FILE_VERSION_DESKTOP_DAV_NAMESPACE = "DAV:" -private const val FILE_VERSION_DESKTOP_NC_NAMESPACE = "http://nextcloud.org/ns" - internal fun parseDesktopSystemTagsDavResponse(xml: ByteArray): List { val factory = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index 95bbc3ae5..baf3fd251 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -63,12 +63,18 @@ class DesktopAccountCredentialPersistenceTest { preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, futureRegistry) val diagnostics = mutableListOf() - val restored = persistence(preferences, secrets, diagnostics).loadActiveSession() + val persistence = persistence(preferences, secrets, diagnostics) + val restored = persistence.loadActiveSession() assertEquals(session, restored) + assertEquals(listOf(session.accountRecord()), persistence.listAccounts()) + assertEquals(session.accountId, persistence.activeAccountId()) assertEquals(futureRegistry, preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) - assertEquals(listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }) + assertEquals( + listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), + diagnostics.mapNotNull { it.code }.distinct(), + ) } @Test diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 36d76eafd..c7c6eac0e 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -12,6 +12,42 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield class DesktopAccountOperationGuardTest { + @Test + fun resourceActivationCannotPassAConcurrentAccountMutation() = runBlocking { + val guard = DesktopAccountOperationGuard() + val mutationEntered = CompletableDeferred() + val releaseMutation = CompletableDeferred() + var resourceActivated = false + + val mutation = async { + guard.serialize { + mutationEntered.complete(Unit) + releaseMutation.await() + } + } + mutationEntered.await() + val activation = async { + guard.serializeResourceActivation { resourceActivated = true } + } + yield() + + assertFalse(resourceActivated) + releaseMutation.complete(Unit) + mutation.await() + activation.await() + assertTrue(resourceActivated) + } + + @Test + fun resourceActivationRejectsAStaleAccountAfterWaitingForTheGuard() { + val first = NextcloudSession("https://first.example.test", "alice", "one") + val second = NextcloudSession("https://second.example.test", "bob", "two") + + assertTrue(desktopResourceActivationMatchesActiveAccount(first.accountId, first.accountId)) + assertFalse(desktopResourceActivationMatchesActiveAccount(second.accountId, first.accountId)) + assertFalse(desktopResourceActivationMatchesActiveAccount(null, first.accountId)) + } + @Test fun differentAccountSaveRequiresTheSelectionTransition() { val first = NextcloudSession("https://first.example.test", "alice", "one") From 83ea0d95e9dd7ab6b54d99f71ab925ac9ae948a8 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 3 Sep 2026 23:37:16 +0200 Subject: [PATCH 007/119] fix(accounts): coordinate background account work --- .../AndroidAccountCredentialController.kt | 77 ++++++++++++------- .../AndroidAccountOperationGuard.kt | 30 ++++++++ .../AndroidDurableMultipartUploads.kt | 63 ++++++++++++--- .../AndroidIncomingShareUploadWorker.kt | 69 +++++++++++------ .../AndroidNextcloudServices.kt | 1 + .../AndroidAccountOperationGuardTest.kt | 58 ++++++++++++++ ...AndroidDurableMultipartUploadPolicyTest.kt | 21 ++++- .../AndroidPersistedSessionTest.kt | 31 ++++++++ .../app/DesktopAccountOperationGuard.kt | 9 +++ .../app/DesktopNextcloudServices.kt | 27 ++++--- .../app/DesktopAccountOperationGuardTest.kt | 21 +++++ 11 files changed, 333 insertions(+), 74 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index cafb9d75b..c5ad67271 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -9,6 +9,7 @@ import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -22,9 +23,9 @@ internal class AndroidAccountCredentialController( private val publishAccountIdentity: (String?) -> Unit, private val clearPreviewAccount: (String) -> Unit, private val notifyDocumentRootsChanged: () -> Unit, + private val resumeQueuedUploads: suspend (String) -> Unit, ) { private val appContext = context.applicationContext - private val mutationMutex = Mutex() fun loadSession(): NextcloudSession? = ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.restorePersistedSession( load = { (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state?.activeSession }, @@ -48,7 +49,7 @@ internal class AndroidAccountCredentialController( ?.get(accountId) ?.also(registerSessionPrivateValues) - suspend fun saveSession(session: NextcloudSession) = mutationMutex.withLock { + suspend fun saveSession(session: NextcloudSession) = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { registerSessionPrivateValues(session) when (val read = readStore()) { is AndroidAccountCredentialStoreRead.Available -> @@ -62,29 +63,42 @@ internal class AndroidAccountCredentialController( } } - suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = mutationMutex.withLock { - val current = requireValidState() - val selected = current.select(accountId) ?: return@withLock null - val session = requireNotNull(selected.activeSession) - registerSessionPrivateValues(session) - replaceActiveState(selected, current.activeSession) - session - } + suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = + ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + val current = requireValidState() + val selected = current.select(accountId) ?: return@withLock null + val session = requireNotNull(selected.activeSession) + registerSessionPrivateValues(session) + replaceActiveState(selected, current.activeSession) + session + } - suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = mutationMutex.withLock { - val current = requireValidState() - if (accountId !in current.sessions) return@withLock false - if (current.registry.activeAccountId == accountId) { - clearSession(current) - } else { - persistState(current.remove(accountId)) + suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = + ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + val current = requireValidState() + val session = current.sessions[accountId] ?: return@withLock false + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(session)) { + if (current.registry.activeAccountId == accountId) { + clearSession(current) + } else { + persistState(current.remove(accountId)) + } + } + true } - true - } - suspend fun clearSession() = mutationMutex.withLock { + suspend fun clearSession() = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { when (val read = readStore()) { - is AndroidAccountCredentialStoreRead.Available -> clearSession(read.state) + is AndroidAccountCredentialStoreRead.Available -> { + val session = read.state.activeSession + if (session == null) { + clearSession(read.state) + } else { + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(session)) { + clearSession(read.state) + } + } + } is AndroidAccountCredentialStoreRead.Invalid -> clearInvalidStore(read.encrypted) } } @@ -171,6 +185,9 @@ internal class AndroidAccountCredentialController( if (previousSession != null && previousSession.accountId != session.accountId) { clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(previousSession)) } + withContext(NonCancellable) { + resumeQueuedUploads(NextcloudDocumentIds.accountKey(session)) + } notifyDocumentRootsChanged() } @@ -179,9 +196,9 @@ internal class AndroidAccountCredentialController( is AndroidAccountCredentialStoreRead.Invalid -> error("The account credential store is invalid.") } - private fun readStore(): AndroidAccountCredentialStoreRead { + private fun readStore(): AndroidAccountCredentialStoreRead = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { val encrypted = preferences.getString(KEY_SESSION, null) - ?: return AndroidAccountCredentialStoreRead.Available(AndroidAccountCredentialState.Empty) + ?: return@serialize AndroidAccountCredentialStoreRead.Available(AndroidAccountCredentialState.Empty) val encoded = try { sessionCipher.decrypt(encrypted) } catch (_: Exception) { @@ -189,7 +206,7 @@ internal class AndroidAccountCredentialController( code = "ACCOUNT_CREDENTIAL_STORE_READ_FAILED", operation = "account-credentials.restore", ) - return AndroidAccountCredentialStoreRead.Invalid(encrypted) + return@serialize AndroidAccountCredentialStoreRead.Invalid(encrypted) } val state = restoreAndroidAccountCredentialState( encoded = encoded, @@ -200,7 +217,7 @@ internal class AndroidAccountCredentialController( }, recordDiagnostic = recordDiagnostic, ) - return state?.let { AndroidAccountCredentialStoreRead.Available(it) } + state?.let { AndroidAccountCredentialStoreRead.Available(it) } ?: AndroidAccountCredentialStoreRead.Invalid(encrypted) } @@ -208,7 +225,7 @@ internal class AndroidAccountCredentialController( commitPreferences(preferences.edit().putString(KEY_SESSION, encryptState(state))) } - private fun commitPreferences(editor: SharedPreferences.Editor) { + private fun commitPreferences(editor: SharedPreferences.Editor) = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { try { requireCommittedAndroidAccountCredentialEdit(editor) } catch (failure: Exception) { @@ -249,6 +266,12 @@ internal sealed interface AndroidAccountCredentialStoreRead { data class Invalid(val encrypted: String) : AndroidAccountCredentialStoreRead } +internal class AndroidAccountCredentialStoreGuard { + private val monitor = Any() + + fun serialize(action: () -> Result): Result = synchronized(monitor, action) +} + internal fun prepareInvalidAndroidAccountCredentialRecoveryEdit( editor: SharedPreferences.Editor, suspectEncrypted: String, @@ -281,3 +304,5 @@ internal fun resolveStoredAndroidAccountSession( private const val KEY_SESSION = "encrypted_session" private const val KEY_QUARANTINED_SESSION = "encrypted_session_quarantine" +private val ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD = AndroidAccountCredentialStoreGuard() +private val ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX = Mutex() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt new file mode 100644 index 000000000..6b388a344 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -0,0 +1,30 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class AndroidAccountOperationGuard { + private val monitor = Any() + private val accountLeases = mutableMapOf() + + suspend fun withAccount(accountId: String, action: suspend () -> Result): Result { + val lease = synchronized(monitor) { + accountLeases.getOrPut(accountId) { AccountLease() }.also { it.references += 1 } + } + return try { + lease.mutex.withLock { action() } + } finally { + synchronized(monitor) { + lease.references -= 1 + if (lease.references == 0) accountLeases.remove(accountId, lease) + } + } + } + + private class AccountLease( + val mutex: Mutex = Mutex(), + var references: Int = 0, + ) +} + +internal val ANDROID_ACCOUNT_OPERATION_GUARD = AndroidAccountOperationGuard() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 68d7ab41b..86c37ff22 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -30,6 +30,7 @@ import dev.obiente.nextcloudnative.app.afterProcessRecovery import dev.obiente.nextcloudnative.app.localUploadFile import dev.obiente.nextcloudnative.app.toSupportDiagnosticExceptionDraft import java.util.UUID +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray @@ -90,6 +91,18 @@ internal class AndroidDurableMultipartUploads(context: Context) { .map(AndroidDurableMultipartUploadJob::status) .toList() + suspend fun resumeQueuedForAccount(accountId: String) { + queuedDurableUploadsForAccount(store.list(), accountId).forEach { job -> + try { + schedule(job, ExistingWorkPolicy.APPEND_OR_REPLACE).await() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // The queue stays authoritative; status refresh or a later activation can retry. + } + } + } + fun dismiss(session: NextcloudSession, scope: DurableUploadScope, uploadId: String): Boolean { val job = store.find(uploadId) ?: return false if ( @@ -104,10 +117,13 @@ internal class AndroidDurableMultipartUploads(context: Context) { return true } - private fun schedule(job: AndroidDurableMultipartUploadJob): Operation = + private fun schedule( + job: AndroidDurableMultipartUploadJob, + policy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP, + ): Operation = WorkManager.getInstance(appContext).enqueueUniqueWork( "deck-attachment-${job.id}", - ExistingWorkPolicy.KEEP, + policy, OneTimeWorkRequestBuilder() .setInputData(Data.Builder().putString(DeckAttachmentUploadWorker.KEY_JOB_ID, job.id).build()) .setConstraints( @@ -151,6 +167,24 @@ internal class DeckAttachmentUploadWorker( } if (initial.state != DurableUploadState.Queued) return@withContext Result.success() + return@withContext uploadQueuedJob(store, initial, picker, jobId) + } + + private suspend fun uploadQueuedJob( + store: AndroidDurableMultipartUploadStore, + initial: AndroidDurableMultipartUploadJob, + picker: AndroidLocalUploadPicker, + jobId: String, + ): Result = ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(initial.accountId) { + performQueuedUpload(store, initial, picker, jobId) + } + + private suspend fun performQueuedUpload( + store: AndroidDurableMultipartUploadStore, + initial: AndroidDurableMultipartUploadJob, + picker: AndroidLocalUploadPicker, + jobId: String, + ): Result { val accountServices = AndroidNextcloudServices(applicationContext) val session = accountServices.loadSession() if (session == null || NextcloudDocumentIds.accountKey(session) != initial.accountId) { @@ -160,7 +194,7 @@ internal class DeckAttachmentUploadWorker( loadSession = { accountId -> accountServices.loadSession(accountId) }, ) if (durableUploadAccountMismatchOutcome(initial.accountId, retainedSession) == - DurableUploadAccountMismatchOutcome.RetryRetainedAccount + DurableUploadAccountMismatchOutcome.DeferRetainedAccount ) { recordUploadDiagnostic( severity = SupportDiagnosticSeverity.Warning, @@ -168,13 +202,13 @@ internal class DeckAttachmentUploadWorker( accountId = initial.accountId, jobId = jobId, ) - return@withContext Result.retry() + return Result.success() } store.transition( jobId, expected = DurableUploadState.Queued, target = DurableUploadState.Failed, - message = "The account used for this upload is no longer active.", + message = "The account used for this upload is no longer available.", ) picker.release(initial.request.file) recordUploadDiagnostic( @@ -183,7 +217,7 @@ internal class DeckAttachmentUploadWorker( accountId = initial.accountId, jobId = jobId, ) - return@withContext Result.failure() + return Result.failure() } val capabilityReady = runCatching { picker.requirePersisted(initial.request.file) @@ -203,14 +237,14 @@ internal class DeckAttachmentUploadWorker( accountId = initial.accountId, jobId = jobId, ) - return@withContext Result.failure() + return Result.failure() } val started = store.transition( jobId, expected = DurableUploadState.Queued, target = DurableUploadState.Uploading, message = null, - ) ?: return@withContext Result.success() + ) ?: return Result.success() val services = AndroidNextcloudServices(applicationContext, localUploadPicker = picker) val outcome = runCatching { services.executeNextcloudMultipartUpload(session, started.request) @@ -269,7 +303,7 @@ internal class DeckAttachmentUploadWorker( ) picker.release(started.request.file) } - Result.success() + return Result.success() } private fun recordUploadDiagnostic( @@ -302,7 +336,7 @@ internal class DeckAttachmentUploadWorker( } internal enum class DurableUploadAccountMismatchOutcome { - RetryRetainedAccount, + DeferRetainedAccount, AccountUnavailable, } @@ -311,11 +345,18 @@ internal fun durableUploadAccountMismatchOutcome( retainedSession: NextcloudSession?, ): DurableUploadAccountMismatchOutcome = if (retainedSession != null && NextcloudDocumentIds.accountKey(retainedSession) == expectedAccountId) { - DurableUploadAccountMismatchOutcome.RetryRetainedAccount + DurableUploadAccountMismatchOutcome.DeferRetainedAccount } else { DurableUploadAccountMismatchOutcome.AccountUnavailable } +internal fun queuedDurableUploadsForAccount( + jobs: List, + accountId: String, +): List = jobs.filter { job -> + job.accountId == accountId && job.state == DurableUploadState.Queued +} + internal data class AndroidDurableMultipartUploadJob( val id: String, val accountId: String, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt index f616e6f23..5a79a32c0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt @@ -80,30 +80,39 @@ internal class AndroidIncomingShareUploadWorker( scheduleIncomingShareRetry(applicationContext, request) return@withContext Result.success() } - val services = AndroidNextcloudServices(applicationContext) - val session = request.accountId?.let { accountIdentity -> - resolveStoredAndroidAccountSession( - accountIdentity = accountIdentity, - listAccounts = services::listAccounts, - loadSession = { accountId -> services.loadSession(accountId) }, - ) + return@withContext uploadQueuedRequest(store, requestId, request) + } + + private suspend fun uploadQueuedRequest( + store: AndroidIncomingShareStore, + requestId: String, + request: AndroidIncomingShareRequest, + ): Result { + val accountIdentity = request.accountId ?: return failUnavailableAccount(store, requestId) + return ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + performQueuedUpload(store, requestId, request, accountIdentity) } + } + + private suspend fun performQueuedUpload( + store: AndroidIncomingShareStore, + requestId: String, + initialRequest: AndroidIncomingShareRequest, + accountIdentity: String, + ): Result { + var request = initialRequest + val services = AndroidNextcloudServices(applicationContext) + val session = resolveStoredAndroidAccountSession( + accountIdentity = accountIdentity, + listAccounts = services::listAccounts, + loadSession = { accountId -> services.loadSession(accountId) }, + ) if (session == null) { - val failed = store.transition( - id = requestId, - expected = setOf(AndroidIncomingShareState.Queued), - target = AndroidIncomingShareState.Failed, - message = "The upload account is not active.", - ) - failed?.let { - publishTerminalNotification(it) - scheduleIncomingShareCleanup(applicationContext, it.id) - } - return@withContext Result.failure() + return failUnavailableAccount(store, requestId) } AndroidNotificationCoordinator(applicationContext).ensureChannels() var foregroundPromotionAvailable = setForegroundIfAvailable(request) - request = store.beginUpload(requestId) ?: return@withContext Result.success() + request = store.beginUpload(requestId) ?: return Result.success() val remote = AndroidFileSyncRemoteTree( session = session, userId = requireNotNull(request.userId), @@ -120,7 +129,7 @@ internal class AndroidIncomingShareUploadWorker( ) val requestCancellation = CoroutineDocumentRequestCancellation(currentCoroutineContext().job) var mutationInFlight = false - try { + return try { val destinationSnapshot = remote.rootChildNames() val occupiedNames = destinationSnapshot.names.toMutableSet().apply { addAll(request.uploadedNames) @@ -194,12 +203,12 @@ internal class AndroidIncomingShareUploadWorker( "Nextcloud asked this upload to wait before retrying." }, retryNotBeforeEpochMillis = retryNotBefore, - ) ?: return@withContext Result.success() + ) ?: return Result.success() if (retryNotBefore != null) { scheduleIncomingShareRetry(applicationContext, queued) - return@withContext Result.success() + return Result.success() } - return@withContext Result.retry() + return Result.retry() } // A transport failure after a conditional PUT starts cannot prove whether the server // committed it. Do not replay automatically and risk a duplicate. @@ -229,6 +238,20 @@ internal class AndroidIncomingShareUploadWorker( } } + private fun failUnavailableAccount(store: AndroidIncomingShareStore, requestId: String): Result { + val failed = store.transition( + id = requestId, + expected = setOf(AndroidIncomingShareState.Queued), + target = AndroidIncomingShareState.Failed, + message = "The upload account is no longer available.", + ) + failed?.let { + publishTerminalNotification(it) + scheduleIncomingShareCleanup(applicationContext, it.id) + } + return Result.failure() + } + private fun ensureNotCanceled(requestId: String, store: AndroidIncomingShareStore) { if (store.load(requestId)?.state == AndroidIncomingShareState.Canceled) { throw CancellationException("Incoming share upload canceled") diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 22a2fbcd7..a6bf36af2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -493,6 +493,7 @@ internal class AndroidNextcloudServices( }, clearPreviewAccount = nativeMediaPreviewCache::clearAccount, notifyDocumentRootsChanged = ::notifyDocumentsRootsChanged, + resumeQueuedUploads = durableMultipartUploads::resumeQueuedForAccount, ) init { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt new file mode 100644 index 000000000..ee03bd366 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -0,0 +1,58 @@ +package dev.obiente.nextcloudnative + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield + +class AndroidAccountOperationGuardTest { + @Test + fun sameAccountRemovalWaitsForTheUploadLease() = runBlocking { + val guard = AndroidAccountOperationGuard() + val uploadEntered = CompletableDeferred() + val releaseUpload = CompletableDeferred() + var removalEntered = false + + val upload = async { + guard.withAccount("account-a") { + uploadEntered.complete(Unit) + releaseUpload.await() + } + } + uploadEntered.await() + val removal = async { + guard.withAccount("account-a") { removalEntered = true } + } + yield() + + assertFalse(removalEntered) + releaseUpload.complete(Unit) + upload.await() + removal.await() + assertTrue(removalEntered) + } + + @Test + fun differentAccountsKeepIndependentOperationLeases() = runBlocking { + val guard = AndroidAccountOperationGuard() + val uploadEntered = CompletableDeferred() + val releaseUpload = CompletableDeferred() + var otherAccountEntered = false + + val upload = async { + guard.withAccount("account-a") { + uploadEntered.complete(Unit) + releaseUpload.await() + } + } + uploadEntered.await() + guard.withAccount("account-b") { otherAccountEntered = true } + + assertTrue(otherAccountEntered) + releaseUpload.complete(Unit) + upload.await() + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 3def10fb3..1d433fe7c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -270,7 +270,7 @@ class AndroidDurableMultipartUploadPolicyTest { } @Test - fun `retained background account retries instead of becoming unavailable`() { + fun `retained background account is deferred instead of becoming unavailable`() { val retainedSession = NextcloudSession( serverUrl = "https://cloud.example.test/nextcloud", loginName = "alice", @@ -279,7 +279,7 @@ class AndroidDurableMultipartUploadPolicyTest { val accountId = NextcloudDocumentIds.accountKey(retainedSession) assertEquals( - DurableUploadAccountMismatchOutcome.RetryRetainedAccount, + DurableUploadAccountMismatchOutcome.DeferRetainedAccount, durableUploadAccountMismatchOutcome(accountId, retainedSession), ) assertEquals( @@ -295,6 +295,23 @@ class AndroidDurableMultipartUploadPolicyTest { ) } + @Test + fun `account activation resumes only its queued uploads`() { + val queuedForA = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val queuedForB = fixtureJob(index = 2, account = ACCOUNT_B, cardId = 43) + val completedForA = fixtureJob( + index = 3, + account = ACCOUNT_A, + cardId = 44, + state = DurableUploadState.Completed, + ) + + assertEquals( + listOf(queuedForA), + queuedDurableUploadsForAccount(listOf(queuedForA, queuedForB, completedForA), ACCOUNT_A), + ) + } + private fun fixtureJob( index: Int, account: String, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index dfcd5f91e..742f0453f 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -15,8 +15,39 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import org.json.JSONObject import java.lang.reflect.Proxy +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread class AndroidPersistedSessionTest { + @Test + fun credentialStoreGuardKeepsMigrationAndMutationWritesOrdered() { + val guard = AndroidAccountCredentialStoreGuard() + val migrationEntered = CountDownLatch(1) + val releaseMigration = CountDownLatch(1) + val mutationAttempted = CountDownLatch(1) + val mutationEntered = CountDownLatch(1) + + val migration = thread { + guard.serialize { + migrationEntered.countDown() + check(releaseMigration.await(5, TimeUnit.SECONDS)) + } + } + check(migrationEntered.await(5, TimeUnit.SECONDS)) + val mutation = thread { + mutationAttempted.countDown() + guard.serialize { mutationEntered.countDown() } + } + check(mutationAttempted.await(5, TimeUnit.SECONDS)) + + assertFalse(mutationEntered.await(100, TimeUnit.MILLISECONDS)) + releaseMigration.countDown() + migration.join() + mutation.join() + assertEquals(0L, mutationEntered.count) + } + @Test fun invalidCredentialStoreCanBeQuarantinedForLoginOrResetRecovery() { val replacementWrites = linkedMapOf() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 1a09d2243..94082770f 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -49,3 +49,12 @@ internal inline fun reopenDesktopSessionAfterSelection( selected: Session?, reopen: () -> Unit, ): Session? = selected.also { if (it != null) reopen() } + +internal suspend inline fun restartDesktopSyncAfterSelection( + select: () -> Session?, + restart: () -> Unit, +): Session? = try { + select() +} finally { + restart() +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 62f259c7f..f4a533557 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3725,20 +3725,23 @@ class DesktopNextcloudServices( val syncJob = synchronized(this@DesktopNextcloudServices) { backgroundFileSyncJob.also { backgroundFileSyncJob = null } } - syncJob?.cancel() - syncJob?.join() - val selected = reopenDesktopSessionAfterSelection( - selected = accountOperationGuard.withSyncRunLock { - sessionPublicationGuard.serialize { - accountCredentials.selectAccount(accountId)?.also { session -> - accountSessionPublication.publish(session) - } - } + restartDesktopSyncAfterSelection( + select = { + syncJob?.cancel() + syncJob?.join() + reopenDesktopSessionAfterSelection( + selected = accountOperationGuard.withSyncRunLock { + sessionPublicationGuard.serialize { + accountCredentials.selectAccount(accountId)?.also { session -> + accountSessionPublication.publish(session) + } + } + }, + reopen = { synchronized(fileRangeSessionLock) { sessionClearing = false } }, + ) }, - reopen = { synchronized(fileRangeSessionLock) { sessionClearing = false } }, + restart = ::startDesktopSyncLifecycle, ) - startDesktopSyncLifecycle() - selected } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index c7c6eac0e..2b3299daf 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -7,11 +7,32 @@ import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield class DesktopAccountOperationGuardTest { + @Test + fun abortedAccountSelectionAlwaysRestartsDesktopSync() = runBlocking { + var restartCount = 0 + + assertFailsWith { + restartDesktopSyncAfterSelection( + select = { throw CancellationException("selection cancelled") }, + restart = { restartCount += 1 }, + ) + } + assertFailsWith { + restartDesktopSyncAfterSelection( + select = { error("credential persistence failed") }, + restart = { restartCount += 1 }, + ) + } + + assertEquals(2, restartCount) + } + @Test fun resourceActivationCannotPassAConcurrentAccountMutation() = runBlocking { val guard = DesktopAccountOperationGuard() From 04c2a0a20da35a5b16781f29a64fdbbe82c26c67 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 00:50:12 +0200 Subject: [PATCH 008/119] fix(accounts): close credential lifecycle races --- .../AndroidAccountCredentialController.kt | 311 ++++++++++++++++-- .../AndroidAccountOperationGuard.kt | 5 + .../AndroidDurableMultipartUploads.kt | 11 +- .../AndroidDurableUploadAccountCleanup.kt | 19 ++ .../AndroidFileOfflineRepository.kt | 15 +- .../AndroidNextcloudServices.kt | 63 +++- .../AndroidPersistedSession.kt | 25 +- .../nextcloudnative/NextcloudDocumentIds.kt | 18 +- .../NextcloudFileSyncWorker.kt | 18 +- .../AndroidAccountOperationGuardTest.kt | 53 +++ ...AndroidDurableMultipartUploadPolicyTest.kt | 13 + .../AndroidPersistedSessionTest.kt | 168 ++++++++++ .../NextcloudDocumentIdsTest.kt | 14 + .../DesktopAccountCredentialPersistence.kt | 38 ++- .../app/DesktopAccountOperationGuard.kt | 15 +- .../app/DesktopAccountSecretReference.kt | 13 + .../app/DesktopNextcloudServices.kt | 32 +- .../nextcloudnative/app/DesktopSecretStore.kt | 12 - ...DesktopAccountCredentialPersistenceTest.kt | 36 ++ .../app/DesktopAccountOperationGuardTest.kt | 62 ++++ 20 files changed, 853 insertions(+), 88 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountSecretReference.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index c5ad67271..1d4c1ced7 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -4,10 +4,17 @@ import android.content.Context import android.content.SharedPreferences import dev.obiente.nextcloudnative.app.NextcloudAccountId import dev.obiente.nextcloudnative.app.NextcloudAccountRecord +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistryRecoveryReason import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity +import dev.obiente.nextcloudnative.app.accountRecord +import dev.obiente.nextcloudnative.app.decodeNextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.encodeNextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.sync.Mutex @@ -24,11 +31,12 @@ internal class AndroidAccountCredentialController( private val clearPreviewAccount: (String) -> Unit, private val notifyDocumentRootsChanged: () -> Unit, private val resumeQueuedUploads: suspend (String) -> Unit, + private val removeQueuedUploads: suspend (String) -> Unit, ) { private val appContext = context.applicationContext fun loadSession(): NextcloudSession? = ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.restorePersistedSession( - load = { (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state?.activeSession }, + load = { activeAccountId()?.let { accountId -> loadSession(accountId) } }, accountIdOf = NextcloudDocumentIds::accountKey, publishAccount = { session, accountIdentity -> session?.let(registerSessionPrivateValues) @@ -36,30 +44,39 @@ internal class AndroidAccountCredentialController( }, ) - fun listAccounts(): List = - (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state?.registry?.accounts.orEmpty() + fun listAccounts(): List = readCredentialFreeRegistry()?.accounts.orEmpty() - fun activeAccountId(): NextcloudAccountId? = - (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state?.registry?.activeAccountId + fun activeAccountId(): NextcloudAccountId? = readCredentialFreeRegistry()?.activeAccountId fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = - (readStore() as? AndroidAccountCredentialStoreRead.Available) - ?.state - ?.sessions - ?.get(accountId) - ?.also(registerSessionPrivateValues) + ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + val registry = readCredentialFreeRegistry() ?: return@serialize null + if (registry.accounts.none { account -> account.id == accountId }) return@serialize null + if (!preferences.contains(androidAccountCredentialSlotKey(accountId))) { + val state = (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state + ?: return@serialize null + commitPreferences(prepareCredentialSlotEdit(preferences.edit(), state)) + } + readCredentialSlot(accountId)?.also(registerSessionPrivateValues) + } suspend fun saveSession(session: NextcloudSession) = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { registerSessionPrivateValues(session) when (val read = readStore()) { is AndroidAccountCredentialStoreRead.Available -> replaceActiveState(read.state.upsertAndSelect(session), read.state.activeSession) - is AndroidAccountCredentialStoreRead.Invalid -> + is AndroidAccountCredentialStoreRead.Invalid -> { + val retained = readIndependentCredentialSlotState() + check(retained != null || !hasIndependentCredentialState()) { + "The aggregate account credential store is invalid; reset it before signing in again." + } replaceActiveState( - replacement = AndroidAccountCredentialState.Empty.upsertAndSelect(session), - previousSession = null, + replacement = (retained ?: AndroidAccountCredentialState.Empty).upsertAndSelect(session), + previousSession = retained?.activeSession, suspectEncrypted = read.encrypted, ) + } + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } } @@ -81,7 +98,16 @@ internal class AndroidAccountCredentialController( if (current.registry.activeAccountId == accountId) { clearSession(current) } else { - persistState(current.remove(accountId)) + val replacement = current.remove(accountId) + persistState(replacement) + try { + removeQueuedUploads(NextcloudDocumentIds.accountKey(session)) + } catch (failure: Exception) { + runCatching { persistState(current) } + .onFailure(failure::addSuppressed) + throw failure + } + clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(session)) } } true @@ -100,6 +126,7 @@ internal class AndroidAccountCredentialController( } } is AndroidAccountCredentialStoreRead.Invalid -> clearInvalidStore(read.encrypted) + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } } @@ -109,18 +136,23 @@ internal class AndroidAccountCredentialController( val encodedReplacement = replacement.takeUnless { state -> state.registry.accounts.isEmpty() && state.sessions.isEmpty() }?.let(::encryptState) - clearPersistedSession(encodedReplacement) + clearPersistedSession(encodedReplacement, replacement) clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(activeSession)) notifyDocumentRootsChanged() } private suspend fun clearInvalidStore(suspectEncrypted: String) { - clearPersistedSession(encodedReplacement = null, suspectEncrypted = suspectEncrypted) + clearPersistedSession( + encodedReplacement = null, + replacement = AndroidAccountCredentialState.Empty, + suspectEncrypted = suspectEncrypted, + ) notifyDocumentRootsChanged() } private suspend fun clearPersistedSession( encodedReplacement: String?, + replacement: AndroidAccountCredentialState, suspectEncrypted: String? = null, ) { withContext(Dispatchers.IO) { AndroidExternalFileHandoffRegistry.clear() } @@ -132,15 +164,17 @@ internal class AndroidAccountCredentialController( preferences.edit().apply { if (encodedReplacement == null) remove(KEY_SESSION) else putString(KEY_SESSION, encodedReplacement) + putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(replacement.registry)) remove(KEY_TEST_READ_ONLY) - } + }.let { editor -> prepareCredentialSlotEdit(editor, replacement) } } else { prepareInvalidAndroidAccountCredentialRecoveryEdit( editor = preferences.edit(), suspectEncrypted = suspectEncrypted, replacementEncrypted = encodedReplacement, hasExistingQuarantine = preferences.contains(KEY_QUARANTINED_SESSION), - ) + ).putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(replacement.registry)) + .let { editor -> prepareCredentialSlotEdit(editor, replacement) } } commitPreferences(editor) }, @@ -154,6 +188,23 @@ internal class AndroidAccountCredentialController( replacement: AndroidAccountCredentialState, previousSession: NextcloudSession?, suspectEncrypted: String? = null, + ) { + val replace = suspend { + replaceActiveStateWhileOperationsIdle(replacement, previousSession, suspectEncrypted) + } + if (previousSession == null) { + replace() + } else { + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(previousSession)) { + replace() + } + } + } + + private suspend fun replaceActiveStateWhileOperationsIdle( + replacement: AndroidAccountCredentialState, + previousSession: NextcloudSession?, + suspectEncrypted: String?, ) { val session = requireNotNull(replacement.activeSession) val encrypted = encryptState(replacement) @@ -166,6 +217,7 @@ internal class AndroidAccountCredentialController( val editor = if (suspectEncrypted == null) { preferences.edit() .putString(KEY_SESSION, encrypted) + .putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(replacement.registry)) .remove(KEY_TEST_READ_ONLY) } else { prepareInvalidAndroidAccountCredentialRecoveryEdit( @@ -173,9 +225,9 @@ internal class AndroidAccountCredentialController( suspectEncrypted = suspectEncrypted, replacementEncrypted = encrypted, hasExistingQuarantine = preferences.contains(KEY_QUARANTINED_SESSION), - ) + ).putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(replacement.registry)) } - commitPreferences(editor) + commitPreferences(prepareCredentialSlotEdit(editor, replacement)) }, cancelAll = scheduler::cancelAll, publishAccount = publishAccountIdentity, @@ -185,17 +237,65 @@ internal class AndroidAccountCredentialController( if (previousSession != null && previousSession.accountId != session.accountId) { clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(previousSession)) } - withContext(NonCancellable) { - resumeQueuedUploads(NextcloudDocumentIds.accountKey(session)) - } - notifyDocumentRootsChanged() + resumeAndroidQueuedUploadsAfterSelection( + resume = { resumeQueuedUploads(NextcloudDocumentIds.accountKey(session)) }, + notifyDocumentRootsChanged = notifyDocumentRootsChanged, + recordFailure = { + recordCredentialFailure( + code = "DURABLE_UPLOAD_RESUME_FAILED", + operation = "account-selection.upload-resume", + component = SupportDiagnosticComponent.Storage, + ) + }, + ) } private fun requireValidState(): AndroidAccountCredentialState = when (val read = readStore()) { is AndroidAccountCredentialStoreRead.Available -> read.state is AndroidAccountCredentialStoreRead.Invalid -> error("The account credential store is invalid.") + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } + private fun readCredentialFreeRegistry(): NextcloudAccountRegistry? = + ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + preferences.getString(KEY_ACCOUNT_REGISTRY, null)?.let { encoded -> + val restored = restoreAndroidCredentialFreeRegistry(encoded) { + val state = (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state + ?: return@restoreAndroidCredentialFreeRegistry null + runCatching { + commitPreferences( + prepareCredentialSlotEdit( + preferences.edit().putString( + KEY_ACCOUNT_REGISTRY, + encodeNextcloudAccountRegistry(state.registry), + ), + state, + ), + ) + } + state.registry + } + restored.diagnosticCode?.let { code -> + recordCredentialFailure(code, operation = "account-registry.restore") + } + return@serialize restored.registry + } + val state = (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state + ?: return@serialize null + runCatching { + commitPreferences( + prepareCredentialSlotEdit( + preferences.edit().putString( + KEY_ACCOUNT_REGISTRY, + encodeNextcloudAccountRegistry(state.registry), + ), + state, + ), + ) + } + state.registry + } + private fun readStore(): AndroidAccountCredentialStoreRead = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { val encrypted = preferences.getString(KEY_SESSION, null) ?: return@serialize AndroidAccountCredentialStoreRead.Available(AndroidAccountCredentialState.Empty) @@ -208,21 +308,80 @@ internal class AndroidAccountCredentialController( ) return@serialize AndroidAccountCredentialStoreRead.Invalid(encrypted) } - val state = restoreAndroidAccountCredentialState( + val restored = restoreAndroidAccountCredentialStore( encoded = encoded, persistMigrated = { migrated -> + val migratedState = requireNotNull(decodeAndroidAccountCredentialState(migrated).state) commitPreferences( - preferences.edit().putString(KEY_SESSION, sessionCipher.encrypt(migrated)), + prepareCredentialSlotEdit( + preferences.edit() + .putString(KEY_SESSION, sessionCipher.encrypt(migrated)) + .putString( + KEY_ACCOUNT_REGISTRY, + encodeNextcloudAccountRegistry(migratedState.registry), + ), + migratedState, + ), ) }, recordDiagnostic = recordDiagnostic, ) - state?.let { AndroidAccountCredentialStoreRead.Available(it) } - ?: AndroidAccountCredentialStoreRead.Invalid(encrypted) + return@serialize when { + restored.unsupportedVersion != null -> + AndroidAccountCredentialStoreRead.Unsupported(encrypted, restored.unsupportedVersion) + restored.state != null -> AndroidAccountCredentialStoreRead.Available(restored.state) + else -> AndroidAccountCredentialStoreRead.Invalid(encrypted) + } + } + + private fun readIndependentCredentialSlotState(): AndroidAccountCredentialState? { + val encodedRegistry = preferences.getString(KEY_ACCOUNT_REGISTRY, null) ?: return null + val restored = restoreNextcloudAccountRegistry(encodedRegistry, legacySession = null) + if (restored.recoveryReason != null) return null + return reconstructAndroidAccountCredentialState(restored.registry, ::readCredentialSlot) + } + + private fun hasIndependentCredentialState(): Boolean = + preferences.contains(KEY_ACCOUNT_REGISTRY) || + preferences.all.keys.any { key -> key.startsWith(KEY_ACCOUNT_CREDENTIAL_SLOT_PREFIX) } + + private fun readCredentialSlot(accountId: NextcloudAccountId): NextcloudSession? = try { + readAndroidAccountCredentialSlot( + accountId = accountId, + readEncrypted = { key -> preferences.getString(key, null) }, + decrypt = sessionCipher::decrypt, + decode = { encoded -> + restoreAndroidAccountCredentialState( + encoded = encoded, + persistMigrated = { migrated -> + commitPreferences( + preferences.edit().putString( + androidAccountCredentialSlotKey(accountId), + sessionCipher.encrypt(migrated), + ), + ) + }, + recordDiagnostic = recordDiagnostic, + )?.activeSession + }, + ) + } catch (_: Exception) { + recordCredentialFailure( + code = "ACCOUNT_CREDENTIAL_STORE_READ_FAILED", + operation = "account-credentials.restore", + ) + null } private suspend fun persistState(state: AndroidAccountCredentialState) = withContext(Dispatchers.IO) { - commitPreferences(preferences.edit().putString(KEY_SESSION, encryptState(state))) + commitPreferences( + prepareCredentialSlotEdit( + preferences.edit() + .putString(KEY_SESSION, encryptState(state)) + .putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(state.registry)), + state, + ), + ) } private fun commitPreferences(editor: SharedPreferences.Editor) = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { @@ -247,11 +406,31 @@ internal class AndroidAccountCredentialController( throw failure } - private fun recordCredentialFailure(code: String, operation: String) { + private fun prepareCredentialSlotEdit( + editor: SharedPreferences.Editor, + state: AndroidAccountCredentialState, + ): SharedPreferences.Editor = editor.apply { + val retainedKeys = state.sessions.keys.mapTo(hashSetOf(), ::androidAccountCredentialSlotKey) + preferences.all.keys + .filter { key -> key.startsWith(KEY_ACCOUNT_CREDENTIAL_SLOT_PREFIX) && key !in retainedKeys } + .forEach(::remove) + state.sessions.forEach { (accountId, session) -> + putString( + androidAccountCredentialSlotKey(accountId), + sessionCipher.encrypt(encodeAndroidPersistedSession(session)), + ) + } + } + + private fun recordCredentialFailure( + code: String, + operation: String, + component: SupportDiagnosticComponent = SupportDiagnosticComponent.Authentication, + ) { recordDiagnostic( SupportDiagnosticEventDraft( severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Authentication, + component = component, operation = operation, outcome = "failed", code = code, @@ -264,6 +443,74 @@ internal class AndroidAccountCredentialController( internal sealed interface AndroidAccountCredentialStoreRead { data class Available(val state: AndroidAccountCredentialState) : AndroidAccountCredentialStoreRead data class Invalid(val encrypted: String) : AndroidAccountCredentialStoreRead + data class Unsupported(val encrypted: String, val version: Int) : AndroidAccountCredentialStoreRead +} + +private fun unsupportedCredentialStoreMutation(version: Int): Nothing = + error("The account credential store version $version is unsupported.") + +internal fun decodeAndroidCredentialFreeRegistry(encoded: String): NextcloudAccountRegistry? = + decodeNextcloudAccountRegistry(encoded) + +internal data class RestoredAndroidCredentialFreeRegistry( + val registry: NextcloudAccountRegistry?, + val diagnosticCode: String? = null, +) + +internal fun restoreAndroidCredentialFreeRegistry( + encoded: String, + recoverMalformed: () -> NextcloudAccountRegistry?, +): RestoredAndroidCredentialFreeRegistry { + val restored = restoreNextcloudAccountRegistry(encoded, legacySession = null) + val recoveryReason = restored.recoveryReason + return when (recoveryReason) { + null -> RestoredAndroidCredentialFreeRegistry(restored.registry) + NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion -> + RestoredAndroidCredentialFreeRegistry(null, recoveryReason.diagnosticCode) + else -> RestoredAndroidCredentialFreeRegistry(recoverMalformed(), recoveryReason.diagnosticCode) + } +} + +internal fun androidAccountCredentialSlotKey(accountId: NextcloudAccountId): String = + "$KEY_ACCOUNT_CREDENTIAL_SLOT_PREFIX${accountId.storageKey}" + +internal fun readAndroidAccountCredentialSlot( + accountId: NextcloudAccountId, + readEncrypted: (String) -> String?, + decrypt: (String) -> String, + decode: (String) -> NextcloudSession?, +): NextcloudSession? { + val encrypted = readEncrypted(androidAccountCredentialSlotKey(accountId)) ?: return null + return decode(decrypt(encrypted))?.takeIf { session -> session.accountId == accountId } +} + +internal fun reconstructAndroidAccountCredentialState( + registry: NextcloudAccountRegistry, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): AndroidAccountCredentialState? { + val sessions = linkedMapOf() + registry.accounts.forEach { account -> + val session = loadSession(account.id)?.takeIf { loaded -> loaded.accountRecord() == account } + ?: return null + sessions[account.id] = session + } + return AndroidAccountCredentialState(registry, sessions) +} + +internal suspend fun resumeAndroidQueuedUploadsAfterSelection( + resume: suspend () -> Unit, + notifyDocumentRootsChanged: () -> Unit, + recordFailure: () -> Unit, +) { + try { + withContext(NonCancellable) { resume() } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + recordFailure() + } finally { + notifyDocumentRootsChanged() + } } internal class AndroidAccountCredentialStoreGuard { @@ -303,6 +550,8 @@ internal fun resolveStoredAndroidAccountSession( } private const val KEY_SESSION = "encrypted_session" +private const val KEY_ACCOUNT_REGISTRY = "account_registry_v1" +private const val KEY_ACCOUNT_CREDENTIAL_SLOT_PREFIX = "account_credential_v1:" private const val KEY_QUARANTINED_SESSION = "encrypted_session_quarantine" private val ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD = AndroidAccountCredentialStoreGuard() private val ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX = Mutex() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index 6b388a344..701ff30f7 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -28,3 +28,8 @@ internal class AndroidAccountOperationGuard { } internal val ANDROID_ACCOUNT_OPERATION_GUARD = AndroidAccountOperationGuard() + +internal fun androidAccountOperationSessionIsCurrent( + expectedAccountId: String, + currentSession: dev.obiente.nextcloudnative.app.NextcloudSession?, +): Boolean = currentSession != null && NextcloudDocumentIds.accountKey(currentSession) == expectedAccountId diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 86c37ff22..fef7d124b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -122,7 +122,7 @@ internal class AndroidDurableMultipartUploads(context: Context) { policy: ExistingWorkPolicy = ExistingWorkPolicy.KEEP, ): Operation = WorkManager.getInstance(appContext).enqueueUniqueWork( - "deck-attachment-${job.id}", + durableUploadWorkName(job.id), policy, OneTimeWorkRequestBuilder() .setInputData(Data.Builder().putString(DeckAttachmentUploadWorker.KEY_JOB_ID, job.id).build()) @@ -139,6 +139,8 @@ internal class AndroidDurableMultipartUploads(context: Context) { } } +internal fun durableUploadWorkName(jobId: String) = "deck-attachment-$jobId" + internal class DeckAttachmentUploadWorker( appContext: Context, params: WorkerParameters, @@ -454,6 +456,13 @@ internal class AndroidDurableMultipartUploadStore( writeAll(readAll().filterNot { it.id == id }) } + fun removeForAccount(accountId: String): List = synchronized(LOCK) { + val current = readAll() + val removed = current.filter { job -> job.accountId == accountId } + if (removed.isNotEmpty()) writeAll(current.filterNot { job -> job.accountId == accountId }) + removed + } + fun transition( id: String, expected: DurableUploadState, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt new file mode 100644 index 000000000..03d84f604 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt @@ -0,0 +1,19 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import androidx.work.WorkManager +import androidx.work.await + +internal class AndroidDurableUploadAccountCleanup(context: Context) { + private val appContext = context.applicationContext + private val store = AndroidDurableMultipartUploadStore(appContext) + + suspend fun removeForAccount(accountId: String) { + store.list().filter { job -> job.accountId == accountId }.forEach { job -> + WorkManager.getInstance(appContext).cancelUniqueWork(durableUploadWorkName(job.id)).await() + } + val removed = store.removeForAccount(accountId) + val picker = AndroidLocalUploadPicker(appContext) + removed.forEach { job -> picker.release(job.request.file) } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt index 223957f70..a7c6bd860 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt @@ -385,7 +385,16 @@ internal class AndroidFileOfflineRepository(context: Context) { return update.state.folderAvailability(accountId, folder.path) } - fun execute( + suspend fun execute( + expectedAccountId: String, + userId: String, + jobId: Long, + cancellation: DocumentRequestCancellation, + ): AndroidOfflineExecutionOutcome = ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(expectedAccountId) { + executeWhileAccountRetained(expectedAccountId, userId, jobId, cancellation) + } + + private fun executeWhileAccountRetained( expectedAccountId: String, userId: String, jobId: Long, @@ -393,9 +402,7 @@ internal class AndroidFileOfflineRepository(context: Context) { ): AndroidOfflineExecutionOutcome { val services = AndroidNextcloudServices(appContext) val session = resolveStoredAndroidAccountSession( - accountIdentity = expectedAccountId, - listAccounts = services::listAccounts, - loadSession = { accountId -> services.loadSession(accountId) }, + expectedAccountId, services::listAccounts, services::loadSession, ) if (session == null) { finish( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index a6bf36af2..39ed74158 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -89,6 +89,7 @@ import dev.obiente.nextcloudnative.app.FileSyncCenterSnapshot import dev.obiente.nextcloudnative.app.FileSyncConfiguration import dev.obiente.nextcloudnative.app.FileSyncDecisionChoice import dev.obiente.nextcloudnative.app.FileSyncLocalRoot +import dev.obiente.nextcloudnative.app.FileSyncRejectionScope import dev.obiente.nextcloudnative.app.IncomingShareRecoveryPage import dev.obiente.nextcloudnative.app.IncomingShareUploadPresentation import dev.obiente.nextcloudnative.app.VirtualFileCachePolicy @@ -469,6 +470,7 @@ internal class AndroidNextcloudServices( ) private val projectContent = AndroidProjectContentClient(appContext, activity) private val durableMultipartUploads = AndroidDurableMultipartUploads(appContext) + private val durableUploadAccountCleanup = AndroidDurableUploadAccountCleanup(appContext) private val deckCardDrafts = AndroidDeckCardDraftStore(appContext) private val supportDiagnostics = AndroidSupportDiagnostics.get(appContext) private val supportBundleExporter = AndroidSupportBundleExporter( @@ -494,6 +496,7 @@ internal class AndroidNextcloudServices( clearPreviewAccount = nativeMediaPreviewCache::clearAccount, notifyDocumentRootsChanged = ::notifyDocumentsRootsChanged, resumeQueuedUploads = durableMultipartUploads::resumeQueuedForAccount, + removeQueuedUploads = durableUploadAccountCleanup::removeForAccount, ) init { @@ -1711,9 +1714,19 @@ internal class AndroidNextcloudServices( ): FileSyncCenterActionResult = withContext(Dispatchers.IO) { val accountIdentity = NextcloudDocumentIds.accountKey(session) val fields = listOf(SupportDiagnosticFieldDraft("pair", pairId, SupportDiagnosticValuePrivacy.Identifier)) - diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.pair-run", fields) { - fileSyncEngine.runPair(session, userId, pairId) - }.also { result -> recordFileSyncResult(accountIdentity, "sync.pair-run", fields, result) } + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + val current = loadSession() + if (current == null || !androidAccountOperationSessionIsCurrent(accountIdentity, current)) { + FileSyncCenterActionResult.Rejected( + "The account changed before folder sync could start.", + FileSyncRejectionScope.Preflight, + ) + } else { + diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.pair-run", fields) { + fileSyncEngine.runPair(current, userId, pairId) + }.also { result -> recordFileSyncResult(accountIdentity, "sync.pair-run", fields, result) } + } + } } override suspend fun resolveFileSyncConflict( @@ -1733,9 +1746,19 @@ internal class AndroidNextcloudServices( ), SupportDiagnosticFieldDraft("choice", choice.name.lowercase()), ) - diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.conflict-resolve", fields) { - fileSyncEngine.resolveConflictAndRun(session, userId, pairId, workId, choice) - }.also { result -> recordFileSyncResult(accountIdentity, "sync.conflict-resolve", fields, result) } + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + val current = loadSession() + if (current == null || !androidAccountOperationSessionIsCurrent(accountIdentity, current)) { + FileSyncCenterActionResult.Rejected( + "The account changed before conflict resolution could start.", + FileSyncRejectionScope.Preflight, + ) + } else { + diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.conflict-resolve", fields) { + fileSyncEngine.resolveConflictAndRun(current, userId, pairId, workId, choice) + }.also { result -> recordFileSyncResult(accountIdentity, "sync.conflict-resolve", fields, result) } + } + } } override suspend fun resolveFileSyncConflicts( @@ -1749,15 +1772,25 @@ internal class AndroidNextcloudServices( SupportDiagnosticFieldDraft("pair", pairId, SupportDiagnosticValuePrivacy.Identifier), SupportDiagnosticFieldDraft("conflict_count", resolutions.size.toString()), ) - diagnoseSupportFailure( - accountIdentity, - SupportDiagnosticComponent.Sync, - "sync.conflict-resolve-batch", - fields, - ) { - fileSyncEngine.resolveConflictsAndRun(session, userId, pairId, resolutions) - }.also { result -> - recordFileSyncResult(accountIdentity, "sync.conflict-resolve-batch", fields, result) + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + val current = loadSession() + if (current == null || !androidAccountOperationSessionIsCurrent(accountIdentity, current)) { + FileSyncCenterActionResult.Rejected( + "The account changed before conflict resolution could start.", + FileSyncRejectionScope.Preflight, + ) + } else { + diagnoseSupportFailure( + accountIdentity, + SupportDiagnosticComponent.Sync, + "sync.conflict-resolve-batch", + fields, + ) { + fileSyncEngine.resolveConflictsAndRun(current, userId, pairId, resolutions) + }.also { result -> + recordFileSyncResult(accountIdentity, "sync.conflict-resolve-batch", fields, result) + } + } } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index 927ac960c..da8b0a896 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -68,13 +68,24 @@ internal data class RestoredAndroidAccountCredentialState( val state: AndroidAccountCredentialState?, val needsPersistence: Boolean = false, val diagnosticCode: String? = null, + val unsupportedVersion: Int? = null, ) internal fun restoreAndroidAccountCredentialState( encoded: String, persistMigrated: (String) -> Unit, recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, -): AndroidAccountCredentialState? { +): AndroidAccountCredentialState? = restoreAndroidAccountCredentialStore( + encoded = encoded, + persistMigrated = persistMigrated, + recordDiagnostic = recordDiagnostic, +).state + +internal fun restoreAndroidAccountCredentialStore( + encoded: String, + persistMigrated: (String) -> Unit, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, +): RestoredAndroidAccountCredentialState { val restored = decodeAndroidAccountCredentialState(encoded) restored.diagnosticCode?.let { code -> recordAccountCredentialDiagnostic(code, recordDiagnostic) } if (restored.needsPersistence && restored.state != null) { @@ -87,7 +98,7 @@ internal fun restoreAndroidAccountCredentialState( ) } } - return restored.state + return restored } internal fun decodeAndroidAccountCredentialState(encoded: String): RestoredAndroidAccountCredentialState { @@ -99,7 +110,15 @@ internal fun decodeAndroidAccountCredentialState(encoded: String): RestoredAndro if (!json.has(KEY_VERSION)) { restoreLegacyAndroidAccountCredentialState(json) } else { - require(json.getInt(KEY_VERSION) == ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION) + val version = json.getInt(KEY_VERSION) + if (version > ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION) { + return RestoredAndroidAccountCredentialState( + state = null, + diagnosticCode = "ACCOUNT_CREDENTIAL_STORE_VERSION_UNSUPPORTED", + unsupportedVersion = version, + ) + } + require(version == ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION) val registry = requireNotNull(decodeNextcloudAccountRegistry(json.getString(KEY_ACCOUNT_REGISTRY))) val encodedSessions = json.getJSONArray(KEY_CREDENTIALS) require(encodedSessions.length() <= MAX_ANDROID_ACCOUNT_CREDENTIALS) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt index dd2fa8686..3b88c5017 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt @@ -3,6 +3,7 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.NextcloudSession import java.security.MessageDigest import java.util.Base64 +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull internal data class NextcloudDocumentReference( val accountKey: String, @@ -18,14 +19,18 @@ internal object NextcloudDocumentIds { private val decoder = Base64.getUrlDecoder() fun accountKey(session: NextcloudSession): String { - return accountDigest(session) + return accountKey(session.serverUrl, session.loginName) + } + + fun accountKey(serverUrl: String, loginName: String): String { + return accountDigest(serverUrl, loginName) .take(16) .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } } /** Full digest for private caches which require a canonical SHA-256 directory key. */ fun cacheAccountId(session: NextcloudSession): String = - accountDigest(session) + accountDigest(session.serverUrl, session.loginName) .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } fun rootId(session: NextcloudSession): String = documentId(session, "") @@ -56,8 +61,13 @@ internal object NextcloudDocumentIds { require(reference.accountKey == accountKey(session)) { "Document belongs to another account." } } - private fun accountDigest(session: NextcloudSession): ByteArray { - val identity = session.serverUrl.trimEnd('/') + "\n" + session.loginName + private fun accountDigest(serverUrl: String, loginName: String): ByteArray { + val url = serverUrl.trim().toHttpUrlOrNull() + requireNotNull(url) { "The account server address is invalid." } + require(url.username.isEmpty() && url.password.isEmpty() && url.query == null && url.fragment == null) { + "The account server address contains unsupported URL components." + } + val identity = url.toString().trimEnd('/') + "\n" + loginName return MessageDigest.getInstance("SHA-256").digest(identity.encodeToByteArray()) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt index a78507bb1..8bd39fc3d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt @@ -37,9 +37,7 @@ internal class NextcloudFileSyncWorker( val services = AndroidNextcloudServices(applicationContext) val session = services.loadSession() ?: return@withContext Result.failure() - if (NextcloudDocumentIds.accountKey(session) != accountId) { - return@withContext Result.failure() - } + if (NextcloudDocumentIds.accountKey(session) != accountId) return@withContext Result.failure() AndroidNotificationCoordinator(applicationContext).ensureChannels() try { setForeground(createForegroundInfo(pairId)) @@ -50,8 +48,16 @@ internal class NextcloudFileSyncWorker( // WorkManager may still execute short work when the OS temporarily refuses an FGS. } val engine = AndroidFileSyncEngine(applicationContext) - val result = runCatching { engine.runPair(session, userId, pairId) } - .getOrElse { failure -> + val result = try { + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountId) { + val current = services.loadSession() + if (current == null || !androidAccountOperationSessionIsCurrent(accountId, current)) { + null + } else { + engine.runPair(current, userId, pairId) + } + } ?: return@withContext Result.failure() + } catch (failure: Throwable) { rethrowAndroidFileSyncCancellation(failure) val disposition = backgroundSyncFailureDisposition(runAttemptCount) services.recordSupportDiagnosticForAccountIdentity( @@ -78,7 +84,7 @@ internal class NextcloudFileSyncWorker( ), ) return@withContext disposition.toWorkerResult() - } + } val pair = engine.loadCenter(session, userId).pairs.firstOrNull { it.id == pairId } ?: return@withContext Result.success() pair.conflicts.firstOrNull()?.let { conflict -> diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index ee03bd366..c98604d0b 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -9,6 +9,33 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield class AndroidAccountOperationGuardTest { + @Test + fun staleSyncSessionIsRejectedAfterAnAccountTransition() { + val previous = dev.obiente.nextcloudnative.app.NextcloudSession( + "https://first.example.test", + "alice", + "old-password", + ) + val replacement = dev.obiente.nextcloudnative.app.NextcloudSession( + "https://second.example.test", + "bob", + "new-password", + ) + + assertTrue( + androidAccountOperationSessionIsCurrent( + NextcloudDocumentIds.accountKey(previous), + previous.copy(appPassword = "rotated-password"), + ), + ) + assertFalse( + androidAccountOperationSessionIsCurrent( + NextcloudDocumentIds.accountKey(previous), + replacement, + ), + ) + } + @Test fun sameAccountRemovalWaitsForTheUploadLease() = runBlocking { val guard = AndroidAccountOperationGuard() @@ -55,4 +82,30 @@ class AndroidAccountOperationGuardTest { releaseUpload.complete(Unit) upload.await() } + + @Test + fun retainedOfflineWorkRevalidatesItsSessionAfterAccountRemoval() = runBlocking { + val guard = AndroidAccountOperationGuard() + val removalCommitted = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var sessionAvailable = true + val removal = async { + guard.withAccount("account-a") { + sessionAvailable = false + removalCommitted.complete(Unit) + releaseRemoval.await() + } + } + removalCommitted.await() + + val offlineSessionAvailable = async { + guard.withAccount("account-a") { sessionAvailable } + } + yield() + assertFalse(offlineSessionAvailable.isCompleted) + + releaseRemoval.complete(Unit) + removal.await() + assertFalse(offlineSessionAvailable.await()) + } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 1d433fe7c..2b0351fd8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -16,6 +16,19 @@ import kotlin.test.assertTrue import org.json.JSONArray class AndroidDurableMultipartUploadPolicyTest { + @Test + fun `removing an account deletes only its queued upload recovery rows`() { + val storage = FakeDurableUploadEncryptedStorage() + val store = AndroidDurableMultipartUploadStore(storage, FakeDurableUploadCipher()) + val first = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val second = fixtureJob(index = 2, account = ACCOUNT_B, cardId = 43) + store.add(first) + store.add(second) + + assertEquals(listOf(first), store.removeForAccount(ACCOUNT_A)) + assertEquals(listOf(second), store.list()) + } + @Test fun `encrypted queue read and decryption failures preserve recoverable jobs`() { listOf("read", "decrypt").forEach { failureMode -> diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 742f0453f..e94090124 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -6,6 +6,8 @@ import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.accountRecord import dev.obiente.nextcloudnative.app.encodeNextcloudAccountRegistry +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -338,6 +340,29 @@ class AndroidPersistedSessionTest { assertFailsWith { encodeAndroidAccountCredentialState(readOnly) } } + @Test + fun unsupportedFutureCredentialStoreIsReadOnlyAndNeverMigrated() { + val diagnostics = mutableListOf() + var migrated = false + val future = JSONObject( + encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(firstSession())), + ).put("version", 3).toString() + + val restored = restoreAndroidAccountCredentialStore( + encoded = future, + persistMigrated = { migrated = true }, + recordDiagnostic = diagnostics::add, + ) + + assertNull(restored.state) + assertEquals(3, restored.unsupportedVersion) + assertFalse(migrated) + assertEquals( + listOf("ACCOUNT_CREDENTIAL_STORE_VERSION_UNSUPPORTED"), + diagnostics.mapNotNull { it.code }, + ) + } + @Test fun migrationFailureUsesABoundedCauseWithoutPrivateValues() { val diagnostics = mutableListOf() @@ -369,6 +394,149 @@ class AndroidPersistedSessionTest { assertEquals(1, payload.getJSONArray("credentials").length()) } + @Test + fun accountListingDecodesTheCredentialFreeRegistryWithoutASecretPayload() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + val encoded = encodeNextcloudAccountRegistry(registry) + + assertFalse(encoded.contains(first.appPassword)) + assertFalse(encoded.contains(second.appPassword)) + assertEquals(registry, decodeAndroidCredentialFreeRegistry(encoded)) + } + + @Test + fun malformedCredentialFreeRegistryRecoversFromTheValidatedAggregateRegistry() { + val registry = NextcloudAccountRegistry.Empty.upsertAndSelect(firstSession().accountRecord()) + var recoveryAttempted = false + + val restored = restoreAndroidCredentialFreeRegistry("{not-json") { + recoveryAttempted = true + registry + } + + assertTrue(recoveryAttempted) + assertEquals(registry, restored.registry) + assertEquals("ACCOUNT_REGISTRY_MALFORMED", restored.diagnosticCode) + } + + @Test + fun futureCredentialFreeRegistryIsNeverRebuiltFromAnOlderAggregate() { + var recoveryAttempted = false + + val restored = restoreAndroidCredentialFreeRegistry("""{"version":99,"accounts":[]}""") { + recoveryAttempted = true + NextcloudAccountRegistry.Empty + } + + assertFalse(recoveryAttempted) + assertNull(restored.registry) + assertEquals("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED", restored.diagnosticCode) + } + + @Test + fun credentialSlotReadDecryptsOnlyTheRequestedAccount() { + val first = firstSession() + val second = secondSession() + val encryptedByKey = mapOf( + androidAccountCredentialSlotKey(first.accountId) to "encrypted-first", + androidAccountCredentialSlotKey(second.accountId) to "encrypted-second", + ) + val requestedKeys = mutableListOf() + val decryptedValues = mutableListOf() + + val restored = readAndroidAccountCredentialSlot( + accountId = second.accountId, + readEncrypted = { key -> + requestedKeys += key + encryptedByKey[key] + }, + decrypt = { encrypted -> + decryptedValues += encrypted + "decoded-second" + }, + decode = { decoded -> second.takeIf { decoded == "decoded-second" } }, + ) + + assertEquals(second, restored) + assertEquals(listOf(androidAccountCredentialSlotKey(second.accountId)), requestedKeys) + assertEquals(listOf("encrypted-second"), decryptedValues) + } + + @Test + fun credentialSlotReadRejectsASecretForAnotherAccount() { + val first = firstSession() + val second = secondSession() + + val restored = readAndroidAccountCredentialSlot( + accountId = second.accountId, + readEncrypted = { "encrypted-first" }, + decrypt = { "decoded-first" }, + decode = { first }, + ) + + assertNull(restored) + } + + @Test + fun validIndependentSlotsCanRecoverAroundAMalformedAggregateStore() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + val slots = mapOf(first.accountId to first, second.accountId to second) + + val restored = reconstructAndroidAccountCredentialState(registry, slots::get) + + assertEquals(slots, requireNotNull(restored).sessions) + assertEquals(second, restored.activeSession) + } + + @Test + fun independentSlotRecoveryRejectsRegistryCredentialMismatch() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty.upsertAndSelect(second.accountRecord()) + + assertNull(reconstructAndroidAccountCredentialState(registry) { first }) + } + + @Test + fun queuedUploadResumeFailureDoesNotHideACommittedAccountSelection() = runBlocking { + val events = mutableListOf() + + resumeAndroidQueuedUploadsAfterSelection( + resume = { + events += "resume" + error("Synthetic unreadable upload queue") + }, + notifyDocumentRootsChanged = { events += "notify" }, + recordFailure = { events += "diagnose" }, + ) + + assertEquals(listOf("resume", "diagnose", "notify"), events) + } + + @Test + fun queuedUploadResumeCancellationNotifiesBeforePropagating() { + val events = mutableListOf() + + assertFailsWith { + runBlocking { + resumeAndroidQueuedUploadsAfterSelection( + resume = { throw CancellationException("Selection owner stopped") }, + notifyDocumentRootsChanged = { events += "notify" }, + recordFailure = { events += "diagnose" }, + ) + } + } + assertEquals(listOf("notify"), events) + } + private fun assertDiagnosticsExcludePrivateValues(diagnostics: List) { val rendered = diagnostics.joinToString() assertFalse(rendered.contains("private-app-password")) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt index 862dca5ff..ebeb185e5 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt @@ -48,6 +48,20 @@ class NextcloudDocumentIdsTest { ) } + @Test + fun accountWorkIdentityIsStableAcrossEquivalentServerSpellings() { + val equivalent = listOf( + session.copy(serverUrl = "https://CLOUD.EXAMPLE"), + session.copy(serverUrl = "https://cloud.example:443/"), + session.copy(serverUrl = " https://cloud.example "), + ) + + equivalent.forEach { candidate -> + assertEquals(NextcloudDocumentIds.accountKey(session), NextcloudDocumentIds.accountKey(candidate)) + assertEquals(NextcloudDocumentIds.cacheAccountId(session), NextcloudDocumentIds.cacheAccountId(candidate)) + } + } + @Test fun accountIdentitySeparatesOtherwiseEqualPaths() { val other = session.copy(loginName = "bob") diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index a17d1ab33..d330be74b 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -52,8 +52,33 @@ internal class DesktopAccountCredentialPersistence( ?: if (read.encoded == null) NextcloudAccountRegistry.Empty else throw invalidRegistryForMutation() val updatedRegistry = registry.upsertAndSelect(session.accountRecord()) val encodedRegistry = prepareRegistry(updatedRegistry) + val secretReference = desktopAccountSecretReference(session.accountId) + val previousRecord = registry.accounts.firstOrNull { account -> account.id == session.accountId } + val previousSecret = loadSecretForRollback(secretReference) saveSecret(session) - persistAccountState(encodedRegistry, updatedRegistry.activeAccount) + try { + persistAccountState(encodedRegistry, updatedRegistry.activeAccount) + } catch (failure: Exception) { + try { + if (previousSecret == null) { + secretStore.clear(secretReference) + } else { + secretStore.save( + secretReference, + previousRecord?.loginName, + previousSecret, + ) + } + } catch (rollbackFailure: Exception) { + failure.addSuppressed(rollbackFailure) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.persist", + rollbackFailure, + ) + } + throw failure + } } fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? { @@ -153,6 +178,17 @@ internal class DesktopAccountCredentialPersistence( null } + private fun loadSecretForRollback(reference: DesktopSecretReference): ByteArray? = try { + secretStore.load(reference) + } catch (failure: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_READ_FAILED", + "account-credentials.persist", + failure, + ) + throw failure + } + private fun clearSecret(reference: DesktopSecretReference) { try { secretStore.clear(reference) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 94082770f..80cba15cb 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -6,9 +6,18 @@ import kotlinx.coroutines.sync.withLock internal class DesktopAccountOperationGuard { private val accountMutationMutex = Mutex() private val syncRunMutex = Mutex() + private val resourceActivationMonitor = Any() + private var accountMutationActive = false suspend fun serialize(action: suspend () -> Result): Result = - accountMutationMutex.withLock { action() } + accountMutationMutex.withLock { + synchronized(resourceActivationMonitor) { accountMutationActive = true } + try { + action() + } finally { + synchronized(resourceActivationMonitor) { accountMutationActive = false } + } + } suspend fun serializeWhenSyncIdle(action: suspend () -> Result): Result = serialize { withSyncRunLock(action) @@ -16,6 +25,10 @@ internal class DesktopAccountOperationGuard { suspend fun serializeResourceActivation(action: suspend () -> Result): Result = serialize(action) + fun tryActivateResource(action: () -> Boolean): Boolean = synchronized(resourceActivationMonitor) { + !accountMutationActive && action() + } + suspend fun withSyncRunLock(action: suspend () -> Result): Result = syncRunMutex.withLock { action() } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountSecretReference.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountSecretReference.kt new file mode 100644 index 000000000..12c3806b4 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountSecretReference.kt @@ -0,0 +1,13 @@ +package dev.obiente.nextcloudnative.app + +internal fun desktopAccountSecretReference(accountId: NextcloudAccountId): DesktopSecretReference = + DesktopSecretReference( + targetName = "Obiente/NextcloudNative/session/v2/${accountId.storageKey}", + label = "Nextcloud Native account credential", + attributes = linkedMapOf( + "application" to "dev.obiente.nextcloudnative", + "purpose" to "account-session", + "account" to accountId.storageKey, + "schema" to "2", + ), + ) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index f4a533557..3eaee286f 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1515,14 +1515,20 @@ class DesktopNextcloudServices( } } } - val accepted = synchronized(virtualFileProviderLock) { - synchronized(virtualFolderHydrationJobs) { - if ( - sessionClearing || - accountId in virtualFileCacheTierMutations || - virtualFolderHydrationJobs[jobKey].occupiesVirtualFolderHydrationSlot() - ) false - else true.also { virtualFolderHydrationJobs[jobKey] = job } + val accepted = accountOperationGuard.tryActivateResource { + if (!desktopResourceActivationMatchesActiveAccount(activeAccountId(), session.accountId)) { + false + } else { + synchronized(virtualFileProviderLock) { + synchronized(virtualFolderHydrationJobs) { + if ( + sessionClearing || + accountId in virtualFileCacheTierMutations || + virtualFolderHydrationJobs[jobKey].occupiesVirtualFolderHydrationSlot() + ) false + else true.also { virtualFolderHydrationJobs[jobKey] = job } + } + } } } if (accepted) job.start() else job.cancel() @@ -4535,8 +4541,14 @@ class DesktopNextcloudServices( } }, ) - val registered = synchronized(fileRangeSessionLock) { - if (sessionClearing) false else activeFileRangeSessions.add(rangeSession) + val registered = accountOperationGuard.tryActivateResource { + if (!desktopResourceActivationMatchesActiveAccount(activeAccountId(), session.accountId)) { + false + } else { + synchronized(fileRangeSessionLock) { + if (sessionClearing) false else activeFileRangeSessions.add(rangeSession) + } + } } if (!registered) { rangeSession.close() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStore.kt index dbea1c32c..3a1ac2267 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStore.kt @@ -262,18 +262,6 @@ internal fun desktopSessionSecretReference(serverUrl: String, loginName: String) ) } -internal fun desktopAccountSecretReference(accountId: NextcloudAccountId): DesktopSecretReference = - DesktopSecretReference( - targetName = "$WINDOWS_CREDENTIAL_PREFIX/session/v2/${accountId.storageKey}", - label = "Nextcloud Native account credential", - attributes = linkedMapOf( - "application" to DESKTOP_APPLICATION_ID, - "purpose" to "account-session", - "account" to accountId.storageKey, - "schema" to "2", - ), - ) - internal fun desktopDeckDraftSecretReference(): DesktopSecretReference = DesktopSecretReference( targetName = "$WINDOWS_CREDENTIAL_PREFIX/deck-card-drafts/v1", label = "nati.ve Deck draft encryption", diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index baf3fd251..d48fec686 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -55,6 +55,42 @@ class DesktopAccountCredentialPersistenceTest { assertEquals(firstSession().loginName, preferences.get("login", null)) } + @Test + fun failedRegistryFlushRemovesANewlyCreatedCredentialSlot() = withStore { preferences, secrets -> + val session = firstSession() + val persistence = persistence(preferences, secrets) { + error("synthetic registry flush failure") + } + + assertFailsWith { persistence.saveSession(session) } + + assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + } + + @Test + fun failedRegistryFlushRestoresThePreviousCredentialDuringReauthentication() = + withStore { preferences, secrets -> + val original = firstSession() + var failFlush = false + val persistence = persistence(preferences, secrets) { + if (failFlush) error("synthetic registry flush failure") + preferences.flush() + } + persistence.saveSession(original) + failFlush = true + + assertFailsWith { + persistence.saveSession(original.copy(appPassword = "replacement-password")) + } + + assertEquals( + original.appPassword, + secrets.load(desktopAccountSecretReference(original.accountId))?.decodeToString(), + ) + assertEquals(original, persistence(preferences, secrets).loadActiveSession()) + } + @Test fun unsupportedFutureRegistryUsesLegacyCredentialWithoutOverwritingIt() = withStore { preferences, secrets -> val session = firstSession() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 2b3299daf..d55673053 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -8,9 +8,13 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread class DesktopAccountOperationGuardTest { @Test @@ -59,14 +63,72 @@ class DesktopAccountOperationGuardTest { assertTrue(resourceActivated) } + @Test + fun synchronousRangeRegistrationCannotEnterDuringAccountMutation() = runBlocking { + val guard = DesktopAccountOperationGuard() + val mutationEntered = CompletableDeferred() + val releaseMutation = CompletableDeferred() + val mutation = async { + guard.serialize { + mutationEntered.complete(Unit) + releaseMutation.await() + } + } + mutationEntered.await() + + assertFalse(guard.tryActivateResource { true }) + + releaseMutation.complete(Unit) + mutation.await() + assertTrue(guard.tryActivateResource { true }) + } + + @Test + fun accountMutationObservesAResourceRegisteredJustBeforeItStarts() = runBlocking { + val guard = DesktopAccountOperationGuard() + val registrationEntered = CountDownLatch(1) + val releaseRegistration = CountDownLatch(1) + val mutationEntered = CompletableDeferred() + val registration = thread { + assertTrue( + guard.tryActivateResource { + registrationEntered.countDown() + check(releaseRegistration.await(5, TimeUnit.SECONDS)) + true + }, + ) + } + check(registrationEntered.await(5, TimeUnit.SECONDS)) + + val mutation = async(Dispatchers.Default) { + guard.serialize { mutationEntered.complete(Unit) } + } + yield() + assertFalse(mutationEntered.isCompleted) + + releaseRegistration.countDown() + registration.join() + mutation.await() + assertTrue(mutationEntered.isCompleted) + } + @Test fun resourceActivationRejectsAStaleAccountAfterWaitingForTheGuard() { val first = NextcloudSession("https://first.example.test", "alice", "one") val second = NextcloudSession("https://second.example.test", "bob", "two") + val guard = DesktopAccountOperationGuard() + var hydrationRegistered = false assertTrue(desktopResourceActivationMatchesActiveAccount(first.accountId, first.accountId)) assertFalse(desktopResourceActivationMatchesActiveAccount(second.accountId, first.accountId)) assertFalse(desktopResourceActivationMatchesActiveAccount(null, first.accountId)) + assertFalse( + guard.tryActivateResource { + desktopResourceActivationMatchesActiveAccount(second.accountId, first.accountId) && + true.also { hydrationRegistered = true } + }, + ) + assertFalse(hydrationRegistered) } @Test From 4f6fa2a156557c8ea4927a6001a9ee229aac94bb Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 04:02:43 +0200 Subject: [PATCH 009/119] fix(accounts): close remaining removal races --- .../AndroidAccountCredentialController.kt | 46 +++++-- .../AndroidAccountOperationGuard.kt | 14 ++ .../AndroidIncomingShareRecovery.kt | 125 +++++++++--------- .../AndroidNextcloudServices.kt | 17 ++- .../AndroidAccountOperationGuardTest.kt | 42 ++++++ .../AndroidPersistedSessionTest.kt | 50 +++++++ 6 files changed, 215 insertions(+), 79 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 1d4c1ced7..26f1d5ae4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -95,18 +95,15 @@ internal class AndroidAccountCredentialController( val current = requireValidState() val session = current.sessions[accountId] ?: return@withLock false ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(session)) { - if (current.registry.activeAccountId == accountId) { - clearSession(current) - } else { - val replacement = current.remove(accountId) - persistState(replacement) - try { - removeQueuedUploads(NextcloudDocumentIds.accountKey(session)) - } catch (failure: Exception) { - runCatching { persistState(current) } - .onFailure(failure::addSuppressed) - throw failure - } + val active = current.registry.activeAccountId == accountId + removeAndroidAccountCredentialData( + active = active, + removeQueuedUploads = { removeQueuedUploads(NextcloudDocumentIds.accountKey(session)) }, + clearActiveAccount = { clearSession(current) }, + persistInactiveRemoval = { persistState(current.remove(accountId)) }, + rollbackInactiveRemoval = { persistState(current) }, + ) + if (!active) { clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(session)) } } @@ -513,6 +510,31 @@ internal suspend fun resumeAndroidQueuedUploadsAfterSelection( } } +internal suspend fun removeAndroidAccountCredentialData( + active: Boolean, + removeQueuedUploads: suspend () -> Unit, + clearActiveAccount: suspend () -> Unit, + persistInactiveRemoval: suspend () -> Unit, + rollbackInactiveRemoval: suspend () -> Unit, +) { + if (active) { + removeQueuedUploads() + clearActiveAccount() + return + } + + persistInactiveRemoval() + try { + removeQueuedUploads() + } catch (failure: Exception) { + withContext(NonCancellable) { + runCatching { rollbackInactiveRemoval() } + .onFailure(failure::addSuppressed) + } + throw failure + } +} + internal class AndroidAccountCredentialStoreGuard { private val monitor = Any() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index 701ff30f7..5448d6a3f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -21,6 +21,20 @@ internal class AndroidAccountOperationGuard { } } + suspend fun withAccountSession( + accountId: String, + resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, + unavailable: suspend () -> Result, + action: suspend (dev.obiente.nextcloudnative.app.NextcloudSession) -> Result, + ): Result = withAccount(accountId) { + val session = resolveSession() + if (androidAccountOperationSessionIsCurrent(accountId, session)) { + action(requireNotNull(session)) + } else { + unavailable() + } + } + private class AccountLease( val mutex: Mutex = Mutex(), var references: Int = 0, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt index 5adc536cb..f0a6b8110 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt @@ -102,80 +102,77 @@ internal class AndroidIncomingShareChunkCleanupWorker( val claimed = store.claimChunkSessionForCleanup(requestId, chunk.uploadId) ?: return@withContext Result.success() val services = AndroidNextcloudServices(applicationContext) - val session = request.accountId?.let { accountIdentity -> - resolveStoredAndroidAccountSession( - accountIdentity = accountIdentity, - listAccounts = services::listAccounts, - loadSession = { accountId -> services.loadSession(accountId) }, - ) - } - if (session == null) { - return@withContext retryOrReleaseIncomingShareChunkCleanup( - store, - requestId, - claimed, - cleanupAttempt, - ) - } - if ( - request.userId.isNullOrBlank() - ) { - return@withContext retryOrReleaseIncomingShareChunkCleanup( + val unavailable = { + retryOrReleaseIncomingShareChunkCleanup( store, requestId, claimed, cleanupAttempt, ) } - val cancellation = CoroutineDocumentRequestCancellation(currentCoroutineContext().job) - try { - val remote = AndroidFileSyncRemoteTree( - session = session, - userId = request.userId, - remoteRootPath = request.destinationPath.orEmpty(), - webDav = NextcloudDocumentWebDav( - client = OkHttpClient.Builder() - .followRedirects(false) - .followSslRedirects(false) - .retryOnConnectionFailure(false) - .useAndroidNextcloudCertificateTrust(applicationContext) - .build(), - cloudMutationsAllowed = applicationContext.cloudMutationGate(), - ), - ) - remote.deleteChunkUpload(claimed.uploadId, cancellation) - store.clearChunkSessionForCleanup(requestId, claimed.uploadId) - releaseDiscardedIncomingShare(store, requestId) - Result.success() - } catch (failure: Throwable) { - cancellation.throwIfCancelled() - if ( - failure.isRetryableIncomingShareChunkCleanupFailure() && - canRetryIncomingShareChunkCleanup(cleanupAttempt) - ) { - val nowEpochMillis = System.currentTimeMillis() - val retryDelayMillis = failure.incomingShareChunkCleanupRetryDelayMillis(nowEpochMillis) - if (retryDelayMillis != null) { - scheduleIncomingShareChunkCleanup( - context = applicationContext, - requestId = requestId, - initialDelayMillis = retryDelayMillis, - cleanupAttempt = cleanupAttempt + 1, - policy = ExistingWorkPolicy.APPEND_OR_REPLACE, - ) - Result.success() - } else { - Result.retry() - } - } else { - // Nextcloud expires abandoned upload collections server-side. Once cleanup is - // definitively rejected or exhausts its bounded retries, release local staging. + val accountIdentity = request.accountId ?: return@withContext unavailable() + val userId = request.userId?.takeIf(String::isNotBlank) ?: return@withContext unavailable() + return@withContext ANDROID_ACCOUNT_OPERATION_GUARD.withAccountSession( + accountId = accountIdentity, + resolveSession = { + resolveStoredAndroidAccountSession( + accountIdentity = accountIdentity, + listAccounts = services::listAccounts, + loadSession = { accountId -> services.loadSession(accountId) }, + ) + }, + unavailable = unavailable, + ) { session -> + val cancellation = CoroutineDocumentRequestCancellation(currentCoroutineContext().job) + try { + val remote = AndroidFileSyncRemoteTree( + session = session, + userId = userId, + remoteRootPath = request.destinationPath.orEmpty(), + webDav = NextcloudDocumentWebDav( + client = OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .retryOnConnectionFailure(false) + .useAndroidNextcloudCertificateTrust(applicationContext) + .build(), + cloudMutationsAllowed = applicationContext.cloudMutationGate(), + ), + ) + remote.deleteChunkUpload(claimed.uploadId, cancellation) store.clearChunkSessionForCleanup(requestId, claimed.uploadId) releaseDiscardedIncomingShare(store, requestId) Result.success() + } catch (failure: Throwable) { + cancellation.throwIfCancelled() + if ( + failure.isRetryableIncomingShareChunkCleanupFailure() && + canRetryIncomingShareChunkCleanup(cleanupAttempt) + ) { + val nowEpochMillis = System.currentTimeMillis() + val retryDelayMillis = failure.incomingShareChunkCleanupRetryDelayMillis(nowEpochMillis) + if (retryDelayMillis != null) { + scheduleIncomingShareChunkCleanup( + context = applicationContext, + requestId = requestId, + initialDelayMillis = retryDelayMillis, + cleanupAttempt = cleanupAttempt + 1, + policy = ExistingWorkPolicy.APPEND_OR_REPLACE, + ) + Result.success() + } else { + Result.retry() + } + } else { + // Nextcloud expires abandoned upload collections server-side. Once cleanup is + // definitively rejected or exhausts its bounded retries, release local staging. + store.clearChunkSessionForCleanup(requestId, claimed.uploadId) + releaseDiscardedIncomingShare(store, requestId) + Result.success() + } + } finally { + cancellation.close() } - } finally { - cancellation.close() } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 39ed74158..c30cc0021 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -1801,9 +1801,20 @@ internal class AndroidNextcloudServices( ): FileSyncCenterActionResult = withContext(Dispatchers.IO) { val accountIdentity = NextcloudDocumentIds.accountKey(session) val fields = listOf(SupportDiagnosticFieldDraft("pair", pairId, SupportDiagnosticValuePrivacy.Identifier)) - diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.pair-remove", fields) { - fileSyncEngine.removePair(session, userId, pairId) - }.also { result -> recordFileSyncResult(accountIdentity, "sync.pair-remove", fields, result) } + ANDROID_ACCOUNT_OPERATION_GUARD.withAccountSession( + accountId = accountIdentity, + resolveSession = { loadSession() }, + unavailable = { + FileSyncCenterActionResult.Rejected( + "The account changed before folder sync removal could start.", + FileSyncRejectionScope.Preflight, + ) + }, + ) { current -> + diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.pair-remove", fields) { + fileSyncEngine.removePair(current, userId, pairId) + }.also { result -> recordFileSyncResult(accountIdentity, "sync.pair-remove", fields, result) } + } } override suspend fun listMedia( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index c98604d0b..1c4e9f08d 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred @@ -108,4 +109,45 @@ class AndroidAccountOperationGuardTest { removal.await() assertFalse(offlineSessionAvailable.await()) } + + @Test + fun accountSessionResolutionWaitsForRemovalAndSkipsTheStaleOperation() = runBlocking { + val guard = AndroidAccountOperationGuard() + val session = dev.obiente.nextcloudnative.app.NextcloudSession( + "https://first.example.test", + "alice", + "old-password", + ) + val accountIdentity = NextcloudDocumentIds.accountKey(session) + val removalCommitted = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var sessionAvailable = true + var operationRan = false + val removal = async { + guard.withAccount(accountIdentity) { + sessionAvailable = false + removalCommitted.complete(Unit) + releaseRemoval.await() + } + } + removalCommitted.await() + + val cleanup = async { + guard.withAccountSession( + accountId = accountIdentity, + resolveSession = { session.takeIf { sessionAvailable } }, + unavailable = { "unavailable" }, + ) { + operationRan = true + "deleted" + } + } + yield() + assertFalse(cleanup.isCompleted) + + releaseRemoval.complete(Unit) + removal.await() + assertEquals("unavailable", cleanup.await()) + assertFalse(operationRan) + } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index e94090124..d94a09f9d 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -7,6 +7,12 @@ import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.accountRecord import dev.obiente.nextcloudnative.app.encodeNextcloudAccountRegistry import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -537,6 +543,50 @@ class AndroidPersistedSessionTest { assertEquals(listOf("notify"), events) } + @Test + fun activeAccountRemovalCleansQueuedUploadsBeforeDeletingTheCredential() = runBlocking { + val events = mutableListOf() + + removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { events += "clear-account" }, + persistInactiveRemoval = { events += "persist-inactive" }, + rollbackInactiveRemoval = { events += "rollback-inactive" }, + ) + + assertEquals(listOf("remove-uploads", "clear-account"), events) + } + + @Test + fun cancelledInactiveAccountRemovalRollsBackNonCancellably() = runBlocking { + val cleanupEntered = CompletableDeferred() + val events = mutableListOf() + var rollbackWasActive = false + val removal = launch { + removeAndroidAccountCredentialData( + active = false, + removeQueuedUploads = { + events += "remove-uploads" + cleanupEntered.complete(Unit) + awaitCancellation() + }, + clearActiveAccount = { events += "clear-account" }, + persistInactiveRemoval = { events += "persist-removal" }, + rollbackInactiveRemoval = { + rollbackWasActive = currentCoroutineContext().isActive + events += "rollback" + }, + ) + } + cleanupEntered.await() + + removal.cancelAndJoin() + + assertTrue(rollbackWasActive) + assertEquals(listOf("persist-removal", "remove-uploads", "rollback"), events) + } + private fun assertDiagnosticsExcludePrivateValues(diagnostics: List) { val rendered = diagnostics.joinToString() assertFalse(rendered.contains("private-app-password")) From 1c31b71e848ffcc59417c752caedd42186f67c71 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 04:19:38 +0200 Subject: [PATCH 010/119] fix(accounts): preserve credential recovery invariants --- .../AndroidAccountCredentialController.kt | 52 ++++++++++++-- .../AndroidDurableUploadAccountCleanup.kt | 26 +++++-- .../AndroidPersistedSession.kt | 7 +- .../nextcloudnative/NextcloudDocumentIds.kt | 8 +-- ...AndroidDurableMultipartUploadPolicyTest.kt | 30 ++++++++ .../AndroidPersistedSessionTest.kt | 70 +++++++++++++++++++ .../NextcloudDocumentIdsTest.kt | 20 +++--- 7 files changed, 183 insertions(+), 30 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 26f1d5ae4..b411fb489 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -52,12 +52,31 @@ internal class AndroidAccountCredentialController( ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { val registry = readCredentialFreeRegistry() ?: return@serialize null if (registry.accounts.none { account -> account.id == accountId }) return@serialize null - if (!preferences.contains(androidAccountCredentialSlotKey(accountId))) { - val state = (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state - ?: return@serialize null - commitPreferences(prepareCredentialSlotEdit(preferences.edit(), state)) + val storedSlot = readCredentialSlot(accountId) + val restoredSlot = recoverAndroidAccountCredentialSlot(accountId, registry, storedSlot, aggregate = null) + val aggregate = if (restoredSlot == null) { + (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state + } else { + null } - readCredentialSlot(accountId)?.also(registerSessionPrivateValues) + val session = restoredSlot ?: recoverAndroidAccountCredentialSlot( + accountId, + registry, + storedSlot = null, + aggregate = aggregate, + ) + ?: return@serialize null + if (storedSlot != session) { + runCatching { + commitPreferences( + preferences.edit().putString( + androidAccountCredentialSlotKey(accountId), + encryptCredentialSlot(session), + ), + ) + } + } + session.also(registerSessionPrivateValues) } suspend fun saveSession(session: NextcloudSession) = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { @@ -403,6 +422,16 @@ internal class AndroidAccountCredentialController( throw failure } + private fun encryptCredentialSlot(session: NextcloudSession): String = try { + sessionCipher.encrypt(encodeAndroidPersistedSession(session)) + } catch (failure: Exception) { + recordCredentialFailure( + code = "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", + operation = "account-credentials.repair-slot", + ) + throw failure + } + private fun prepareCredentialSlotEdit( editor: SharedPreferences.Editor, state: AndroidAccountCredentialState, @@ -481,6 +510,17 @@ internal fun readAndroidAccountCredentialSlot( return decode(decrypt(encrypted))?.takeIf { session -> session.accountId == accountId } } +internal fun recoverAndroidAccountCredentialSlot( + accountId: NextcloudAccountId, + registry: NextcloudAccountRegistry, + storedSlot: NextcloudSession?, + aggregate: AndroidAccountCredentialState?, +): NextcloudSession? { + val account = registry.accounts.firstOrNull { candidate -> candidate.id == accountId } ?: return null + return storedSlot?.takeIf { session -> session.accountRecord() == account } + ?: aggregate?.sessions?.get(accountId)?.takeIf { session -> session.accountRecord() == account } +} + internal fun reconstructAndroidAccountCredentialState( registry: NextcloudAccountRegistry, loadSession: (NextcloudAccountId) -> NextcloudSession?, @@ -500,7 +540,7 @@ internal suspend fun resumeAndroidQueuedUploadsAfterSelection( recordFailure: () -> Unit, ) { try { - withContext(NonCancellable) { resume() } + resume() } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt index 03d84f604..926b6c9ba 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableUploadAccountCleanup.kt @@ -9,11 +9,27 @@ internal class AndroidDurableUploadAccountCleanup(context: Context) { private val store = AndroidDurableMultipartUploadStore(appContext) suspend fun removeForAccount(accountId: String) { - store.list().filter { job -> job.accountId == accountId }.forEach { job -> - WorkManager.getInstance(appContext).cancelUniqueWork(durableUploadWorkName(job.id)).await() - } - val removed = store.removeForAccount(accountId) val picker = AndroidLocalUploadPicker(appContext) - removed.forEach { job -> picker.release(job.request.file) } + removeAndroidDurableUploadJobs( + jobs = store.list().filter { job -> job.accountId == accountId }, + cancelWork = { job -> + WorkManager.getInstance(appContext).cancelUniqueWork(durableUploadWorkName(job.id)).await() + }, + releaseCapability = { job -> picker.release(job.request.file) }, + removeJob = store::remove, + ) + } +} + +internal suspend fun removeAndroidDurableUploadJobs( + jobs: List, + cancelWork: suspend (AndroidDurableMultipartUploadJob) -> Unit, + releaseCapability: (AndroidDurableMultipartUploadJob) -> Boolean, + removeJob: (String) -> Unit, +) { + jobs.forEach { job -> cancelWork(job) } + jobs.forEach { job -> + check(releaseCapability(job)) { "The durable upload source capability could not be released." } + removeJob(job.id) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index da8b0a896..eed016493 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -35,9 +35,12 @@ internal data class AndroidAccountCredentialState( fun upsertAndSelect(session: NextcloudSession): AndroidAccountCredentialState { requireMutationsAllowed() + val stableSession = sessions[session.accountId] + ?.let { retained -> session.copy(serverUrl = retained.serverUrl) } + ?: session return copy( - registry = registry.upsertAndSelect(session.accountRecord()), - sessions = sessions + (session.accountId to session), + registry = registry.upsertAndSelect(stableSession.accountRecord()), + sessions = sessions + (stableSession.accountId to stableSession), ) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt index 3b88c5017..a703d3172 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt @@ -3,7 +3,6 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.NextcloudSession import java.security.MessageDigest import java.util.Base64 -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull internal data class NextcloudDocumentReference( val accountKey: String, @@ -62,12 +61,7 @@ internal object NextcloudDocumentIds { } private fun accountDigest(serverUrl: String, loginName: String): ByteArray { - val url = serverUrl.trim().toHttpUrlOrNull() - requireNotNull(url) { "The account server address is invalid." } - require(url.username.isEmpty() && url.password.isEmpty() && url.query == null && url.fragment == null) { - "The account server address contains unsupported URL components." - } - val identity = url.toString().trimEnd('/') + "\n" + loginName + val identity = serverUrl.trimEnd('/') + "\n" + loginName return MessageDigest.getInstance("SHA-256").digest(identity.encodeToByteArray()) } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 2b0351fd8..7efb683ab 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -16,6 +16,36 @@ import kotlin.test.assertTrue import org.json.JSONArray class AndroidDurableMultipartUploadPolicyTest { + @Test + fun `account cleanup removes a row only after its source capability is released`() = runBlocking { + val first = fixtureJob(index = 1, account = ACCOUNT_A, cardId = 42) + val second = fixtureJob(index = 2, account = ACCOUNT_A, cardId = 43) + val events = mutableListOf() + + assertFailsWith { + removeAndroidDurableUploadJobs( + jobs = listOf(first, second), + cancelWork = { job -> events += "cancel:${job.id}" }, + releaseCapability = { job -> + events += "release:${job.id}" + job == first + }, + removeJob = { jobId -> events += "remove:$jobId" }, + ) + } + + assertEquals( + listOf( + "cancel:${first.id}", + "cancel:${second.id}", + "release:${first.id}", + "remove:${first.id}", + "release:${second.id}", + ), + events, + ) + } + @Test fun `removing an account deletes only its queued upload recovery rows`() { val storage = FakeDurableUploadEncryptedStorage() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index d94a09f9d..4b07e5bd8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -194,6 +194,23 @@ class AndroidPersistedSessionTest { assertEquals(first, requireNotNull(restarted.select(first.accountId)).activeSession) } + @Test + fun equivalentReauthenticationRetainsThePersistedAndroidWorkIdentity() { + val original = firstSession().copy(serverUrl = "https://CLOUD.EXAMPLE.TEST:443/") + val reauthenticated = firstSession().copy(appPassword = "rotated-private-password") + assertEquals(original.accountId, reauthenticated.accountId) + + val updated = AndroidAccountCredentialState.Empty + .upsertAndSelect(original) + .upsertAndSelect(reauthenticated) + + val active = requireNotNull(updated.activeSession) + assertEquals(original.serverUrl, active.serverUrl) + assertEquals(reauthenticated.appPassword, active.appPassword) + assertEquals(NextcloudDocumentIds.accountKey(original), NextcloudDocumentIds.accountKey(active)) + assertEquals(NextcloudDocumentIds.cacheAccountId(original), NextcloudDocumentIds.cacheAccountId(active)) + } + @Test fun encodingIsDeterministicAcrossCredentialInsertionOrder() { val firstThenSecond = AndroidAccountCredentialState.Empty @@ -487,6 +504,37 @@ class AndroidPersistedSessionTest { assertNull(restored) } + @Test + fun damagedCredentialSlotRecoversFromTheMatchingAggregateCredential() { + val session = firstSession() + val aggregate = AndroidAccountCredentialState.Empty.upsertAndSelect(session) + + val recovered = recoverAndroidAccountCredentialSlot( + accountId = session.accountId, + registry = aggregate.registry, + storedSlot = null, + aggregate = aggregate, + ) + + assertEquals(session, recovered) + } + + @Test + fun credentialSlotRecoveryRejectsAnAggregateThatDoesNotMatchTheVisibleRegistry() { + val original = firstSession().copy(serverUrl = "https://CLOUD.EXAMPLE:443/") + val aggregate = AndroidAccountCredentialState.Empty.upsertAndSelect(firstSession()) + val visibleRegistry = NextcloudAccountRegistry.Empty.upsertAndSelect(original.accountRecord()) + + val recovered = recoverAndroidAccountCredentialSlot( + accountId = original.accountId, + registry = visibleRegistry, + storedSlot = null, + aggregate = aggregate, + ) + + assertNull(recovered) + } + @Test fun validIndependentSlotsCanRecoverAroundAMalformedAggregateStore() { val first = firstSession() @@ -543,6 +591,28 @@ class AndroidPersistedSessionTest { assertEquals(listOf("notify"), events) } + @Test + fun parentCancellationStopsQueuedUploadResumeAndStillNotifies() = runBlocking { + val resumeEntered = CompletableDeferred() + val events = mutableListOf() + val selection = launch { + resumeAndroidQueuedUploadsAfterSelection( + resume = { + events += "resume" + resumeEntered.complete(Unit) + awaitCancellation() + }, + notifyDocumentRootsChanged = { events += "notify" }, + recordFailure = { events += "diagnose" }, + ) + } + resumeEntered.await() + + selection.cancelAndJoin() + + assertEquals(listOf("resume", "notify"), events) + } + @Test fun activeAccountRemovalCleansQueuedUploadsBeforeDeletingTheCredential() = runBlocking { val events = mutableListOf() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt index ebeb185e5..678ae76f8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIdsTest.kt @@ -49,17 +49,17 @@ class NextcloudDocumentIdsTest { } @Test - fun accountWorkIdentityIsStableAcrossEquivalentServerSpellings() { - val equivalent = listOf( - session.copy(serverUrl = "https://CLOUD.EXAMPLE"), - session.copy(serverUrl = "https://cloud.example:443/"), - session.copy(serverUrl = " https://cloud.example "), - ) + fun accountWorkIdentityRetainsThePreRegistryRawServerDigest() { + val legacySession = session.copy(serverUrl = "https://CLOUD.EXAMPLE:443/") - equivalent.forEach { candidate -> - assertEquals(NextcloudDocumentIds.accountKey(session), NextcloudDocumentIds.accountKey(candidate)) - assertEquals(NextcloudDocumentIds.cacheAccountId(session), NextcloudDocumentIds.cacheAccountId(candidate)) - } + assertEquals( + "c21f46fbb8dbbf9611423baaaf1dd45a", + NextcloudDocumentIds.accountKey(legacySession), + ) + assertEquals( + "c21f46fbb8dbbf9611423baaaf1dd45a664f9593a1d14bb41d486e01b0e54c24", + NextcloudDocumentIds.cacheAccountId(legacySession), + ) } @Test From cec7136c2baf59f1e09b5352ec0108a1e306f784 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 04:32:00 +0200 Subject: [PATCH 011/119] fix(accounts): retire account-bound transfer resources --- .../AndroidAccountCredentialController.kt | 4 +- .../AndroidIncomingShareAccountCleanup.kt | 112 ++++++++++++++++++ .../AndroidIncomingShareRecovery.kt | 5 +- .../AndroidNextcloudServices.kt | 6 +- .../AndroidIncomingShareStateTest.kt | 49 ++++++++ .../app/DesktopAccountOperationGuard.kt | 8 +- .../app/DesktopNextcloudServices.kt | 12 +- .../app/DesktopAccountOperationGuardTest.kt | 20 ++++ 8 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index b411fb489..b68a01457 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -31,7 +31,7 @@ internal class AndroidAccountCredentialController( private val clearPreviewAccount: (String) -> Unit, private val notifyDocumentRootsChanged: () -> Unit, private val resumeQueuedUploads: suspend (String) -> Unit, - private val removeQueuedUploads: suspend (String) -> Unit, + private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, ) { private val appContext = context.applicationContext @@ -117,7 +117,7 @@ internal class AndroidAccountCredentialController( val active = current.registry.activeAccountId == accountId removeAndroidAccountCredentialData( active = active, - removeQueuedUploads = { removeQueuedUploads(NextcloudDocumentIds.accountKey(session)) }, + removeQueuedUploads = { removeQueuedUploads(session) }, clearActiveAccount = { clearSession(current) }, persistInactiveRemoval = { persistState(current.remove(accountId)) }, rollbackInactiveRemoval = { persistState(current) }, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt new file mode 100644 index 000000000..0eebd1073 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt @@ -0,0 +1,112 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import androidx.work.WorkManager +import androidx.work.await +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.useAndroidNextcloudCertificateTrust +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.job +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient + +internal data class AndroidIncomingShareAccountRequest( + val id: String, + val request: AndroidIncomingShareRequest?, +) + +internal class AndroidIncomingShareAccountCleanup(context: Context) { + private val appContext = context.applicationContext + private val store = AndroidIncomingShareStore(appContext) + + suspend fun removeForAccount(session: NextcloudSession) = withContext(Dispatchers.IO) { + val accountId = NextcloudDocumentIds.accountKey(session) + val workManager = WorkManager.getInstance(appContext) + val webDav = NextcloudDocumentWebDav( + client = OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .retryOnConnectionFailure(false) + .useAndroidNextcloudCertificateTrust(appContext) + .build(), + cloudMutationsAllowed = appContext.cloudMutationGate(), + ) + removeAndroidIncomingShareRequests( + requests = store.listForAccount(accountId), + cancelWork = { requestId -> + incomingShareAccountWorkNames(requestId).forEach { workName -> + workManager.cancelUniqueWork(workName).await() + } + }, + releaseChunk = { request, uploadId -> + val userId = requireNotNull(request.userId?.takeIf(String::isNotBlank)) { + "The staged share chunk is missing its account owner." + } + val cancellation = CoroutineDocumentRequestCancellation(currentCoroutineContext().job) + try { + webDav.deleteChunkUpload(session, userId, uploadId, cancellation) + } finally { + cancellation.close() + } + }, + removeRequest = { requestId -> + check(store.remove(requestId)) { "The staged share data could not be released." } + NotificationManagerCompat.from(appContext).apply { + cancel(incomingShareNotificationId(requestId)) + cancel(incomingShareForegroundNotificationId(requestId)) + } + }, + ) + } +} + +internal fun AndroidIncomingShareStore.listForAccount(accountId: String): List { + require(accountId.isNotBlank()) + return synchronized(AndroidIncomingShareStore.LOCK) { + root.listFiles().orEmpty() + .asSequence() + .filter { directory -> + directory.isDirectory && runCatching { UUID.fromString(directory.name) }.isSuccess + } + .mapNotNull { directory -> + val id = directory.name + when (val loaded = loadResult(id)) { + AndroidIncomingShareLoadResult.Missing -> null + is AndroidIncomingShareLoadResult.Available -> loaded.request + .takeIf { request -> request.accountId == accountId } + ?.let { request -> AndroidIncomingShareAccountRequest(id, request) } + is AndroidIncomingShareLoadResult.Corrupt -> + id.takeIf { corruptRecoveryAccountId(id) == accountId } + ?.let { AndroidIncomingShareAccountRequest(it, request = null) } + } + } + .toList() + } +} + +internal fun incomingShareAccountWorkNames(requestId: String): List = listOf( + incomingShareUploadWorkName(requestId), + incomingShareRetryWorkName(requestId), + incomingShareCleanupWorkName(requestId), + incomingShareChunkCleanupWorkName(requestId), + incomingShareReleaseWorkName(requestId), + incomingShareAbandonedStagingWorkName(requestId), +) + +internal suspend fun removeAndroidIncomingShareRequests( + requests: List, + cancelWork: suspend (String) -> Unit, + releaseChunk: suspend (AndroidIncomingShareRequest, String) -> Unit, + removeRequest: (String) -> Unit, +) { + requests.forEach { request -> cancelWork(request.id) } + requests.forEach { accountRequest -> + accountRequest.request?.chunkSession?.let { chunk -> + releaseChunk(accountRequest.request, chunk.uploadId) + } + } + requests.forEach { request -> removeRequest(request.id) } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt index f0a6b8110..bd08ca752 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareRecovery.kt @@ -230,7 +230,7 @@ internal fun scheduleIncomingShareCleanup(context: Context, requestId: String) { internal fun scheduleIncomingShareAbandonedStagingCleanup(context: Context, requestId: String) { WorkManager.getInstance(context).enqueueUniqueWork( - "incoming-share-abandoned-staging-$requestId", + incomingShareAbandonedStagingWorkName(requestId), ExistingWorkPolicy.KEEP, OneTimeWorkRequestBuilder() .setInitialDelay(ABANDONED_INCOMING_SHARE_STAGING_RETENTION_MILLIS, TimeUnit.MILLISECONDS) @@ -269,6 +269,9 @@ internal fun incomingShareCleanupWorkName(requestId: String) = "incoming-share-c internal fun incomingShareChunkCleanupWorkName(requestId: String) = "incoming-share-chunk-cleanup-$requestId" +internal fun incomingShareAbandonedStagingWorkName(requestId: String) = + "incoming-share-abandoned-staging-$requestId" + internal fun incomingShareRecoveryPendingIntent(context: Context, requestId: String): PendingIntent = PendingIntent.getActivity( context, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index c30cc0021..3ae58139a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -471,6 +471,7 @@ internal class AndroidNextcloudServices( private val projectContent = AndroidProjectContentClient(appContext, activity) private val durableMultipartUploads = AndroidDurableMultipartUploads(appContext) private val durableUploadAccountCleanup = AndroidDurableUploadAccountCleanup(appContext) + private val incomingShareAccountCleanup = AndroidIncomingShareAccountCleanup(appContext) private val deckCardDrafts = AndroidDeckCardDraftStore(appContext) private val supportDiagnostics = AndroidSupportDiagnostics.get(appContext) private val supportBundleExporter = AndroidSupportBundleExporter( @@ -496,7 +497,10 @@ internal class AndroidNextcloudServices( clearPreviewAccount = nativeMediaPreviewCache::clearAccount, notifyDocumentRootsChanged = ::notifyDocumentsRootsChanged, resumeQueuedUploads = durableMultipartUploads::resumeQueuedForAccount, - removeQueuedUploads = durableUploadAccountCleanup::removeForAccount, + removeQueuedUploads = { session -> + incomingShareAccountCleanup.removeForAccount(session) + durableUploadAccountCleanup.removeForAccount(NextcloudDocumentIds.accountKey(session)) + }, ) init { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt index af194ad05..99b2183ec 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt @@ -13,6 +13,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking class AndroidIncomingShareStateTest { @Test @@ -708,6 +709,54 @@ class AndroidIncomingShareStateTest { } } + @Test + fun accountRemovalCancelsEveryShareWorkerBeforeReleasingChunksAndStaging() = runBlocking { + val uploadId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + val staged = request(AndroidIncomingShareState.Uploading).copy( + userId = "alice", + destinationPath = "Shared", + chunkSession = AndroidIncomingShareChunkSession( + fileIndex = 0, + targetName = "first.txt", + uploadId = uploadId, + ), + ) + val corruptId = "fedcba98-7654-3210-fedc-ba9876543210" + val events = mutableListOf() + + removeAndroidIncomingShareRequests( + requests = listOf( + AndroidIncomingShareAccountRequest(staged.id, staged), + AndroidIncomingShareAccountRequest(corruptId, request = null), + ), + cancelWork = { requestId -> events += "cancel:$requestId" }, + releaseChunk = { request, chunkId -> events += "release:${request.id}:$chunkId" }, + removeRequest = { requestId -> events += "remove:$requestId" }, + ) + + assertEquals( + listOf( + "cancel:${staged.id}", + "cancel:$corruptId", + "release:${staged.id}:$uploadId", + "remove:${staged.id}", + "remove:$corruptId", + ), + events, + ) + assertEquals( + setOf( + incomingShareUploadWorkName(staged.id), + incomingShareRetryWorkName(staged.id), + incomingShareCleanupWorkName(staged.id), + incomingShareChunkCleanupWorkName(staged.id), + incomingShareReleaseWorkName(staged.id), + incomingShareAbandonedStagingWorkName(staged.id), + ), + incomingShareAccountWorkNames(staged.id).toSet(), + ) + } + private fun request(state: AndroidIncomingShareState) = AndroidIncomingShareRequest( id = "01234567-89ab-cdef-0123-456789abcdef", files = listOf( diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 80cba15cb..9b716146e 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -44,6 +44,12 @@ internal fun desktopSessionSaveSwitchesAccount( savedAccountId: NextcloudAccountId, ): Boolean = activeAccountId != null && activeAccountId != savedAccountId +internal fun desktopSessionSaveReplacesActiveCredential( + activeSession: NextcloudSession?, + savedSession: NextcloudSession, +): Boolean = activeSession?.accountId == savedSession.accountId && + activeSession.appPassword != savedSession.appPassword + internal fun desktopResourceActivationMatchesActiveAccount( activeAccountId: NextcloudAccountId?, requestedAccountId: NextcloudAccountId, @@ -55,7 +61,7 @@ internal fun requireDesktopSessionSaveAllowed( ) { if (allowed) return recordBlocked(desktopAccountSelectionBlockedDiagnostic()) - error("Close files and virtual folders before switching accounts.") + error("Close files and virtual folders before switching accounts or replacing credentials.") } internal inline fun reopenDesktopSessionAfterSelection( 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 3eaee286f..ec60539d6 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3699,11 +3699,15 @@ class DesktopNextcloudServices( override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { accountOperationGuard.serializeWhenSyncIdle { - requireDesktopSessionSaveAllowed( - !desktopSessionSaveSwitchesAccount(activeAccountId(), session.accountId) || !hasLiveAccountResources(), - ::recordSupportDiagnostic, - ) sessionPublicationGuard.serialize { + val activeAccountId = accountCredentials.activeAccountId() + val activeSession = activeAccountId?.let(accountCredentials::loadSession) + val invalidatesLiveResources = desktopSessionSaveSwitchesAccount(activeAccountId, session.accountId) || + desktopSessionSaveReplacesActiveCredential(activeSession, session) + requireDesktopSessionSaveAllowed( + !invalidatesLiveResources || !hasLiveAccountResources(), + ::recordSupportDiagnostic, + ) accountCredentials.saveSession(session) accountSessionPublication.publish(session) } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index d55673053..4472c5d89 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -141,6 +141,26 @@ class DesktopAccountOperationGuardTest { assertTrue(desktopSessionSaveSwitchesAccount(first.accountId, second.accountId)) } + @Test + fun activeCredentialReplacementRequiresLiveResourcesToClose() { + val original = NextcloudSession("https://first.example.test", "alice", "one") + + assertFalse(desktopSessionSaveReplacesActiveCredential(activeSession = null, savedSession = original)) + assertFalse(desktopSessionSaveReplacesActiveCredential(original, original.copy())) + assertTrue( + desktopSessionSaveReplacesActiveCredential( + original, + original.copy(appPassword = "replacement-password"), + ), + ) + assertFalse( + desktopSessionSaveReplacesActiveCredential( + original, + NextcloudSession("https://second.example.test", "alice", "replacement-password"), + ), + ) + } + @Test fun blockedAccountSaveRecordsTheSelectionDiagnosticBeforeFailing() { val diagnostics = mutableListOf() From 1e404f6ec6f2100c34c530176be86e0433470f77 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 05:01:14 +0200 Subject: [PATCH 012/119] fix(accounts): preserve active resource identity --- .../AndroidAccountCredentialController.kt | 20 +++++++- .../AndroidPersistedSessionTest.kt | 48 ++++++++++++++++++- .../DesktopAccountCredentialPersistence.kt | 11 +++-- .../app/DesktopAccountOperationGuard.kt | 5 ++ .../app/DesktopNextcloudServices.kt | 15 ++++++ ...DesktopAccountCredentialPersistenceTest.kt | 26 ++++++++++ .../app/DesktopAccountOperationGuardTest.kt | 16 +++++++ 7 files changed, 133 insertions(+), 8 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index b68a01457..047df2fc0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -119,6 +119,13 @@ internal class AndroidAccountCredentialController( active = active, removeQueuedUploads = { removeQueuedUploads(session) }, clearActiveAccount = { clearSession(current) }, + rollbackActiveRemoval = { + replaceActiveStateWhileOperationsIdle( + replacement = current, + previousSession = null, + suspectEncrypted = null, + ) + }, persistInactiveRemoval = { persistState(current.remove(accountId)) }, rollbackInactiveRemoval = { persistState(current) }, ) @@ -554,12 +561,21 @@ internal suspend fun removeAndroidAccountCredentialData( active: Boolean, removeQueuedUploads: suspend () -> Unit, clearActiveAccount: suspend () -> Unit, + rollbackActiveRemoval: suspend () -> Unit, persistInactiveRemoval: suspend () -> Unit, rollbackInactiveRemoval: suspend () -> Unit, ) { if (active) { - removeQueuedUploads() - clearActiveAccount() + try { + clearActiveAccount() + removeQueuedUploads() + } catch (failure: Exception) { + withContext(NonCancellable) { + runCatching { rollbackActiveRemoval() } + .onFailure(failure::addSuppressed) + } + throw failure + } return } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 4b07e5bd8..ee578ce62 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -614,18 +614,61 @@ class AndroidPersistedSessionTest { } @Test - fun activeAccountRemovalCleansQueuedUploadsBeforeDeletingTheCredential() = runBlocking { + fun activeAccountRemovalDeletesTheCredentialBeforeIrreversibleUploadCleanup() = runBlocking { val events = mutableListOf() removeAndroidAccountCredentialData( active = true, removeQueuedUploads = { events += "remove-uploads" }, clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, persistInactiveRemoval = { events += "persist-inactive" }, rollbackInactiveRemoval = { events += "rollback-inactive" }, ) - assertEquals(listOf("remove-uploads", "clear-account"), events) + assertEquals(listOf("clear-account", "remove-uploads"), events) + } + + @Test + fun failedActiveUploadCleanupRestoresTheRemovedCredentialState() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = { + events += "remove-uploads" + error("synthetic cleanup failure") + }, + clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { events += "persist-inactive" }, + rollbackInactiveRemoval = { events += "rollback-inactive" }, + ) + } + + assertEquals(listOf("clear-account", "remove-uploads", "rollback-active"), events) + } + + @Test + fun failedActiveCredentialRemovalDoesNotStartUploadCleanupAndAttemptsRollback() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { + events += "clear-account" + error("synthetic credential persistence failure") + }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { events += "persist-inactive" }, + rollbackInactiveRemoval = { events += "rollback-inactive" }, + ) + } + + assertEquals(listOf("clear-account", "rollback-active"), events) } @Test @@ -642,6 +685,7 @@ class AndroidPersistedSessionTest { awaitCancellation() }, clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, persistInactiveRemoval = { events += "persist-removal" }, rollbackInactiveRemoval = { rollbackWasActive = currentCoroutineContext().isActive diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index d330be74b..6f4cbdcf9 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -50,12 +50,15 @@ internal class DesktopAccountCredentialPersistence( val registry = read.registry ?: restoreLegacySession(read.encoded != null)?.let { requireNotNull(readRegistry().registry) } ?: if (read.encoded == null) NextcloudAccountRegistry.Empty else throw invalidRegistryForMutation() - val updatedRegistry = registry.upsertAndSelect(session.accountRecord()) - val encodedRegistry = prepareRegistry(updatedRegistry) - val secretReference = desktopAccountSecretReference(session.accountId) val previousRecord = registry.accounts.firstOrNull { account -> account.id == session.accountId } + val persistedSession = previousRecord + ?.let { record -> session.copy(serverUrl = record.serverUrl, loginName = record.loginName) } + ?: session + val updatedRegistry = registry.upsertAndSelect(persistedSession.accountRecord()) + val encodedRegistry = prepareRegistry(updatedRegistry) + val secretReference = desktopAccountSecretReference(persistedSession.accountId) val previousSecret = loadSecretForRollback(secretReference) - saveSecret(session) + saveSecret(persistedSession) try { persistAccountState(encodedRegistry, updatedRegistry.activeAccount) } catch (failure: Exception) { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 9b716146e..ad800d963 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -55,6 +55,11 @@ internal fun desktopResourceActivationMatchesActiveAccount( requestedAccountId: NextcloudAccountId, ): Boolean = activeAccountId == requestedAccountId +internal fun desktopSyncRunMatchesActiveSession( + activeSession: NextcloudSession?, + requestedSession: NextcloudSession, +): Boolean = activeSession == requestedSession + internal fun requireDesktopSessionSaveAllowed( allowed: Boolean, recordBlocked: (SupportDiagnosticEventDraft) -> Unit, 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 ec60539d6..8a14fdd5a 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -2724,6 +2724,11 @@ class DesktopNextcloudServices( ) diagnoseDesktopSupportFailure(accountId, "sync.pair-run", diagnosticFields) { accountOperationGuard.withSyncRunLock syncRun@{ + if (!desktopSyncRunMatchesActiveSession(loadSession(), session)) { + return@syncRun FileSyncCenterActionResult.Rejected( + "The account changed before this desktop sync could start.", + ) + } if (isFileSyncPaused()) { return@syncRun FileSyncCenterActionResult.Rejected( "Desktop syncing is paused. Resume it from the system tray first.", @@ -2774,6 +2779,11 @@ class DesktopNextcloudServices( ) diagnoseDesktopSupportFailure(accountId, "sync.conflict-resolve", diagnosticFields) { accountOperationGuard.withSyncRunLock syncRun@{ + if (!desktopSyncRunMatchesActiveSession(loadSession(), session)) { + return@syncRun FileSyncCenterActionResult.Rejected( + "The account changed before this desktop sync could start.", + ) + } if (isFileSyncPaused()) { return@syncRun FileSyncCenterActionResult.Rejected( "Desktop syncing is paused. Resume it from the system tray first.", @@ -2821,6 +2831,11 @@ class DesktopNextcloudServices( ) diagnoseDesktopSupportFailure(accountId, "sync.conflict-resolve-batch", diagnosticFields) { accountOperationGuard.withSyncRunLock syncRun@{ + if (!desktopSyncRunMatchesActiveSession(loadSession(), session)) { + return@syncRun FileSyncCenterActionResult.Rejected( + "The account changed before this desktop sync could start.", + ) + } if (isFileSyncPaused()) { return@syncRun FileSyncCenterActionResult.Rejected( "Desktop syncing is paused. Resume it from the system tray first.", diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index d48fec686..c84d9e2d8 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -91,6 +91,32 @@ class DesktopAccountCredentialPersistenceTest { assertEquals(original, persistence(preferences, secrets).loadActiveSession()) } + @Test + fun canonicalEquivalentReauthenticationPreservesDesktopStorageIdentity() = + withStore { preferences, secrets -> + val original = NextcloudSession( + serverUrl = "https://CLOUD.example.test:443/nextcloud", + loginName = "alice", + appPassword = "original-password", + ) + val replacement = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud/", + loginName = "alice", + appPassword = "replacement-password", + ) + assertEquals(original.accountId, replacement.accountId) + val persistence = persistence(preferences, secrets) + persistence.saveSession(original) + + persistence.saveSession(replacement) + + val restored = persistence(preferences, secrets).loadActiveSession() + assertEquals(original.serverUrl, restored?.serverUrl) + assertEquals(replacement.appPassword, restored?.appPassword) + assertEquals(desktopFileCacheAccountId(original), restored?.let(::desktopFileCacheAccountId)) + assertEquals(original.serverUrl, decodeRegistry(preferences).activeAccount?.serverUrl) + } + @Test fun unsupportedFutureRegistryUsesLegacyCredentialWithoutOverwritingIt() = withStore { preferences, secrets -> val session = firstSession() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 4472c5d89..13c5b17a2 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -131,6 +131,22 @@ class DesktopAccountOperationGuardTest { assertFalse(hydrationRegistered) } + @Test + fun syncRunRejectsAStaleAccountAfterWaitingForSelection() { + val first = NextcloudSession("https://first.example.test", "alice", "one") + val second = NextcloudSession("https://second.example.test", "bob", "two") + + assertTrue(desktopSyncRunMatchesActiveSession(first, first.copy())) + assertFalse(desktopSyncRunMatchesActiveSession(second, first)) + assertFalse(desktopSyncRunMatchesActiveSession(activeSession = null, first)) + assertFalse( + desktopSyncRunMatchesActiveSession( + first.copy(appPassword = "rotated"), + first, + ), + ) + } + @Test fun differentAccountSaveRequiresTheSelectionTransition() { val first = NextcloudSession("https://first.example.test", "alice", "one") From 6a1f33e762002ba5e6dc20d1bb94da54d2769ce5 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 05:27:21 +0200 Subject: [PATCH 013/119] fix(android): serialize account-bound writebacks --- .../AndroidAccountCredentialController.kt | 12 ++-- .../AndroidAccountOperationGuard.kt | 65 ++++++++++++++++--- .../AndroidDocumentWritebackRecovery.kt | 29 +++++++++ .../AndroidIncomingShareAccountCleanup.kt | 15 ++++- .../NextcloudDocumentsProvider.kt | 17 ++--- .../AndroidAccountOperationGuardTest.kt | 50 ++++++++++++++ .../AndroidIncomingShareStateTest.kt | 45 +++++++++++++ 7 files changed, 205 insertions(+), 28 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 047df2fc0..251563527 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -212,16 +212,12 @@ internal class AndroidAccountCredentialController( previousSession: NextcloudSession?, suspectEncrypted: String? = null, ) { - val replace = suspend { + val replacementSession = requireNotNull(replacement.activeSession) + val affectedAccountIds = listOfNotNull(previousSession, replacementSession) + .map(NextcloudDocumentIds::accountKey) + ANDROID_ACCOUNT_OPERATION_GUARD.withAccounts(affectedAccountIds) { replaceActiveStateWhileOperationsIdle(replacement, previousSession, suspectEncrypted) } - if (previousSession == null) { - replace() - } else { - ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(previousSession)) { - replace() - } - } } private suspend fun replaceActiveStateWhileOperationsIdle( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index 5448d6a3f..17439a26c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -1,26 +1,34 @@ package dev.obiente.nextcloudnative +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock internal class AndroidAccountOperationGuard { private val monitor = Any() private val accountLeases = mutableMapOf() suspend fun withAccount(accountId: String, action: suspend () -> Result): Result { - val lease = synchronized(monitor) { - accountLeases.getOrPut(accountId) { AccountLease() }.also { it.references += 1 } - } + val lease = acquire(accountId) return try { - lease.mutex.withLock { action() } + action() + } finally { + lease.close() + } + } + + suspend fun withAccounts(accountIds: Collection, action: suspend () -> Result): Result { + val leases = mutableListOf() + try { + accountIds.distinct().sorted().forEach { accountId -> leases += acquire(accountId) } + return action() } finally { - synchronized(monitor) { - lease.references -= 1 - if (lease.references == 0) accountLeases.remove(accountId, lease) - } + leases.asReversed().forEach(AndroidAccountOperationLease::close) } } + fun acquireBlocking(accountId: String): AndroidAccountOperationLease = runBlocking { acquire(accountId) } + suspend fun withAccountSession( accountId: String, resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, @@ -35,15 +43,54 @@ internal class AndroidAccountOperationGuard { } } + private suspend fun acquire(accountId: String): AndroidAccountOperationLease { + require(accountId.isNotBlank()) + val lease = synchronized(monitor) { + accountLeases.getOrPut(accountId) { AccountLease() }.also { it.references += 1 } + } + try { + lease.mutex.lock() + } catch (failure: Throwable) { + releaseReference(accountId, lease) + throw failure + } + return AndroidAccountOperationLease { + lease.mutex.unlock() + releaseReference(accountId, lease) + } + } + + private fun releaseReference(accountId: String, lease: AccountLease) { + synchronized(monitor) { + lease.references -= 1 + if (lease.references == 0) accountLeases.remove(accountId, lease) + } + } + private class AccountLease( val mutex: Mutex = Mutex(), var references: Int = 0, ) } +internal class AndroidAccountOperationLease( + private val release: () -> Unit, +) : AutoCloseable { + private val closed = AtomicBoolean(false) + + override fun close() { + if (closed.compareAndSet(false, true)) release() + } +} + internal val ANDROID_ACCOUNT_OPERATION_GUARD = AndroidAccountOperationGuard() internal fun androidAccountOperationSessionIsCurrent( expectedAccountId: String, currentSession: dev.obiente.nextcloudnative.app.NextcloudSession?, ): Boolean = currentSession != null && NextcloudDocumentIds.accountKey(currentSession) == expectedAccountId + +internal fun androidDocumentWritebackSessionIsCurrent( + expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, + currentSession: dev.obiente.nextcloudnative.app.NextcloudSession?, +): Boolean = currentSession == expectedSession diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt index 627175697..3eeb96031 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt @@ -1,7 +1,9 @@ package dev.obiente.nextcloudnative +import android.os.ParcelFileDescriptor import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.File +import java.io.FileNotFoundException import java.io.FileOutputStream import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files @@ -13,6 +15,33 @@ import org.json.JSONObject internal const val MAX_ANDROID_DOCUMENT_WRITEBACK_BYTES = Long.MAX_VALUE internal const val MIN_ANDROID_DOCUMENT_FREE_BYTES = 512L * 1024L * 1024L +internal fun descriptorMode(mode: String): Int = when (mode) { + "w" -> ParcelFileDescriptor.MODE_WRITE_ONLY + "wt" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_TRUNCATE + "wa" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_APPEND + "rw" -> ParcelFileDescriptor.MODE_READ_WRITE + "rwt" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_TRUNCATE + else -> error("Unsupported writable mode: $mode") +} + +internal fun acquireAndroidDocumentWritebackAccountLease( + session: NextcloudSession, + remotePath: String, + loadCurrentSession: () -> NextcloudSession?, +): AndroidAccountOperationLease { + val lease = ANDROID_ACCOUNT_OPERATION_GUARD.acquireBlocking(NextcloudDocumentIds.accountKey(session)) + return try { + if (!androidDocumentWritebackSessionIsCurrent(session, loadCurrentSession())) { + throw FileNotFoundException("The active Nextcloud account changed before the document could be opened.") + } + reserveAndroidDocumentWritebackPath(session, remotePath) + lease + } catch (failure: Throwable) { + lease.close() + throw failure + } +} + internal fun requireAndroidDocumentWritebackCapacity(remoteSize: Long, availableBytes: Long) { require(remoteSize >= 0L && availableBytes >= 0L) require(remoteSize <= (availableBytes - MIN_ANDROID_DOCUMENT_FREE_BYTES).coerceAtLeast(0L)) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt index 0eebd1073..c909511c6 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative import android.content.Context +import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.work.WorkManager import androidx.work.await @@ -52,6 +53,9 @@ internal class AndroidIncomingShareAccountCleanup(context: Context) { cancellation.close() } }, + recordChunkReleaseFailure = { failure -> + Log.w(LOG_TAG, "Remote staged-share chunk cleanup deferred during account removal", failure) + }, removeRequest = { requestId -> check(store.remove(requestId)) { "The staged share data could not be released." } NotificationManagerCompat.from(appContext).apply { @@ -100,13 +104,22 @@ internal suspend fun removeAndroidIncomingShareRequests( requests: List, cancelWork: suspend (String) -> Unit, releaseChunk: suspend (AndroidIncomingShareRequest, String) -> Unit, + recordChunkReleaseFailure: (Throwable) -> Unit = {}, removeRequest: (String) -> Unit, ) { requests.forEach { request -> cancelWork(request.id) } requests.forEach { accountRequest -> accountRequest.request?.chunkSession?.let { chunk -> - releaseChunk(accountRequest.request, chunk.uploadId) + try { + releaseChunk(accountRequest.request, chunk.uploadId) + } catch (failure: kotlinx.coroutines.CancellationException) { + throw failure + } catch (failure: Exception) { + recordChunkReleaseFailure(failure) + } } } requests.forEach { request -> removeRequest(request.id) } } + +private const val LOG_TAG = "IncomingShareCleanup" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index f0943d165..8450fd7b9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -552,7 +552,11 @@ class NextcloudDocumentsProvider : DocumentsProvider() { mode: String, signal: CancellationSignal?, ): ParcelFileDescriptor { - reserveAndroidDocumentWritebackPath(session, file.path) + val accountLease = acquireAndroidDocumentWritebackAccountLease( + session, + file.path, + services::loadSession, + ) val recovered: AndroidDocumentPendingWriteback? val writeback: AndroidDocumentPendingWriteback try { @@ -619,6 +623,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { retainFailedWriteback(writeback, failure) } finally { writeback.releaseActive() + accountLease.close() } } return try { @@ -632,19 +637,11 @@ class NextcloudDocumentsProvider : DocumentsProvider() { if (writeback.manifest.isFile) { if (recovered == null) writeback.discard() else writeback.releaseActive() } + accountLease.close() throw failure } } - private fun descriptorMode(mode: String): Int = when (mode) { - "w" -> ParcelFileDescriptor.MODE_WRITE_ONLY - "wt" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_TRUNCATE - "wa" -> ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_APPEND - "rw" -> ParcelFileDescriptor.MODE_READ_WRITE - "rwt" -> ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_TRUNCATE - else -> error("Unsupported writable mode: $mode") - } - private fun createLocalStagingFile(): File { val providerContext = requireNotNull(context) { "Provider context is unavailable." } val directory = File(providerContext.cacheDir, STAGING_DIRECTORY).apply { mkdirs() } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 1c4e9f08d..9a105248b 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -35,6 +35,13 @@ class AndroidAccountOperationGuardTest { replacement, ), ) + assertTrue(androidDocumentWritebackSessionIsCurrent(previous, previous)) + assertFalse( + androidDocumentWritebackSessionIsCurrent( + previous, + previous.copy(appPassword = "rotated-password"), + ), + ) } @Test @@ -84,6 +91,49 @@ class AndroidAccountOperationGuardTest { upload.await() } + @Test + fun writableDescriptorLeaseBlocksAccountTransitionUntilClose() = runBlocking { + val guard = AndroidAccountOperationGuard() + val descriptorLease = guard.acquireBlocking("account-a") + var transitionEntered = false + + val transition = async { + guard.withAccount("account-a") { transitionEntered = true } + } + yield() + + assertFalse(transitionEntered) + descriptorLease.close() + transition.await() + assertTrue(transitionEntered) + } + + @Test + fun replacementTransitionWaitsForBothAffectedAccounts() = runBlocking { + val guard = AndroidAccountOperationGuard() + val retainedWorkEntered = CompletableDeferred() + val releaseRetainedWork = CompletableDeferred() + var transitionEntered = false + + val retainedWork = async { + guard.withAccount("account-b") { + retainedWorkEntered.complete(Unit) + releaseRetainedWork.await() + } + } + retainedWorkEntered.await() + val transition = async { + guard.withAccounts(listOf("account-b", "account-a")) { transitionEntered = true } + } + yield() + + assertFalse(transitionEntered) + releaseRetainedWork.complete(Unit) + retainedWork.await() + transition.await() + assertTrue(transitionEntered) + } + @Test fun retainedOfflineWorkRevalidatesItsSessionAfterAccountRemoval() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt index 99b2183ec..23d0d131f 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt @@ -13,6 +13,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking class AndroidIncomingShareStateTest { @@ -757,6 +758,50 @@ class AndroidIncomingShareStateTest { ) } + @Test + fun accountRemovalPurgesLocalShareAfterRemoteChunkCleanupFails() = runBlocking { + val staged = request(AndroidIncomingShareState.Uploading).copy( + chunkSession = AndroidIncomingShareChunkSession( + fileIndex = 0, + targetName = "first.txt", + uploadId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + ) + val events = mutableListOf() + + removeAndroidIncomingShareRequests( + requests = listOf(AndroidIncomingShareAccountRequest(staged.id, staged)), + cancelWork = { events += "cancel" }, + releaseChunk = { _, _ -> error("synthetic offline cleanup failure") }, + recordChunkReleaseFailure = { events += "release-failed" }, + removeRequest = { events += "remove" }, + ) + + assertEquals(listOf("cancel", "release-failed", "remove"), events) + } + + @Test + fun accountRemovalPreservesChunkCleanupCancellation() = runBlocking { + val staged = request(AndroidIncomingShareState.Uploading).copy( + chunkSession = AndroidIncomingShareChunkSession( + fileIndex = 0, + targetName = "first.txt", + uploadId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + ) + var removed = false + + assertFailsWith { + removeAndroidIncomingShareRequests( + requests = listOf(AndroidIncomingShareAccountRequest(staged.id, staged)), + cancelWork = {}, + releaseChunk = { _, _ -> throw CancellationException("synthetic cancellation") }, + removeRequest = { removed = true }, + ) + } + assertFalse(removed) + } + private fun request(state: AndroidIncomingShareState) = AndroidIncomingShareRequest( id = "01234567-89ab-cdef-0123-456789abcdef", files = listOf( From 5dcc9c01ae77a39fff046fdea9ea92bffe09f2c6 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 06:08:21 +0200 Subject: [PATCH 014/119] fix(accounts): retain credential recovery paths --- .../AndroidAccountCredentialController.kt | 34 ++--- .../AndroidNextcloudServices.kt | 3 +- .../NextcloudFileSyncWorker.kt | 6 +- .../AndroidFileSyncEngineInvariantTest.kt | 3 +- .../app/NextcloudAccountCredentialServices.kt | 3 +- .../nextcloudnative/app/NextcloudNativeApp.kt | 3 +- .../DesktopAccountCredentialPersistence.kt | 120 +++++++++++++++++- .../app/DesktopNextcloudServices.kt | 10 +- ...DesktopAccountCredentialPersistenceTest.kt | 74 ++++++++++- 9 files changed, 220 insertions(+), 36 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 251563527..c635fb639 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -79,25 +79,27 @@ internal class AndroidAccountCredentialController( session.also(registerSessionPrivateValues) } - suspend fun saveSession(session: NextcloudSession) = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { - registerSessionPrivateValues(session) - when (val read = readStore()) { - is AndroidAccountCredentialStoreRead.Available -> - replaceActiveState(read.state.upsertAndSelect(session), read.state.activeSession) - is AndroidAccountCredentialStoreRead.Invalid -> { - val retained = readIndependentCredentialSlotState() - check(retained != null || !hasIndependentCredentialState()) { - "The aggregate account credential store is invalid; reset it before signing in again." + suspend fun saveSession(session: NextcloudSession): NextcloudSession = + ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + registerSessionPrivateValues(session) + when (val read = readStore()) { + is AndroidAccountCredentialStoreRead.Available -> + replaceActiveState(read.state.upsertAndSelect(session), read.state.activeSession) + is AndroidAccountCredentialStoreRead.Invalid -> { + val retained = readIndependentCredentialSlotState() + check(retained != null || !hasIndependentCredentialState()) { + "The aggregate account credential store is invalid; reset it before signing in again." + } + replaceActiveState( + replacement = (retained ?: AndroidAccountCredentialState.Empty).upsertAndSelect(session), + previousSession = retained?.activeSession, + suspectEncrypted = read.encrypted, + ) } - replaceActiveState( - replacement = (retained ?: AndroidAccountCredentialState.Empty).upsertAndSelect(session), - previousSession = retained?.activeSession, - suspectEncrypted = read.encrypted, - ) + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } - is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) + requireNotNull(loadSession(session.accountId)) } - } suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 3ae58139a..e30894d38 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -946,7 +946,8 @@ internal class AndroidNextcloudServices( deckCardDrafts.migrateLegacyEntries(session) } - override suspend fun saveSession(session: NextcloudSession) = accountCredentials.saveSession(session) + override suspend fun saveSession(session: NextcloudSession): NextcloudSession = + accountCredentials.saveSession(session) override fun listAccounts() = accountCredentials.listAccounts() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt index 8bd39fc3d..0f38f29ff 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt @@ -208,8 +208,10 @@ internal fun isAndroidFileSyncScheduleRestorationCurrent( session: NextcloudSession, ): Boolean = NextcloudDocumentIds.accountKey(session) == expectedAccountId -internal fun scheduleRestorationFailureDisposition(runAttemptCount: Int): BackgroundSyncWorkerDisposition = - backgroundSyncFailureDisposition(runAttemptCount) +internal fun scheduleRestorationFailureDisposition(runAttemptCount: Int): BackgroundSyncWorkerDisposition { + require(runAttemptCount >= 0) + return BackgroundSyncWorkerDisposition.Retry +} internal fun syncConflictNotificationDetail(conflictCount: Int): String { require(conflictCount > 0) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index 1a5ee4c3f..94aac6aa0 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -82,7 +82,8 @@ class AndroidFileSyncEngineInvariantTest { fun scheduleRestorationStopsImmediateRetriesAfterTheBoundedBudget() { assertEquals(BackgroundSyncWorkerDisposition.Retry, scheduleRestorationFailureDisposition(0)) assertEquals(BackgroundSyncWorkerDisposition.Retry, scheduleRestorationFailureDisposition(1)) - assertEquals(BackgroundSyncWorkerDisposition.WaitForNextPeriod, scheduleRestorationFailureDisposition(2)) + assertEquals(BackgroundSyncWorkerDisposition.Retry, scheduleRestorationFailureDisposition(2)) + assertEquals(BackgroundSyncWorkerDisposition.Retry, scheduleRestorationFailureDisposition(20)) } @Test diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt index 2c3641a6f..c9fba7504 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt @@ -3,7 +3,8 @@ package dev.obiente.nextcloudnative.app interface NextcloudAccountCredentialServices { fun loadSession(): NextcloudSession? - suspend fun saveSession(session: NextcloudSession) + /** Persists and returns the exact session identity published to account-scoped resources. */ + suspend fun saveSession(session: NextcloudSession): NextcloudSession suspend fun clearSession() diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 31c7202dc..015d311ca 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -574,8 +574,7 @@ fun NextcloudNativeApp( LoginScreen( services = services, onLoggedIn = { authenticated -> - services.saveSession(authenticated) - session = authenticated + session = services.saveSession(authenticated) }, ) } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 6f4cbdcf9..10921ffcd 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative.app import java.util.prefs.Preferences +import kotlinx.coroutines.CancellationException internal class DesktopAccountCredentialPersistence( private val preferences: Preferences, @@ -9,6 +10,7 @@ internal class DesktopAccountCredentialPersistence( private val flushPreferences: () -> Unit = preferences::flush, ) { fun loadActiveSession(): NextcloudSession? { + retryPendingLegacyCredentialCleanup() val read = readRegistry() if (read.registry == null) { return restoreLegacySession(read.encoded != null) @@ -18,6 +20,7 @@ internal class DesktopAccountCredentialPersistence( } fun listAccounts(): List { + retryPendingLegacyCredentialCleanup() val read = readRegistry() if (read.registry != null) return read.registry.accounts val legacy = restoreLegacySession(read.encoded != null) @@ -25,12 +28,14 @@ internal class DesktopAccountCredentialPersistence( } fun activeAccountId(): NextcloudAccountId? { + retryPendingLegacyCredentialCleanup() val read = readRegistry() if (read.registry != null) return read.registry.activeAccountId return restoreLegacySession(read.encoded != null)?.accountId } fun loadSession(accountId: NextcloudAccountId): NextcloudSession? { + retryPendingLegacyCredentialCleanup() val registry = readRegistry().registry ?: return null val record = registry.accounts.firstOrNull { account -> account.id == accountId } ?: return null val secret = loadSecret(desktopAccountSecretReference(accountId)) @@ -45,7 +50,8 @@ internal class DesktopAccountCredentialPersistence( return legacy } - fun saveSession(session: NextcloudSession) { + fun saveSession(session: NextcloudSession): NextcloudSession { + retryPendingLegacyCredentialCleanup() val read = readRegistry() val registry = read.registry ?: restoreLegacySession(read.encoded != null)?.let { requireNotNull(readRegistry().registry) } @@ -82,9 +88,11 @@ internal class DesktopAccountCredentialPersistence( } throw failure } + return persistedSession } fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? { + retryPendingLegacyCredentialCleanup() val registry = readRegistry().registry ?: return null val session = loadSession(accountId) ?: return null val selected = requireNotNull(registry.select(accountId)) @@ -93,6 +101,7 @@ internal class DesktopAccountCredentialPersistence( } fun removeAccount(accountId: NextcloudAccountId): Boolean { + retryPendingLegacyCredentialCleanup() val registry = readRegistry().registry ?: return false val record = registry.accounts.firstOrNull { account -> account.id == accountId } ?: return false val clearLegacyCredential = legacyMetadataMatches(record) @@ -123,7 +132,11 @@ internal class DesktopAccountCredentialPersistence( try { val encodedRegistry = prepareRegistry(restored.registry) saveSecret(legacy) - persistAccountState(encodedRegistry, restored.registry.activeAccount) + persistAccountState( + encodedRegistry, + restored.registry.activeAccount, + pendingLegacyCleanup = legacy, + ) } catch (failure: Exception) { recordCredentialDiagnostic( code = "ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED", @@ -144,18 +157,94 @@ internal class DesktopAccountCredentialPersistence( } private fun migrateLegacyCredential(session: NextcloudSession) { + persistPendingLegacyCredentialCleanup(session) saveSecret(session) clearLegacyCredentialAfterMigration(session) } private fun clearLegacyCredentialAfterMigration(session: NextcloudSession) { + retryPendingLegacyCredentialCleanup(session) + } + + private fun retryPendingLegacyCredentialCleanup(expected: NextcloudSession? = null) { + val server = preferences.get(KEY_PENDING_LEGACY_CLEANUP_SERVER, null) + val login = preferences.get(KEY_PENDING_LEGACY_CLEANUP_LOGIN, null) + if (server == null && login == null) return + if (server.isNullOrBlank() || login.isNullOrBlank()) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", + "account-credentials.migrate", + ) + return + } + if (expected != null && (expected.serverUrl != server || expected.loginName != login)) return + val replacementAvailable = try { + val accountId = deriveNextcloudAccountId(server, login) + loadSecret(desktopAccountSecretReference(accountId)) != null + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", + "account-credentials.migrate", + ) + false + } + if (!replacementAvailable) return try { - secretStore.clear(desktopSessionSecretReference(session.serverUrl, session.loginName)) + secretStore.clear(desktopSessionSecretReference(server, login)) + } catch (cancelled: CancellationException) { + throw cancelled } catch (_: Exception) { recordCredentialDiagnostic( "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", "account-credentials.migrate", ) + return + } + try { + preferences.remove(KEY_PENDING_LEGACY_CLEANUP_SERVER) + preferences.remove(KEY_PENDING_LEGACY_CLEANUP_LOGIN) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + preferences.put(KEY_PENDING_LEGACY_CLEANUP_SERVER, server) + preferences.put(KEY_PENDING_LEGACY_CLEANUP_LOGIN, login) + try { + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // The in-memory marker remains available for another retry in this process. + } + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", + "account-credentials.migrate", + ) + } + } + + private fun persistPendingLegacyCredentialCleanup(session: NextcloudSession) { + val previousServer = preferences.get(KEY_PENDING_LEGACY_CLEANUP_SERVER, null) + val previousLogin = preferences.get(KEY_PENDING_LEGACY_CLEANUP_LOGIN, null) + try { + preferences.put(KEY_PENDING_LEGACY_CLEANUP_SERVER, session.serverUrl) + preferences.put(KEY_PENDING_LEGACY_CLEANUP_LOGIN, session.loginName) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_SERVER, previousServer) + preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_LOGIN, previousLogin) + try { + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // The next load will retry from the last durable marker state. + } + throw DesktopSecretDeletionRecoveryUnavailableException(failure) } } @@ -176,9 +265,17 @@ internal class DesktopAccountCredentialPersistence( secretStore.load(reference) ?.decodeToString() ?.takeIf(String::isNotBlank) - } catch (_: Exception) { + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: NextcloudSessionStorageUnavailableException) { recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_STORE_READ_FAILED", "account-credentials.restore") - null + throw failure + } catch (failure: Exception) { + recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_STORE_READ_FAILED", "account-credentials.restore") + throw DesktopSecretStoreUnavailableException( + "The desktop secure credential store could not be read.", + cause = failure, + ) } private fun loadSecretForRollback(reference: DesktopSecretReference): ByteArray? = try { @@ -216,17 +313,24 @@ internal class DesktopAccountCredentialPersistence( private fun persistAccountState( encodedRegistry: String, activeAccount: NextcloudAccountRecord?, + pendingLegacyCleanup: NextcloudSession? = null, ) { require(encodedRegistry.length <= Preferences.MAX_VALUE_LENGTH) val previous = DesktopAccountPreferenceSnapshot( registry = preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null), server = preferences.get(KEY_SERVER, null), login = preferences.get(KEY_LOGIN, null), + pendingLegacyCleanupServer = preferences.get(KEY_PENDING_LEGACY_CLEANUP_SERVER, null), + pendingLegacyCleanupLogin = preferences.get(KEY_PENDING_LEGACY_CLEANUP_LOGIN, null), ) try { preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodedRegistry) preferences.putOrRemove(KEY_SERVER, activeAccount?.serverUrl) preferences.putOrRemove(KEY_LOGIN, activeAccount?.loginName) + pendingLegacyCleanup?.let { session -> + preferences.put(KEY_PENDING_LEGACY_CLEANUP_SERVER, session.serverUrl) + preferences.put(KEY_PENDING_LEGACY_CLEANUP_LOGIN, session.loginName) + } flushPreferences() } catch (failure: Exception) { previous.restore(preferences) @@ -275,17 +379,23 @@ internal class DesktopAccountCredentialPersistence( val registry: String?, val server: String?, val login: String?, + val pendingLegacyCleanupServer: String?, + val pendingLegacyCleanupLogin: String?, ) { fun restore(preferences: Preferences) { preferences.putOrRemove(DESKTOP_ACCOUNT_REGISTRY_KEY, registry) preferences.putOrRemove(KEY_SERVER, server) preferences.putOrRemove(KEY_LOGIN, login) + preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_SERVER, pendingLegacyCleanupServer) + preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_LOGIN, pendingLegacyCleanupLogin) } } private companion object { const val KEY_SERVER = "server" const val KEY_LOGIN = "login" + const val KEY_PENDING_LEGACY_CLEANUP_SERVER = "accountLegacyCleanupServer" + const val KEY_PENDING_LEGACY_CLEANUP_LOGIN = "accountLegacyCleanupLogin" } } 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 8a14fdd5a..a2c13fab4 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3713,7 +3713,7 @@ class DesktopNextcloudServices( } override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { - accountOperationGuard.serializeWhenSyncIdle { + val persistedSession = accountOperationGuard.serializeWhenSyncIdle { sessionPublicationGuard.serialize { val activeAccountId = accountCredentials.activeAccountId() val activeSession = activeAccountId?.let(accountCredentials::loadSession) @@ -3723,12 +3723,12 @@ class DesktopNextcloudServices( !invalidatesLiveResources || !hasLiveAccountResources(), ::recordSupportDiagnostic, ) - accountCredentials.saveSession(session) - accountSessionPublication.publish(session) + accountCredentials.saveSession(session).also(accountSessionPublication::publish) } - synchronized(fileRangeSessionLock) { sessionClearing = false } - startDesktopSyncLifecycle() } + synchronized(fileRangeSessionLock) { sessionClearing = false } + startDesktopSyncLifecycle() + persistedSession } override fun listAccounts() = sessionPublicationGuard.serialize(accountCredentials::listAccounts) override fun activeAccountId() = sessionPublicationGuard.serialize(accountCredentials::activeAccountId) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index c84d9e2d8..72d3736f2 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -108,9 +108,11 @@ class DesktopAccountCredentialPersistenceTest { val persistence = persistence(preferences, secrets) persistence.saveSession(original) - persistence.saveSession(replacement) + val persisted = persistence.saveSession(replacement) val restored = persistence(preferences, secrets).loadActiveSession() + assertEquals(original.serverUrl, persisted.serverUrl) + assertEquals(replacement.appPassword, persisted.appPassword) assertEquals(original.serverUrl, restored?.serverUrl) assertEquals(replacement.appPassword, restored?.appPassword) assertEquals(desktopFileCacheAccountId(original), restored?.let(::desktopFileCacheAccountId)) @@ -162,7 +164,7 @@ class DesktopAccountCredentialPersistenceTest { var legacyPresentAtFlush = false val restored = persistence(preferences, secrets) { - legacyPresentAtFlush = secrets.load(legacyReference) != null + legacyPresentAtFlush = legacyPresentAtFlush || secrets.load(legacyReference) != null }.loadActiveSession() assertEquals(session, restored) @@ -170,6 +172,68 @@ class DesktopAccountCredentialPersistenceTest { assertNull(secrets.load(legacyReference)) } + @Test + fun failedLegacyCleanupIsRetriedAfterMigration() = withStore { preferences, secrets -> + val session = firstSession() + val legacyReference = desktopSessionSecretReference(session.serverUrl, session.loginName) + putLegacySession(preferences, secrets, session) + val persistence = persistence(preferences, secrets) + secrets.failClears = true + + assertEquals(session, persistence.loadActiveSession()) + assertNotNull(secrets.load(legacyReference)) + + secrets.failClears = false + assertEquals(session, persistence.loadActiveSession()) + assertNull(secrets.load(legacyReference)) + } + + @Test + fun pendingCleanupNeverDeletesTheOnlyReadableLegacyCredential() = withStore { preferences, secrets -> + val session = firstSession() + val legacyReference = desktopSessionSecretReference(session.serverUrl, session.loginName) + putLegacySession(preferences, secrets, session) + secrets.failSaves = true + + assertEquals(session, persistence(preferences, secrets).loadActiveSession()) + assertNotNull(secrets.load(legacyReference)) + + secrets.failSaves = false + assertEquals(session, persistence(preferences, secrets).loadActiveSession()) + assertNull(secrets.load(legacyReference)) + } + + @Test + fun accountRemovalRetriesPendingLegacyCleanupAfterSelectionChanged() = + withStore { preferences, secrets -> + val migrated = firstSession() + val other = secondSession() + val legacyReference = desktopSessionSecretReference(migrated.serverUrl, migrated.loginName) + putLegacySession(preferences, secrets, migrated) + val persistence = persistence(preferences, secrets) + secrets.failClears = true + + assertEquals(migrated, persistence.loadActiveSession()) + persistence.saveSession(other) + assertNotNull(secrets.load(legacyReference)) + + secrets.failClears = false + assertTrue(persistence.removeAccount(migrated.accountId)) + assertNull(secrets.load(legacyReference)) + } + + @Test + fun secureStoreReadFailureIsNotReportedAsMissingCredentials() = withStore { preferences, secrets -> + val persistence = persistence(preferences, secrets) + persistence.saveSession(firstSession()) + secrets.loadFailure = DesktopSecretStoreUnavailableException("synthetic locked keychain") + + assertEquals( + NextcloudSessionLoadState.SecureStorageUnavailable, + loadNextcloudSessionSafely(persistence::loadActiveSession), + ) + } + @Test fun failedMigrationFlushKeepsLegacyCredentialAndRollsBackCachedMetadata() = withStore { preferences, secrets -> @@ -391,8 +455,12 @@ class DesktopAccountCredentialPersistenceTest { private val values = mutableMapOf() var failSaves = false var failClears = false + var loadFailure: RuntimeException? = null - override fun load(reference: DesktopSecretReference): ByteArray? = values[reference.targetName]?.copyOf() + override fun load(reference: DesktopSecretReference): ByteArray? { + loadFailure?.let { throw it } + return values[reference.targetName]?.copyOf() + } override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { if (failSaves) error("private-app-password at cloud.example.test for alice") From 30941b0d1a9c1fb30cb189276e9407b8ed8c60c8 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 07:27:59 +0200 Subject: [PATCH 015/119] fix(android): close credential recovery gaps --- .../AndroidAccountCredentialController.kt | 61 ++++++++++++++++--- .../AndroidDocumentWritebackRecovery.kt | 11 ++++ .../NextcloudDocumentsProvider.kt | 4 +- .../AndroidAccountOperationGuardTest.kt | 15 +++++ .../AndroidPersistedSessionTest.kt | 34 +++++++++++ 5 files changed, 117 insertions(+), 8 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index c635fb639..d840f4aa5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -96,6 +96,16 @@ internal class AndroidAccountCredentialController( suspectEncrypted = read.encrypted, ) } + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> { + val retained = readIndependentCredentialSlotState() + check(retained != null || !hasIndependentCredentialState()) { + "The independent account credential slots could not be recovered." + } + replaceActiveState( + replacement = (retained ?: AndroidAccountCredentialState.Empty).upsertAndSelect(session), + previousSession = retained?.activeSession, + ) + } is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } requireNotNull(loadSession(session.accountId)) @@ -146,11 +156,28 @@ internal class AndroidAccountCredentialController( clearSession(read.state) } else { ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(session)) { - clearSession(read.state) + removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = { removeQueuedUploads(session) }, + clearActiveAccount = { clearSession(read.state) }, + rollbackActiveRemoval = { + replaceActiveStateWhileOperationsIdle( + replacement = read.state, + previousSession = null, + suspectEncrypted = null, + ) + }, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + ) } } } is AndroidAccountCredentialStoreRead.Invalid -> clearInvalidStore(read.encrypted) + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> { + clearPersistedSession(null, AndroidAccountCredentialState.Empty) + notifyDocumentRootsChanged() + } is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } } @@ -274,6 +301,8 @@ internal class AndroidAccountCredentialController( private fun requireValidState(): AndroidAccountCredentialState = when (val read = readStore()) { is AndroidAccountCredentialStoreRead.Available -> read.state is AndroidAccountCredentialStoreRead.Invalid -> error("The account credential store is invalid.") + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> + error("The independent account credential slots could not be recovered.") is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } @@ -318,8 +347,14 @@ internal class AndroidAccountCredentialController( } private fun readStore(): AndroidAccountCredentialStoreRead = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { - val encrypted = preferences.getString(KEY_SESSION, null) - ?: return@serialize AndroidAccountCredentialStoreRead.Available(AndroidAccountCredentialState.Empty) + val encrypted = preferences.getString(KEY_SESSION, null) ?: return@serialize run { + val retained = readIndependentCredentialSlotState() + when { + retained != null -> AndroidAccountCredentialStoreRead.Available(retained) + hasIndependentCredentialState() -> AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable + else -> AndroidAccountCredentialStoreRead.Available(AndroidAccountCredentialState.Empty) + } + } val encoded = try { sessionCipher.decrypt(encrypted) } catch (_: Exception) { @@ -356,10 +391,10 @@ internal class AndroidAccountCredentialController( } private fun readIndependentCredentialSlotState(): AndroidAccountCredentialState? { - val encodedRegistry = preferences.getString(KEY_ACCOUNT_REGISTRY, null) ?: return null - val restored = restoreNextcloudAccountRegistry(encodedRegistry, legacySession = null) - if (restored.recoveryReason != null) return null - return reconstructAndroidAccountCredentialState(restored.registry, ::readCredentialSlot) + return restoreAndroidAccountCredentialStateWithoutAggregate( + encodedRegistry = preferences.getString(KEY_ACCOUNT_REGISTRY, null), + loadSession = ::readCredentialSlot, + ) } private fun hasIndependentCredentialState(): Boolean = @@ -474,6 +509,7 @@ internal class AndroidAccountCredentialController( internal sealed interface AndroidAccountCredentialStoreRead { data class Available(val state: AndroidAccountCredentialState) : AndroidAccountCredentialStoreRead data class Invalid(val encrypted: String) : AndroidAccountCredentialStoreRead + data object IndependentRecoveryUnavailable : AndroidAccountCredentialStoreRead data class Unsupported(val encrypted: String, val version: Int) : AndroidAccountCredentialStoreRead } @@ -539,6 +575,17 @@ internal fun reconstructAndroidAccountCredentialState( return AndroidAccountCredentialState(registry, sessions) } +internal fun restoreAndroidAccountCredentialStateWithoutAggregate( + encodedRegistry: String?, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): AndroidAccountCredentialState? { + val restored = encodedRegistry + ?.let { encoded -> restoreNextcloudAccountRegistry(encoded, legacySession = null) } + ?: return null + if (restored.recoveryReason != null) return null + return reconstructAndroidAccountCredentialState(restored.registry, loadSession) +} + internal suspend fun resumeAndroidQueuedUploadsAfterSelection( resume: suspend () -> Unit, notifyDocumentRootsChanged: () -> Unit, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt index 3eeb96031..bf3ea2ce4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt @@ -42,6 +42,17 @@ internal fun acquireAndroidDocumentWritebackAccountLease( } } +internal fun releaseAndroidDocumentWritebackSetup( + accountLease: AndroidAccountOperationLease, + releasePath: () -> Unit, +) { + try { + releasePath() + } finally { + accountLease.close() + } +} + internal fun requireAndroidDocumentWritebackCapacity(remoteSize: Long, availableBytes: Long) { require(remoteSize >= 0L && availableBytes >= 0L) require(remoteSize <= (availableBytes - MIN_ANDROID_DOCUMENT_FREE_BYTES).coerceAtLeast(0L)) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index 8450fd7b9..0a18732ce 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -567,7 +567,9 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } writeback = recovered ?: createDurableWriteback(session, file, requireMutationEtag(file)) } catch (failure: Throwable) { - releaseAndroidDocumentWritebackPath(session, file.path) + releaseAndroidDocumentWritebackSetup(accountLease) { + releaseAndroidDocumentWritebackPath(session, file.path) + } throw failure } val expectedEtag = writeback.expectedRemoteEtag diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 9a105248b..b3766d722 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -7,6 +7,7 @@ import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield class AndroidAccountOperationGuardTest { @@ -108,6 +109,20 @@ class AndroidAccountOperationGuardTest { assertTrue(transitionEntered) } + @Test + fun failedWritebackSetupReleasesItsPathAndAccountLease() = runBlocking { + val guard = AndroidAccountOperationGuard() + val descriptorLease = guard.acquireBlocking("account-a") + var pathReleased = false + + releaseAndroidDocumentWritebackSetup(descriptorLease) { pathReleased = true } + + withTimeout(1_000L) { + guard.withAccount("account-a") { } + } + assertTrue(pathReleased) + } + @Test fun replacementTransitionWaitsForBothAffectedAccounts() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index ee578ce62..613a44ae0 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -550,6 +550,24 @@ class AndroidPersistedSessionTest { assertEquals(second, restored.activeSession) } + @Test + fun validIndependentSlotsRecoverWhenTheAggregateKeyIsAbsent() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + val slots = mapOf(first.accountId to first, second.accountId to second) + + val restored = restoreAndroidAccountCredentialStateWithoutAggregate( + encodedRegistry = encodeNextcloudAccountRegistry(registry), + loadSession = slots::get, + ) + + assertEquals(slots, requireNotNull(restored).sessions) + assertEquals(second, restored.activeSession) + } + @Test fun independentSlotRecoveryRejectsRegistryCredentialMismatch() { val first = firstSession() @@ -629,6 +647,22 @@ class AndroidPersistedSessionTest { assertEquals(listOf("clear-account", "remove-uploads"), events) } + @Test + fun activeSignOutDeletesQueuedUploadsAfterTheCredentialIsCleared() = runBlocking { + val events = mutableListOf() + + removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { events += "clear-session" }, + rollbackActiveRemoval = { events += "rollback-session" }, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + ) + + assertEquals(listOf("clear-session", "remove-uploads"), events) + } + @Test fun failedActiveUploadCleanupRestoresTheRemovedCredentialState() = runBlocking { val events = mutableListOf() From 3965ead573f9c18112c0198e9f676c014f0a7f3c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 08:39:51 +0200 Subject: [PATCH 016/119] fix(accounts): close remaining transition races --- .../AndroidAccountCredentialController.kt | 46 +++++- .../AndroidDocumentWritebackRecovery.kt | 33 ++++- .../nextcloudnative/AndroidFileSyncEngine.kt | 4 +- .../AndroidFileSyncExecutionCoordination.kt | 14 ++ .../nextcloudnative/AndroidFileSyncStore.kt | 13 ++ .../AndroidNextcloudServices.kt | 1 + .../NextcloudDocumentsProvider.kt | 136 +++++++++--------- .../AndroidAccountOperationGuardTest.kt | 21 +++ .../AndroidFileSyncEngineInvariantTest.kt | 27 ++++ .../AndroidPersistedSessionTest.kt | 18 +++ .../app/DesktopAccountOperationGuard.kt | 8 +- .../app/DesktopNextcloudServices.kt | 15 +- .../app/DesktopAccountOperationGuardTest.kt | 41 +++++- 13 files changed, 288 insertions(+), 89 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index d840f4aa5..91aec1412 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -173,11 +173,17 @@ internal class AndroidAccountCredentialController( } } } - is AndroidAccountCredentialStoreRead.Invalid -> clearInvalidStore(read.encrypted) - AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> { - clearPersistedSession(null, AndroidAccountCredentialState.Empty) - notifyDocumentRootsChanged() + is AndroidAccountCredentialStoreRead.Invalid -> { + val retained = readIndependentCredentialSlotState() + when { + retained != null -> clearRecoveredInvalidStore(retained, read.encrypted) + hasIndependentCredentialState() -> + error("The independent account credential slots could not be recovered.") + else -> clearInvalidStore(read.encrypted) + } } + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> + error("The independent account credential slots could not be recovered.") is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } } @@ -202,6 +208,34 @@ internal class AndroidAccountCredentialController( notifyDocumentRootsChanged() } + private suspend fun clearRecoveredInvalidStore( + current: AndroidAccountCredentialState, + suspectEncrypted: String, + ) { + val activeSession = current.activeSession + if (activeSession != null) { + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(activeSession)) { + persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted) + } + } else { + persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted) + } + } + + private suspend fun persistRecoveredInvalidStoreAfterClear( + current: AndroidAccountCredentialState, + suspectEncrypted: String, + ) { + val activeSession = current.activeSession + val replacement = removeActiveAndroidAccountCredentialState(current) + val encodedReplacement = replacement.takeUnless { state -> + state.registry.accounts.isEmpty() && state.sessions.isEmpty() + }?.let(::encryptState) + clearPersistedSession(encodedReplacement, replacement, suspectEncrypted) + activeSession?.let { session -> clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(session)) } + notifyDocumentRootsChanged() + } + private suspend fun clearPersistedSession( encodedReplacement: String?, replacement: AndroidAccountCredentialState, @@ -586,6 +620,10 @@ internal fun restoreAndroidAccountCredentialStateWithoutAggregate( return reconstructAndroidAccountCredentialState(restored.registry, loadSession) } +internal fun removeActiveAndroidAccountCredentialState( + state: AndroidAccountCredentialState, +): AndroidAccountCredentialState = state.registry.activeAccountId?.let(state::remove) ?: state + internal suspend fun resumeAndroidQueuedUploadsAfterSelection( resume: suspend () -> Unit, notifyDocumentRootsChanged: () -> Unit, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt index bf3ea2ce4..fd79179de 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt @@ -29,12 +29,26 @@ internal fun acquireAndroidDocumentWritebackAccountLease( remotePath: String, loadCurrentSession: () -> NextcloudSession?, ): AndroidAccountOperationLease { - val lease = ANDROID_ACCOUNT_OPERATION_GUARD.acquireBlocking(NextcloudDocumentIds.accountKey(session)) + val lease = acquireAndroidDocumentMutationAccountLease(session, loadCurrentSession) + return try { + reserveAndroidDocumentWritebackPath(session, remotePath) + lease + } catch (failure: Throwable) { + lease.close() + throw failure + } +} + +internal fun acquireAndroidDocumentMutationAccountLease( + session: NextcloudSession, + loadCurrentSession: () -> NextcloudSession?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, +): AndroidAccountOperationLease { + val lease = guard.acquireBlocking(NextcloudDocumentIds.accountKey(session)) return try { if (!androidDocumentWritebackSessionIsCurrent(session, loadCurrentSession())) { - throw FileNotFoundException("The active Nextcloud account changed before the document could be opened.") + throw FileNotFoundException("The active Nextcloud account changed before the document mutation could start.") } - reserveAndroidDocumentWritebackPath(session, remotePath) lease } catch (failure: Throwable) { lease.close() @@ -42,6 +56,19 @@ internal fun acquireAndroidDocumentWritebackAccountLease( } } +internal inline fun withAndroidDocumentMutation( + session: NextcloudSession, + noinline loadCurrentSession: () -> NextcloudSession?, + action: (NextcloudSession) -> Result, +): Result { + val lease = acquireAndroidDocumentMutationAccountLease(session, loadCurrentSession) + return try { + action(session) + } finally { + lease.close() + } +} + internal fun releaseAndroidDocumentWritebackSetup( accountLease: AndroidAccountOperationLease, releasePath: () -> Unit, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index b695249dc..6feb78ac5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -840,7 +840,7 @@ internal class AndroidFileSyncEngine(context: Context) { return FileSyncBaseline(path, localEntry.kind, localEntry.revision, remoteEntry.etag, contentHash) } - private companion object { - val ENGINE_LOCK = Mutex() + internal companion object { + internal val ENGINE_LOCK = Mutex() } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index 0f66a976c..ea2602cf3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -229,3 +229,17 @@ internal fun releaseSafGrantAfterPairRemoval( // The pair is gone, so a later picker can release or replace this stale grant. } } + +internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, accountId: String) { + AndroidFileSyncEngine.ENGINE_LOCK.withLock { + val store = AndroidFileSyncStore(context) + val current = store.load() + val retiredPairIds = current.coordinator.pairs + .filter { pair -> pair.accountId == accountId } + .map { pair -> pair.id } + if (retiredPairIds.isEmpty()) return@withLock + store.save(removeAndroidFileSyncAccountPairs(current, accountId)) + val scheduler = AndroidFileSyncScheduler(context) + retiredPairIds.forEach { pairId -> scheduler.cancel(pairId) } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt index 3bfc8356e..0c9b2eee6 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt @@ -27,6 +27,19 @@ internal data class AndroidFileSyncPersistedState( } } +internal fun removeAndroidFileSyncAccountPairs( + state: AndroidFileSyncPersistedState, + accountId: String, +): AndroidFileSyncPersistedState { + require(accountId.isNotBlank()) + val retainedPairs = state.coordinator.pairs.filterNot { pair -> pair.accountId == accountId } + val retainedPairIds = retainedPairs.mapTo(hashSetOf()) { pair -> pair.id } + return AndroidFileSyncPersistedState( + coordinator = FileSyncCoordinatorState(retainedPairs), + localDisplayNames = state.localDisplayNames.filterKeys(retainedPairIds::contains), + ) +} + internal class AndroidFileSyncStore internal constructor( private val stateFile: File, private val maximumSnapshotBytes: Int = MAX_SNAPSHOT_BYTES, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index e30894d38..d173dffd4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -500,6 +500,7 @@ internal class AndroidNextcloudServices( removeQueuedUploads = { session -> incomingShareAccountCleanup.removeForAccount(session) durableUploadAccountCleanup.removeForAccount(NextcloudDocumentIds.accountKey(session)) + retireAndroidFileSyncAccountPairs(appContext, NextcloudDocumentIds.accountKey(session)) }, ) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index 0a18732ce..a6ed66330 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -462,88 +462,88 @@ class NextcloudDocumentsProvider : DocumentsProvider() { throw failure } - override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { - val session = requireSession() - val parent = requireReference(parentDocumentId, session) - val account = resolveAccount(session) - requireDirectory(session, account, parent) - val path = childPath(parent.path, requireSafeDisplayName(displayName)) - withNoBlockingAndroidDocumentWriteback(context, session, path) { - mutationCall { - if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { - webDav.createFolder(session, account.userId, path) - } else { - val empty = createLocalStagingFile() - try { webDav.createFile(session, account.userId, path, empty) } finally { empty.delete() } + override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String = + withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> + val parent = requireReference(parentDocumentId, session) + val account = resolveAccount(session) + requireDirectory(session, account, parent) + val path = childPath(parent.path, requireSafeDisplayName(displayName)) + withNoBlockingAndroidDocumentWriteback(context, session, path) { + mutationCall { + if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { + webDav.createFolder(session, account.userId, path) + } else { + val empty = createLocalStagingFile() + try { webDav.createFile(session, account.userId, path, empty) } finally { empty.delete() } + } } } + notifyDocumentChanged(session, path) + NextcloudDocumentIds.documentId(session, path) } - notifyDocumentChanged(session, path) - return NextcloudDocumentIds.documentId(session, path) - } - override fun renameDocument(documentId: String, displayName: String): String { - val session = requireSession() - val reference = requireReference(documentId, session) - if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be renamed.") - val account = resolveAccount(session) - val file = findDocument(session, account, reference.path) - val destination = childPath(NextcloudDocumentIds.parentPath(reference.path), requireSafeDisplayName(displayName)) - if (destination == reference.path) return documentId - val etag = requireMutationEtag(file) - withNoBlockingAndroidDocumentWriteback(context, session, reference.path, destination) { - mutationCall { webDav.move(session, account.userId, reference.path, destination, etag) } - } - notifyMove(session, reference.path, destination) - return NextcloudDocumentIds.documentId(session, destination) - } + override fun renameDocument(documentId: String, displayName: String): String = + withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> + val reference = requireReference(documentId, session) + if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be renamed.") + val account = resolveAccount(session) + val file = findDocument(session, account, reference.path) + val destination = childPath(NextcloudDocumentIds.parentPath(reference.path), requireSafeDisplayName(displayName)) + if (destination == reference.path) return@withAndroidDocumentMutation documentId + val etag = requireMutationEtag(file) + withNoBlockingAndroidDocumentWriteback(context, session, reference.path, destination) { + mutationCall { webDav.move(session, account.userId, reference.path, destination, etag) } + } + notifyMove(session, reference.path, destination) + NextcloudDocumentIds.documentId(session, destination) + } - override fun deleteDocument(documentId: String) { - val session = requireSession() - val reference = requireReference(documentId, session) - if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be deleted.") - val account = resolveAccount(session) - val file = findDocument(session, account, reference.path) - withNoBlockingAndroidDocumentWriteback(context, session, reference.path) { - mutationCall { - webDav.delete( - session, - account.userId, - reference.path, - requireMutationEtag(file), - isDirectory = file.isDirectory, - ) + override fun deleteDocument(documentId: String) = + withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> + val reference = requireReference(documentId, session) + if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be deleted.") + val account = resolveAccount(session) + val file = findDocument(session, account, reference.path) + withNoBlockingAndroidDocumentWriteback(context, session, reference.path) { + mutationCall { + webDav.delete( + session, + account.userId, + reference.path, + requireMutationEtag(file), + isDirectory = file.isDirectory, + ) + } } + notifyDocumentChanged(session, reference.path) } - notifyDocumentChanged(session, reference.path) - } override fun moveDocument( sourceDocumentId: String, sourceParentDocumentId: String, targetParentDocumentId: String, - ): String { - val session = requireSession() - val source = requireReference(sourceDocumentId, session) - val sourceParent = requireReference(sourceParentDocumentId, session) - val targetParent = requireReference(targetParentDocumentId, session) - if (source.isRoot) throw SecurityException("The Nextcloud root cannot be moved.") - require(NextcloudDocumentIds.parentPath(source.path) == sourceParent.path) { - "The supplied source parent does not contain this document." - } - val account = resolveAccount(session) - requireDirectory(session, account, targetParent) - val file = findDocument(session, account, source.path) - val destination = childPath(targetParent.path, file.name) - if (destination == source.path) return sourceDocumentId - withNoBlockingAndroidDocumentWriteback(context, session, source.path, destination) { - mutationCall { - webDav.move(session, account.userId, source.path, destination, requireMutationEtag(file)) + ): String = + withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> + val source = requireReference(sourceDocumentId, session) + val sourceParent = requireReference(sourceParentDocumentId, session) + val targetParent = requireReference(targetParentDocumentId, session) + if (source.isRoot) throw SecurityException("The Nextcloud root cannot be moved.") + require(NextcloudDocumentIds.parentPath(source.path) == sourceParent.path) { + "The supplied source parent does not contain this document." } + val account = resolveAccount(session) + requireDirectory(session, account, targetParent) + val file = findDocument(session, account, source.path) + val destination = childPath(targetParent.path, file.name) + if (destination == source.path) return@withAndroidDocumentMutation sourceDocumentId + withNoBlockingAndroidDocumentWriteback(context, session, source.path, destination) { + mutationCall { + webDav.move(session, account.userId, source.path, destination, requireMutationEtag(file)) + } + } + notifyMove(session, source.path, destination) + NextcloudDocumentIds.documentId(session, destination) } - notifyMove(session, source.path, destination) - return NextcloudDocumentIds.documentId(session, destination) - } private fun openWritableDocument( session: NextcloudSession, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index b3766d722..b8a1f52f2 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -1,7 +1,10 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.io.FileNotFoundException import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred @@ -109,6 +112,24 @@ class AndroidAccountOperationGuardTest { assertTrue(transitionEntered) } + @Test + fun directDocumentMutationLeaseRejectsReauthenticatedSessionAndReleasesTheGuard() = runBlocking { + val guard = AndroidAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "original-password") + + assertFailsWith { + acquireAndroidDocumentMutationAccountLease( + session = original, + loadCurrentSession = { original.copy(appPassword = "replacement-password") }, + guard = guard, + ) + } + + withTimeout(1_000L) { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { } + } + } + @Test fun failedWritebackSetupReleasesItsPathAndAccountLease() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index 94aac6aa0..e73d656f0 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -59,6 +59,33 @@ class AndroidFileSyncEngineInvariantTest { assertEquals(scanHashes.getValue(unverified.relativePath), reconciled[1].contentHash) } + @Test + fun accountRetirementRemovesOnlyItsPersistedSyncPairsAndLabels() { + val first = FileSyncPair( + id = "first-pair", + accountId = "first-account", + localRootId = "first-root", + remoteRootPath = "Pictures", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) + val retained = FileSyncPair( + id = "retained-pair", + accountId = "retained-account", + localRootId = "retained-root", + remoteRootPath = "Documents", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) + val state = AndroidFileSyncPersistedState( + coordinator = FileSyncCoordinatorState(listOf(first, retained)), + localDisplayNames = mapOf(first.id to "Camera", retained.id to "Documents"), + ) + + val retired = removeAndroidFileSyncAccountPairs(state, first.accountId) + + assertEquals(listOf(retained), retired.coordinator.pairs) + assertEquals(mapOf(retained.id to "Documents"), retired.localDisplayNames) + } + @Test fun scheduleRestorationRejectsAStaleAccountSwitch() { val selected = NextcloudSession("https://cloud.example.test/nextcloud", "alice", "secret") diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 613a44ae0..e0c5ec25e 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -100,6 +100,24 @@ class AndroidPersistedSessionTest { assertEquals(second, resolved) } + @Test + fun clearingRecoveredIndependentStateRemovesOnlyItsActiveAccount() { + val first = firstSession() + val second = secondSession() + val recovered = requireNotNull( + AndroidAccountCredentialState.Empty + .upsertAndSelect(first) + .upsertAndSelect(second) + .select(first.accountId), + ) + + val cleared = removeActiveAndroidAccountCredentialState(recovered) + + assertNull(cleared.activeSession) + assertEquals(listOf(second.accountRecord()), cleared.registry.accounts) + assertEquals(second, cleared.sessions[second.accountId]) + } + @Test fun retainedAccountResolutionRejectsMismatchedCredential() { val first = firstSession() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index ad800d963..4de0c7d42 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -50,10 +50,10 @@ internal fun desktopSessionSaveReplacesActiveCredential( ): Boolean = activeSession?.accountId == savedSession.accountId && activeSession.appPassword != savedSession.appPassword -internal fun desktopResourceActivationMatchesActiveAccount( - activeAccountId: NextcloudAccountId?, - requestedAccountId: NextcloudAccountId, -): Boolean = activeAccountId == requestedAccountId +internal fun desktopResourceActivationMatchesActiveSession( + activeSession: NextcloudSession?, + requestedSession: NextcloudSession, +): Boolean = activeSession == requestedSession internal fun desktopSyncRunMatchesActiveSession( activeSession: NextcloudSession?, 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 a2c13fab4..dbcb641d5 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1516,7 +1516,7 @@ class DesktopNextcloudServices( } } val accepted = accountOperationGuard.tryActivateResource { - if (!desktopResourceActivationMatchesActiveAccount(activeAccountId(), session.accountId)) { + if (!desktopResourceActivationMatchesActiveSession(loadSession(), session)) { false } else { synchronized(virtualFileProviderLock) { @@ -1957,7 +1957,7 @@ class DesktopNextcloudServices( session: NextcloudSession, userId: String, ): VirtualFileStorageActionResult { - if (!desktopResourceActivationMatchesActiveAccount(activeAccountId(), session.accountId)) { + if (!desktopResourceActivationMatchesActiveSession(loadSession(), session)) { return VirtualFileStorageActionResult.Rejected( "The account changed before virtual file storage could be activated.", ) @@ -2879,7 +2879,14 @@ class DesktopNextcloudServices( SupportDiagnosticFieldDraft("pair", pairId, SupportDiagnosticValuePrivacy.Identifier), ) diagnoseDesktopSupportFailure(accountId, "sync.pair-remove", diagnosticFields) { - fileSyncEngine.removePair(session, userId, pairId) + accountOperationGuard.withSyncRunLock syncRun@{ + if (!desktopSyncRunMatchesActiveSession(loadSession(), session)) { + return@syncRun FileSyncCenterActionResult.Rejected( + "The account changed before this desktop sync pair could be removed.", + ) + } + fileSyncEngine.removePair(session, userId, pairId) + } }.also { result -> recordDesktopFileSyncResult(accountId, "sync.pair-remove", diagnosticFields, result) runCatching { @@ -4561,7 +4568,7 @@ class DesktopNextcloudServices( }, ) val registered = accountOperationGuard.tryActivateResource { - if (!desktopResourceActivationMatchesActiveAccount(activeAccountId(), session.accountId)) { + if (!desktopResourceActivationMatchesActiveSession(loadSession(), session)) { false } else { synchronized(fileRangeSessionLock) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 13c5b17a2..e1d728e74 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -119,12 +119,18 @@ class DesktopAccountOperationGuardTest { val guard = DesktopAccountOperationGuard() var hydrationRegistered = false - assertTrue(desktopResourceActivationMatchesActiveAccount(first.accountId, first.accountId)) - assertFalse(desktopResourceActivationMatchesActiveAccount(second.accountId, first.accountId)) - assertFalse(desktopResourceActivationMatchesActiveAccount(null, first.accountId)) + assertTrue(desktopResourceActivationMatchesActiveSession(first, first.copy())) + assertFalse(desktopResourceActivationMatchesActiveSession(second, first)) + assertFalse(desktopResourceActivationMatchesActiveSession(null, first)) + assertFalse( + desktopResourceActivationMatchesActiveSession( + first.copy(appPassword = "rotated"), + first, + ), + ) assertFalse( guard.tryActivateResource { - desktopResourceActivationMatchesActiveAccount(second.accountId, first.accountId) && + desktopResourceActivationMatchesActiveSession(second, first) && true.also { hydrationRegistered = true } }, ) @@ -252,4 +258,31 @@ class DesktopAccountOperationGuardTest { mutation.await() assertEquals(listOf("account-mutated"), events) } + + @Test + fun pairRemovalWaitsForTheSelectionSyncBoundary() = runBlocking { + val guard = DesktopAccountOperationGuard() + val selectionEntered = CompletableDeferred() + val releaseSelection = CompletableDeferred() + var removalEntered = false + val selection = async { + guard.serialize { + guard.withSyncRunLock { + selectionEntered.complete(Unit) + releaseSelection.await() + } + } + } + selectionEntered.await() + val removal = async { + guard.withSyncRunLock { removalEntered = true } + } + yield() + + assertFalse(removalEntered) + releaseSelection.complete(Unit) + selection.await() + removal.await() + assertTrue(removalEntered) + } } From b4770880f951fe900fbd4784d7479ed94341b740 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 09:40:13 +0200 Subject: [PATCH 017/119] fix(accounts): retire removed account resources --- .../AndroidAccountOperationGuard.kt | 10 ++++ .../AndroidFileOfflineAccountCleanup.kt | 49 +++++++++++++++++++ .../AndroidFileOfflineRepository.kt | 2 +- .../AndroidNextcloudServices.kt | 36 ++++++++++++-- .../AndroidShareUploadActivity.kt | 38 +++++++++----- .../AndroidAccountOperationGuardTest.kt | 38 ++++++++++++++ .../AndroidOfflineFolderPlanningTest.kt | 43 ++++++++++++++++ .../nextcloudnative/app/NextcloudPlatform.kt | 1 - .../DesktopAccountCredentialPersistence.kt | 13 +++-- .../app/DesktopFileSyncAccountCleanup.kt | 19 +++++++ .../app/DesktopFileSyncEngine.kt | 4 +- .../app/DesktopNextcloudServices.kt | 8 ++- ...DesktopAccountCredentialPersistenceTest.kt | 27 ++++++++++ .../app/DesktopFileSyncStoreTest.kt | 36 ++++++++++++++ 14 files changed, 297 insertions(+), 27 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineAccountCleanup.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index 17439a26c..00a0534d8 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -43,6 +43,16 @@ internal class AndroidAccountOperationGuard { } } + suspend fun withExactAccountSession( + expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, + resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, + unavailable: suspend () -> Result, + action: suspend (dev.obiente.nextcloudnative.app.NextcloudSession) -> Result, + ): Result = withAccount(NextcloudDocumentIds.accountKey(expectedSession)) { + val current = resolveSession() + if (current == expectedSession) action(current) else unavailable() + } + private suspend fun acquire(accountId: String): AndroidAccountOperationLease { require(accountId.isNotBlank()) val lease = synchronized(monitor) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineAccountCleanup.kt new file mode 100644 index 000000000..f055e9702 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineAccountCleanup.kt @@ -0,0 +1,49 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import androidx.work.WorkManager +import androidx.work.await +import dev.obiente.nextcloudnative.app.FileOfflineQueueState +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal class AndroidFileOfflineAccountCleanup(context: Context) { + private val appContext = context.applicationContext + private val store = AndroidFileOfflineQueueStore(appContext) + + suspend fun removeForAccount(accountId: String) = withContext(Dispatchers.IO) { + val pendingJobIds = synchronized(AndroidFileOfflineRepository.STATE_LOCK) { + store.load().queue.jobs.filter { job -> job.key.accountId == accountId }.map { job -> job.id } + } + val workManager = WorkManager.getInstance(appContext) + pendingJobIds.forEach { jobId -> + workManager.cancelUniqueWork(AndroidFileOfflineRepository.workName(accountId, jobId)).await() + } + synchronized(AndroidFileOfflineRepository.STATE_LOCK) { + store.save(removeAndroidFileOfflineAccountState(store.load(), accountId)) + } + val accountContent = File( + File(appContext.filesDir, AndroidFileOfflineRepository.CONTENT_DIRECTORY), + accountId, + ) + check(!accountContent.exists() || accountContent.deleteRecursively()) { + "Could not remove this account's offline files." + } + } +} + +internal fun removeAndroidFileOfflineAccountState( + current: AndroidFileOfflinePersistedState, + accountId: String, +): AndroidFileOfflinePersistedState = current.copy( + queue = FileOfflineQueueState( + records = current.queue.records.filterNot { record -> record.descriptor.key.accountId == accountId }, + jobs = current.queue.jobs.filterNot { job -> job.key.accountId == accountId }, + nextJobId = current.queue.nextJobId, + ), + folders = current.folders.copy( + directPins = current.folders.directPins.filterNotTo(linkedSetOf()) { key -> key.accountId == accountId }, + roots = current.folders.roots.filterNot { root -> root.accountId == accountId }, + ), +) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt index a7c6bd860..04ae7f32f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt @@ -705,7 +705,7 @@ internal class AndroidFileOfflineRepository(context: Context) { val record: dev.obiente.nextcloudnative.app.FileOfflinePinRecord, ) - private companion object { + internal companion object { const val CONTENT_DIRECTORY = "offline-content-v1" const val WORK_TAG = "nextcloud-native-offline-files" const val MAX_OFFLINE_CENTER_VISIBLE_ITEMS = 10_000 diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index d173dffd4..60cea16cc 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -448,6 +448,7 @@ internal class AndroidNextcloudServices( private val dynamicDiscoveryCacheDirectory = File(appContext.filesDir, "contracts/discoveries-v1") private val pendingDynamicMutationDirectory = File(appContext.filesDir, "mutations/dynamic-v1") private val fileOfflineRepository = AndroidFileOfflineRepository(appContext) + private val fileOfflineAccountCleanup = AndroidFileOfflineAccountCleanup(appContext) private val fileReadCache = AndroidFileReadCache(File(appContext.cacheDir, "files-read-v1")) private val virtualFileCache = AndroidVirtualFileCache(appContext) private val dynamicApiReadCache = DynamicApiResponseCache(File(appContext.cacheDir, "dynamic-api-v1")) @@ -498,6 +499,7 @@ internal class AndroidNextcloudServices( notifyDocumentRootsChanged = ::notifyDocumentsRootsChanged, resumeQueuedUploads = durableMultipartUploads::resumeQueuedForAccount, removeQueuedUploads = { session -> + fileOfflineAccountCleanup.removeForAccount(NextcloudDocumentIds.accountKey(session)) incomingShareAccountCleanup.removeForAccount(session) durableUploadAccountCleanup.removeForAccount(NextcloudDocumentIds.accountKey(session)) retireAndroidFileSyncAccountPairs(appContext, NextcloudDocumentIds.accountKey(session)) @@ -1492,7 +1494,13 @@ internal class AndroidNextcloudServices( file: NextcloudFile, available: Boolean, ): FileOfflineAvailability = withContext(Dispatchers.IO) { - fileOfflineRepository.setAvailable(session, userId, file, available) + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = session, + resolveSession = { loadSession(session.accountId) }, + unavailable = { error("The account changed before offline storage could be updated.") }, + ) { current -> + fileOfflineRepository.setAvailable(current, userId, file, available) + } } override suspend fun loadFileOfflineCenter( @@ -1507,7 +1515,13 @@ internal class AndroidNextcloudServices( userId: String, key: FileOfflineKey, ): FileOfflineCenterActionResult = withContext(Dispatchers.IO) { - fileOfflineRepository.retryCenterItem(session, userId, key) + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = session, + resolveSession = { loadSession(session.accountId) }, + unavailable = { FileOfflineCenterActionResult.Rejected("The account changed before this retry started.") }, + ) { current -> + fileOfflineRepository.retryCenterItem(current, userId, key) + } } override suspend fun removeFileOfflineItem( @@ -1515,7 +1529,13 @@ internal class AndroidNextcloudServices( userId: String, key: FileOfflineKey, ): FileOfflineCenterActionResult = withContext(Dispatchers.IO) { - fileOfflineRepository.removeCenterItem(session, userId, key) + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = session, + resolveSession = { loadSession(session.accountId) }, + unavailable = { FileOfflineCenterActionResult.Rejected("The account changed before offline storage was removed.") }, + ) { current -> + fileOfflineRepository.removeCenterItem(current, userId, key) + } } override suspend fun loadVirtualFileStorage( @@ -2911,7 +2931,15 @@ internal class AndroidNextcloudServices( scope: DurableUploadScope, request: NextcloudMultipartUploadRequest, ): DurableUploadEnqueueResult = withContext(Dispatchers.IO) { - durableMultipartUploads.enqueue(session, scope, request) + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = session, + resolveSession = { loadSession(session.accountId) }, + unavailable = { + DurableUploadEnqueueResult.Rejected("The account changed before the upload could be queued.") + }, + ) { current -> + durableMultipartUploads.enqueue(current, scope, request) + } } override suspend fun durableMultipartUploadStatuses( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt index f9a344cd2..0cff45e69 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt @@ -180,19 +180,25 @@ class AndroidShareUploadActivity : ComponentActivity() { ?: error("Sign in to nati.ve before sharing files to it.") activeAccountId = NextcloudDocumentIds.accountKey(activeSession) val staged = withContext(Dispatchers.IO) { - val restored = validatedRequestId?.let { requestId -> - store.requireAvailable(requestId) - } ?: store.stage( - sourceIntent, - NextcloudDocumentIds.accountKey(activeSession), - ).also { newlyStaged -> - unclaimedStagedRequestId = newlyStaged.id - } - require(restored.accountId == NextcloudDocumentIds.accountKey(activeSession)) { - "Switch back to the account that received this share before reviewing it." + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = activeSession, + resolveSession = { services.loadSession(activeSession.accountId) }, + unavailable = { error("The account changed before the shared files could be prepared.") }, + ) { + val restored = validatedRequestId?.let { requestId -> + store.requireAvailable(requestId) + } ?: store.stage( + sourceIntent, + NextcloudDocumentIds.accountKey(activeSession), + ).also { newlyStaged -> + unclaimedStagedRequestId = newlyStaged.id + } + require(restored.accountId == NextcloudDocumentIds.accountKey(activeSession)) { + "Switch back to the account that received this share before reviewing it." + } + uploads.ensureQueuedRequestScheduled(restored) + restored } - uploads.ensureQueuedRequestScheduled(restored) - restored } ensureActive() if (generation != restoreGeneration) return@launch @@ -249,7 +255,13 @@ class AndroidShareUploadActivity : ComponentActivity() { queueJob = lifecycleScope.launch { val result = runCatching { withContext(Dispatchers.IO) { - uploads.enqueue(activeSession, info.userId, staged.id, destinationPath) + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = activeSession, + resolveSession = { services.loadSession(activeSession.accountId) }, + unavailable = { error("The account changed before the upload could be queued.") }, + ) { current -> + uploads.enqueue(current, info.userId, staged.id, destinationPath) + } } } if (!isCurrentIncomingShareEnqueue(generation, restoreGeneration, staged.id, request?.id)) return@launch diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index b8a1f52f2..8a34b5947 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -236,4 +236,42 @@ class AndroidAccountOperationGuardTest { assertEquals("unavailable", cleanup.await()) assertFalse(operationRan) } + + @Test + fun uploadCreationWaitsForRemovalAndRejectsAReplacementCredential() = runBlocking { + val guard = AndroidAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val replacement = original.copy(appPassword = "new-password") + val accountIdentity = NextcloudDocumentIds.accountKey(original) + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var currentSession: NextcloudSession? = original + var uploadCreated = false + val removal = async { + guard.withAccount(accountIdentity) { + currentSession = replacement + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + removalEntered.await() + + val upload = async { + guard.withExactAccountSession( + expectedSession = original, + resolveSession = { currentSession }, + unavailable = { false }, + ) { + uploadCreated = true + true + } + } + yield() + assertFalse(upload.isCompleted) + + releaseRemoval.complete(Unit) + removal.await() + assertFalse(upload.await()) + assertFalse(uploadCreated) + } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidOfflineFolderPlanningTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidOfflineFolderPlanningTest.kt index 796fcb09d..e990f72b6 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidOfflineFolderPlanningTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidOfflineFolderPlanningTest.kt @@ -165,6 +165,49 @@ class AndroidOfflineFolderPlanningTest { assertTrue(AndroidOfflineFolderState(roots = listOf(root)).offlineDirectories("other").isEmpty()) } + @Test + fun accountRemovalPurgesOnlyThatAccountsOfflineQueueAndFolders() { + val first = planAndroidOfflineFolderPin( + current = AndroidFileOfflinePersistedState(), + accountId = "account-a", + inventory = planAndroidOfflineFolder(directory("First")) { + listOf(file("First/a.txt", 1, "\"a\"")) + }, + nowEpochMillis = 10, + localGenerationExists = { _, _ -> false }, + ) + val both = planAndroidOfflineFolderPin( + current = first, + accountId = "account-b", + inventory = planAndroidOfflineFolder(directory("Second")) { + listOf(file("Second/b.txt", 2, "\"b\"")) + }, + nowEpochMillis = 20, + localGenerationExists = { _, _ -> false }, + ).copy( + folders = first.folders.copy( + directPins = setOf(FileOfflineKey("account-a", "First/a.txt")), + roots = first.folders.roots + planAndroidOfflineFolderPin( + current = AndroidFileOfflinePersistedState(), + accountId = "account-b", + inventory = planAndroidOfflineFolder(directory("Second")) { + listOf(file("Second/b.txt", 2, "\"b\"")) + }, + nowEpochMillis = 20, + localGenerationExists = { _, _ -> false }, + ).folders.roots, + ), + ) + + val retained = removeAndroidFileOfflineAccountState(both, "account-a") + + assertTrue(retained.queue.records.all { it.descriptor.key.accountId == "account-b" }) + assertTrue(retained.queue.jobs.all { it.key.accountId == "account-b" }) + assertTrue(retained.folders.directPins.isEmpty()) + assertEquals(listOf("account-b"), retained.folders.roots.map { it.accountId }) + assertEquals(both.queue.nextJobId, retained.queue.nextJobId) + } + private fun directory(path: String) = NextcloudFile( path = path, name = path.substringAfterLast('/'), diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index d27eb4971..c034cb47a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -428,7 +428,6 @@ data class NextcloudPerson( val coverEtag: String?, val backend: String, ) - interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCardDraftPlatformServices { /** Loads public project news from the fixed Obiente feed, with a bounded platform cache. */ suspend fun loadProjectNews(forceRefresh: Boolean = false): ProjectNewsResult = diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 10921ffcd..817aba3b3 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -20,18 +20,15 @@ internal class DesktopAccountCredentialPersistence( } fun listAccounts(): List { - retryPendingLegacyCredentialCleanup() val read = readRegistry() if (read.registry != null) return read.registry.accounts - val legacy = restoreLegacySession(read.encoded != null) - return readRegistry().registry?.accounts ?: legacy?.let { listOf(it.accountRecord()) }.orEmpty() + return readLegacyAccountRecord()?.let(::listOf).orEmpty() } fun activeAccountId(): NextcloudAccountId? { - retryPendingLegacyCredentialCleanup() val read = readRegistry() if (read.registry != null) return read.registry.activeAccountId - return restoreLegacySession(read.encoded != null)?.accountId + return readLegacyAccountRecord()?.id } fun loadSession(accountId: NextcloudAccountId): NextcloudSession? { @@ -156,6 +153,12 @@ internal class DesktopAccountCredentialPersistence( return NextcloudSession(server, login, password) } + private fun readLegacyAccountRecord(): NextcloudAccountRecord? { + val server = preferences.get(KEY_SERVER, null) ?: return null + val login = preferences.get(KEY_LOGIN, null) ?: return null + return runCatching { NextcloudSession(server, login, appPassword = "").accountRecord() }.getOrNull() + } + private fun migrateLegacyCredential(session: NextcloudSession) { persistPendingLegacyCredentialCleanup(session) saveSecret(session) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt new file mode 100644 index 000000000..d6b8fb747 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt @@ -0,0 +1,19 @@ +package dev.obiente.nextcloudnative.app + +internal fun DesktopFileSyncStore.removeDesktopFileSyncAccountPairs(accountId: String) { + require(accountId.isNotBlank() && accountId.length <= 256) + withExclusiveAccess { + val current = load() + val removed = current.coordinator.pairs.filter { pair -> pair.accountId == accountId } + val retainedRootIds = current.coordinator.pairs.asSequence() + .filterNot { pair -> pair.accountId == accountId } + .mapTo(mutableSetOf(), FileSyncPair::localRootId) + removed.forEach { pair -> + deletePair( + pairId = pair.id, + rootId = pair.localRootId, + deleteRoot = pair.localRootId !in retainedRootIds, + ) + } + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt index 8a918f197..9887896f6 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt @@ -23,7 +23,6 @@ internal class DesktopFileSyncEngine( ) { private val selectedRoots = ConcurrentHashMap() private val lock = Mutex() - suspend fun chooseLocalRoot(initialRootHint: String?): FileSyncLocalRoot? = withContext(Dispatchers.IO) { val initialDirectory = initialRootHint?.let(selectedRoots::get)?.takeIf(File::isDirectory) val chosen = folderPicker.choose(initialDirectory) ?: return@withContext null @@ -217,6 +216,8 @@ internal class DesktopFileSyncEngine( } } + suspend fun removeAccountPairs(accountId: String) = lock.withLock { store.removeDesktopFileSyncAccountPairs(accountId) } + suspend fun runPair( session: NextcloudSession, userId: String, @@ -826,7 +827,6 @@ internal class DesktopFileSyncEngine( private fun filesMatch(first: File, second: File): Boolean = first.length() == second.length() && Files.mismatch(first.toPath(), second.toPath()) == -1L - private fun synchronizedResult( path: String, local: DesktopFileSyncLocalTree, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index dbcb641d5..a02977945 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3791,7 +3791,12 @@ class DesktopNextcloudServices( clearSessionForAccountOperation() true } else { - sessionPublicationGuard.serialize { accountCredentials.removeAccount(accountId) } + val account = listAccounts().firstOrNull { record -> record.id == accountId } + ?: return@serialize false + accountOperationGuard.withSyncRunLock { + fileSyncEngine.removeAccountPairs(desktopFileCacheAccountId(account)) + sessionPublicationGuard.serialize { accountCredentials.removeAccount(accountId) } + } } } } @@ -3903,6 +3908,7 @@ class DesktopNextcloudServices( mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot( phase = DesktopFileSyncTrayPhase.Idle, ) + accountId?.let { fileSyncEngine.removeAccountPairs(it) } sessionPublicationGuard.serialize { if (activeAccountId != null) { check(accountCredentials.removeAccount(activeAccountId)) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index 72d3736f2..747ae4acd 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -42,6 +42,22 @@ class DesktopAccountCredentialPersistenceTest { assertEquals(first, persistence(preferences, secrets).loadActiveSession()) } + @Test + fun credentialFreeAccountReadsDoNotRetrySecretCleanup() = withStore { preferences, secrets -> + val session = firstSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(session) + preferences.put("accountLegacyCleanupServer", session.serverUrl) + preferences.put("accountLegacyCleanupLogin", session.loginName) + secrets.resetOperationCounts() + + assertEquals(listOf(session.accountRecord()), persistence.listAccounts()) + assertEquals(session.accountId, persistence.activeAccountId()) + assertEquals(0, secrets.loadCount) + assertEquals(0, secrets.clearCount) + assertEquals(session.serverUrl, preferences.get("accountLegacyCleanupServer", null)) + } + @Test fun selectionFlushesRegistryAndLegacyMetadataBeforeReturning() = withStore { preferences, secrets -> var flushCount = 0 @@ -456,8 +472,13 @@ class DesktopAccountCredentialPersistenceTest { var failSaves = false var failClears = false var loadFailure: RuntimeException? = null + var loadCount = 0 + private set + var clearCount = 0 + private set override fun load(reference: DesktopSecretReference): ByteArray? { + loadCount += 1 loadFailure?.let { throw it } return values[reference.targetName]?.copyOf() } @@ -468,8 +489,14 @@ class DesktopAccountCredentialPersistenceTest { } override fun clear(reference: DesktopSecretReference) { + clearCount += 1 if (failClears) error("synthetic secret deletion failure") values.remove(reference.targetName) } + + fun resetOperationCounts() { + loadCount = 0 + clearCount = 0 + } } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt index 7918f7ca4..372151609 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt @@ -20,6 +20,42 @@ import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put class DesktopFileSyncStoreTest { + @Test + fun `account removal deletes only that account's sync pairs and roots`() { + val directory = Files.createTempDirectory("desktop-sync-account-removal-").toFile() + try { + val first = FileSyncPair( + id = "first-pair", + accountId = "account-a", + localRootId = "first-root", + remoteRootPath = "Documents", + configuration = FileSyncConfiguration(deviceLabel = "Workstation"), + ) + val second = FileSyncPair( + id = "second-pair", + accountId = "account-b", + localRootId = "second-root", + remoteRootPath = "Photos", + configuration = FileSyncConfiguration(deviceLabel = "Workstation"), + ) + val roots = listOf( + DesktopFileSyncRootRecord(first.localRootId, directory.resolve("first").absolutePath, "First"), + DesktopFileSyncRootRecord(second.localRootId, directory.resolve("second").absolutePath, "Second"), + ) + val store = DesktopFileSyncStore(File(directory, "state.db"), legacyStateFile = null) + store.savePair(DesktopFileSyncPersistedState(FileSyncCoordinatorState(listOf(first)), roots.take(1)), first.id) + store.savePair(DesktopFileSyncPersistedState(FileSyncCoordinatorState(listOf(second)), roots.drop(1)), second.id) + + store.removeDesktopFileSyncAccountPairs("account-a") + + val retained = store.load() + assertEquals(listOf(second), retained.coordinator.pairs) + assertEquals(roots.drop(1), retained.roots) + } finally { + directory.deleteRecursively() + } + } + @Test fun `legacy json state imports once into the transactional database`() { val directory = Files.createTempDirectory("desktop-sync-legacy-import-").toFile() From 32fd78b251c2f841b06ccfdf37272e15e5061fcf Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 10:08:49 +0200 Subject: [PATCH 018/119] fix(accounts): close recovery transition gaps --- .../AndroidAccountCredentialController.kt | 32 +++++++++- .../AndroidFileSyncScheduler.kt | 18 +++--- .../nextcloudnative/AndroidFileSyncStore.kt | 8 +++ .../AndroidFileSyncEngineInvariantTest.kt | 63 +++++++++++++++++++ .../AndroidPersistedSessionTest.kt | 13 ++++ .../app/DesktopAccountOperationGuard.kt | 7 +++ .../app/DesktopNextcloudServices.kt | 50 +++++++++------ .../app/DesktopAccountOperationGuardTest.kt | 13 ++++ 8 files changed, 176 insertions(+), 28 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 91aec1412..6c1e2ae73 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -215,7 +215,17 @@ internal class AndroidAccountCredentialController( val activeSession = current.activeSession if (activeSession != null) { ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(activeSession)) { - persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted) + removeRecoveredAndroidAccountCredentialData( + removeQueuedUploads = { removeQueuedUploads(activeSession) }, + clearRecoveredAccount = { persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted) }, + rollbackRecoveredAccount = { + replaceActiveStateWhileOperationsIdle( + replacement = current, + previousSession = null, + suspectEncrypted = suspectEncrypted, + ) + }, + ) } } else { persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted) @@ -314,6 +324,13 @@ internal class AndroidAccountCredentialController( cancelAll = scheduler::cancelAll, publishAccount = publishAccountIdentity, restoreSchedules = scheduler::restorePersistedPairSchedules, + onScheduleMaintenanceFailure = { + recordCredentialFailure( + code = "FILE_SYNC_SCHEDULE_MAINTENANCE_FAILED", + operation = "account-selection.schedule-maintenance", + component = SupportDiagnosticComponent.Sync, + ) + }, ) } if (previousSession != null && previousSession.accountId != session.accountId) { @@ -674,6 +691,19 @@ internal suspend fun removeAndroidAccountCredentialData( } } +internal suspend fun removeRecoveredAndroidAccountCredentialData( + removeQueuedUploads: suspend () -> Unit, + clearRecoveredAccount: suspend () -> Unit, + rollbackRecoveredAccount: suspend () -> Unit, +) = removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = removeQueuedUploads, + clearActiveAccount = clearRecoveredAccount, + rollbackActiveRemoval = rollbackRecoveredAccount, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, +) + internal class AndroidAccountCredentialStoreGuard { private val monitor = Any() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt index 7ad0562f4..a1e6cae67 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt @@ -56,6 +56,7 @@ internal class AndroidFileSyncSessionSchedulingGuard { cancelAll: () -> Unit, publishAccount: (String) -> Unit = {}, restoreSchedules: (String) -> Unit = {}, + onScheduleMaintenanceFailure: (Exception) -> Unit = {}, ) { synchronized(monitor) { val accountChanged = accountId != replacementAccountId @@ -66,14 +67,9 @@ internal class AndroidFileSyncSessionSchedulingGuard { publishAccount(replacementAccountId) } finally { if (accountChanged) { - try { - cancelAll() - } finally { - restoreSchedules(replacementAccountId) - } - } else { - restoreSchedules(replacementAccountId) + runScheduleMaintenance(onScheduleMaintenanceFailure, cancelAll) } + runScheduleMaintenance(onScheduleMaintenanceFailure) { restoreSchedules(replacementAccountId) } } } } @@ -113,6 +109,14 @@ internal class AndroidFileSyncSessionSchedulingGuard { true } } + + private fun runScheduleMaintenance(onFailure: (Exception) -> Unit, action: () -> Unit) { + try { + action() + } catch (failure: Exception) { + runCatching { onFailure(failure) } + } + } } internal data class DeferredFileSyncPairScheduling( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt index 0c9b2eee6..eb3352fff 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt @@ -4,6 +4,7 @@ import android.content.Context import dev.obiente.nextcloudnative.app.FileSyncCoordinatorState import dev.obiente.nextcloudnative.app.decodeFileSyncCoordinatorSnapshot import dev.obiente.nextcloudnative.app.encodeFileSyncCoordinatorSnapshot +import dev.obiente.nextcloudnative.app.fileSyncOwnedUploads import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.DataInputStream @@ -32,6 +33,13 @@ internal fun removeAndroidFileSyncAccountPairs( accountId: String, ): AndroidFileSyncPersistedState { require(accountId.isNotBlank()) + state.coordinator.pairs + .filter { pair -> pair.accountId == accountId } + .forEach { pair -> + require(fileSyncOwnedUploads(pair).isEmpty()) { + "Owned remote upload state must be recovered before removing this account's sync pairs." + } + } val retainedPairs = state.coordinator.pairs.filterNot { pair -> pair.accountId == accountId } val retainedPairIds = retainedPairs.mapTo(hashSetOf()) { pair -> pair.id } return AndroidFileSyncPersistedState( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index e73d656f0..f820dc815 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -6,6 +6,7 @@ import dev.obiente.nextcloudnative.app.FileSyncCoordinatorState import dev.obiente.nextcloudnative.app.FileSyncDirection import dev.obiente.nextcloudnative.app.FileSyncOperation import dev.obiente.nextcloudnative.app.FileSyncPair +import dev.obiente.nextcloudnative.app.FileSyncPendingUploadCleanup import dev.obiente.nextcloudnative.app.LocalSyncEntry import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.RemoteSyncEntry @@ -86,6 +87,33 @@ class AndroidFileSyncEngineInvariantTest { assertEquals(mapOf(retained.id to "Documents"), retired.localDisplayNames) } + @Test + fun accountRetirementPreservesPairsThatStillOwnRemoteUploadRecovery() { + val pair = FileSyncPair( + id = "pair", + accountId = "account", + localRootId = "root", + remoteRootPath = "Pictures", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + pendingUploadCleanups = listOf( + FileSyncPendingUploadCleanup( + uploadId = "123e4567-e89b-12d3-a456-426614174000", + relativePath = "photo.jpg", + ), + ), + ) + val state = AndroidFileSyncPersistedState( + coordinator = FileSyncCoordinatorState(listOf(pair)), + localDisplayNames = mapOf(pair.id to "Camera"), + ) + + assertFailsWith { + removeAndroidFileSyncAccountPairs(state, pair.accountId) + } + assertEquals(listOf(pair), state.coordinator.pairs) + assertEquals(mapOf(pair.id to "Camera"), state.localDisplayNames) + } + @Test fun scheduleRestorationRejectsAStaleAccountSwitch() { val selected = NextcloudSession("https://cloud.example.test/nextcloud", "alice", "secret") @@ -817,6 +845,41 @@ class AndroidFileSyncEngineInvariantTest { assertEquals(null, guard.capture("account-new")) } + @Test + fun postCommitScheduleFailureDoesNotRejectTheSelectedAccount() { + val guard = AndroidFileSyncSessionSchedulingGuard() + guard.restorePersistedSession(load = { "account-old" }, accountIdOf = { it }) + val events = mutableListOf() + + guard.replaceSession( + replacementAccountId = "account-new", + persist = { events += "save-new-session" }, + cancelAll = { + events += "cancel-old-work" + error("synthetic WorkManager failure") + }, + publishAccount = { events += "publish-$it" }, + restoreSchedules = { + events += "restore-$it-work" + error("synthetic enqueue failure") + }, + onScheduleMaintenanceFailure = { events += "diagnose" }, + ) + + assertEquals( + listOf( + "save-new-session", + "publish-account-new", + "cancel-old-work", + "diagnose", + "restore-account-new-work", + "diagnose", + ), + events, + ) + assertTrue(guard.capture("account-new") != null) + } + @Test fun failedSessionClearPreservesOldAuthorityAndSchedules() { val guard = AndroidFileSyncSessionSchedulingGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index e0c5ec25e..3dc8a4676 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -665,6 +665,19 @@ class AndroidPersistedSessionTest { assertEquals(listOf("clear-account", "remove-uploads"), events) } + @Test + fun recoveredInvalidStoreRemovalAlsoCleansQueuedAccountWork() = runBlocking { + val events = mutableListOf() + + removeRecoveredAndroidAccountCredentialData( + removeQueuedUploads = { events += "remove-queued-work" }, + clearRecoveredAccount = { events += "clear-recovered-account" }, + rollbackRecoveredAccount = { events += "rollback-recovered-account" }, + ) + + assertEquals(listOf("clear-recovered-account", "remove-queued-work"), events) + } + @Test fun activeSignOutDeletesQueuedUploadsAfterTheCredentialIsCleared() = runBlocking { val events = mutableListOf() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 4de0c7d42..586a7030b 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -55,6 +55,13 @@ internal fun desktopResourceActivationMatchesActiveSession( requestedSession: NextcloudSession, ): Boolean = activeSession == requestedSession +internal fun desktopResourceDeactivationTargetsCurrentProvider( + activeSession: NextcloudSession?, + requestedSession: NextcloudSession, + providerAccountId: String?, +): Boolean = desktopResourceActivationMatchesActiveSession(activeSession, requestedSession) && + providerAccountId == desktopFileCacheAccountId(requestedSession) + internal fun desktopSyncRunMatchesActiveSession( activeSession: NextcloudSession?, requestedSession: NextcloudSession, 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 a02977945..0d93c85c5 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -2209,28 +2209,38 @@ class DesktopNextcloudServices( session: NextcloudSession, userId: String, ): VirtualFileStorageActionResult = withContext(Dispatchers.IO) { - synchronized(virtualFileProviderLock) { - linuxVirtualFileSystem?.unmount() - linuxVirtualFileSystem = null - linuxVirtualMetadataBackend = null - linuxVirtualFileMountIdentity = null - linuxVirtualFileFailure = null - windowsCloudFilesProvider?.close() - windowsCloudFilesProvider = null - windowsCloudFilesIdentity = null - windowsCloudFilesFailure = null - preferences.putBoolean( - virtualFileProviderPreferenceKey(desktopFileCacheAccountId(session)), - false, + accountOperationGuard.serializeResourceActivation { + val activeSession = loadSession() + if (!desktopResourceActivationMatchesActiveSession(activeSession, session)) { + return@serializeResourceActivation VirtualFileStorageActionResult.Rejected( + "The account changed before virtual file storage could be deactivated.", + ) + } + val accountId = desktopFileCacheAccountId(session) + synchronized(virtualFileProviderLock) { + if (desktopResourceDeactivationTargetsCurrentProvider(activeSession, session, linuxVirtualFileMountIdentity)) { + linuxVirtualFileSystem?.unmount() + linuxVirtualFileSystem = null + linuxVirtualMetadataBackend = null + linuxVirtualFileMountIdentity = null + linuxVirtualFileFailure = null + } + if (desktopResourceDeactivationTargetsCurrentProvider(activeSession, session, windowsCloudFilesIdentity)) { + windowsCloudFilesProvider?.close() + windowsCloudFilesProvider = null + windowsCloudFilesIdentity = null + windowsCloudFilesFailure = null + } + preferences.putBoolean(virtualFileProviderPreferenceKey(accountId), false) + } + VirtualFileStorageActionResult.Completed( + if (isWindowsDesktop()) { + "Windows Cloud Files disconnected. Placeholders, cached content, and remote files were kept." + } else { + "Virtual files unmounted. Cached content and remote files were kept." + }, ) } - VirtualFileStorageActionResult.Completed( - if (isWindowsDesktop()) { - "Windows Cloud Files disconnected. Placeholders, cached content, and remote files were kept." - } else { - "Virtual files unmounted. Cached content and remote files were kept." - }, - ) } override suspend fun acknowledgeVirtualFileProviderRecovery( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index e1d728e74..d40711b63 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -137,6 +137,19 @@ class DesktopAccountOperationGuardTest { assertFalse(hydrationRegistered) } + @Test + fun resourceDeactivationRejectsAStaleAccountAndAnotherAccountsProvider() { + val first = NextcloudSession("https://first.example.test", "alice", "one") + val second = NextcloudSession("https://second.example.test", "bob", "two") + val firstIdentity = desktopFileCacheAccountId(first) + val secondIdentity = desktopFileCacheAccountId(second) + + assertTrue(desktopResourceDeactivationTargetsCurrentProvider(first, first.copy(), firstIdentity)) + assertFalse(desktopResourceDeactivationTargetsCurrentProvider(second, first, secondIdentity)) + assertFalse(desktopResourceDeactivationTargetsCurrentProvider(first, first, secondIdentity)) + assertFalse(desktopResourceDeactivationTargetsCurrentProvider(null, first, firstIdentity)) + } + @Test fun syncRunRejectsAStaleAccountAfterWaitingForSelection() { val first = NextcloudSession("https://first.example.test", "alice", "one") From 1cbd3fc58a8f507b02afbc41c9af94e0c14391db Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 10:38:18 +0200 Subject: [PATCH 019/119] fix(accounts): harden credential recovery boundaries --- .../AndroidAccountCredentialController.kt | 32 +++--- .../AndroidNextcloudServices.kt | 13 ++- .../AndroidAccountOperationGuardTest.kt | 37 ++++++ .../AndroidPersistedSessionTest.kt | 44 +++++++- .../DesktopAccountCredentialPersistence.kt | 106 +++++++++++++++++- .../app/DesktopNextcloudServices.kt | 9 +- ...DesktopAccountCredentialPersistenceTest.kt | 67 ++++++++++- .../app/DesktopAccountOperationGuardTest.kt | 37 ++++++ 8 files changed, 321 insertions(+), 24 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 6c1e2ae73..efa22628f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -266,9 +266,7 @@ internal class AndroidAccountCredentialController( } else { prepareInvalidAndroidAccountCredentialRecoveryEdit( editor = preferences.edit(), - suspectEncrypted = suspectEncrypted, replacementEncrypted = encodedReplacement, - hasExistingQuarantine = preferences.contains(KEY_QUARANTINED_SESSION), ).putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(replacement.registry)) .let { editor -> prepareCredentialSlotEdit(editor, replacement) } } @@ -314,9 +312,7 @@ internal class AndroidAccountCredentialController( } else { prepareInvalidAndroidAccountCredentialRecoveryEdit( editor = preferences.edit(), - suspectEncrypted = suspectEncrypted, replacementEncrypted = encrypted, - hasExistingQuarantine = preferences.contains(KEY_QUARANTINED_SESSION), ).putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(replacement.registry)) } commitPreferences(prepareCredentialSlotEdit(editor, replacement)) @@ -401,9 +397,9 @@ internal class AndroidAccountCredentialController( val encrypted = preferences.getString(KEY_SESSION, null) ?: return@serialize run { val retained = readIndependentCredentialSlotState() when { - retained != null -> AndroidAccountCredentialStoreRead.Available(retained) + retained != null -> availableCredentialStore(retained) hasIndependentCredentialState() -> AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable - else -> AndroidAccountCredentialStoreRead.Available(AndroidAccountCredentialState.Empty) + else -> availableCredentialStore(AndroidAccountCredentialState.Empty) } } val encoded = try { @@ -436,11 +432,20 @@ internal class AndroidAccountCredentialController( return@serialize when { restored.unsupportedVersion != null -> AndroidAccountCredentialStoreRead.Unsupported(encrypted, restored.unsupportedVersion) - restored.state != null -> AndroidAccountCredentialStoreRead.Available(restored.state) + restored.state != null -> availableCredentialStore(restored.state) else -> AndroidAccountCredentialStoreRead.Invalid(encrypted) } } + private fun availableCredentialStore( + state: AndroidAccountCredentialState, + ): AndroidAccountCredentialStoreRead.Available { + if (preferences.contains(KEY_QUARANTINED_SESSION)) { + runCatching { commitPreferences(preferences.edit().remove(KEY_QUARANTINED_SESSION)) } + } + return AndroidAccountCredentialStoreRead.Available(state) + } + private fun readIndependentCredentialSlotState(): AndroidAccountCredentialState? { return restoreAndroidAccountCredentialStateWithoutAggregate( encodedRegistry = preferences.getString(KEY_ACCOUNT_REGISTRY, null), @@ -527,6 +532,7 @@ internal class AndroidAccountCredentialController( editor: SharedPreferences.Editor, state: AndroidAccountCredentialState, ): SharedPreferences.Editor = editor.apply { + remove(KEY_QUARANTINED_SESSION) val retainedKeys = state.sessions.keys.mapTo(hashSetOf(), ::androidAccountCredentialSlotKey) preferences.all.keys .filter { key -> key.startsWith(KEY_ACCOUNT_CREDENTIAL_SLOT_PREFIX) && key !in retainedKeys } @@ -618,12 +624,14 @@ internal fun reconstructAndroidAccountCredentialState( loadSession: (NextcloudAccountId) -> NextcloudSession?, ): AndroidAccountCredentialState? { val sessions = linkedMapOf() + val unavailableAccounts = mutableListOf() registry.accounts.forEach { account -> val session = loadSession(account.id)?.takeIf { loaded -> loaded.accountRecord() == account } - ?: return null - sessions[account.id] = session + if (session == null) unavailableAccounts += account.id else sessions[account.id] = session } - return AndroidAccountCredentialState(registry, sessions) + if (registry.activeAccountId in unavailableAccounts) return null + val retainedRegistry = unavailableAccounts.fold(registry) { retained, accountId -> retained.remove(accountId) } + return AndroidAccountCredentialState(retainedRegistry, sessions) } internal fun restoreAndroidAccountCredentialStateWithoutAggregate( @@ -712,11 +720,9 @@ internal class AndroidAccountCredentialStoreGuard { internal fun prepareInvalidAndroidAccountCredentialRecoveryEdit( editor: SharedPreferences.Editor, - suspectEncrypted: String, replacementEncrypted: String?, - hasExistingQuarantine: Boolean, ): SharedPreferences.Editor = editor.apply { - if (!hasExistingQuarantine) putString(KEY_QUARANTINED_SESSION, suspectEncrypted) + remove(KEY_QUARANTINED_SESSION) if (replacementEncrypted == null) remove(KEY_SESSION) else putString(KEY_SESSION, replacementEncrypted) remove(KEY_TEST_READ_ONLY) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 60cea16cc..5cc8a8264 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -1729,7 +1729,18 @@ internal class AndroidNextcloudServices( SupportDiagnosticFieldDraft("remote_root", remoteRootPath, SupportDiagnosticValuePrivacy.RemotePath), ) diagnoseSupportFailure(accountIdentity, SupportDiagnosticComponent.Sync, "sync.pair-add", fields) { - fileSyncEngine.addPair(session, userId, localRoot, remoteRootPath, configuration) + ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + expectedSession = session, + resolveSession = ::loadSession, + unavailable = { + FileSyncCenterActionResult.Rejected( + "The account changed before this folder sync could be added.", + FileSyncRejectionScope.Preflight, + ) + }, + ) { current -> + fileSyncEngine.addPair(current, userId, localRoot, remoteRootPath, configuration) + } }.also { result -> recordFileSyncResult(accountIdentity, "sync.pair-add", fields, result) } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 8a34b5947..1cef8a296 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -74,6 +74,43 @@ class AndroidAccountOperationGuardTest { assertTrue(removalEntered) } + @Test + fun fileSyncPairCreationWaitsForRemovalAndRejectsTheReauthenticatedSession() = runBlocking { + val guard = AndroidAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "original-password") + val replacement = original.copy(appPassword = "replacement-password") + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var current = original + var pairCreated = false + val removal = async { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { + current = replacement + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + removalEntered.await() + + val result = async { + guard.withExactAccountSession( + expectedSession = original, + resolveSession = { current }, + unavailable = { "rejected" }, + ) { + pairCreated = true + "created" + } + } + yield() + + assertFalse(result.isCompleted) + releaseRemoval.complete(Unit) + removal.await() + assertEquals("rejected", result.await()) + assertFalse(pairCreated) + } + @Test fun differentAccountsKeepIndependentOperationLeases() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 3dc8a4676..7de603eb7 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -57,17 +57,16 @@ class AndroidPersistedSessionTest { } @Test - fun invalidCredentialStoreCanBeQuarantinedForLoginOrResetRecovery() { + fun invalidCredentialStoreRecoveryPurgesCredentialBearingQuarantine() { val replacementWrites = linkedMapOf() val replacementRemovals = linkedSetOf() prepareInvalidAndroidAccountCredentialRecoveryEdit( editor = recoveryRecordingEditor(replacementWrites, replacementRemovals), - suspectEncrypted = "suspect-encrypted-store", replacementEncrypted = "new-encrypted-session", - hasExistingQuarantine = false, ) - assertEquals("suspect-encrypted-store", replacementWrites["encrypted_session_quarantine"]) + assertFalse("encrypted_session_quarantine" in replacementWrites) + assertTrue("encrypted_session_quarantine" in replacementRemovals) assertEquals("new-encrypted-session", replacementWrites["encrypted_session"]) assertTrue("emulator_test_read_only" in replacementRemovals) @@ -75,12 +74,11 @@ class AndroidPersistedSessionTest { val resetRemovals = linkedSetOf() prepareInvalidAndroidAccountCredentialRecoveryEdit( editor = recoveryRecordingEditor(resetWrites, resetRemovals), - suspectEncrypted = "newer-suspect-store", replacementEncrypted = null, - hasExistingQuarantine = true, ) assertFalse("encrypted_session_quarantine" in resetWrites) + assertTrue("encrypted_session_quarantine" in resetRemovals) assertTrue("encrypted_session" in resetRemovals) assertTrue("emulator_test_read_only" in resetRemovals) } @@ -568,6 +566,40 @@ class AndroidPersistedSessionTest { assertEquals(second, restored.activeSession) } + @Test + fun corruptInactiveCredentialSlotDoesNotHideTheHealthyActiveAccount() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + .select(first.accountId) + .let(::requireNotNull) + + val restored = reconstructAndroidAccountCredentialState(registry) { accountId -> + first.takeIf { accountId == first.accountId } + } + + assertEquals(mapOf(first.accountId to first), requireNotNull(restored).sessions) + assertEquals(listOf(first.accountRecord()), restored.registry.accounts) + assertEquals(first, restored.activeSession) + } + + @Test + fun corruptActiveCredentialSlotStillFailsClosed() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + + assertNull( + reconstructAndroidAccountCredentialState(registry) { accountId -> + first.takeIf { accountId == first.accountId } + }, + ) + } + @Test fun validIndependentSlotsRecoverWhenTheAggregateKeyIsAbsent() { val first = firstSession() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 817aba3b3..f75668e18 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -10,6 +10,7 @@ internal class DesktopAccountCredentialPersistence( private val flushPreferences: () -> Unit = preferences::flush, ) { fun loadActiveSession(): NextcloudSession? { + retryPendingCredentialSave() retryPendingLegacyCredentialCleanup() val read = readRegistry() if (read.registry == null) { @@ -32,6 +33,7 @@ internal class DesktopAccountCredentialPersistence( } fun loadSession(accountId: NextcloudAccountId): NextcloudSession? { + retryPendingCredentialSave() retryPendingLegacyCredentialCleanup() val registry = readRegistry().registry ?: return null val record = registry.accounts.firstOrNull { account -> account.id == accountId } ?: return null @@ -48,6 +50,7 @@ internal class DesktopAccountCredentialPersistence( } fun saveSession(session: NextcloudSession): NextcloudSession { + retryPendingCredentialSave() retryPendingLegacyCredentialCleanup() val read = readRegistry() val registry = read.registry @@ -61,8 +64,10 @@ internal class DesktopAccountCredentialPersistence( val encodedRegistry = prepareRegistry(updatedRegistry) val secretReference = desktopAccountSecretReference(persistedSession.accountId) val previousSecret = loadSecretForRollback(secretReference) - saveSecret(persistedSession) + val journalNewCredential = previousRecord == null + if (journalNewCredential) persistPendingCredentialSave(persistedSession) try { + saveSecret(persistedSession) persistAccountState(encodedRegistry, updatedRegistry.activeAccount) } catch (failure: Exception) { try { @@ -83,12 +88,15 @@ internal class DesktopAccountCredentialPersistence( rollbackFailure, ) } + if (journalNewCredential) clearPendingCredentialSave() throw failure } + if (journalNewCredential) clearPendingCredentialSave() return persistedSession } fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? { + retryPendingCredentialSave() retryPendingLegacyCredentialCleanup() val registry = readRegistry().registry ?: return null val session = loadSession(accountId) ?: return null @@ -98,6 +106,7 @@ internal class DesktopAccountCredentialPersistence( } fun removeAccount(accountId: NextcloudAccountId): Boolean { + retryPendingCredentialSave() retryPendingLegacyCredentialCleanup() val registry = readRegistry().registry ?: return false val record = registry.accounts.firstOrNull { account -> account.id == accountId } ?: return false @@ -169,6 +178,99 @@ internal class DesktopAccountCredentialPersistence( retryPendingLegacyCredentialCleanup(session) } + private fun retryPendingCredentialSave() { + val server = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_SERVER, null) + val login = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, null) + if (server == null && login == null) return + if (server.isNullOrBlank() || login.isNullOrBlank()) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + ) + return + } + val accountId = try { + deriveNextcloudAccountId(server, login) + } catch (failure: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + failure, + ) + return + } + val registryRead = readRegistry() + if (registryRead.encoded != null && registryRead.registry == null) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + ) + return + } + val credentialCommitted = registryRead.registry + ?.accounts + ?.any { account -> account.id == accountId } == true + if (!credentialCommitted) { + try { + secretStore.clear(desktopAccountSecretReference(accountId)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + failure, + ) + return + } + } + clearPendingCredentialSave() + } + + private fun persistPendingCredentialSave(session: NextcloudSession) { + val previousServer = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_SERVER, null) + val previousLogin = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, null) + try { + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_SERVER, session.serverUrl) + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, session.loginName) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_SERVER, previousServer) + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, previousLogin) + runCatching(flushPreferences) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", + "account-credentials.persist", + failure, + ) + throw failure + } + } + + private fun clearPendingCredentialSave() { + val server = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_SERVER, null) + val login = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, null) + if (server == null && login == null) return + try { + preferences.remove(KEY_PENDING_CREDENTIAL_SAVE_SERVER) + preferences.remove(KEY_PENDING_CREDENTIAL_SAVE_LOGIN) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_SERVER, server) + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, login) + runCatching(flushPreferences) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + failure, + ) + } + } + private fun retryPendingLegacyCredentialCleanup(expected: NextcloudSession? = null) { val server = preferences.get(KEY_PENDING_LEGACY_CLEANUP_SERVER, null) val login = preferences.get(KEY_PENDING_LEGACY_CLEANUP_LOGIN, null) @@ -399,6 +501,8 @@ internal class DesktopAccountCredentialPersistence( const val KEY_LOGIN = "login" const val KEY_PENDING_LEGACY_CLEANUP_SERVER = "accountLegacyCleanupServer" const val KEY_PENDING_LEGACY_CLEANUP_LOGIN = "accountLegacyCleanupLogin" + const val KEY_PENDING_CREDENTIAL_SAVE_SERVER = "accountCredentialSaveServer" + const val KEY_PENDING_CREDENTIAL_SAVE_LOGIN = "accountCredentialSaveLogin" } } 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 0d93c85c5..8f53117c6 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -2711,7 +2711,14 @@ class DesktopNextcloudServices( SupportDiagnosticFieldDraft("remote_root", remoteRootPath, SupportDiagnosticValuePrivacy.RemotePath), ) diagnoseDesktopSupportFailure(accountId, "sync.pair-add", diagnosticFields) { - fileSyncEngine.addPair(session, localRoot, remoteRootPath, configuration) + accountOperationGuard.serializeWhenSyncIdle addPair@{ + if (!desktopSyncRunMatchesActiveSession(loadSession(), session)) { + return@addPair FileSyncCenterActionResult.Rejected( + "The account changed before this desktop sync could be added.", + ) + } + fileSyncEngine.addPair(session, localRoot, remoteRootPath, configuration) + } }.also { result -> recordDesktopFileSyncResult(accountId, "sync.pair-add", diagnosticFields, result) runCatching { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index 747ae4acd..a8bb85336 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -49,6 +49,8 @@ class DesktopAccountCredentialPersistenceTest { persistence.saveSession(session) preferences.put("accountLegacyCleanupServer", session.serverUrl) preferences.put("accountLegacyCleanupLogin", session.loginName) + preferences.put("accountCredentialSaveServer", session.serverUrl) + preferences.put("accountCredentialSaveLogin", session.loginName) secrets.resetOperationCounts() assertEquals(listOf(session.accountRecord()), persistence.listAccounts()) @@ -56,6 +58,7 @@ class DesktopAccountCredentialPersistenceTest { assertEquals(0, secrets.loadCount) assertEquals(0, secrets.clearCount) assertEquals(session.serverUrl, preferences.get("accountLegacyCleanupServer", null)) + assertEquals(session.serverUrl, preferences.get("accountCredentialSaveServer", null)) } @Test @@ -66,7 +69,7 @@ class DesktopAccountCredentialPersistenceTest { persistence.saveSession(secondSession()) assertEquals(firstSession(), persistence.selectAccount(firstSession().accountId)) - assertEquals(3, flushCount) + assertEquals(7, flushCount) assertEquals(firstSession().serverUrl, preferences.get("server", null)) assertEquals(firstSession().loginName, preferences.get("login", null)) } @@ -84,6 +87,66 @@ class DesktopAccountCredentialPersistenceTest { assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) } + @Test + fun startupRecoveryRemovesANewCredentialWhoseRegistryCommitNeverCompleted() = + withStore { preferences, secrets -> + val session = firstSession() + preferences.put("accountCredentialSaveServer", session.serverUrl) + preferences.put("accountCredentialSaveLogin", session.loginName) + secrets.save( + desktopAccountSecretReference(session.accountId), + session.loginName, + session.appPassword.encodeToByteArray(), + ) + + assertNull(persistence(preferences, secrets).loadActiveSession()) + + assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertNull(preferences.get("accountCredentialSaveServer", null)) + assertNull(preferences.get("accountCredentialSaveLogin", null)) + } + + @Test + fun startupRecoveryKeepsANewCredentialAfterItsRegistryCommitCompleted() = + withStore { preferences, secrets -> + val session = firstSession() + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodeNextcloudAccountRegistry( + NextcloudAccountRegistry.Empty.upsertAndSelect(session.accountRecord()), + )) + preferences.put("accountCredentialSaveServer", session.serverUrl) + preferences.put("accountCredentialSaveLogin", session.loginName) + secrets.save( + desktopAccountSecretReference(session.accountId), + session.loginName, + session.appPassword.encodeToByteArray(), + ) + + assertEquals(session, persistence(preferences, secrets).loadActiveSession()) + + assertNotNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertNull(preferences.get("accountCredentialSaveServer", null)) + assertNull(preferences.get("accountCredentialSaveLogin", null)) + } + + @Test + fun startupRecoveryPreservesPendingCredentialWhenRegistryVersionIsUnreadable() = + withStore { preferences, secrets -> + val session = firstSession() + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, """{"version":2,"accounts":[]}""") + preferences.put("accountCredentialSaveServer", session.serverUrl) + preferences.put("accountCredentialSaveLogin", session.loginName) + secrets.save( + desktopAccountSecretReference(session.accountId), + session.loginName, + session.appPassword.encodeToByteArray(), + ) + + assertNull(persistence(preferences, secrets).loadActiveSession()) + + assertNotNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertEquals(session.serverUrl, preferences.get("accountCredentialSaveServer", null)) + } + @Test fun failedRegistryFlushRestoresThePreviousCredentialDuringReauthentication() = withStore { preferences, secrets -> @@ -359,7 +422,7 @@ class DesktopAccountCredentialPersistenceTest { var flushAttempts = 0 val persistence = persistence(preferences, secrets) { flushAttempts += 1 - if (flushAttempts == 3) error("synthetic removal flush failure") + if (flushAttempts == 7) error("synthetic removal flush failure") preferences.flush() } persistence.saveSession(first) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index d40711b63..bc10f1980 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -166,6 +166,43 @@ class DesktopAccountOperationGuardTest { ) } + @Test + fun fileSyncPairCreationWaitsForRemovalAndRejectsTheStaleSession() = runBlocking { + val first = NextcloudSession("https://first.example.test", "alice", "one") + val second = NextcloudSession("https://second.example.test", "bob", "two") + val guard = DesktopAccountOperationGuard() + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var current = first + var pairCreated = false + val removal = async { + guard.serializeWhenSyncIdle { + current = second + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + removalEntered.await() + + val result = async { + guard.serializeWhenSyncIdle { + if (!desktopSyncRunMatchesActiveSession(current, first)) { + "rejected" + } else { + pairCreated = true + "created" + } + } + } + yield() + + assertFalse(result.isCompleted) + releaseRemoval.complete(Unit) + removal.await() + assertEquals("rejected", result.await()) + assertFalse(pairCreated) + } + @Test fun differentAccountSaveRequiresTheSelectionTransition() { val first = NextcloudSession("https://first.example.test", "alice", "one") From 5040557f8eabf3305dd019bfc5f6dace158dd4be Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 12:00:45 +0200 Subject: [PATCH 020/119] fix(accounts): serialize account-bound mutations --- .../AndroidAccountCredentialController.kt | 8 ++ .../AndroidAccountOperationGuard.kt | 11 ++ .../nextcloudnative/AndroidAccountRemoval.kt | 26 ++++ .../nextcloudnative/AndroidDocumentEditing.kt | 2 +- .../AndroidDocumentWritebackRecovery.kt | 40 +++++- .../AndroidNextcloudServices.kt | 135 ++++++++++-------- .../AndroidAccountOperationGuardTest.kt | 36 +++++ .../AndroidPersistedSessionTest.kt | 25 +++- .../NextcloudDocumentsContractTest.kt | 19 +++ .../app/DesktopAccountOperationGuard.kt | 37 +++++ .../app/DesktopAccountRemoval.kt | 34 +++++ .../DesktopLinuxVirtualFileWritebackStore.kt | 7 +- .../app/DesktopNextcloudServices.kt | 28 ++-- .../app/DesktopAccountOperationGuardTest.kt | 79 ++++++++++ 14 files changed, 409 insertions(+), 78 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index efa22628f..fbe0b689f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -31,6 +31,7 @@ internal class AndroidAccountCredentialController( private val clearPreviewAccount: (String) -> Unit, private val notifyDocumentRootsChanged: () -> Unit, private val resumeQueuedUploads: suspend (String) -> Unit, + private val prepareAccountRemoval: suspend (NextcloudSession) -> Unit, private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, ) { private val appContext = context.applicationContext @@ -129,6 +130,7 @@ internal class AndroidAccountCredentialController( val active = current.registry.activeAccountId == accountId removeAndroidAccountCredentialData( active = active, + prepareAccountRemoval = { prepareAccountRemoval(session) }, removeQueuedUploads = { removeQueuedUploads(session) }, clearActiveAccount = { clearSession(current) }, rollbackActiveRemoval = { @@ -158,6 +160,7 @@ internal class AndroidAccountCredentialController( ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(session)) { removeAndroidAccountCredentialData( active = true, + prepareAccountRemoval = { prepareAccountRemoval(session) }, removeQueuedUploads = { removeQueuedUploads(session) }, clearActiveAccount = { clearSession(read.state) }, rollbackActiveRemoval = { @@ -216,6 +219,7 @@ internal class AndroidAccountCredentialController( if (activeSession != null) { ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(activeSession)) { removeRecoveredAndroidAccountCredentialData( + prepareAccountRemoval = { prepareAccountRemoval(activeSession) }, removeQueuedUploads = { removeQueuedUploads(activeSession) }, clearRecoveredAccount = { persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted) }, rollbackRecoveredAccount = { @@ -667,12 +671,14 @@ internal suspend fun resumeAndroidQueuedUploadsAfterSelection( internal suspend fun removeAndroidAccountCredentialData( active: Boolean, + prepareAccountRemoval: suspend () -> Unit = {}, removeQueuedUploads: suspend () -> Unit, clearActiveAccount: suspend () -> Unit, rollbackActiveRemoval: suspend () -> Unit, persistInactiveRemoval: suspend () -> Unit, rollbackInactiveRemoval: suspend () -> Unit, ) { + prepareAccountRemoval() if (active) { try { clearActiveAccount() @@ -700,11 +706,13 @@ internal suspend fun removeAndroidAccountCredentialData( } internal suspend fun removeRecoveredAndroidAccountCredentialData( + prepareAccountRemoval: suspend () -> Unit = {}, removeQueuedUploads: suspend () -> Unit, clearRecoveredAccount: suspend () -> Unit, rollbackRecoveredAccount: suspend () -> Unit, ) = removeAndroidAccountCredentialData( active = true, + prepareAccountRemoval = prepareAccountRemoval, removeQueuedUploads = removeQueuedUploads, clearActiveAccount = clearRecoveredAccount, rollbackActiveRemoval = rollbackRecoveredAccount, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index 00a0534d8..6e367f633 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -104,3 +104,14 @@ internal fun androidDocumentWritebackSessionIsCurrent( expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, currentSession: dev.obiente.nextcloudnative.app.NextcloudSession?, ): Boolean = currentSession == expectedSession + +internal suspend fun AndroidAccountOperationGuard.withAuthenticatedMutationSession( + expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, + resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, + action: suspend (dev.obiente.nextcloudnative.app.NextcloudSession) -> Result, +): Result = withExactAccountSession( + expectedSession = expectedSession, + resolveSession = resolveSession, + unavailable = { error("The account changed before the authenticated change could be sent.") }, + action = action, +) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt new file mode 100644 index 000000000..9ee0c414f --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -0,0 +1,26 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.content.Intent +import android.provider.DocumentsContract +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal val NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS: Int = + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + +internal fun requireAndroidAccountRemovalWritebacksResolved(resolved: Boolean) { + check(resolved) { + "Finish or discard pending document changes before removing this account." + } +} + +internal fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) { + requireAndroidAccountRemovalWritebacksResolved(androidDocumentPendingWritebacks(context, session).isEmpty()) + val accountDocumentScope = DocumentsContract.buildDocumentUri( + nextcloudDocumentsAuthority(context.packageName), + NextcloudDocumentIds.rootId(session), + ) + context.revokeUriPermission(accountDocumentScope, NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentEditing.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentEditing.kt index ca9c4bcb2..e2e8a5a41 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentEditing.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentEditing.kt @@ -46,7 +46,7 @@ internal data class AndroidDocumentEditingHttpResponse( ) internal class AndroidDocumentEditingTransport( - private val execute: (NextcloudSession, AndroidDocumentEditingHttpRequest) -> AndroidDocumentEditingHttpResponse, + private val execute: suspend (NextcloudSession, AndroidDocumentEditingHttpRequest) -> AndroidDocumentEditingHttpResponse, ) { suspend fun loadCapabilities( session: NextcloudSession, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt index fd79179de..6795c95c9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDocumentWritebackRecovery.kt @@ -271,11 +271,38 @@ internal fun withNoBlockingAndroidDocumentWriteback( vararg remotePaths: String, operation: () -> T, ): T { + val reservation = reserveAndroidDocumentMutation(context, session, remotePaths) + return try { + operation() + } finally { + releaseAndroidDocumentMutation(reservation) + } +} + +internal suspend fun withNoBlockingAndroidDocumentWritebackSuspending( + context: android.content.Context?, + session: NextcloudSession, + vararg remotePaths: String, + operation: suspend () -> T, +): T { + val reservation = reserveAndroidDocumentMutation(context, session, remotePaths) + return try { + operation() + } finally { + releaseAndroidDocumentMutation(reservation) + } +} + +private fun reserveAndroidDocumentMutation( + context: android.content.Context?, + session: NextcloudSession, + remotePaths: Array, +): ActiveAndroidDocumentMutation { val providerContext = requireNotNull(context) { "Provider context is unavailable." } val accountId = NextcloudDocumentIds.accountKey(session) val paths = remotePaths.toSet() require(paths.isNotEmpty() && paths.none(String::isBlank)) - val reservation = synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + return synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { val activePaths = ACTIVE_ANDROID_DOCUMENT_WRITEBACK_PATHS.asSequence() .filter { active -> active.accountId == accountId } .map(ActiveAndroidDocumentWritebackPath::remotePath) @@ -294,12 +321,11 @@ internal fun withNoBlockingAndroidDocumentWriteback( } ActiveAndroidDocumentMutation(accountId, paths).also(ACTIVE_ANDROID_DOCUMENT_MUTATIONS::add) } - return try { - operation() - } finally { - synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { - check(ACTIVE_ANDROID_DOCUMENT_MUTATIONS.remove(reservation)) - } +} + +private fun releaseAndroidDocumentMutation(reservation: ActiveAndroidDocumentMutation) { + synchronized(ANDROID_DOCUMENT_WRITEBACK_LOCK) { + check(ACTIVE_ANDROID_DOCUMENT_MUTATIONS.remove(reservation)) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 5cc8a8264..924407801 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -498,6 +498,7 @@ internal class AndroidNextcloudServices( clearPreviewAccount = nativeMediaPreviewCache::clearAccount, notifyDocumentRootsChanged = ::notifyDocumentsRootsChanged, resumeQueuedUploads = durableMultipartUploads::resumeQueuedForAccount, + prepareAccountRemoval = { session -> prepareAndroidAccountRemoval(appContext, session) }, removeQueuedUploads = { session -> fileOfflineAccountCleanup.removeForAccount(NextcloudDocumentIds.accountKey(session)) incomingShareAccountCleanup.removeForAccount(session) @@ -1274,7 +1275,7 @@ internal class AndroidNextcloudServices( fallbackAlreadySelected = challenge.token in loginPollFallbackTokens, poll = { endpoint -> networkFailure = null - request( + kotlinx.coroutines.runBlocking { request( method = "POST", url = endpoint, body = formBody, @@ -1283,7 +1284,7 @@ internal class AndroidNextcloudServices( maxResponseBytes = LOGIN_FLOW_RESPONSE_MAX_BYTES, diagnosticIgnoredHttpStatuses = setOf(404), onNetworkFailure = { networkFailure = it }, - ).let { LoginPollHttpResponse(it.status, it.text) } + ) }.let { LoginPollHttpResponse(it.status, it.text) } }, networkFailure = { networkFailure }, ) @@ -1546,64 +1547,69 @@ internal class AndroidNextcloudServices( val offline = fileOfflineRepository.loadCenter(session) val documentWritebacks = androidDocumentPendingWritebacks(appContext, session) if (documentWritebacks.isNotEmpty()) { - val webDav = NextcloudDocumentWebDav( - client = OkHttpClient.Builder() - .useAndroidNextcloudCertificateTrust(appContext) - .build(), - cloudMutationsAllowed = appContext.cloudMutationGate(), - ) - documentWritebacks.forEach { discovered -> - currentCoroutineContext().ensureActive() - val pending = claimAndroidDocumentPendingWritebackForRecovery( - appContext, - session, - discovered.remotePath, - ) ?: return@forEach - runCatching { - if (pending.conflict) { - pending.releaseActive() - return@runCatching - } - requireAndroidDocumentStagedWritebackCapacity( - stagedBytes = pending.staging.length(), - availableBytes = pending.staging.parentFile?.usableSpace ?: 0L, - ) - CoroutineDocumentRequestCancellation( - requireNotNull(currentCoroutineContext()[Job]), - ).use { cancellation -> - val remote = compareAndroidDocumentWriteback( - webDav = webDav, - session = session, - userId = userId, - pending = pending, - cancellation = cancellation, - ) - currentCoroutineContext().ensureActive() - if (remote.contentsMatch) { - virtualFileCache.invalidate(session, pending.remotePath) - notifyDocumentsDocumentChanged(session, pending.remotePath) - pending.complete() - return@runCatching - } - if (remote.etag == null || remote.etag != pending.expectedRemoteEtag) { - pending.markConflict(remote.etag) + ANDROID_ACCOUNT_OPERATION_GUARD.withAuthenticatedMutationSession( + expectedSession = session, + resolveSession = ::loadSession, + ) { current -> + val webDav = NextcloudDocumentWebDav( + client = OkHttpClient.Builder() + .useAndroidNextcloudCertificateTrust(appContext) + .build(), + cloudMutationsAllowed = appContext.cloudMutationGate(), + ) + documentWritebacks.forEach { discovered -> + currentCoroutineContext().ensureActive() + val pending = claimAndroidDocumentPendingWritebackForRecovery( + appContext, + current, + discovered.remotePath, + ) ?: return@forEach + runCatching { + if (pending.conflict) { pending.releaseActive() return@runCatching } - webDav.replaceFileAtomically( - session = session, - userId = userId, - path = pending.remotePath, - source = pending.staging, - expectedEtag = pending.expectedRemoteEtag, - cancellation = cancellation, + requireAndroidDocumentStagedWritebackCapacity( + stagedBytes = pending.staging.length(), + availableBytes = pending.staging.parentFile?.usableSpace ?: 0L, ) + CoroutineDocumentRequestCancellation( + requireNotNull(currentCoroutineContext()[Job]), + ).use { cancellation -> + val remote = compareAndroidDocumentWriteback( + webDav = webDav, + session = current, + userId = userId, + pending = pending, + cancellation = cancellation, + ) + currentCoroutineContext().ensureActive() + if (remote.contentsMatch) { + virtualFileCache.invalidate(current, pending.remotePath) + notifyDocumentsDocumentChanged(current, pending.remotePath) + pending.complete() + return@runCatching + } + if (remote.etag == null || remote.etag != pending.expectedRemoteEtag) { + pending.markConflict(remote.etag) + pending.releaseActive() + return@runCatching + } + webDav.replaceFileAtomically( + session = current, + userId = userId, + path = pending.remotePath, + source = pending.staging, + expectedEtag = pending.expectedRemoteEtag, + cancellation = cancellation, + ) + } + virtualFileCache.invalidate(current, pending.remotePath) + notifyDocumentsDocumentChanged(current, pending.remotePath) + pending.complete() + }.onFailure { failure -> + handleAndroidDocumentWritebackRecoveryFailure(failure, pending::releaseActive) } - virtualFileCache.invalidate(session, pending.remotePath) - notifyDocumentsDocumentChanged(session, pending.remotePath) - pending.complete() - }.onFailure { failure -> - handleAndroidDocumentWritebackRecoveryFailure(failure, pending::releaseActive) } } } @@ -2679,7 +2685,7 @@ internal class AndroidNextcloudServices( text: String, expectedEtag: String, ): SavedTextFile = withContext(Dispatchers.IO) { - withNoBlockingAndroidDocumentWriteback(appContext, session, path) { + withNoBlockingAndroidDocumentWritebackSuspending(appContext, session, path) { val specification = textFileDavSaveRequest(text, expectedEtag) val response = request( method = "PUT", @@ -2751,7 +2757,7 @@ internal class AndroidNextcloudServices( mutation: NextcloudFileMutation, ): NextcloudFileMutationResult = withContext(Dispatchers.IO) { val spec = mutation.toWebDavMutationSpec() - withNoBlockingAndroidDocumentWriteback( + withNoBlockingAndroidDocumentWritebackSuspending( appContext, session, *listOfNotNull(spec.sourcePath, spec.destinationPath).toTypedArray(), @@ -3454,7 +3460,7 @@ internal class AndroidNextcloudServices( Unit } - private fun ocsGet(session: NextcloudSession, path: String): JSONObject { + private suspend fun ocsGet(session: NextcloudSession, path: String): JSONObject { val separator = if ('?' in path) '&' else '?' val response = request( method = "GET", @@ -3466,7 +3472,7 @@ internal class AndroidNextcloudServices( return JSONObject(response.text) } - private fun loadFileEtag(session: NextcloudSession, userId: String, path: String): String? { + private suspend fun loadFileEtag(session: NextcloudSession, userId: String, path: String): String? { val response = request( method = "PROPFIND", url = buildNextcloudFileUrl(session.serverUrl, userId, path), @@ -3479,7 +3485,7 @@ internal class AndroidNextcloudServices( return SafeXmlParser.parse(response.body).documentElement.firstText(DAV_NAMESPACE, "getetag") } - private fun request( + private suspend fun request( method: String, url: String, session: NextcloudSession? = null, @@ -3496,7 +3502,16 @@ internal class AndroidNextcloudServices( onNetworkFailure: (JvmNetworkFailureDiagnostic) -> Unit = {}, onFailurePhase: (JvmNetworkFailurePhase) -> Unit = {}, diagnosticIgnoredHttpStatuses: Set = emptySet(), + accountMutationSerialized: Boolean = false, ): HttpResponse { + if (session != null && !method.isReadOnlyJvmNetworkMethod() && !accountMutationSerialized) { + return ANDROID_ACCOUNT_OPERATION_GUARD.withAuthenticatedMutationSession(session, ::loadSession) { current -> + request( + method, url, current, body, contentType, ocsRequest, headers, rawBody, maxResponseBytes, expectedSuccessResponseBytes, expectedSuccessResponseStatus, + client, streamingBody, onNetworkFailure, onFailurePhase, diagnosticIgnoredHttpStatuses, true, + ) + } + } val started = System.nanoTime() require((expectedSuccessResponseBytes == null) == (expectedSuccessResponseStatus == null)) check(appContext.isAllowedTestRequest(method, url)) { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 1cef8a296..a71b881b8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -311,4 +311,40 @@ class AndroidAccountOperationGuardTest { assertFalse(upload.await()) assertFalse(uploadCreated) } + + @Test + fun authenticatedMutationWaitsForSelectionAndRejectsTheStaleSession() = runBlocking { + val guard = AndroidAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val replacement = NextcloudSession("https://other.example.test", "bob", "new-password") + val selectionEntered = CompletableDeferred() + val releaseSelection = CompletableDeferred() + var current = original + var requestSent = false + val selection = async { + guard.withAccounts( + listOf(NextcloudDocumentIds.accountKey(original), NextcloudDocumentIds.accountKey(replacement)), + ) { + current = replacement + selectionEntered.complete(Unit) + releaseSelection.await() + } + } + selectionEntered.await() + + val mutation = async { + runCatching { + guard.withAuthenticatedMutationSession(original, { current }) { + requestSent = true + } + } + } + yield() + + assertFalse(mutation.isCompleted) + releaseSelection.complete(Unit) + selection.await() + assertTrue(mutation.await().isFailure) + assertFalse(requestSent) + } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 7de603eb7..5c2d53046 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -687,6 +687,7 @@ class AndroidPersistedSessionTest { removeAndroidAccountCredentialData( active = true, + prepareAccountRemoval = { events += "prepare-removal" }, removeQueuedUploads = { events += "remove-uploads" }, clearActiveAccount = { events += "clear-account" }, rollbackActiveRemoval = { events += "rollback-active" }, @@ -694,7 +695,29 @@ class AndroidPersistedSessionTest { rollbackInactiveRemoval = { events += "rollback-inactive" }, ) - assertEquals(listOf("clear-account", "remove-uploads"), events) + assertEquals(listOf("prepare-removal", "clear-account", "remove-uploads"), events) + } + + @Test + fun blockedAccountRemovalDoesNotDeleteCredentialsOrQueuedWork() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + removeAndroidAccountCredentialData( + active = true, + prepareAccountRemoval = { + events += "prepare-removal" + error("pending document writeback") + }, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { events += "persist-inactive" }, + rollbackInactiveRemoval = { events += "rollback-inactive" }, + ) + } + + assertEquals(listOf("prepare-removal"), events) } @Test diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt index fc8562de6..1938bdfb9 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt @@ -3,6 +3,7 @@ package dev.obiente.nextcloudnative import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertTrue class NextcloudDocumentsContractTest { @Test @@ -21,4 +22,22 @@ class NextcloudDocumentsContractTest { fun `documents authority rejects a missing application id`() { assertFailsWith { nextcloudDocumentsAuthority(" ") } } + + @Test + fun `account removal rejects retained document writebacks`() { + requireAndroidAccountRemovalWritebacksResolved(resolved = true) + + val failure = assertFailsWith { + requireAndroidAccountRemovalWritebacksResolved(resolved = false) + } + + assertTrue(failure.message.orEmpty().contains("pending document changes")) + } + + @Test + fun `document grant revocation covers reads writes and descendants`() { + assertTrue(NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS and android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION != 0) + assertTrue(NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS and android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) + assertTrue(NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS and android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION != 0) + } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 586a7030b..7fc780507 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -67,6 +67,43 @@ internal fun desktopSyncRunMatchesActiveSession( requestedSession: NextcloudSession, ): Boolean = activeSession == requestedSession +internal suspend fun DesktopAccountOperationGuard.withAuthenticatedMutationSession( + expectedSession: NextcloudSession, + resolveSession: suspend () -> NextcloudSession?, + action: suspend (NextcloudSession) -> Result, +): Result = serialize { + val current = resolveSession() + check(desktopSyncRunMatchesActiveSession(current, expectedSession)) { + "The account changed before the authenticated change could be sent." + } + action(requireNotNull(current)) +} + +internal fun requireDesktopAccountRemovalWritebacksResolved(pendingWritebackCount: Int) { + check(pendingWritebackCount == 0) { + "Finish or discard pending virtual file changes before removing this account." + } +} + +internal fun removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled: Boolean, + clearProviderPreference: () -> Unit, + restoreProviderPreference: (Boolean) -> Unit, + removeCredential: () -> Boolean, +): Boolean { + clearProviderPreference() + return try { + removeCredential().also { removed -> + if (!removed) restoreProviderPreference(providerWasEnabled) + } + } catch (failure: Throwable) { + runCatching { restoreProviderPreference(providerWasEnabled) } + .exceptionOrNull() + ?.let(failure::addSuppressed) + throw failure + } +} + internal fun requireDesktopSessionSaveAllowed( allowed: Boolean, recordBlocked: (SupportDiagnosticEventDraft) -> Unit, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt new file mode 100644 index 000000000..ef50305bf --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -0,0 +1,34 @@ +package dev.obiente.nextcloudnative.app + +import java.util.prefs.Preferences + +internal fun requireDesktopAccountRemovalReady(accountId: String, linuxDesktop: Boolean) { + if (linuxDesktop) { + requireDesktopAccountRemovalWritebacksResolved( + defaultDesktopLinuxWritebackStore(accountId).pendingWritebacks().size, + ) + } +} + +internal fun removeDesktopAccountCredential( + preferences: Preferences, + providerAccountId: String?, + removeCredential: () -> Boolean, +): Boolean { + val providerKey = providerAccountId?.let(::virtualFileProviderPreferenceKey) + val providerWasEnabled = providerKey?.let { key -> preferences.getBoolean(key, false) } == true + return removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = providerWasEnabled, + clearProviderPreference = { + providerKey?.let(preferences::remove) + preferences.flush() + }, + restoreProviderPreference = { enabled -> + providerKey?.let { key -> + if (enabled) preferences.putBoolean(key, true) else preferences.remove(key) + } + preferences.flush() + }, + removeCredential = removeCredential, + ) +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStore.kt index b697fddfc..c59fab5c2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxVirtualFileWritebackStore.kt @@ -367,10 +367,15 @@ internal fun linuxWritebackGrowthFitsCapacity( } internal fun defaultDesktopLinuxWritebackStore(session: NextcloudSession): DesktopLinuxVirtualFileWritebackStore { + return defaultDesktopLinuxWritebackStore(desktopFileCacheAccountId(session)) +} + +internal fun defaultDesktopLinuxWritebackStore(accountId: String): DesktopLinuxVirtualFileWritebackStore { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) val xdgData = System.getenv("XDG_DATA_HOME")?.takeIf(String::isNotBlank) val dataRoot = xdgData?.let(::File) ?: File(System.getProperty("user.home"), ".local/share") return DesktopLinuxVirtualFileWritebackStore( - File(dataRoot, "nextcloud-native/vfs-writeback/${desktopFileCacheAccountId(session)}"), + File(dataRoot, "nextcloud-native/vfs-writeback/$accountId"), ) } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 8f53117c6..40eb6e563 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3810,9 +3810,13 @@ class DesktopNextcloudServices( } else { val account = listAccounts().firstOrNull { record -> record.id == accountId } ?: return@serialize false + val providerAccountId = desktopFileCacheAccountId(account) + requireDesktopAccountRemovalReady(providerAccountId, isLinuxDesktop()) accountOperationGuard.withSyncRunLock { - fileSyncEngine.removeAccountPairs(desktopFileCacheAccountId(account)) - sessionPublicationGuard.serialize { accountCredentials.removeAccount(accountId) } + fileSyncEngine.removeAccountPairs(providerAccountId) + sessionPublicationGuard.serialize { + removeDesktopAccountCredential(preferences, providerAccountId) { accountCredentials.removeAccount(accountId) } + } } } } @@ -3837,6 +3841,7 @@ class DesktopNextcloudServices( } val accountId = activeSession?.let(::desktopFileCacheAccountId) ?: activeRecord?.let(::desktopFileCacheAccountId) + accountId?.let { requireDesktopAccountRemovalReady(it, isLinuxDesktop()) } val syncJob = synchronized(this) { val active = backgroundFileSyncJob backgroundFileSyncJob = null @@ -3928,7 +3933,7 @@ class DesktopNextcloudServices( accountId?.let { fileSyncEngine.removeAccountPairs(it) } sessionPublicationGuard.serialize { if (activeAccountId != null) { - check(accountCredentials.removeAccount(activeAccountId)) + check(removeDesktopAccountCredential(preferences, accountId) { accountCredentials.removeAccount(activeAccountId) }) } supportDiagnostics.setActiveAccountIdentity(null) supportIntake.setActiveAccountIdentity(null) @@ -3942,6 +3947,7 @@ class DesktopNextcloudServices( } } } + override suspend fun loadDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, @@ -4082,7 +4088,7 @@ class DesktopNextcloudServices( fallbackAlreadySelected = challenge.token in loginPollFallbackTokens, poll = { endpoint -> networkFailure = null - request( + kotlinx.coroutines.runBlocking { request( "POST", endpoint, body = "token=" + encodeForm(challenge.token), @@ -4091,7 +4097,7 @@ class DesktopNextcloudServices( maxResponseBytes = LOGIN_FLOW_RESPONSE_MAX_BYTES, diagnosticIgnoredHttpStatuses = setOf(404), onNetworkFailure = { networkFailure = it }, - ).let { LoginPollHttpResponse(it.status, it.text) } + ) }.let { LoginPollHttpResponse(it.status, it.text) } }, networkFailure = { networkFailure }, ) @@ -5632,14 +5638,14 @@ class DesktopNextcloudServices( Unit } - private fun ocsGet(session: NextcloudSession, path: String): JSONObject { + private suspend fun ocsGet(session: NextcloudSession, path: String): JSONObject { val separator = if ('?' in path) '&' else '?' val response = request("GET", session.serverUrl + path + separator + "format=json", session, ocsRequest = true) check(response.status in 200..299) { "Nextcloud API request failed (HTTP ${response.status})." } return JSONObject(response.text) } - private fun loadFileEtag(session: NextcloudSession, userId: String, path: String): String? { + private suspend fun loadFileEtag(session: NextcloudSession, userId: String, path: String): String? { val response = request( "PROPFIND", buildNextcloudFileUrl(session.serverUrl, userId, path), @@ -5659,7 +5665,7 @@ class DesktopNextcloudServices( .documentElement.firstText(DAV, "getetag") } - private fun request( + private suspend fun request( method: String, url: String, session: NextcloudSession? = null, @@ -5678,7 +5684,13 @@ class DesktopNextcloudServices( onNetworkFailure: (JvmNetworkFailureDiagnostic) -> Unit = {}, onFailurePhase: (JvmNetworkFailurePhase) -> Unit = {}, diagnosticIgnoredHttpStatuses: Set = emptySet(), + accountMutationSerialized: Boolean = false, ): HttpResponse { + if (session != null && !method.isReadOnlyJvmNetworkMethod() && !accountMutationSerialized) { + return accountOperationGuard.withAuthenticatedMutationSession(session, ::loadSession) { current -> request( + method, url, current, body, contentType, ocsRequest, headers, rawBody, maxResponseBytes, expectedSuccessResponseBytes, expectedSuccessResponseStatus, client, streamingBody, mutationExecutor, + onAmbiguousMutationResult, onNetworkFailure, onFailurePhase, diagnosticIgnoredHttpStatuses, true) } + } val started = System.nanoTime() require((expectedSuccessResponseBytes == null) == (expectedSuccessResponseStatus == null)) val requestBody = when { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index bc10f1980..49d2e8837 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -335,4 +335,83 @@ class DesktopAccountOperationGuardTest { removal.await() assertTrue(removalEntered) } + + @Test + fun authenticatedMutationWaitsForSelectionAndRejectsTheStaleSession() = runBlocking { + val first = NextcloudSession("https://first.example.test", "alice", "one") + val second = NextcloudSession("https://second.example.test", "bob", "two") + val guard = DesktopAccountOperationGuard() + val selectionEntered = CompletableDeferred() + val releaseSelection = CompletableDeferred() + var current = first + var requestSent = false + val selection = async { + guard.serialize { + current = second + selectionEntered.complete(Unit) + releaseSelection.await() + } + } + selectionEntered.await() + + val mutation = async { + runCatching { + guard.withAuthenticatedMutationSession(first, { current }) { + requestSent = true + } + } + } + yield() + + assertFalse(mutation.isCompleted) + releaseSelection.complete(Unit) + selection.await() + assertTrue(mutation.await().isFailure) + assertFalse(requestSent) + } + + @Test + fun pendingLinuxWritebackBlocksAccountRemoval() { + requireDesktopAccountRemovalWritebacksResolved(0) + assertFailsWith { requireDesktopAccountRemovalWritebacksResolved(1) } + } + + @Test + fun failedCredentialRemovalRestoresProviderActivationPreference() = runBlocking { + val events = mutableListOf() + + val failure = runCatching { + removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = true, + clearProviderPreference = { events += "cleared" }, + restoreProviderPreference = { enabled -> events += "restored:$enabled" }, + removeCredential = { + events += "remove" + error("credential removal failed") + }, + ) + } + + assertTrue(failure.isFailure) + assertEquals(listOf("cleared", "remove", "restored:true"), events) + } + + @Test + fun successfulCredentialRemovalLeavesProviderPreferenceDisabled() = runBlocking { + val events = mutableListOf() + + assertTrue( + removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = true, + clearProviderPreference = { events += "cleared" }, + restoreProviderPreference = { enabled -> events += "restored:$enabled" }, + removeCredential = { + events += "remove" + true + }, + ), + ) + + assertEquals(listOf("cleared", "remove"), events) + } } From de408fa4b0364be43059091df85128e0f51f47ef Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 12:39:55 +0200 Subject: [PATCH 021/119] fix(accounts): preserve removal recovery state --- .../AndroidAccountCredentialController.kt | 6 +-- .../AndroidNextcloudServices.kt | 2 +- .../AndroidAccountOperationGuardTest.kt | 20 +++++++++ .../AndroidPersistedSessionTest.kt | 17 +++----- .../DesktopAccountCredentialPersistence.kt | 4 +- .../app/DesktopFileSyncAccountCleanup.kt | 3 ++ ...DesktopAccountCredentialPersistenceTest.kt | 24 +++++++++++ .../app/DesktopFileSyncStoreTest.kt | 42 +++++++++++++++++++ 8 files changed, 101 insertions(+), 17 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index fbe0b689f..dda0759a2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -682,7 +682,6 @@ internal suspend fun removeAndroidAccountCredentialData( if (active) { try { clearActiveAccount() - removeQueuedUploads() } catch (failure: Exception) { withContext(NonCancellable) { runCatching { rollbackActiveRemoval() } @@ -690,12 +689,12 @@ internal suspend fun removeAndroidAccountCredentialData( } throw failure } + removeQueuedUploads() return } - persistInactiveRemoval() try { - removeQueuedUploads() + persistInactiveRemoval() } catch (failure: Exception) { withContext(NonCancellable) { runCatching { rollbackInactiveRemoval() } @@ -703,6 +702,7 @@ internal suspend fun removeAndroidAccountCredentialData( } throw failure } + removeQueuedUploads() } internal suspend fun removeRecoveredAndroidAccountCredentialData( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 924407801..6b977cf9a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -2950,7 +2950,7 @@ internal class AndroidNextcloudServices( ): DurableUploadEnqueueResult = withContext(Dispatchers.IO) { ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( expectedSession = session, - resolveSession = { loadSession(session.accountId) }, + resolveSession = ::loadSession, unavailable = { DurableUploadEnqueueResult.Rejected("The account changed before the upload could be queued.") }, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index a71b881b8..5b5c02425 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -312,6 +312,26 @@ class AndroidAccountOperationGuardTest { assertFalse(uploadCreated) } + @Test + fun uploadCreationRejectsARetainedCredentialAfterAnotherAccountBecomesActive() = runBlocking { + val guard = AndroidAccountOperationGuard() + val retained = NextcloudSession("https://first.example.test", "alice", "old-password") + val active = NextcloudSession("https://second.example.test", "bob", "new-password") + var uploadCreated = false + + val accepted = guard.withExactAccountSession( + expectedSession = retained, + resolveSession = { active }, + unavailable = { false }, + ) { + uploadCreated = true + true + } + + assertFalse(accepted) + assertFalse(uploadCreated) + } + @Test fun authenticatedMutationWaitsForSelectionAndRejectsTheStaleSession() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 5c2d53046..d7d50bd04 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -10,8 +10,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlin.test.Test @@ -750,7 +748,7 @@ class AndroidPersistedSessionTest { } @Test - fun failedActiveUploadCleanupRestoresTheRemovedCredentialState() = runBlocking { + fun failedActiveUploadCleanupKeepsTheCredentialRemovalCommitted() = runBlocking { val events = mutableListOf() assertFailsWith { @@ -767,7 +765,7 @@ class AndroidPersistedSessionTest { ) } - assertEquals(listOf("clear-account", "remove-uploads", "rollback-active"), events) + assertEquals(listOf("clear-account", "remove-uploads"), events) } @Test @@ -792,10 +790,9 @@ class AndroidPersistedSessionTest { } @Test - fun cancelledInactiveAccountRemovalRollsBackNonCancellably() = runBlocking { + fun cancelledInactiveAccountCleanupKeepsTheCredentialRemovalCommitted() = runBlocking { val cleanupEntered = CompletableDeferred() val events = mutableListOf() - var rollbackWasActive = false val removal = launch { removeAndroidAccountCredentialData( active = false, @@ -807,18 +804,14 @@ class AndroidPersistedSessionTest { clearActiveAccount = { events += "clear-account" }, rollbackActiveRemoval = { events += "rollback-active" }, persistInactiveRemoval = { events += "persist-removal" }, - rollbackInactiveRemoval = { - rollbackWasActive = currentCoroutineContext().isActive - events += "rollback" - }, + rollbackInactiveRemoval = { events += "rollback" }, ) } cleanupEntered.await() removal.cancelAndJoin() - assertTrue(rollbackWasActive) - assertEquals(listOf("persist-removal", "remove-uploads", "rollback"), events) + assertEquals(listOf("persist-removal", "remove-uploads"), events) } private fun assertDiagnosticsExcludePrivateValues(diagnostics: List) { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index f75668e18..34029f843 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -70,6 +70,7 @@ internal class DesktopAccountCredentialPersistence( saveSecret(persistedSession) persistAccountState(encodedRegistry, updatedRegistry.activeAccount) } catch (failure: Exception) { + var credentialRollbackCompleted = false try { if (previousSecret == null) { secretStore.clear(secretReference) @@ -80,6 +81,7 @@ internal class DesktopAccountCredentialPersistence( previousSecret, ) } + credentialRollbackCompleted = true } catch (rollbackFailure: Exception) { failure.addSuppressed(rollbackFailure) recordCredentialDiagnostic( @@ -88,7 +90,7 @@ internal class DesktopAccountCredentialPersistence( rollbackFailure, ) } - if (journalNewCredential) clearPendingCredentialSave() + if (journalNewCredential && credentialRollbackCompleted) clearPendingCredentialSave() throw failure } if (journalNewCredential) clearPendingCredentialSave() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt index d6b8fb747..c8bfba695 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt @@ -5,6 +5,9 @@ internal fun DesktopFileSyncStore.removeDesktopFileSyncAccountPairs(accountId: S withExclusiveAccess { val current = load() val removed = current.coordinator.pairs.filter { pair -> pair.accountId == accountId } + check(removed.none { pair -> fileSyncOwnedUploads(pair).isNotEmpty() }) { + "Owned remote upload state must be recovered before removing this account." + } val retainedRootIds = current.coordinator.pairs.asSequence() .filterNot { pair -> pair.accountId == accountId } .mapTo(mutableSetOf(), FileSyncPair::localRootId) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index a8bb85336..212f4478b 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -87,6 +87,30 @@ class DesktopAccountCredentialPersistenceTest { assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) } + @Test + fun failedNewCredentialRollbackRetainsTheRecoveryJournal() = withStore { preferences, secrets -> + val session = firstSession() + var flushCount = 0 + val persistence = persistence(preferences, secrets) { + flushCount += 1 + if (flushCount == 2) error("synthetic registry flush failure") + preferences.flush() + } + secrets.failClears = true + + assertFailsWith { persistence.saveSession(session) } + + assertNotNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertEquals(session.serverUrl, preferences.get("accountCredentialSaveServer", null)) + assertEquals(session.loginName, preferences.get("accountCredentialSaveLogin", null)) + + secrets.failClears = false + assertNull(persistence(preferences, secrets).loadActiveSession()) + assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertNull(preferences.get("accountCredentialSaveServer", null)) + assertNull(preferences.get("accountCredentialSaveLogin", null)) + } + @Test fun startupRecoveryRemovesANewCredentialWhoseRegistryCommitNeverCompleted() = withStore { preferences, secrets -> diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt index 372151609..0dc96d5f0 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt @@ -56,6 +56,48 @@ class DesktopFileSyncStoreTest { } } + @Test + fun `account removal retains every pair when one owns an unfinished remote upload`() { + val directory = Files.createTempDirectory("desktop-sync-account-upload-removal-").toFile() + try { + val owned = FileSyncPair( + id = "owned-pair", + accountId = "account-a", + localRootId = "owned-root", + remoteRootPath = "Documents", + configuration = FileSyncConfiguration(deviceLabel = "Workstation"), + pendingUploadCleanups = listOf( + FileSyncPendingUploadCleanup( + uploadId = "11111111-1111-4111-8111-111111111111", + relativePath = "draft.txt", + ), + ), + ) + val clear = FileSyncPair( + id = "clear-pair", + accountId = "account-a", + localRootId = "clear-root", + remoteRootPath = "Photos", + configuration = FileSyncConfiguration(deviceLabel = "Workstation"), + ) + val roots = listOf( + DesktopFileSyncRootRecord(owned.localRootId, directory.resolve("owned").absolutePath, "Owned"), + DesktopFileSyncRootRecord(clear.localRootId, directory.resolve("clear").absolutePath, "Clear"), + ) + val store = DesktopFileSyncStore(File(directory, "state.db"), legacyStateFile = null) + store.savePair(DesktopFileSyncPersistedState(FileSyncCoordinatorState(listOf(owned)), roots.take(1)), owned.id) + store.savePair(DesktopFileSyncPersistedState(FileSyncCoordinatorState(listOf(clear)), roots.drop(1)), clear.id) + + assertFails { store.removeDesktopFileSyncAccountPairs("account-a") } + + val retained = store.load() + assertEquals(setOf(owned, clear), retained.coordinator.pairs.toSet()) + assertEquals(roots.toSet(), retained.roots.toSet()) + } finally { + directory.deleteRecursively() + } + } + @Test fun `legacy json state imports once into the transactional database`() { val directory = Files.createTempDirectory("desktop-sync-legacy-import-").toFile() From 44e80715668102a989cbcd5ad9905d2c95aba304 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 13:22:05 +0200 Subject: [PATCH 022/119] fix(accounts): preflight account-bound transitions --- .../nextcloudnative/AndroidAccountRemoval.kt | 29 ++++++++++++--- .../AndroidNextcloudServices.kt | 26 +++++++------- .../NextcloudDocumentsContractTest.kt | 21 +++++++++++ .../app/DesktopNextcloudServices.kt | 8 ++--- .../app/JvmLoginFlowTransportTest.kt | 35 ++++++++++++++----- .../app/JvmLoginFlowTransport.kt | 6 ++-- 6 files changed, 92 insertions(+), 33 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index 9ee0c414f..4945d7711 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -16,11 +16,30 @@ internal fun requireAndroidAccountRemovalWritebacksResolved(resolved: Boolean) { } } +internal suspend fun revokeAndroidSessionAfterWritebackPreflight( + writebacksResolved: Boolean, + revoke: suspend () -> Unit, +) { + requireAndroidAccountRemovalWritebacksResolved(writebacksResolved) + revoke() +} + +internal enum class AndroidAccountDocumentGrantScope(val pathSegment: String) { + Document("document"), + Tree("tree"), +} + +internal fun AndroidAccountDocumentGrantScope.uri(authority: String, rootId: String) = when (this) { + AndroidAccountDocumentGrantScope.Document -> DocumentsContract.buildDocumentUri(authority, rootId) + AndroidAccountDocumentGrantScope.Tree -> DocumentsContract.buildTreeDocumentUri(authority, rootId) +} + internal fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) { requireAndroidAccountRemovalWritebacksResolved(androidDocumentPendingWritebacks(context, session).isEmpty()) - val accountDocumentScope = DocumentsContract.buildDocumentUri( - nextcloudDocumentsAuthority(context.packageName), - NextcloudDocumentIds.rootId(session), - ) - context.revokeUriPermission(accountDocumentScope, NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS) + AndroidAccountDocumentGrantScope.entries.forEach { scope -> + context.revokeUriPermission( + scope.uri(nextcloudDocumentsAuthority(context.packageName), NextcloudDocumentIds.rootId(session)), + NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS, + ) + } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 6b977cf9a..52beb5e93 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -1275,7 +1275,7 @@ internal class AndroidNextcloudServices( fallbackAlreadySelected = challenge.token in loginPollFallbackTokens, poll = { endpoint -> networkFailure = null - kotlinx.coroutines.runBlocking { request( + request( method = "POST", url = endpoint, body = formBody, @@ -1284,7 +1284,7 @@ internal class AndroidNextcloudServices( maxResponseBytes = LOGIN_FLOW_RESPONSE_MAX_BYTES, diagnosticIgnoredHttpStatuses = setOf(404), onNetworkFailure = { networkFailure = it }, - ) }.let { LoginPollHttpResponse(it.status, it.text) } + ).let { LoginPollHttpResponse(it.status, it.text) } }, networkFailure = { networkFailure }, ) @@ -1497,7 +1497,7 @@ internal class AndroidNextcloudServices( ): FileOfflineAvailability = withContext(Dispatchers.IO) { ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( expectedSession = session, - resolveSession = { loadSession(session.accountId) }, + resolveSession = ::loadSession, unavailable = { error("The account changed before offline storage could be updated.") }, ) { current -> fileOfflineRepository.setAvailable(current, userId, file, available) @@ -1518,7 +1518,7 @@ internal class AndroidNextcloudServices( ): FileOfflineCenterActionResult = withContext(Dispatchers.IO) { ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( expectedSession = session, - resolveSession = { loadSession(session.accountId) }, + resolveSession = ::loadSession, unavailable = { FileOfflineCenterActionResult.Rejected("The account changed before this retry started.") }, ) { current -> fileOfflineRepository.retryCenterItem(current, userId, key) @@ -1532,7 +1532,7 @@ internal class AndroidNextcloudServices( ): FileOfflineCenterActionResult = withContext(Dispatchers.IO) { ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( expectedSession = session, - resolveSession = { loadSession(session.accountId) }, + resolveSession = ::loadSession, unavailable = { FileOfflineCenterActionResult.Rejected("The account changed before offline storage was removed.") }, ) { current -> fileOfflineRepository.removeCenterItem(current, userId, key) @@ -3449,15 +3449,15 @@ internal class AndroidNextcloudServices( check(response.status in 200..299) { "Sending the Talk message failed (HTTP ${response.status})." } Unit } - override suspend fun revokeSession(session: NextcloudSession) = withContext(Dispatchers.IO) { - request( - method = "DELETE", - url = session.serverUrl + "/ocs/v2.php/core/apppassword", - session = session, - ocsRequest = true, - ) - Unit + revokeAndroidSessionAfterWritebackPreflight(androidDocumentPendingWritebacks(appContext, session).isEmpty()) { + request( + method = "DELETE", + url = session.serverUrl + "/ocs/v2.php/core/apppassword", + session = session, + ocsRequest = true, + ) + } } private suspend fun ocsGet(session: NextcloudSession, path: String): JSONObject { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt index 1938bdfb9..9b13698de 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt @@ -3,7 +3,9 @@ package dev.obiente.nextcloudnative import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking class NextcloudDocumentsContractTest { @Test @@ -40,4 +42,23 @@ class NextcloudDocumentsContractTest { assertTrue(NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS and android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION != 0) assertTrue(NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS and android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION != 0) } + + @Test + fun `account removal revokes both document and tree grant scopes`() { + assertEquals( + listOf("document", "tree"), + AndroidAccountDocumentGrantScope.entries.map(AndroidAccountDocumentGrantScope::pathSegment), + ) + } + + @Test + fun `pending writeback preflight runs before remote credential revocation`() = runBlocking { + var revoked = false + + assertFailsWith { + revokeAndroidSessionAfterWritebackPreflight(writebacksResolved = false) { revoked = true } + } + + assertFalse(revoked) + } } 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 40eb6e563..fc530b8a1 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -4088,7 +4088,7 @@ class DesktopNextcloudServices( fallbackAlreadySelected = challenge.token in loginPollFallbackTokens, poll = { endpoint -> networkFailure = null - kotlinx.coroutines.runBlocking { request( + request( "POST", endpoint, body = "token=" + encodeForm(challenge.token), @@ -4097,7 +4097,7 @@ class DesktopNextcloudServices( maxResponseBytes = LOGIN_FLOW_RESPONSE_MAX_BYTES, diagnosticIgnoredHttpStatuses = setOf(404), onNetworkFailure = { networkFailure = it }, - ) }.let { LoginPollHttpResponse(it.status, it.text) } + ).let { LoginPollHttpResponse(it.status, it.text) } }, networkFailure = { networkFailure }, ) @@ -5633,9 +5633,9 @@ class DesktopNextcloudServices( Unit } - override suspend fun revokeSession(session: NextcloudSession) = withContext(Dispatchers.IO) { + override suspend fun revokeSession(session: NextcloudSession): Unit = withContext(Dispatchers.IO) { + requireDesktopAccountRemovalReady(desktopFileCacheAccountId(session), isLinuxDesktop()) request("DELETE", session.serverUrl + "/ocs/v2.php/core/apppassword", session, ocsRequest = true) - Unit } private suspend fun ocsGet(session: NextcloudSession, path: String): JSONObject { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransportTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransportTest.kt index bcc28a5f9..f942950d8 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransportTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransportTest.kt @@ -1,14 +1,17 @@ package dev.obiente.nextcloudnative.app import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertIs import kotlin.test.assertNull class JvmLoginFlowTransportTest { @Test - fun `not found advertised path accepts approval from entered base path compatibility endpoint`() { + fun `not found advertised path accepts approval from entered base path compatibility endpoint`() = runBlocking { val endpoints = mutableListOf() val execution = executeLoginPollHttp( challenge = challenge( @@ -41,7 +44,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `dual not found responses keep probing without abandoning advertised route`() { + fun `dual not found responses keep probing without abandoning advertised route`() = runBlocking { val endpoints = mutableListOf() val execution = executeLoginPollHttp( challenge = challenge( @@ -82,7 +85,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `incompatible compatibility response leaves advertised pending route selected`() { + fun `incompatible compatibility response leaves advertised pending route selected`() = runBlocking { val execution = executeLoginPollHttp( challenge = challenge( pollEndpoint = "https://cloud.example.test/login/v2/poll", @@ -105,7 +108,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `pre exchange DNS failure probes pending compatibility endpoint without pinning it`() { + fun `pre exchange DNS failure probes pending compatibility endpoint without pinning it`() = runBlocking { var diagnostic: JvmNetworkFailureDiagnostic? = null val endpoints = mutableListOf() val execution = executeLoginPollHttp( @@ -140,7 +143,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `pre exchange DNS failure selects compatibility endpoint after approval`() { + fun `pre exchange DNS failure selects compatibility endpoint after approval`() = runBlocking { var diagnostic: JvmNetworkFailureDiagnostic? = null val execution = executeLoginPollHttp( challenge = challenge( @@ -163,7 +166,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `incompatible fallback response preserves retryable advertised endpoint failure`() { + fun `incompatible fallback response preserves retryable advertised endpoint failure`() = runBlocking { listOf(405, 503).forEach { fallbackStatus -> var diagnostic: JvmNetworkFailureDiagnostic? = null val endpoints = mutableListOf() @@ -209,7 +212,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `failure after compatibility exchange is ambiguous`() { + fun `failure after compatibility exchange is ambiguous`() = runBlocking { var diagnostic: JvmNetworkFailureDiagnostic? = null val execution = executeLoginPollHttp( challenge = challenge( @@ -233,7 +236,7 @@ class JvmLoginFlowTransportTest { } @Test - fun `malformed compatibility approval is never diagnosed as retry safe`() { + fun `malformed compatibility approval is never diagnosed as retry safe`() = runBlocking { val execution = executeLoginPollHttp( challenge = challenge( pollEndpoint = "https://cloud.example.test/login/v2/poll", @@ -259,6 +262,22 @@ class JvmLoginFlowTransportTest { assertEquals("false", fields["safe_to_retry"]) } + @Test + fun `poll cancellation is never detached or classified as a network failure`() = runBlocking { + assertFailsWith { + executeLoginPollHttp( + challenge = challenge( + pollEndpoint = "https://cloud.example.test/login/v2/poll", + fallbackEndpoint = "https://cloud.example.test/index.php/login/v2/poll", + ), + fallbackAlreadySelected = false, + poll = { throw CancellationException("screen left composition") }, + networkFailure = { null }, + ) + } + Unit + } + private fun challenge(pollEndpoint: String, fallbackEndpoint: String?) = LoginChallenge( enteredServerUrl = "https://cloud.example.test/nextcloud", pollEndpoint = pollEndpoint, diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransport.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransport.kt index 6f92593c2..a216d8ddc 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransport.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowTransport.kt @@ -21,10 +21,10 @@ data class LoginPollHttpExecution( val selectedFallbackReason: LoginPollFallbackReason? = null, ) -fun executeLoginPollHttp( +suspend fun executeLoginPollHttp( challenge: LoginChallenge, fallbackAlreadySelected: Boolean, - poll: (String) -> LoginPollHttpResponse, + poll: suspend (String) -> LoginPollHttpResponse, networkFailure: () -> JvmNetworkFailureDiagnostic?, ): LoginPollHttpExecution { val fallbackEndpoint = challenge.pollFallbackEndpoint @@ -39,7 +39,7 @@ fun executeLoginPollHttp( selectedFallbackReason = selectedFallbackReason, ) - fun attempt(endpoint: String): LoginPollHttpResponse = try { + suspend fun attempt(endpoint: String): LoginPollHttpResponse = try { poll(endpoint) } catch (failure: Throwable) { if (failure is CancellationException) throw failure From 4a851d96070edbdfd11acfba7609d26a03a4ddb7 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 13:58:47 +0200 Subject: [PATCH 023/119] fix(accounts): close retained-session safety gaps --- .../AndroidAccountCredentialController.kt | 42 +++++++++++++++---- .../AndroidShareUploadActivity.kt | 2 +- .../AndroidPersistedSessionTest.kt | 37 +++++++++------- 3 files changed, 58 insertions(+), 23 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index dda0759a2..b0df20eb6 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -53,13 +53,11 @@ internal class AndroidAccountCredentialController( ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { val registry = readCredentialFreeRegistry() ?: return@serialize null if (registry.accounts.none { account -> account.id == accountId }) return@serialize null + val aggregateRead = readStore() + if (!androidCredentialStoreAllowsSessionRestore(aggregateRead)) return@serialize null + val aggregate = (aggregateRead as? AndroidAccountCredentialStoreRead.Available)?.state val storedSlot = readCredentialSlot(accountId) val restoredSlot = recoverAndroidAccountCredentialSlot(accountId, registry, storedSlot, aggregate = null) - val aggregate = if (restoredSlot == null) { - (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state - } else { - null - } val session = restoredSlot ?: recoverAndroidAccountCredentialSlot( accountId, registry, @@ -142,6 +140,7 @@ internal class AndroidAccountCredentialController( }, persistInactiveRemoval = { persistState(current.remove(accountId)) }, rollbackInactiveRemoval = { persistState(current) }, + recordCommittedCleanupFailure = { recordAccountRemovalCleanupFailure() }, ) if (!active) { clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(session)) @@ -172,6 +171,7 @@ internal class AndroidAccountCredentialController( }, persistInactiveRemoval = {}, rollbackInactiveRemoval = {}, + recordCommittedCleanupFailure = { recordAccountRemovalCleanupFailure() }, ) } } @@ -229,6 +229,7 @@ internal class AndroidAccountCredentialController( suspectEncrypted = suspectEncrypted, ) }, + recordCommittedCleanupFailure = { recordAccountRemovalCleanupFailure() }, ) } } else { @@ -565,8 +566,13 @@ internal class AndroidAccountCredentialController( ) } -} + private fun recordAccountRemovalCleanupFailure() = recordCredentialFailure( + code = "ACCOUNT_REMOVAL_CLEANUP_FAILED", + operation = "account.remove-cleanup", + component = SupportDiagnosticComponent.Sync, + ) +} internal sealed interface AndroidAccountCredentialStoreRead { data class Available(val state: AndroidAccountCredentialState) : AndroidAccountCredentialStoreRead data class Invalid(val encrypted: String) : AndroidAccountCredentialStoreRead @@ -677,6 +683,7 @@ internal suspend fun removeAndroidAccountCredentialData( rollbackActiveRemoval: suspend () -> Unit, persistInactiveRemoval: suspend () -> Unit, rollbackInactiveRemoval: suspend () -> Unit, + recordCommittedCleanupFailure: (Exception) -> Unit = {}, ) { prepareAccountRemoval() if (active) { @@ -689,7 +696,7 @@ internal suspend fun removeAndroidAccountCredentialData( } throw failure } - removeQueuedUploads() + finishCommittedAndroidAccountRemovalCleanup(removeQueuedUploads, recordCommittedCleanupFailure) return } @@ -702,7 +709,20 @@ internal suspend fun removeAndroidAccountCredentialData( } throw failure } - removeQueuedUploads() + finishCommittedAndroidAccountRemovalCleanup(removeQueuedUploads, recordCommittedCleanupFailure) +} + +private suspend fun finishCommittedAndroidAccountRemovalCleanup( + removeQueuedUploads: suspend () -> Unit, + recordFailure: (Exception) -> Unit, +) { + try { + removeQueuedUploads() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + recordFailure(failure) + } } internal suspend fun removeRecoveredAndroidAccountCredentialData( @@ -710,6 +730,7 @@ internal suspend fun removeRecoveredAndroidAccountCredentialData( removeQueuedUploads: suspend () -> Unit, clearRecoveredAccount: suspend () -> Unit, rollbackRecoveredAccount: suspend () -> Unit, + recordCommittedCleanupFailure: (Exception) -> Unit = {}, ) = removeAndroidAccountCredentialData( active = true, prepareAccountRemoval = prepareAccountRemoval, @@ -718,8 +739,13 @@ internal suspend fun removeRecoveredAndroidAccountCredentialData( rollbackActiveRemoval = rollbackRecoveredAccount, persistInactiveRemoval = {}, rollbackInactiveRemoval = {}, + recordCommittedCleanupFailure = recordCommittedCleanupFailure, ) +internal fun androidCredentialStoreAllowsSessionRestore( + read: AndroidAccountCredentialStoreRead, +): Boolean = read !is AndroidAccountCredentialStoreRead.Unsupported + internal class AndroidAccountCredentialStoreGuard { private val monitor = Any() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt index 0cff45e69..2780503a6 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt @@ -257,7 +257,7 @@ class AndroidShareUploadActivity : ComponentActivity() { withContext(Dispatchers.IO) { ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( expectedSession = activeSession, - resolveSession = { services.loadSession(activeSession.accountId) }, + resolveSession = services::loadSession, unavailable = { error("The account changed before the upload could be queued.") }, ) { current -> uploads.enqueue(current, info.userId, staged.id, destinationPath) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index d7d50bd04..5d4950ebd 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -398,6 +398,16 @@ class AndroidPersistedSessionTest { listOf("ACCOUNT_CREDENTIAL_STORE_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }, ) + assertFalse( + androidCredentialStoreAllowsSessionRestore( + AndroidAccountCredentialStoreRead.Unsupported("encrypted-future-store", 3), + ), + ) + assertTrue( + androidCredentialStoreAllowsSessionRestore( + AndroidAccountCredentialStoreRead.Invalid("encrypted-malformed-store"), + ), + ) } @Test @@ -751,21 +761,20 @@ class AndroidPersistedSessionTest { fun failedActiveUploadCleanupKeepsTheCredentialRemovalCommitted() = runBlocking { val events = mutableListOf() - assertFailsWith { - removeAndroidAccountCredentialData( - active = true, - removeQueuedUploads = { - events += "remove-uploads" - error("synthetic cleanup failure") - }, - clearActiveAccount = { events += "clear-account" }, - rollbackActiveRemoval = { events += "rollback-active" }, - persistInactiveRemoval = { events += "persist-inactive" }, - rollbackInactiveRemoval = { events += "rollback-inactive" }, - ) - } + removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = { + events += "remove-uploads" + error("synthetic cleanup failure") + }, + clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { events += "persist-inactive" }, + rollbackInactiveRemoval = { events += "rollback-inactive" }, + recordCommittedCleanupFailure = { events += "diagnose-cleanup" }, + ) - assertEquals(listOf("clear-account", "remove-uploads"), events) + assertEquals(listOf("clear-account", "remove-uploads", "diagnose-cleanup"), events) } @Test From 0e96293dd5bdc2886f55fba5d13e0c2ff35b8388 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 14:32:08 +0200 Subject: [PATCH 024/119] fix(accounts): finish committed account transitions --- .../AndroidAccountCredentialController.kt | 15 ++++++++--- .../AndroidAccountSelectionMaintenance.kt | 17 +++++++++++++ .../AndroidShareUploadActivity.kt | 13 ++++++++-- .../AndroidAccountOperationGuardTest.kt | 13 +++++----- .../AndroidPersistedSessionTest.kt | 19 ++++++++++++++ .../app/DesktopAccountRemoval.kt | 25 +++++++++++++++++++ .../app/DesktopNextcloudServices.kt | 6 ++--- .../app/DesktopAccountOperationGuardTest.kt | 20 +++++++++++++++ 8 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index b0df20eb6..7850bef4e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -334,9 +334,12 @@ internal class AndroidAccountCredentialController( }, ) } - if (previousSession != null && previousSession.accountId != session.accountId) { - clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(previousSession)) - } + clearAndroidPreviousPreviewAfterCommittedSelection( + previousSession = previousSession, + selectedSession = session, + clearPreviewAccount = clearPreviewAccount, + recordFailure = { recordAccountSelectionCacheCleanupFailure() }, + ) resumeAndroidQueuedUploadsAfterSelection( resume = { resumeQueuedUploads(NextcloudDocumentIds.accountKey(session)) }, notifyDocumentRootsChanged = notifyDocumentRootsChanged, @@ -565,12 +568,16 @@ internal class AndroidAccountCredentialController( ), ) } - private fun recordAccountRemovalCleanupFailure() = recordCredentialFailure( code = "ACCOUNT_REMOVAL_CLEANUP_FAILED", operation = "account.remove-cleanup", component = SupportDiagnosticComponent.Sync, ) + private fun recordAccountSelectionCacheCleanupFailure() = recordCredentialFailure( + code = "ACCOUNT_SELECTION_CACHE_CLEANUP_FAILED", + operation = "account-selection.cache-cleanup", + component = SupportDiagnosticComponent.Cache, + ) } internal sealed interface AndroidAccountCredentialStoreRead { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt new file mode 100644 index 000000000..868865657 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt @@ -0,0 +1,17 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal fun clearAndroidPreviousPreviewAfterCommittedSelection( + previousSession: NextcloudSession?, + selectedSession: NextcloudSession, + clearPreviewAccount: (String) -> Unit, + recordFailure: (Exception) -> Unit, +) { + if (previousSession == null || previousSession.accountId == selectedSession.accountId) return + try { + clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(previousSession)) + } catch (failure: Exception) { + runCatching { recordFailure(failure) } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt index 2780503a6..4631255f7 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidShareUploadActivity.kt @@ -180,9 +180,10 @@ class AndroidShareUploadActivity : ComponentActivity() { ?: error("Sign in to nati.ve before sharing files to it.") activeAccountId = NextcloudDocumentIds.accountKey(activeSession) val staged = withContext(Dispatchers.IO) { - ANDROID_ACCOUNT_OPERATION_GUARD.withExactAccountSession( + restoreIncomingShareForActiveSession( + guard = ANDROID_ACCOUNT_OPERATION_GUARD, expectedSession = activeSession, - resolveSession = { services.loadSession(activeSession.accountId) }, + resolveActiveSession = services::loadSession, unavailable = { error("The account changed before the shared files could be prepared.") }, ) { val restored = validatedRequestId?.let { requestId -> @@ -408,6 +409,14 @@ internal fun isValidIncomingShareRequestId(value: String): Boolean = internal fun AndroidIncomingShareRequest.canReleaseForIncomingShareReplacement(): Boolean = chunkSession == null && state == AndroidIncomingShareState.Completed +internal suspend fun restoreIncomingShareForActiveSession( + guard: AndroidAccountOperationGuard, + expectedSession: NextcloudSession, + resolveActiveSession: suspend () -> NextcloudSession?, + unavailable: suspend () -> Result, + restore: suspend (NextcloudSession) -> Result, +): Result = guard.withExactAccountSession(expectedSession, resolveActiveSession, unavailable, restore) + private fun AndroidShareUploadActivity.incomingShareFolderPickerOperations( services: AndroidNextcloudServices, session: NextcloudSession, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 5b5c02425..0d7b0accd 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -313,23 +313,24 @@ class AndroidAccountOperationGuardTest { } @Test - fun uploadCreationRejectsARetainedCredentialAfterAnotherAccountBecomesActive() = runBlocking { + fun incomingShareRestoreRejectsARetainedCredentialAfterAnotherAccountBecomesActive() = runBlocking { val guard = AndroidAccountOperationGuard() val retained = NextcloudSession("https://first.example.test", "alice", "old-password") val active = NextcloudSession("https://second.example.test", "bob", "new-password") - var uploadCreated = false + var restored = false - val accepted = guard.withExactAccountSession( + val accepted = restoreIncomingShareForActiveSession( + guard = guard, expectedSession = retained, - resolveSession = { active }, + resolveActiveSession = { active }, unavailable = { false }, ) { - uploadCreated = true + restored = true true } assertFalse(accepted) - assertFalse(uploadCreated) + assertFalse(restored) } @Test diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 5d4950ebd..9d2546629 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -651,6 +651,25 @@ class AndroidPersistedSessionTest { assertEquals(listOf("resume", "diagnose", "notify"), events) } + @Test + fun previewCleanupFailureDoesNotHideACommittedAccountSelection() { + val previous = firstSession() + val selected = secondSession() + val events = mutableListOf() + + clearAndroidPreviousPreviewAfterCommittedSelection( + previousSession = previous, + selectedSession = selected, + clearPreviewAccount = { + events += "clear-preview" + error("synthetic preview cleanup failure") + }, + recordFailure = { events += "diagnose-cleanup" }, + ) + + assertEquals(listOf("clear-preview", "diagnose-cleanup"), events) + } + @Test fun queuedUploadResumeCancellationNotifiesBeforePropagating() { val events = mutableListOf() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index ef50305bf..c67e8fd45 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -32,3 +32,28 @@ internal fun removeDesktopAccountCredential( removeCredential = removeCredential, ) } + +internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( + removeCredential: suspend () -> Boolean, + removeSyncPairs: suspend () -> Unit, + recordCleanupFailure: suspend (Exception) -> Unit, +): Boolean { + val removed = removeCredential() + if (!removed) return false + try { + removeSyncPairs() + } catch (failure: Exception) { + runCatching { recordCleanupFailure(failure) } + } + return true +} + +internal fun desktopAccountSyncPairCleanupFailureDiagnostic(accountId: String, failure: Exception) = + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.Sync, + operation = "account.remove-sync-cleanup", + outcome = "failed", + fields = desktopAccountDiagnosticFields(accountId), + exception = failure.toSupportDiagnosticExceptionDraft(), + ) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index fc530b8a1..747be73aa 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3813,15 +3813,15 @@ class DesktopNextcloudServices( val providerAccountId = desktopFileCacheAccountId(account) requireDesktopAccountRemovalReady(providerAccountId, isLinuxDesktop()) accountOperationGuard.withSyncRunLock { - fileSyncEngine.removeAccountPairs(providerAccountId) - sessionPublicationGuard.serialize { + removeDesktopAccountBeforeSyncPairCleanup({ sessionPublicationGuard.serialize { removeDesktopAccountCredential(preferences, providerAccountId) { accountCredentials.removeAccount(accountId) } + } }, { fileSyncEngine.removeAccountPairs(providerAccountId) }) { + recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(providerAccountId, it)) } } } } } - override suspend fun clearSession() = withContext(Dispatchers.IO) { accountOperationGuard.serialize { clearSessionForAccountOperation() } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 49d2e8837..821f1210e 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -414,4 +414,24 @@ class DesktopAccountOperationGuardTest { assertEquals(listOf("cleared", "remove"), events) } + + @Test + fun committedInactiveRemovalSurvivesSyncPairCleanupFailure() = runBlocking { + val events = mutableListOf() + + val removed = removeDesktopAccountBeforeSyncPairCleanup( + removeCredential = { + events += "remove-credential" + true + }, + removeSyncPairs = { + events += "remove-pairs" + error("synthetic pair cleanup failure") + }, + recordCleanupFailure = { events += "diagnose-cleanup" }, + ) + + assertTrue(removed) + assertEquals(listOf("remove-credential", "remove-pairs", "diagnose-cleanup"), events) + } } From aa763813313c63be91911d3f5cb1ef97369e9489 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 15:58:10 +0200 Subject: [PATCH 025/119] fix(accounts): isolate credential recovery mutations --- .../AndroidAccountCredentialController.kt | 146 ++++++++---------- .../AndroidPersistedSession.kt | 35 +++++ .../AndroidPersistedSessionTest.kt | 34 +++- .../app/DesktopAccountRemoval.kt | 18 +++ .../app/DesktopNextcloudServices.kt | 19 ++- .../app/DesktopAccountOperationGuardTest.kt | 19 +++ 6 files changed, 173 insertions(+), 98 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 7850bef4e..e18679a22 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -5,13 +5,11 @@ import android.content.SharedPreferences import dev.obiente.nextcloudnative.app.NextcloudAccountId import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry -import dev.obiente.nextcloudnative.app.NextcloudAccountRegistryRecoveryReason import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity import dev.obiente.nextcloudnative.app.accountRecord -import dev.obiente.nextcloudnative.app.decodeNextcloudAccountRegistry import dev.obiente.nextcloudnative.app.encodeNextcloudAccountRegistry import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry import kotlinx.coroutines.CancellationException @@ -37,7 +35,10 @@ internal class AndroidAccountCredentialController( private val appContext = context.applicationContext fun loadSession(): NextcloudSession? = ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.restorePersistedSession( - load = { activeAccountId()?.let { accountId -> loadSession(accountId) } }, + load = { + val registry = readRegistryForCredentialLoad() + registry?.activeAccountId?.let { accountId -> loadSession(accountId, registry) } + }, accountIdOf = NextcloudDocumentIds::accountKey, publishAccount = { session, accountIdentity -> session?.let(registerSessionPrivateValues) @@ -51,32 +52,38 @@ internal class AndroidAccountCredentialController( fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { - val registry = readCredentialFreeRegistry() ?: return@serialize null - if (registry.accounts.none { account -> account.id == accountId }) return@serialize null - val aggregateRead = readStore() - if (!androidCredentialStoreAllowsSessionRestore(aggregateRead)) return@serialize null - val aggregate = (aggregateRead as? AndroidAccountCredentialStoreRead.Available)?.state - val storedSlot = readCredentialSlot(accountId) - val restoredSlot = recoverAndroidAccountCredentialSlot(accountId, registry, storedSlot, aggregate = null) - val session = restoredSlot ?: recoverAndroidAccountCredentialSlot( - accountId, - registry, - storedSlot = null, - aggregate = aggregate, - ) - ?: return@serialize null - if (storedSlot != session) { - runCatching { - commitPreferences( - preferences.edit().putString( - androidAccountCredentialSlotKey(accountId), - encryptCredentialSlot(session), - ), - ) - } + val registry = readRegistryForCredentialLoad() ?: return@serialize null + loadSession(accountId, registry) + } + + private fun loadSession( + accountId: NextcloudAccountId, + registry: NextcloudAccountRegistry, + ): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + if (registry.accounts.none { account -> account.id == accountId }) return@serialize null + val aggregateRead = readStore() + if (!androidCredentialStoreAllowsSessionRestore(aggregateRead)) return@serialize null + val aggregate = (aggregateRead as? AndroidAccountCredentialStoreRead.Available)?.state + val storedSlot = readCredentialSlot(accountId) + val restoredSlot = recoverAndroidAccountCredentialSlot(accountId, registry, storedSlot, aggregate = null) + val session = restoredSlot ?: recoverAndroidAccountCredentialSlot( + accountId, + registry, + storedSlot = null, + aggregate = aggregate, + ) ?: return@serialize null + if (storedSlot != session) { + runCatching { + commitPreferences( + preferences.edit().putString( + androidAccountCredentialSlotKey(accountId), + encryptCredentialSlot(session), + ), + ) } - session.also(registerSessionPrivateValues) } + session.also(registerSessionPrivateValues) + } suspend fun saveSession(session: NextcloudSession): NextcloudSession = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { @@ -363,44 +370,41 @@ internal class AndroidAccountCredentialController( private fun readCredentialFreeRegistry(): NextcloudAccountRegistry? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { - preferences.getString(KEY_ACCOUNT_REGISTRY, null)?.let { encoded -> - val restored = restoreAndroidCredentialFreeRegistry(encoded) { - val state = (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state - ?: return@restoreAndroidCredentialFreeRegistry null - runCatching { - commitPreferences( - prepareCredentialSlotEdit( - preferences.edit().putString( - KEY_ACCOUNT_REGISTRY, - encodeNextcloudAccountRegistry(state.registry), - ), - state, + val encoded = preferences.getString(KEY_ACCOUNT_REGISTRY, null) ?: return@serialize null + val restored = restoreAndroidCredentialFreeRegistry(encoded) + recordCredentialFreeRegistryDiagnostic(restored) + restored.registry + } + + private fun readRegistryForCredentialLoad(): NextcloudAccountRegistry? = + ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + val encoded = preferences.getString(KEY_ACCOUNT_REGISTRY, null) + val restored = encoded?.let(::restoreAndroidCredentialFreeRegistry) + restored?.let(::recordCredentialFreeRegistryDiagnostic) + recoverAndroidCredentialFreeRegistryForCredentialLoad(restored) { + val state = (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state + ?: return@recoverAndroidCredentialFreeRegistryForCredentialLoad null + runCatching { + commitPreferences( + prepareCredentialSlotEdit( + preferences.edit().putString( + KEY_ACCOUNT_REGISTRY, + encodeNextcloudAccountRegistry(state.registry), ), - ) - } - state.registry - } - restored.diagnosticCode?.let { code -> - recordCredentialFailure(code, operation = "account-registry.restore") - } - return@serialize restored.registry - } - val state = (readStore() as? AndroidAccountCredentialStoreRead.Available)?.state - ?: return@serialize null - runCatching { - commitPreferences( - prepareCredentialSlotEdit( - preferences.edit().putString( - KEY_ACCOUNT_REGISTRY, - encodeNextcloudAccountRegistry(state.registry), + state, ), - state, - ), - ) + ) + } + state.registry } - state.registry } + private fun recordCredentialFreeRegistryDiagnostic(restored: RestoredAndroidCredentialFreeRegistry) { + restored.diagnosticCode?.let { code -> + recordCredentialFailure(code, operation = "account-registry.restore") + } + } + private fun readStore(): AndroidAccountCredentialStoreRead = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { val encrypted = preferences.getString(KEY_SESSION, null) ?: return@serialize run { val retained = readIndependentCredentialSlotState() @@ -590,28 +594,6 @@ internal sealed interface AndroidAccountCredentialStoreRead { private fun unsupportedCredentialStoreMutation(version: Int): Nothing = error("The account credential store version $version is unsupported.") -internal fun decodeAndroidCredentialFreeRegistry(encoded: String): NextcloudAccountRegistry? = - decodeNextcloudAccountRegistry(encoded) - -internal data class RestoredAndroidCredentialFreeRegistry( - val registry: NextcloudAccountRegistry?, - val diagnosticCode: String? = null, -) - -internal fun restoreAndroidCredentialFreeRegistry( - encoded: String, - recoverMalformed: () -> NextcloudAccountRegistry?, -): RestoredAndroidCredentialFreeRegistry { - val restored = restoreNextcloudAccountRegistry(encoded, legacySession = null) - val recoveryReason = restored.recoveryReason - return when (recoveryReason) { - null -> RestoredAndroidCredentialFreeRegistry(restored.registry) - NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion -> - RestoredAndroidCredentialFreeRegistry(null, recoveryReason.diagnosticCode) - else -> RestoredAndroidCredentialFreeRegistry(recoverMalformed(), recoveryReason.diagnosticCode) - } -} - internal fun androidAccountCredentialSlotKey(accountId: NextcloudAccountId): String = "$KEY_ACCOUNT_CREDENTIAL_SLOT_PREFIX${accountId.storageKey}" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index eed016493..ec4750ed1 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -192,6 +192,41 @@ internal fun restoreAndroidPersistedSession( internal fun encodeAndroidPersistedSession(session: NextcloudSession): String = encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(session)) +internal fun decodeAndroidCredentialFreeRegistry(encoded: String): NextcloudAccountRegistry? = + decodeNextcloudAccountRegistry(encoded) + +internal data class RestoredAndroidCredentialFreeRegistry( + val registry: NextcloudAccountRegistry?, + val diagnosticCode: String? = null, + val credentialRecoveryRequired: Boolean = false, +) + +internal fun restoreAndroidCredentialFreeRegistry( + encoded: String, +): RestoredAndroidCredentialFreeRegistry { + val restored = restoreNextcloudAccountRegistry(encoded, legacySession = null) + val recoveryReason = restored.recoveryReason + return when (recoveryReason) { + null -> RestoredAndroidCredentialFreeRegistry(restored.registry) + NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion -> + RestoredAndroidCredentialFreeRegistry(null, recoveryReason.diagnosticCode) + else -> RestoredAndroidCredentialFreeRegistry( + registry = null, + diagnosticCode = recoveryReason.diagnosticCode, + credentialRecoveryRequired = true, + ) + } +} + +internal fun recoverAndroidCredentialFreeRegistryForCredentialLoad( + restored: RestoredAndroidCredentialFreeRegistry?, + recover: () -> NextcloudAccountRegistry?, +): NextcloudAccountRegistry? = when { + restored?.registry != null -> restored.registry + restored == null || restored.credentialRecoveryRequired -> recover() + else -> null +} + private fun restoreLegacyAndroidAccountCredentialState( json: JSONObject, ): RestoredAndroidAccountCredentialState { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 9d2546629..60ecc7fdc 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -456,31 +456,53 @@ class AndroidPersistedSessionTest { } @Test - fun malformedCredentialFreeRegistryRecoversFromTheValidatedAggregateRegistry() { + fun malformedCredentialFreeRegistryDefersCredentialBearingRecovery() { val registry = NextcloudAccountRegistry.Empty.upsertAndSelect(firstSession().accountRecord()) var recoveryAttempted = false - val restored = restoreAndroidCredentialFreeRegistry("{not-json") { + val restored = restoreAndroidCredentialFreeRegistry("{not-json") + + assertFalse(recoveryAttempted) + assertNull(restored.registry) + assertTrue(restored.credentialRecoveryRequired) + assertEquals("ACCOUNT_REGISTRY_MALFORMED", restored.diagnosticCode) + + val recovered = recoverAndroidCredentialFreeRegistryForCredentialLoad(restored) { recoveryAttempted = true registry } assertTrue(recoveryAttempted) - assertEquals(registry, restored.registry) - assertEquals("ACCOUNT_REGISTRY_MALFORMED", restored.diagnosticCode) + assertEquals(registry, recovered) + } + + @Test + fun missingCredentialFreeRegistryIsRecoveredOnlyForCredentialLoad() { + val registry = NextcloudAccountRegistry.Empty.upsertAndSelect(firstSession().accountRecord()) + var recoveryAttempted = false + + val recovered = recoverAndroidCredentialFreeRegistryForCredentialLoad(restored = null) { + recoveryAttempted = true + registry + } + + assertTrue(recoveryAttempted) + assertEquals(registry, recovered) } @Test fun futureCredentialFreeRegistryIsNeverRebuiltFromAnOlderAggregate() { var recoveryAttempted = false - val restored = restoreAndroidCredentialFreeRegistry("""{"version":99,"accounts":[]}""") { + val restored = restoreAndroidCredentialFreeRegistry("""{"version":99,"accounts":[]}""") + val recovered = recoverAndroidCredentialFreeRegistryForCredentialLoad(restored) { recoveryAttempted = true NextcloudAccountRegistry.Empty } assertFalse(recoveryAttempted) - assertNull(restored.registry) + assertNull(recovered) + assertFalse(restored.credentialRecoveryRequired) assertEquals("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED", restored.diagnosticCode) } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index c67e8fd45..8af3017a2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -48,6 +48,24 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( return true } +internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( + accountId: String?, + commitRemoval: suspend () -> Unit, + removeSyncPairs: suspend (String) -> Unit, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, +) { + removeDesktopAccountBeforeSyncPairCleanup( + removeCredential = { + commitRemoval() + true + }, + removeSyncPairs = { accountId?.let { removeSyncPairs(it) } }, + recordCleanupFailure = { failure -> + accountId?.let { recordDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(it, failure)) } + }, + ) +} + internal fun desktopAccountSyncPairCleanupFailureDiagnostic(accountId: String, failure: Exception) = SupportDiagnosticEventDraft( severity = SupportDiagnosticSeverity.Error, 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 747be73aa..adb12fc33 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3927,17 +3927,16 @@ class DesktopNextcloudServices( } } } - mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot( - phase = DesktopFileSyncTrayPhase.Idle, - ) - accountId?.let { fileSyncEngine.removeAccountPairs(it) } - sessionPublicationGuard.serialize { - if (activeAccountId != null) { - check(removeDesktopAccountCredential(preferences, accountId) { accountCredentials.removeAccount(activeAccountId) }) + mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot(phase = DesktopFileSyncTrayPhase.Idle) + clearDesktopActiveAccountBeforeSyncPairCleanup(accountId, { + sessionPublicationGuard.serialize { + check(activeAccountId == null || removeDesktopAccountCredential(preferences, accountId) { + accountCredentials.removeAccount(activeAccountId) + }) + supportDiagnostics.setActiveAccountIdentity(null) + supportIntake.setActiveAccountIdentity(null) } - supportDiagnostics.setActiveAccountIdentity(null) - supportIntake.setActiveAccountIdentity(null) - } + }, fileSyncEngine::removeAccountPairs, ::recordSupportDiagnostic) cleared = true } } finally { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 821f1210e..6c06be0e8 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -434,4 +434,23 @@ class DesktopAccountOperationGuardTest { assertTrue(removed) assertEquals(listOf("remove-credential", "remove-pairs", "diagnose-cleanup"), events) } + + @Test + fun failedActiveCredentialCommitPreservesSyncPairs() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + clearDesktopActiveAccountBeforeSyncPairCleanup( + accountId = "account-old", + commitRemoval = { + events += "remove-credential" + error("synthetic credential commit failure") + }, + removeSyncPairs = { events += "remove-pairs-$it" }, + recordDiagnostic = { events += "diagnose-cleanup" }, + ) + } + + assertEquals(listOf("remove-credential"), events) + } } From 6b9ff2787f0a3bee99648560938cbae34de04f5e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 16:49:29 +0200 Subject: [PATCH 026/119] fix(accounts): preflight sync ownership on removal --- .../nextcloudnative/AndroidAccountRemoval.kt | 3 ++- .../AndroidFileSyncExecutionCoordination.kt | 6 ++++++ .../nextcloudnative/AndroidFileSyncStore.kt | 19 +++++++++++------ .../AndroidFileSyncStoreTest.kt | 18 ++++++++++++++++ .../app/DesktopAccountRemoval.kt | 3 +++ .../app/DesktopAccountOperationGuardTest.kt | 21 +++++++++++++++++++ 6 files changed, 63 insertions(+), 7 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index 4945d7711..507c1b47e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -34,8 +34,9 @@ internal fun AndroidAccountDocumentGrantScope.uri(authority: String, rootId: Str AndroidAccountDocumentGrantScope.Tree -> DocumentsContract.buildTreeDocumentUri(authority, rootId) } -internal fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) { +internal suspend fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) { requireAndroidAccountRemovalWritebacksResolved(androidDocumentPendingWritebacks(context, session).isEmpty()) + requireAndroidFileSyncAccountRemovalReady(context, NextcloudDocumentIds.accountKey(session)) AndroidAccountDocumentGrantScope.entries.forEach { scope -> context.revokeUriPermission( scope.uri(nextcloudDocumentsAuthority(context.packageName), NextcloudDocumentIds.rootId(session)), diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index ea2602cf3..fca1536fa 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -243,3 +243,9 @@ internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, account retiredPairIds.forEach { pairId -> scheduler.cancel(pairId) } } } + +internal suspend fun requireAndroidFileSyncAccountRemovalReady(context: Context, accountId: String) { + AndroidFileSyncEngine.ENGINE_LOCK.withLock { + requireAndroidFileSyncAccountRemovalReady(AndroidFileSyncStore(context).load(), accountId) + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt index eb3352fff..e4f5acbff 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStore.kt @@ -32,6 +32,19 @@ internal fun removeAndroidFileSyncAccountPairs( state: AndroidFileSyncPersistedState, accountId: String, ): AndroidFileSyncPersistedState { + requireAndroidFileSyncAccountRemovalReady(state, accountId) + val retainedPairs = state.coordinator.pairs.filterNot { pair -> pair.accountId == accountId } + val retainedPairIds = retainedPairs.mapTo(hashSetOf()) { pair -> pair.id } + return AndroidFileSyncPersistedState( + coordinator = FileSyncCoordinatorState(retainedPairs), + localDisplayNames = state.localDisplayNames.filterKeys(retainedPairIds::contains), + ) +} + +internal fun requireAndroidFileSyncAccountRemovalReady( + state: AndroidFileSyncPersistedState, + accountId: String, +) { require(accountId.isNotBlank()) state.coordinator.pairs .filter { pair -> pair.accountId == accountId } @@ -40,12 +53,6 @@ internal fun removeAndroidFileSyncAccountPairs( "Owned remote upload state must be recovered before removing this account's sync pairs." } } - val retainedPairs = state.coordinator.pairs.filterNot { pair -> pair.accountId == accountId } - val retainedPairIds = retainedPairs.mapTo(hashSetOf()) { pair -> pair.id } - return AndroidFileSyncPersistedState( - coordinator = FileSyncCoordinatorState(retainedPairs), - localDisplayNames = state.localDisplayNames.filterKeys(retainedPairIds::contains), - ) } internal class AndroidFileSyncStore internal constructor( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt index c6475eec7..00ceed34a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStoreTest.kt @@ -203,6 +203,24 @@ class AndroidFileSyncStoreTest { } } + @Test + fun `owned uploads block account removal before pair deletion`() { + val accountPair = pair().copy( + pendingUploadCleanups = listOf( + FileSyncPendingUploadCleanup( + uploadId = "01234567-89ab-cdef-0123-456789abcdef", + relativePath = "Archive/large.bin", + ), + ), + ) + val state = AndroidFileSyncPersistedState(FileSyncCoordinatorState(listOf(accountPair))) + + assertFailsWith { + requireAndroidFileSyncAccountRemovalReady(state, accountPair.accountId) + } + assertEquals(listOf(accountPair), state.coordinator.pairs) + } + private fun pair() = FileSyncPair( id = "pair-1", accountId = "account-1", diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 8af3017a2..5fbafe1b2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative.app import java.util.prefs.Preferences +import kotlinx.coroutines.CancellationException internal fun requireDesktopAccountRemovalReady(accountId: String, linuxDesktop: Boolean) { if (linuxDesktop) { @@ -42,6 +43,8 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( if (!removed) return false try { removeSyncPairs() + } catch (cancelled: CancellationException) { + throw cancelled } catch (failure: Exception) { runCatching { recordCleanupFailure(failure) } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 6c06be0e8..e3ee62e71 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -435,6 +435,27 @@ class DesktopAccountOperationGuardTest { assertEquals(listOf("remove-credential", "remove-pairs", "diagnose-cleanup"), events) } + @Test + fun committedRemovalPreservesPairCleanupCancellation() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + removeDesktopAccountBeforeSyncPairCleanup( + removeCredential = { + events += "remove-credential" + true + }, + removeSyncPairs = { + events += "remove-pairs" + throw CancellationException("pair cleanup owner stopped") + }, + recordCleanupFailure = { events += "diagnose-cleanup" }, + ) + } + + assertEquals(listOf("remove-credential", "remove-pairs"), events) + } + @Test fun failedActiveCredentialCommitPreservesSyncPairs() = runBlocking { val events = mutableListOf() From 262127bcadc560c998e2dc25adddd34a95c52a61 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 17:36:45 +0200 Subject: [PATCH 027/119] fix(accounts): reject unsupported desktop registries --- .../app/NextcloudAccountRegistry.kt | 4 +-- .../DesktopAccountCredentialPersistence.kt | 27 +++++++++++-------- ...DesktopAccountCredentialPersistenceTest.kt | 9 ++++--- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt index e9c6a28ed..7a1d8816d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt @@ -185,7 +185,7 @@ fun encodeNextcloudAccountRegistry(registry: NextcloudAccountRegistry): String = fun decodeNextcloudAccountRegistry(encoded: String): NextcloudAccountRegistry? = (decodeNextcloudAccountRegistryResult(encoded) as? NextcloudAccountRegistryDecodeResult.Valid)?.registry -private fun decodeNextcloudAccountRegistryResult(encoded: String): NextcloudAccountRegistryDecodeResult { +internal fun decodeNextcloudAccountRegistryResult(encoded: String): NextcloudAccountRegistryDecodeResult { val envelopeVersionToken = accountRegistryVersionEnvelope .find(encoded.take(MAX_ACCOUNT_REGISTRY_VERSION_ENVELOPE_CHARACTERS)) ?.groupValues @@ -255,7 +255,7 @@ private enum class AccountRegistryVersionClassification { Malformed, } -private sealed interface NextcloudAccountRegistryDecodeResult { +internal sealed interface NextcloudAccountRegistryDecodeResult { data class Valid(val registry: NextcloudAccountRegistry) : NextcloudAccountRegistryDecodeResult data object Malformed : NextcloudAccountRegistryDecodeResult diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 34029f843..616725bf2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -14,7 +14,7 @@ internal class DesktopAccountCredentialPersistence( retryPendingLegacyCredentialCleanup() val read = readRegistry() if (read.registry == null) { - return restoreLegacySession(read.encoded != null) + return restoreLegacySession(read) } val active = read.registry.activeAccount ?: return null return loadSession(active.id) @@ -23,12 +23,14 @@ internal class DesktopAccountCredentialPersistence( fun listAccounts(): List { val read = readRegistry() if (read.registry != null) return read.registry.accounts + if (read.unsupportedVersion) return emptyList() return readLegacyAccountRecord()?.let(::listOf).orEmpty() } fun activeAccountId(): NextcloudAccountId? { val read = readRegistry() if (read.registry != null) return read.registry.activeAccountId + if (read.unsupportedVersion) return null return readLegacyAccountRecord()?.id } @@ -54,7 +56,7 @@ internal class DesktopAccountCredentialPersistence( retryPendingLegacyCredentialCleanup() val read = readRegistry() val registry = read.registry - ?: restoreLegacySession(read.encoded != null)?.let { requireNotNull(readRegistry().registry) } + ?: restoreLegacySession(read)?.let { requireNotNull(readRegistry().registry) } ?: if (read.encoded == null) NextcloudAccountRegistry.Empty else throw invalidRegistryForMutation() val previousRecord = registry.accounts.firstOrNull { account -> account.id == session.accountId } val persistedSession = previousRecord @@ -122,20 +124,17 @@ internal class DesktopAccountCredentialPersistence( return true } - private fun restoreLegacySession(malformedRegistry: Boolean): NextcloudSession? { - val legacy = loadLegacySession() ?: run { - if (malformedRegistry) { - recordCredentialDiagnostic("ACCOUNT_REGISTRY_MALFORMED", "account-registry.restore") - } - return null - } + private fun restoreLegacySession(read: DesktopRegistryRead): NextcloudSession? { + val legacy = loadLegacySession() val restored = restoreNextcloudAccountRegistry( - encoded = preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null), + encoded = read.encoded, legacySession = legacy, ) restored.recoveryReason?.diagnosticCode?.let { code -> recordCredentialDiagnostic(code, "account-registry.restore") } + if (read.unsupportedVersion) return null + legacy ?: return null if (!restored.needsPersistence) return legacy try { val encodedRegistry = prepareRegistry(restored.registry) @@ -407,7 +406,12 @@ internal class DesktopAccountCredentialPersistence( private fun readRegistry(): DesktopRegistryRead { val encoded = preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null) - return DesktopRegistryRead(encoded, encoded?.let(::decodeNextcloudAccountRegistry)) + val decoded = encoded?.let(::decodeNextcloudAccountRegistryResult) + return DesktopRegistryRead( + encoded = encoded, + registry = (decoded as? NextcloudAccountRegistryDecodeResult.Valid)?.registry, + unsupportedVersion = decoded == NextcloudAccountRegistryDecodeResult.UnsupportedVersion, + ) } private fun prepareRegistry(registry: NextcloudAccountRegistry): String = @@ -480,6 +484,7 @@ internal class DesktopAccountCredentialPersistence( private data class DesktopRegistryRead( val encoded: String?, val registry: NextcloudAccountRegistry?, + val unsupportedVersion: Boolean, ) private data class DesktopAccountPreferenceSnapshot( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index 212f4478b..ee9c6e460 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -223,7 +223,7 @@ class DesktopAccountCredentialPersistenceTest { } @Test - fun unsupportedFutureRegistryUsesLegacyCredentialWithoutOverwritingIt() = withStore { preferences, secrets -> + fun unsupportedFutureRegistryPreservesLegacyCredentialWithoutExposingIt() = withStore { preferences, secrets -> val session = firstSession() val futureRegistry = """{"version":2,"futureAccounts":[{"id":"future"}]}""" putLegacySession(preferences, secrets, session) @@ -233,11 +233,12 @@ class DesktopAccountCredentialPersistenceTest { val persistence = persistence(preferences, secrets, diagnostics) val restored = persistence.loadActiveSession() - assertEquals(session, restored) - assertEquals(listOf(session.accountRecord()), persistence.listAccounts()) - assertEquals(session.accountId, persistence.activeAccountId()) + assertNull(restored) + assertTrue(persistence.listAccounts().isEmpty()) + assertNull(persistence.activeAccountId()) assertEquals(futureRegistry, preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertNotNull(secrets.load(desktopSessionSecretReference(session.serverUrl, session.loginName))) assertEquals( listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }.distinct(), From 628d2ca73e9f9173b553cd701698fc6eecba39a2 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 19:43:15 +0200 Subject: [PATCH 028/119] fix(accounts): retain removal cleanup ownership --- .../AndroidAccountCredentialController.kt | 198 ++++++++---------- .../AndroidAccountCredentialTransitions.kt | 105 ++++++++++ .../AndroidAccountOwnedStateCleanup.kt | 34 +++ .../nextcloudnative/AndroidAccountRemoval.kt | 28 ++- .../AndroidIncomingShareAccountCleanup.kt | 29 ++- .../AndroidNextcloudServices.kt | 16 +- .../AndroidPersistedSessionTest.kt | 28 ++- .../NextcloudDocumentsContractTest.kt | 7 +- 8 files changed, 311 insertions(+), 134 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index e18679a22..e87d381ba 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -14,7 +14,6 @@ import dev.obiente.nextcloudnative.app.encodeNextcloudAccountRegistry import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -31,6 +30,7 @@ internal class AndroidAccountCredentialController( private val resumeQueuedUploads: suspend (String) -> Unit, private val prepareAccountRemoval: suspend (NextcloudSession) -> Unit, private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, + private val retryQueuedUploadsCleanup: suspend (String) -> Unit, ) { private val appContext = context.applicationContext @@ -87,6 +87,7 @@ internal class AndroidAccountCredentialController( suspend fun saveSession(session: NextcloudSession): NextcloudSession = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + retryPendingAccountRemovalCleanup(NextcloudDocumentIds.accountKey(session)) registerSessionPrivateValues(session) when (val read = readStore()) { is AndroidAccountCredentialStoreRead.Available -> @@ -131,22 +132,28 @@ internal class AndroidAccountCredentialController( ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { val current = requireValidState() val session = current.sessions[accountId] ?: return@withLock false - ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(session)) { + val accountIdentity = NextcloudDocumentIds.accountKey(session) + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { val active = current.registry.activeAccountId == accountId removeAndroidAccountCredentialData( active = active, prepareAccountRemoval = { prepareAccountRemoval(session) }, removeQueuedUploads = { removeQueuedUploads(session) }, - clearActiveAccount = { clearSession(current) }, + clearActiveAccount = { clearSession(current, accountIdentity) }, rollbackActiveRemoval = { replaceActiveStateWhileOperationsIdle( replacement = current, previousSession = null, suspectEncrypted = null, ) + clearPendingAccountRemovalCleanup(accountIdentity) }, - persistInactiveRemoval = { persistState(current.remove(accountId)) }, - rollbackInactiveRemoval = { persistState(current) }, + persistInactiveRemoval = { persistState(current.remove(accountId), accountIdentity) }, + rollbackInactiveRemoval = { + persistState(current) + clearPendingAccountRemovalCleanup(accountIdentity) + }, + completeCommittedCleanup = { clearPendingAccountRemovalCleanup(accountIdentity) }, recordCommittedCleanupFailure = { recordAccountRemovalCleanupFailure() }, ) if (!active) { @@ -163,21 +170,26 @@ internal class AndroidAccountCredentialController( if (session == null) { clearSession(read.state) } else { - ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(session)) { + val accountIdentity = NextcloudDocumentIds.accountKey(session) + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { removeAndroidAccountCredentialData( active = true, prepareAccountRemoval = { prepareAccountRemoval(session) }, removeQueuedUploads = { removeQueuedUploads(session) }, - clearActiveAccount = { clearSession(read.state) }, + clearActiveAccount = { clearSession(read.state, accountIdentity) }, rollbackActiveRemoval = { replaceActiveStateWhileOperationsIdle( replacement = read.state, previousSession = null, suspectEncrypted = null, ) + clearPendingAccountRemovalCleanup(accountIdentity) }, persistInactiveRemoval = {}, rollbackInactiveRemoval = {}, + completeCommittedCleanup = { + clearPendingAccountRemovalCleanup(accountIdentity) + }, recordCommittedCleanupFailure = { recordAccountRemovalCleanupFailure() }, ) } @@ -198,13 +210,16 @@ internal class AndroidAccountCredentialController( } } - private suspend fun clearSession(current: AndroidAccountCredentialState) { + private suspend fun clearSession( + current: AndroidAccountCredentialState, + pendingCleanupAccountIdentity: String? = null, + ) { val activeSession = current.activeSession ?: return val replacement = current.remove(activeSession.accountId) val encodedReplacement = replacement.takeUnless { state -> state.registry.accounts.isEmpty() && state.sessions.isEmpty() }?.let(::encryptState) - clearPersistedSession(encodedReplacement, replacement) + clearPersistedSession(encodedReplacement, replacement, pendingCleanupAccountIdentity = pendingCleanupAccountIdentity) clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(activeSession)) notifyDocumentRootsChanged() } @@ -224,18 +239,23 @@ internal class AndroidAccountCredentialController( ) { val activeSession = current.activeSession if (activeSession != null) { - ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(NextcloudDocumentIds.accountKey(activeSession)) { + val accountIdentity = NextcloudDocumentIds.accountKey(activeSession) + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { removeRecoveredAndroidAccountCredentialData( prepareAccountRemoval = { prepareAccountRemoval(activeSession) }, removeQueuedUploads = { removeQueuedUploads(activeSession) }, - clearRecoveredAccount = { persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted) }, + clearRecoveredAccount = { + persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted, accountIdentity) + }, rollbackRecoveredAccount = { replaceActiveStateWhileOperationsIdle( replacement = current, previousSession = null, suspectEncrypted = suspectEncrypted, ) + clearPendingAccountRemovalCleanup(accountIdentity) }, + completeCommittedCleanup = { clearPendingAccountRemovalCleanup(accountIdentity) }, recordCommittedCleanupFailure = { recordAccountRemovalCleanupFailure() }, ) } @@ -247,13 +267,19 @@ internal class AndroidAccountCredentialController( private suspend fun persistRecoveredInvalidStoreAfterClear( current: AndroidAccountCredentialState, suspectEncrypted: String, + pendingCleanupAccountIdentity: String? = null, ) { val activeSession = current.activeSession val replacement = removeActiveAndroidAccountCredentialState(current) val encodedReplacement = replacement.takeUnless { state -> state.registry.accounts.isEmpty() && state.sessions.isEmpty() }?.let(::encryptState) - clearPersistedSession(encodedReplacement, replacement, suspectEncrypted) + clearPersistedSession( + encodedReplacement, + replacement, + suspectEncrypted, + pendingCleanupAccountIdentity, + ) activeSession?.let { session -> clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(session)) } notifyDocumentRootsChanged() } @@ -262,6 +288,7 @@ internal class AndroidAccountCredentialController( encodedReplacement: String?, replacement: AndroidAccountCredentialState, suspectEncrypted: String? = null, + pendingCleanupAccountIdentity: String? = null, ) { withContext(Dispatchers.IO) { AndroidExternalFileHandoffRegistry.clear() } val scheduler = AndroidFileSyncScheduler(appContext) @@ -282,7 +309,7 @@ internal class AndroidAccountCredentialController( ).putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(replacement.registry)) .let { editor -> prepareCredentialSlotEdit(editor, replacement) } } - commitPreferences(editor) + commitPreferences(preparePendingAccountRemovalCleanupEdit(editor, pendingCleanupAccountIdentity)) }, cancelAll = scheduler::cancelAll, clearPublishedAccount = { publishAccountIdentity(null) }, @@ -497,17 +524,62 @@ internal class AndroidAccountCredentialController( null } - private suspend fun persistState(state: AndroidAccountCredentialState) = withContext(Dispatchers.IO) { + private suspend fun persistState( + state: AndroidAccountCredentialState, + pendingCleanupAccountIdentity: String? = null, + ) = withContext(Dispatchers.IO) { commitPreferences( - prepareCredentialSlotEdit( - preferences.edit() - .putString(KEY_SESSION, encryptState(state)) - .putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(state.registry)), - state, + preparePendingAccountRemovalCleanupEdit( + prepareCredentialSlotEdit( + preferences.edit() + .putString(KEY_SESSION, encryptState(state)) + .putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(state.registry)), + state, + ), + pendingCleanupAccountIdentity, ), ) } + private suspend fun retryPendingAccountRemovalCleanup(accountIdentity: String) { + if (accountIdentity !in pendingAccountRemovalCleanupIdentities()) return + try { + retryQueuedUploadsCleanup(accountIdentity) + clearPendingAccountRemovalCleanup(accountIdentity) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + recordAccountRemovalCleanupFailure() + throw IllegalStateException( + "Previous account cleanup must finish before this account can be added again.", + failure, + ) + } + } + + private fun pendingAccountRemovalCleanupIdentities(): Set = + preferences.getStringSet(KEY_PENDING_ACCOUNT_REMOVAL_CLEANUP, emptySet())?.toSet().orEmpty() + + private fun preparePendingAccountRemovalCleanupEdit( + editor: SharedPreferences.Editor, + accountIdentity: String?, + ): SharedPreferences.Editor = if (accountIdentity == null) { + editor + } else { + editor.putStringSet( + KEY_PENDING_ACCOUNT_REMOVAL_CLEANUP, + pendingAccountRemovalCleanupIdentities() + accountIdentity, + ) + } + + private fun clearPendingAccountRemovalCleanup(accountIdentity: String) { + val remaining = pendingAccountRemovalCleanupIdentities() - accountIdentity + val editor = preferences.edit() + if (remaining.isEmpty()) editor.remove(KEY_PENDING_ACCOUNT_REMOVAL_CLEANUP) + else editor.putStringSet(KEY_PENDING_ACCOUNT_REMOVAL_CLEANUP, remaining) + commitPreferences(editor) + } + private fun commitPreferences(editor: SharedPreferences.Editor) = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { try { requireCommittedAndroidAccountCredentialEdit(editor) @@ -644,93 +716,6 @@ internal fun restoreAndroidAccountCredentialStateWithoutAggregate( return reconstructAndroidAccountCredentialState(restored.registry, loadSession) } -internal fun removeActiveAndroidAccountCredentialState( - state: AndroidAccountCredentialState, -): AndroidAccountCredentialState = state.registry.activeAccountId?.let(state::remove) ?: state - -internal suspend fun resumeAndroidQueuedUploadsAfterSelection( - resume: suspend () -> Unit, - notifyDocumentRootsChanged: () -> Unit, - recordFailure: () -> Unit, -) { - try { - resume() - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - recordFailure() - } finally { - notifyDocumentRootsChanged() - } -} - -internal suspend fun removeAndroidAccountCredentialData( - active: Boolean, - prepareAccountRemoval: suspend () -> Unit = {}, - removeQueuedUploads: suspend () -> Unit, - clearActiveAccount: suspend () -> Unit, - rollbackActiveRemoval: suspend () -> Unit, - persistInactiveRemoval: suspend () -> Unit, - rollbackInactiveRemoval: suspend () -> Unit, - recordCommittedCleanupFailure: (Exception) -> Unit = {}, -) { - prepareAccountRemoval() - if (active) { - try { - clearActiveAccount() - } catch (failure: Exception) { - withContext(NonCancellable) { - runCatching { rollbackActiveRemoval() } - .onFailure(failure::addSuppressed) - } - throw failure - } - finishCommittedAndroidAccountRemovalCleanup(removeQueuedUploads, recordCommittedCleanupFailure) - return - } - - try { - persistInactiveRemoval() - } catch (failure: Exception) { - withContext(NonCancellable) { - runCatching { rollbackInactiveRemoval() } - .onFailure(failure::addSuppressed) - } - throw failure - } - finishCommittedAndroidAccountRemovalCleanup(removeQueuedUploads, recordCommittedCleanupFailure) -} - -private suspend fun finishCommittedAndroidAccountRemovalCleanup( - removeQueuedUploads: suspend () -> Unit, - recordFailure: (Exception) -> Unit, -) { - try { - removeQueuedUploads() - } catch (cancelled: CancellationException) { - throw cancelled - } catch (failure: Exception) { - recordFailure(failure) - } -} - -internal suspend fun removeRecoveredAndroidAccountCredentialData( - prepareAccountRemoval: suspend () -> Unit = {}, - removeQueuedUploads: suspend () -> Unit, - clearRecoveredAccount: suspend () -> Unit, - rollbackRecoveredAccount: suspend () -> Unit, - recordCommittedCleanupFailure: (Exception) -> Unit = {}, -) = removeAndroidAccountCredentialData( - active = true, - prepareAccountRemoval = prepareAccountRemoval, - removeQueuedUploads = removeQueuedUploads, - clearActiveAccount = clearRecoveredAccount, - rollbackActiveRemoval = rollbackRecoveredAccount, - persistInactiveRemoval = {}, - rollbackInactiveRemoval = {}, - recordCommittedCleanupFailure = recordCommittedCleanupFailure, -) - internal fun androidCredentialStoreAllowsSessionRestore( read: AndroidAccountCredentialStoreRead, ): Boolean = read !is AndroidAccountCredentialStoreRead.Unsupported @@ -773,5 +758,6 @@ private const val KEY_SESSION = "encrypted_session" private const val KEY_ACCOUNT_REGISTRY = "account_registry_v1" private const val KEY_ACCOUNT_CREDENTIAL_SLOT_PREFIX = "account_credential_v1:" private const val KEY_QUARANTINED_SESSION = "encrypted_session_quarantine" +private const val KEY_PENDING_ACCOUNT_REMOVAL_CLEANUP = "pending_account_removal_cleanup_v1" private val ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD = AndroidAccountCredentialStoreGuard() private val ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX = Mutex() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt new file mode 100644 index 000000000..dee10acc2 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -0,0 +1,105 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext + +internal fun removeActiveAndroidAccountCredentialState( + state: AndroidAccountCredentialState, +): AndroidAccountCredentialState = state.registry.activeAccountId?.let(state::remove) ?: state + +internal suspend fun resumeAndroidQueuedUploadsAfterSelection( + resume: suspend () -> Unit, + notifyDocumentRootsChanged: () -> Unit, + recordFailure: () -> Unit, +) { + try { + resume() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + recordFailure() + } finally { + notifyDocumentRootsChanged() + } +} + +internal suspend fun removeAndroidAccountCredentialData( + active: Boolean, + prepareAccountRemoval: suspend () -> Unit = {}, + removeQueuedUploads: suspend () -> Unit, + clearActiveAccount: suspend () -> Unit, + rollbackActiveRemoval: suspend () -> Unit, + persistInactiveRemoval: suspend () -> Unit, + rollbackInactiveRemoval: suspend () -> Unit, + completeCommittedCleanup: suspend () -> Unit = {}, + recordCommittedCleanupFailure: (Exception) -> Unit = {}, +) { + prepareAccountRemoval() + if (active) { + try { + clearActiveAccount() + } catch (failure: Exception) { + withContext(NonCancellable) { + runCatching { rollbackActiveRemoval() } + .onFailure(failure::addSuppressed) + } + throw failure + } + finishCommittedAndroidAccountRemovalCleanup( + removeQueuedUploads, + completeCommittedCleanup, + recordCommittedCleanupFailure, + ) + return + } + + try { + persistInactiveRemoval() + } catch (failure: Exception) { + withContext(NonCancellable) { + runCatching { rollbackInactiveRemoval() } + .onFailure(failure::addSuppressed) + } + throw failure + } + finishCommittedAndroidAccountRemovalCleanup( + removeQueuedUploads, + completeCommittedCleanup, + recordCommittedCleanupFailure, + ) +} + +private suspend fun finishCommittedAndroidAccountRemovalCleanup( + removeQueuedUploads: suspend () -> Unit, + completeCommittedCleanup: suspend () -> Unit, + recordFailure: (Exception) -> Unit, +) { + try { + removeQueuedUploads() + completeCommittedCleanup() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + recordFailure(failure) + } +} + +internal suspend fun removeRecoveredAndroidAccountCredentialData( + prepareAccountRemoval: suspend () -> Unit = {}, + removeQueuedUploads: suspend () -> Unit, + clearRecoveredAccount: suspend () -> Unit, + rollbackRecoveredAccount: suspend () -> Unit, + completeCommittedCleanup: suspend () -> Unit = {}, + recordCommittedCleanupFailure: (Exception) -> Unit = {}, +) = removeAndroidAccountCredentialData( + active = true, + prepareAccountRemoval = prepareAccountRemoval, + removeQueuedUploads = removeQueuedUploads, + clearActiveAccount = clearRecoveredAccount, + rollbackActiveRemoval = rollbackRecoveredAccount, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + completeCommittedCleanup = completeCommittedCleanup, + recordCommittedCleanupFailure = recordCommittedCleanupFailure, +) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt new file mode 100644 index 000000000..a9491b702 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -0,0 +1,34 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal class AndroidAccountOwnedStateCleanup(context: Context) { + private val appContext = context.applicationContext + private val fileOffline = AndroidFileOfflineAccountCleanup(appContext) + private val incomingShares = AndroidIncomingShareAccountCleanup(appContext) + private val durableUploads = AndroidDurableUploadAccountCleanup(appContext) + + suspend fun remove(session: NextcloudSession) { + val accountIdentity = NextcloudDocumentIds.accountKey(session) + runAndroidAccountRemovalCleanups( + listOf( + { fileOffline.removeForAccount(accountIdentity) }, + { incomingShares.removeForAccount(session) }, + { durableUploads.removeForAccount(accountIdentity) }, + { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + ), + ) + } + + suspend fun retry(accountIdentity: String) { + runAndroidAccountRemovalCleanups( + listOf( + { fileOffline.removeForAccount(accountIdentity) }, + { incomingShares.removeForAccount(accountIdentity) }, + { durableUploads.removeForAccount(accountIdentity) }, + { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + ), + ) + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index 507c1b47e..558c8c7d3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -16,11 +16,11 @@ internal fun requireAndroidAccountRemovalWritebacksResolved(resolved: Boolean) { } } -internal suspend fun revokeAndroidSessionAfterWritebackPreflight( - writebacksResolved: Boolean, +internal suspend fun revokeAndroidSessionAfterRemovalPreflight( + preflight: suspend () -> Unit, revoke: suspend () -> Unit, ) { - requireAndroidAccountRemovalWritebacksResolved(writebacksResolved) + preflight() revoke() } @@ -34,9 +34,13 @@ internal fun AndroidAccountDocumentGrantScope.uri(authority: String, rootId: Str AndroidAccountDocumentGrantScope.Tree -> DocumentsContract.buildTreeDocumentUri(authority, rootId) } -internal suspend fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) { +internal suspend fun preflightAndroidAccountRemoval(context: Context, session: NextcloudSession) { requireAndroidAccountRemovalWritebacksResolved(androidDocumentPendingWritebacks(context, session).isEmpty()) requireAndroidFileSyncAccountRemovalReady(context, NextcloudDocumentIds.accountKey(session)) +} + +internal suspend fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) { + preflightAndroidAccountRemoval(context, session) AndroidAccountDocumentGrantScope.entries.forEach { scope -> context.revokeUriPermission( scope.uri(nextcloudDocumentsAuthority(context.packageName), NextcloudDocumentIds.rootId(session)), @@ -44,3 +48,19 @@ internal suspend fun prepareAndroidAccountRemoval(context: Context, session: Nex ) } } + +internal suspend fun runAndroidAccountRemovalCleanups( + cleanups: List Unit>, +) { + var firstFailure: Exception? = null + cleanups.forEach { cleanup -> + try { + cleanup() + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (failure: Exception) { + if (firstFailure == null) firstFailure = failure else firstFailure.addSuppressed(failure) + } + } + firstFailure?.let { throw it } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt index c909511c6..e9aa0cd5c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt @@ -23,18 +23,24 @@ internal class AndroidIncomingShareAccountCleanup(context: Context) { private val appContext = context.applicationContext private val store = AndroidIncomingShareStore(appContext) - suspend fun removeForAccount(session: NextcloudSession) = withContext(Dispatchers.IO) { - val accountId = NextcloudDocumentIds.accountKey(session) + suspend fun removeForAccount(session: NextcloudSession) = + removeForAccount(NextcloudDocumentIds.accountKey(session), session) + + suspend fun removeForAccount(accountId: String) = removeForAccount(accountId, session = null) + + private suspend fun removeForAccount(accountId: String, session: NextcloudSession?) = withContext(Dispatchers.IO) { val workManager = WorkManager.getInstance(appContext) - val webDav = NextcloudDocumentWebDav( - client = OkHttpClient.Builder() - .followRedirects(false) - .followSslRedirects(false) - .retryOnConnectionFailure(false) - .useAndroidNextcloudCertificateTrust(appContext) - .build(), - cloudMutationsAllowed = appContext.cloudMutationGate(), - ) + val webDav = session?.let { + NextcloudDocumentWebDav( + client = OkHttpClient.Builder() + .followRedirects(false) + .followSslRedirects(false) + .retryOnConnectionFailure(false) + .useAndroidNextcloudCertificateTrust(appContext) + .build(), + cloudMutationsAllowed = appContext.cloudMutationGate(), + ) + } removeAndroidIncomingShareRequests( requests = store.listForAccount(accountId), cancelWork = { requestId -> @@ -43,6 +49,7 @@ internal class AndroidIncomingShareAccountCleanup(context: Context) { } }, releaseChunk = { request, uploadId -> + if (session == null || webDav == null) return@removeAndroidIncomingShareRequests val userId = requireNotNull(request.userId?.takeIf(String::isNotBlank)) { "The staged share chunk is missing its account owner." } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 52beb5e93..2e305f70e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -448,7 +448,7 @@ internal class AndroidNextcloudServices( private val dynamicDiscoveryCacheDirectory = File(appContext.filesDir, "contracts/discoveries-v1") private val pendingDynamicMutationDirectory = File(appContext.filesDir, "mutations/dynamic-v1") private val fileOfflineRepository = AndroidFileOfflineRepository(appContext) - private val fileOfflineAccountCleanup = AndroidFileOfflineAccountCleanup(appContext) + private val accountOwnedStateCleanup = AndroidAccountOwnedStateCleanup(appContext) private val fileReadCache = AndroidFileReadCache(File(appContext.cacheDir, "files-read-v1")) private val virtualFileCache = AndroidVirtualFileCache(appContext) private val dynamicApiReadCache = DynamicApiResponseCache(File(appContext.cacheDir, "dynamic-api-v1")) @@ -471,8 +471,6 @@ internal class AndroidNextcloudServices( ) private val projectContent = AndroidProjectContentClient(appContext, activity) private val durableMultipartUploads = AndroidDurableMultipartUploads(appContext) - private val durableUploadAccountCleanup = AndroidDurableUploadAccountCleanup(appContext) - private val incomingShareAccountCleanup = AndroidIncomingShareAccountCleanup(appContext) private val deckCardDrafts = AndroidDeckCardDraftStore(appContext) private val supportDiagnostics = AndroidSupportDiagnostics.get(appContext) private val supportBundleExporter = AndroidSupportBundleExporter( @@ -499,12 +497,8 @@ internal class AndroidNextcloudServices( notifyDocumentRootsChanged = ::notifyDocumentsRootsChanged, resumeQueuedUploads = durableMultipartUploads::resumeQueuedForAccount, prepareAccountRemoval = { session -> prepareAndroidAccountRemoval(appContext, session) }, - removeQueuedUploads = { session -> - fileOfflineAccountCleanup.removeForAccount(NextcloudDocumentIds.accountKey(session)) - incomingShareAccountCleanup.removeForAccount(session) - durableUploadAccountCleanup.removeForAccount(NextcloudDocumentIds.accountKey(session)) - retireAndroidFileSyncAccountPairs(appContext, NextcloudDocumentIds.accountKey(session)) - }, + removeQueuedUploads = accountOwnedStateCleanup::remove, + retryQueuedUploadsCleanup = accountOwnedStateCleanup::retry, ) init { @@ -3450,7 +3444,9 @@ internal class AndroidNextcloudServices( Unit } override suspend fun revokeSession(session: NextcloudSession) = withContext(Dispatchers.IO) { - revokeAndroidSessionAfterWritebackPreflight(androidDocumentPendingWritebacks(appContext, session).isEmpty()) { + revokeAndroidSessionAfterRemovalPreflight( + preflight = { preflightAndroidAccountRemoval(appContext, session) }, + ) { request( method = "DELETE", url = session.serverUrl + "/ocs/v2.php/core/apppassword", diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 60ecc7fdc..f3b86c529 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -742,9 +742,10 @@ class AndroidPersistedSessionTest { rollbackActiveRemoval = { events += "rollback-active" }, persistInactiveRemoval = { events += "persist-inactive" }, rollbackInactiveRemoval = { events += "rollback-inactive" }, + completeCommittedCleanup = { events += "complete-cleanup" }, ) - assertEquals(listOf("prepare-removal", "clear-account", "remove-uploads"), events) + assertEquals(listOf("prepare-removal", "clear-account", "remove-uploads", "complete-cleanup"), events) } @Test @@ -812,12 +813,37 @@ class AndroidPersistedSessionTest { rollbackActiveRemoval = { events += "rollback-active" }, persistInactiveRemoval = { events += "persist-inactive" }, rollbackInactiveRemoval = { events += "rollback-inactive" }, + completeCommittedCleanup = { events += "complete-cleanup" }, recordCommittedCleanupFailure = { events += "diagnose-cleanup" }, ) assertEquals(listOf("clear-account", "remove-uploads", "diagnose-cleanup"), events) } + @Test + fun accountRemovalCleanupAttemptsEveryOwnerBeforeReportingFailure() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + runAndroidAccountRemovalCleanups( + listOf( + { + events += "remove-offline" + error("synthetic offline cleanup failure") + }, + { events += "remove-shares" }, + { events += "remove-uploads" }, + { events += "remove-sync-pairs" }, + ), + ) + } + + assertEquals( + listOf("remove-offline", "remove-shares", "remove-uploads", "remove-sync-pairs"), + events, + ) + } + @Test fun failedActiveCredentialRemovalDoesNotStartUploadCleanupAndAttemptsRollback() = runBlocking { val events = mutableListOf() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt index 9b13698de..6d3a89b1d 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt @@ -52,11 +52,14 @@ class NextcloudDocumentsContractTest { } @Test - fun `pending writeback preflight runs before remote credential revocation`() = runBlocking { + fun `account removal preflight runs before remote credential revocation`() = runBlocking { var revoked = false assertFailsWith { - revokeAndroidSessionAfterWritebackPreflight(writebacksResolved = false) { revoked = true } + revokeAndroidSessionAfterRemovalPreflight( + preflight = { error("pending account-owned recovery") }, + revoke = { revoked = true }, + ) } assertFalse(revoked) From db72523219f685899f6259c1342256e0ad3f99d4 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 20:30:02 +0200 Subject: [PATCH 029/119] fix(accounts): finish removal recovery --- .../AndroidAccountCredentialController.kt | 455 +++++++++--------- .../AndroidAccountCredentialRecovery.kt | 179 +++++++ .../AndroidAccountOwnedStateCleanup.kt | 6 +- .../nextcloudnative/AndroidAccountRemoval.kt | 17 +- .../AndroidAccountSelectionMaintenance.kt | 13 + .../AndroidDurableMultipartUploads.kt | 6 +- .../AndroidFileSyncExecutionCoordination.kt | 16 +- .../AndroidIncomingShareAccountCleanup.kt | 27 +- .../AndroidNextcloudServices.kt | 7 +- .../nextcloudnative/NextcloudDocumentIds.kt | 7 +- .../AndroidAccountOperationGuardTest.kt | 35 ++ ...AndroidDurableMultipartUploadPolicyTest.kt | 1 + .../AndroidFileSyncEngineInvariantTest.kt | 18 + .../AndroidIncomingShareStateTest.kt | 20 +- .../AndroidPersistedSessionTest.kt | 96 +++- .../NextcloudDocumentsContractTest.kt | 16 + .../app/NextcloudAccountRegistry.kt | 2 +- .../DesktopAccountCredentialPersistence.kt | 136 +++++- .../app/DesktopAccountOperationGuard.kt | 26 +- .../app/DesktopAccountRegistryPersistence.kt | 14 +- .../DesktopAccountRegistryPreferenceStore.kt | 117 +++++ .../app/DesktopAccountRemoval.kt | 148 +++++- .../nextcloudnative/app/DesktopCachePolicy.kt | 52 ++ .../app/DesktopFileSyncAccountCleanup.kt | 15 +- .../app/DesktopFileSyncEngine.kt | 6 +- .../app/DesktopNextcloudServices.kt | 184 +++---- ...DesktopAccountCredentialPersistenceTest.kt | 83 +++- .../app/DesktopAccountOperationGuardTest.kt | 206 +++++++- .../DesktopAccountRegistryPersistenceTest.kt | 61 ++- .../app/DesktopFileSyncStoreTest.kt | 1 + 30 files changed, 1545 insertions(+), 425 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPreferenceStore.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCachePolicy.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index e87d381ba..0241a9cbc 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -11,10 +11,9 @@ import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity import dev.obiente.nextcloudnative.app.accountRecord import dev.obiente.nextcloudnative.app.encodeNextcloudAccountRegistry -import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.toSupportDiagnosticExceptionDraft import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -30,7 +29,7 @@ internal class AndroidAccountCredentialController( private val resumeQueuedUploads: suspend (String) -> Unit, private val prepareAccountRemoval: suspend (NextcloudSession) -> Unit, private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, - private val retryQueuedUploadsCleanup: suspend (String) -> Unit, + private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String) -> Unit, ) { private val appContext = context.applicationContext @@ -64,7 +63,9 @@ internal class AndroidAccountCredentialController( val aggregateRead = readStore() if (!androidCredentialStoreAllowsSessionRestore(aggregateRead)) return@serialize null val aggregate = (aggregateRead as? AndroidAccountCredentialStoreRead.Available)?.state - val storedSlot = readCredentialSlot(accountId) + val slotRead = readCredentialSlot(accountId) + if (slotRead is AndroidAccountCredentialSlotRead.Unsupported) return@serialize null + val storedSlot = (slotRead as? AndroidAccountCredentialSlotRead.Available)?.session val restoredSlot = recoverAndroidAccountCredentialSlot(accountId, registry, storedSlot, aggregate = null) val session = restoredSlot ?: recoverAndroidAccountCredentialSlot( accountId, @@ -87,11 +88,13 @@ internal class AndroidAccountCredentialController( suspend fun saveSession(session: NextcloudSession): NextcloudSession = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { - retryPendingAccountRemovalCleanup(NextcloudDocumentIds.accountKey(session)) + retryPendingAccountRemovalCleanup(session) registerSessionPrivateValues(session) when (val read = readStore()) { - is AndroidAccountCredentialStoreRead.Available -> + is AndroidAccountCredentialStoreRead.Available -> { + requireSupportedCredentialSlots(read.state.registry) replaceActiveState(read.state.upsertAndSelect(session), read.state.activeSession) + } is AndroidAccountCredentialStoreRead.Invalid -> { val retained = readIndependentCredentialSlotState() check(retained != null || !hasIndependentCredentialState()) { @@ -131,30 +134,32 @@ internal class AndroidAccountCredentialController( suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { val current = requireValidState() - val session = current.sessions[accountId] ?: return@withLock false + val session = current.sessions[accountId] + ?: return@withLock removeUnavailableAccount(accountId, current) val accountIdentity = NextcloudDocumentIds.accountKey(session) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { val active = current.registry.activeAccountId == accountId removeAndroidAccountCredentialData( active = active, prepareAccountRemoval = { prepareAccountRemoval(session) }, removeQueuedUploads = { removeQueuedUploads(session) }, - clearActiveAccount = { clearSession(current, accountIdentity) }, + clearActiveAccount = { clearSession(current, pendingCleanup) }, rollbackActiveRemoval = { replaceActiveStateWhileOperationsIdle( replacement = current, previousSession = null, suspectEncrypted = null, ) - clearPendingAccountRemovalCleanup(accountIdentity) + clearPendingAccountRemovalCleanup(accountId.storageKey) }, - persistInactiveRemoval = { persistState(current.remove(accountId), accountIdentity) }, + persistInactiveRemoval = { persistState(current.remove(accountId), pendingCleanup) }, rollbackInactiveRemoval = { persistState(current) - clearPendingAccountRemovalCleanup(accountIdentity) + clearPendingAccountRemovalCleanup(accountId.storageKey) }, - completeCommittedCleanup = { clearPendingAccountRemovalCleanup(accountIdentity) }, - recordCommittedCleanupFailure = { recordAccountRemovalCleanupFailure() }, + completeCommittedCleanup = { clearPendingAccountRemovalCleanup(accountId.storageKey) }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) if (!active) { clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(session)) @@ -163,34 +168,95 @@ internal class AndroidAccountCredentialController( true } + private suspend fun removeUnavailableAccount( + accountId: NextcloudAccountId, + recovered: AndroidAccountCredentialState, + ): Boolean { + val record = readCredentialFreeRegistry()?.accounts?.firstOrNull { account -> account.id == accountId } + ?: return false + val unavailableSession = NextcloudSession(record.serverUrl, record.loginName, appPassword = "") + val accountIdentity = NextcloudDocumentIds.accountKey(unavailableSession) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(unavailableSession) + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + removeAndroidAccountCredentialData( + active = false, + prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, + removeQueuedUploads = { retryQueuedUploadsCleanup(unavailableSession, accountIdentity) }, + clearActiveAccount = {}, + rollbackActiveRemoval = {}, + persistInactiveRemoval = { persistState(recovered, pendingCleanup) }, + rollbackInactiveRemoval = { clearPendingAccountRemovalCleanup(accountId.storageKey) }, + completeCommittedCleanup = { clearPendingAccountRemovalCleanup(accountId.storageKey) }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, + ) + } + return true + } + + suspend fun revokeSession( + expectedSession: NextcloudSession, + revokeRemoteSession: suspend () -> Unit, + ) = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + val current = requireValidState() + check(current.activeSession == expectedSession) { + "The account changed before its remote session could be revoked." + } + val accountIdentity = NextcloudDocumentIds.accountKey(expectedSession) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(expectedSession) + revokeAndroidSessionWithAccountLease( + accountIdentity = accountIdentity, + preflight = { prepareAccountRemoval(expectedSession) }, + revoke = revokeRemoteSession, + removeLocalAccount = { + removeAndroidAccountCredentialData( + active = true, + removeQueuedUploads = { removeQueuedUploads(expectedSession) }, + clearActiveAccount = { clearSession(current, pendingCleanup) }, + rollbackActiveRemoval = { + replaceActiveStateWhileOperationsIdle(current, previousSession = null, suspectEncrypted = null) + clearPendingAccountRemovalCleanup(expectedSession.accountId.storageKey) + }, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + completeCommittedCleanup = { + clearPendingAccountRemovalCleanup(expectedSession.accountId.storageKey) + }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, + ) + }, + ) + } + suspend fun clearSession() = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { when (val read = readStore()) { is AndroidAccountCredentialStoreRead.Available -> { + requireSupportedCredentialSlots(read.state.registry) val session = read.state.activeSession if (session == null) { clearSession(read.state) } else { val accountIdentity = NextcloudDocumentIds.accountKey(session) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { removeAndroidAccountCredentialData( active = true, prepareAccountRemoval = { prepareAccountRemoval(session) }, removeQueuedUploads = { removeQueuedUploads(session) }, - clearActiveAccount = { clearSession(read.state, accountIdentity) }, + clearActiveAccount = { clearSession(read.state, pendingCleanup) }, rollbackActiveRemoval = { replaceActiveStateWhileOperationsIdle( replacement = read.state, previousSession = null, suspectEncrypted = null, ) - clearPendingAccountRemovalCleanup(accountIdentity) + clearPendingAccountRemovalCleanup(session.accountId.storageKey) }, persistInactiveRemoval = {}, rollbackInactiveRemoval = {}, completeCommittedCleanup = { - clearPendingAccountRemovalCleanup(accountIdentity) + clearPendingAccountRemovalCleanup(session.accountId.storageKey) }, - recordCommittedCleanupFailure = { recordAccountRemovalCleanupFailure() }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) } } @@ -212,14 +278,14 @@ internal class AndroidAccountCredentialController( private suspend fun clearSession( current: AndroidAccountCredentialState, - pendingCleanupAccountIdentity: String? = null, + pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, ) { val activeSession = current.activeSession ?: return val replacement = current.remove(activeSession.accountId) val encodedReplacement = replacement.takeUnless { state -> state.registry.accounts.isEmpty() && state.sessions.isEmpty() }?.let(::encryptState) - clearPersistedSession(encodedReplacement, replacement, pendingCleanupAccountIdentity = pendingCleanupAccountIdentity) + clearPersistedSession(encodedReplacement, replacement, pendingCleanup = pendingCleanup) clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(activeSession)) notifyDocumentRootsChanged() } @@ -240,12 +306,13 @@ internal class AndroidAccountCredentialController( val activeSession = current.activeSession if (activeSession != null) { val accountIdentity = NextcloudDocumentIds.accountKey(activeSession) + val pendingCleanup = pendingAndroidAccountRemovalCleanup(activeSession) ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { removeRecoveredAndroidAccountCredentialData( prepareAccountRemoval = { prepareAccountRemoval(activeSession) }, removeQueuedUploads = { removeQueuedUploads(activeSession) }, clearRecoveredAccount = { - persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted, accountIdentity) + persistRecoveredInvalidStoreAfterClear(current, suspectEncrypted, pendingCleanup) }, rollbackRecoveredAccount = { replaceActiveStateWhileOperationsIdle( @@ -253,10 +320,12 @@ internal class AndroidAccountCredentialController( previousSession = null, suspectEncrypted = suspectEncrypted, ) - clearPendingAccountRemovalCleanup(accountIdentity) + clearPendingAccountRemovalCleanup(activeSession.accountId.storageKey) + }, + completeCommittedCleanup = { + clearPendingAccountRemovalCleanup(activeSession.accountId.storageKey) }, - completeCommittedCleanup = { clearPendingAccountRemovalCleanup(accountIdentity) }, - recordCommittedCleanupFailure = { recordAccountRemovalCleanupFailure() }, + recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) } } else { @@ -267,7 +336,7 @@ internal class AndroidAccountCredentialController( private suspend fun persistRecoveredInvalidStoreAfterClear( current: AndroidAccountCredentialState, suspectEncrypted: String, - pendingCleanupAccountIdentity: String? = null, + pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, ) { val activeSession = current.activeSession val replacement = removeActiveAndroidAccountCredentialState(current) @@ -278,7 +347,7 @@ internal class AndroidAccountCredentialController( encodedReplacement, replacement, suspectEncrypted, - pendingCleanupAccountIdentity, + pendingCleanup, ) activeSession?.let { session -> clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(session)) } notifyDocumentRootsChanged() @@ -288,31 +357,41 @@ internal class AndroidAccountCredentialController( encodedReplacement: String?, replacement: AndroidAccountCredentialState, suspectEncrypted: String? = null, - pendingCleanupAccountIdentity: String? = null, + pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, ) { - withContext(Dispatchers.IO) { AndroidExternalFileHandoffRegistry.clear() } val scheduler = AndroidFileSyncScheduler(appContext) withContext(Dispatchers.IO) { - ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.clearSession( - persist = { - val editor = if (suspectEncrypted == null) { - preferences.edit().apply { - if (encodedReplacement == null) remove(KEY_SESSION) - else putString(KEY_SESSION, encodedReplacement) - putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(replacement.registry)) - remove(KEY_TEST_READ_ONLY) - }.let { editor -> prepareCredentialSlotEdit(editor, replacement) } - } else { - prepareInvalidAndroidAccountCredentialRecoveryEdit( - editor = preferences.edit(), - replacementEncrypted = encodedReplacement, - ).putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(replacement.registry)) - .let { editor -> prepareCredentialSlotEdit(editor, replacement) } - } - commitPreferences(preparePendingAccountRemovalCleanupEdit(editor, pendingCleanupAccountIdentity)) + commitAndroidAccountTransitionBeforeHandoffCleanup( + commitTransition = { + ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.clearSession( + persist = { + val editor = if (suspectEncrypted == null) { + preferences.edit().apply { + if (encodedReplacement == null) remove(ANDROID_ACCOUNT_SESSION_KEY) + else putString(ANDROID_ACCOUNT_SESSION_KEY, encodedReplacement) + putString( + ANDROID_ACCOUNT_REGISTRY_KEY, + encodeNextcloudAccountRegistry(replacement.registry), + ) + remove(KEY_TEST_READ_ONLY) + }.let { editor -> prepareCredentialSlotEdit(editor, replacement) } + } else { + prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor = preferences.edit(), + replacementEncrypted = encodedReplacement, + ).putString( + ANDROID_ACCOUNT_REGISTRY_KEY, + encodeNextcloudAccountRegistry(replacement.registry), + ).let { editor -> prepareCredentialSlotEdit(editor, replacement) } + } + commitPreferences(preparePendingAccountRemovalCleanupEdit(editor, pendingCleanup)) + }, + cancelAll = scheduler::cancelAll, + clearPublishedAccount = { publishAccountIdentity(null) }, + ) }, - cancelAll = scheduler::cancelAll, - clearPublishedAccount = { publishAccountIdentity(null) }, + clearHandoffs = AndroidExternalFileHandoffRegistry::clear, + recordFailure = ::recordAccountHandoffCleanupFailure, ) } } @@ -337,35 +416,46 @@ internal class AndroidAccountCredentialController( ) { val session = requireNotNull(replacement.activeSession) val encrypted = encryptState(replacement) - withContext(Dispatchers.IO) { AndroidExternalFileHandoffRegistry.clear() } val scheduler = AndroidFileSyncScheduler(appContext) withContext(Dispatchers.IO) { - ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.replaceSession( - replacementAccountId = NextcloudDocumentIds.accountKey(session), - persist = { - val editor = if (suspectEncrypted == null) { - preferences.edit() - .putString(KEY_SESSION, encrypted) - .putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(replacement.registry)) - .remove(KEY_TEST_READ_ONLY) - } else { - prepareInvalidAndroidAccountCredentialRecoveryEdit( - editor = preferences.edit(), - replacementEncrypted = encrypted, - ).putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(replacement.registry)) - } - commitPreferences(prepareCredentialSlotEdit(editor, replacement)) - }, - cancelAll = scheduler::cancelAll, - publishAccount = publishAccountIdentity, - restoreSchedules = scheduler::restorePersistedPairSchedules, - onScheduleMaintenanceFailure = { - recordCredentialFailure( - code = "FILE_SYNC_SCHEDULE_MAINTENANCE_FAILED", - operation = "account-selection.schedule-maintenance", - component = SupportDiagnosticComponent.Sync, + commitAndroidAccountTransitionBeforeHandoffCleanup( + commitTransition = { + ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.replaceSession( + replacementAccountId = NextcloudDocumentIds.accountKey(session), + persist = { + val editor = if (suspectEncrypted == null) { + preferences.edit() + .putString(ANDROID_ACCOUNT_SESSION_KEY, encrypted) + .putString( + ANDROID_ACCOUNT_REGISTRY_KEY, + encodeNextcloudAccountRegistry(replacement.registry), + ) + .remove(KEY_TEST_READ_ONLY) + } else { + prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor = preferences.edit(), + replacementEncrypted = encrypted, + ).putString( + ANDROID_ACCOUNT_REGISTRY_KEY, + encodeNextcloudAccountRegistry(replacement.registry), + ) + } + commitPreferences(prepareCredentialSlotEdit(editor, replacement)) + }, + cancelAll = scheduler::cancelAll, + publishAccount = publishAccountIdentity, + restoreSchedules = scheduler::restorePersistedPairSchedules, + onScheduleMaintenanceFailure = { + recordCredentialFailure( + code = "FILE_SYNC_SCHEDULE_MAINTENANCE_FAILED", + operation = "account-selection.schedule-maintenance", + component = SupportDiagnosticComponent.Sync, + ) + }, ) }, + clearHandoffs = AndroidExternalFileHandoffRegistry::clear, + recordFailure = ::recordAccountHandoffCleanupFailure, ) } clearAndroidPreviousPreviewAfterCommittedSelection( @@ -388,7 +478,9 @@ internal class AndroidAccountCredentialController( } private fun requireValidState(): AndroidAccountCredentialState = when (val read = readStore()) { - is AndroidAccountCredentialStoreRead.Available -> read.state + is AndroidAccountCredentialStoreRead.Available -> read.state.also { state -> + requireSupportedCredentialSlots(state.registry) + } is AndroidAccountCredentialStoreRead.Invalid -> error("The account credential store is invalid.") AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> error("The independent account credential slots could not be recovered.") @@ -397,7 +489,7 @@ internal class AndroidAccountCredentialController( private fun readCredentialFreeRegistry(): NextcloudAccountRegistry? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { - val encoded = preferences.getString(KEY_ACCOUNT_REGISTRY, null) ?: return@serialize null + val encoded = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) ?: return@serialize null val restored = restoreAndroidCredentialFreeRegistry(encoded) recordCredentialFreeRegistryDiagnostic(restored) restored.registry @@ -405,7 +497,7 @@ internal class AndroidAccountCredentialController( private fun readRegistryForCredentialLoad(): NextcloudAccountRegistry? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { - val encoded = preferences.getString(KEY_ACCOUNT_REGISTRY, null) + val encoded = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) val restored = encoded?.let(::restoreAndroidCredentialFreeRegistry) restored?.let(::recordCredentialFreeRegistryDiagnostic) recoverAndroidCredentialFreeRegistryForCredentialLoad(restored) { @@ -415,7 +507,7 @@ internal class AndroidAccountCredentialController( commitPreferences( prepareCredentialSlotEdit( preferences.edit().putString( - KEY_ACCOUNT_REGISTRY, + ANDROID_ACCOUNT_REGISTRY_KEY, encodeNextcloudAccountRegistry(state.registry), ), state, @@ -433,7 +525,7 @@ internal class AndroidAccountCredentialController( } private fun readStore(): AndroidAccountCredentialStoreRead = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { - val encrypted = preferences.getString(KEY_SESSION, null) ?: return@serialize run { + val encrypted = preferences.getString(ANDROID_ACCOUNT_SESSION_KEY, null) ?: return@serialize run { val retained = readIndependentCredentialSlotState() when { retained != null -> availableCredentialStore(retained) @@ -457,9 +549,9 @@ internal class AndroidAccountCredentialController( commitPreferences( prepareCredentialSlotEdit( preferences.edit() - .putString(KEY_SESSION, sessionCipher.encrypt(migrated)) + .putString(ANDROID_ACCOUNT_SESSION_KEY, sessionCipher.encrypt(migrated)) .putString( - KEY_ACCOUNT_REGISTRY, + ANDROID_ACCOUNT_REGISTRY_KEY, encodeNextcloudAccountRegistry(migratedState.registry), ), migratedState, @@ -479,30 +571,33 @@ internal class AndroidAccountCredentialController( private fun availableCredentialStore( state: AndroidAccountCredentialState, ): AndroidAccountCredentialStoreRead.Available { - if (preferences.contains(KEY_QUARANTINED_SESSION)) { - runCatching { commitPreferences(preferences.edit().remove(KEY_QUARANTINED_SESSION)) } + if (preferences.contains(ANDROID_QUARANTINED_SESSION_KEY)) { + runCatching { commitPreferences(preferences.edit().remove(ANDROID_QUARANTINED_SESSION_KEY)) } } return AndroidAccountCredentialStoreRead.Available(state) } private fun readIndependentCredentialSlotState(): AndroidAccountCredentialState? { - return restoreAndroidAccountCredentialStateWithoutAggregate( - encodedRegistry = preferences.getString(KEY_ACCOUNT_REGISTRY, null), - loadSession = ::readCredentialSlot, - ) + val encodedRegistry = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) ?: return null + val registry = restoreAndroidCredentialFreeRegistry(encodedRegistry).registry ?: return null + val slots = registry.accounts.associate { account -> account.id to readCredentialSlot(account.id) } + if (slots.values.any { slot -> slot is AndroidAccountCredentialSlotRead.Unsupported }) return null + return reconstructAndroidAccountCredentialState(registry) { accountId -> + (slots[accountId] as? AndroidAccountCredentialSlotRead.Available)?.session + } } private fun hasIndependentCredentialState(): Boolean = - preferences.contains(KEY_ACCOUNT_REGISTRY) || - preferences.all.keys.any { key -> key.startsWith(KEY_ACCOUNT_CREDENTIAL_SLOT_PREFIX) } + preferences.contains(ANDROID_ACCOUNT_REGISTRY_KEY) || + preferences.all.keys.any { key -> key.startsWith(ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX) } - private fun readCredentialSlot(accountId: NextcloudAccountId): NextcloudSession? = try { + private fun readCredentialSlot(accountId: NextcloudAccountId): AndroidAccountCredentialSlotRead = try { readAndroidAccountCredentialSlot( accountId = accountId, readEncrypted = { key -> preferences.getString(key, null) }, decrypt = sessionCipher::decrypt, decode = { encoded -> - restoreAndroidAccountCredentialState( + restoreAndroidAccountCredentialStore( encoded = encoded, persistMigrated = { migrated -> commitPreferences( @@ -513,7 +608,7 @@ internal class AndroidAccountCredentialController( ) }, recordDiagnostic = recordDiagnostic, - )?.activeSession + ) }, ) } catch (_: Exception) { @@ -521,35 +616,44 @@ internal class AndroidAccountCredentialController( code = "ACCOUNT_CREDENTIAL_STORE_READ_FAILED", operation = "account-credentials.restore", ) - null + AndroidAccountCredentialSlotRead.Invalid + } + + private fun requireSupportedCredentialSlots(registry: NextcloudAccountRegistry) { + registry.accounts.forEach { account -> + val slot = readCredentialSlot(account.id) + if (slot is AndroidAccountCredentialSlotRead.Unsupported) { + unsupportedCredentialStoreMutation(slot.version) + } + } } private suspend fun persistState( state: AndroidAccountCredentialState, - pendingCleanupAccountIdentity: String? = null, + pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, ) = withContext(Dispatchers.IO) { commitPreferences( preparePendingAccountRemovalCleanupEdit( prepareCredentialSlotEdit( preferences.edit() - .putString(KEY_SESSION, encryptState(state)) - .putString(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(state.registry)), + .putString(ANDROID_ACCOUNT_SESSION_KEY, encryptState(state)) + .putString(ANDROID_ACCOUNT_REGISTRY_KEY, encodeNextcloudAccountRegistry(state.registry)), state, ), - pendingCleanupAccountIdentity, + pendingCleanup, ), ) } - private suspend fun retryPendingAccountRemovalCleanup(accountIdentity: String) { - if (accountIdentity !in pendingAccountRemovalCleanupIdentities()) return + private suspend fun retryPendingAccountRemovalCleanup(session: NextcloudSession) { + val pending = pendingAndroidAccountRemovalCleanupForSession(session, pendingAccountRemovalCleanups()) ?: return try { - retryQueuedUploadsCleanup(accountIdentity) - clearPendingAccountRemovalCleanup(accountIdentity) + retryQueuedUploadsCleanup(session, pending.workIdentity) + clearPendingAccountRemovalCleanup(pending.accountStorageKey) } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { - recordAccountRemovalCleanupFailure() + recordAccountRemovalCleanupFailure(failure) throw IllegalStateException( "Previous account cleanup must finish before this account can be added again.", failure, @@ -557,26 +661,39 @@ internal class AndroidAccountCredentialController( } } - private fun pendingAccountRemovalCleanupIdentities(): Set = - preferences.getStringSet(KEY_PENDING_ACCOUNT_REMOVAL_CLEANUP, emptySet())?.toSet().orEmpty() + private fun pendingAccountRemovalCleanups(): Set = + preferences.getStringSet(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, emptySet()) + ?.mapTo(linkedSetOf()) { encoded -> + requireNotNull(decodeAndroidPendingAccountRemovalCleanup(encoded)) { + "The pending account cleanup journal is invalid." + } + } + .orEmpty() private fun preparePendingAccountRemovalCleanupEdit( editor: SharedPreferences.Editor, - accountIdentity: String?, - ): SharedPreferences.Editor = if (accountIdentity == null) { + pendingCleanup: AndroidPendingAccountRemovalCleanup?, + ): SharedPreferences.Editor = if (pendingCleanup == null) { editor } else { + val retained = pendingAccountRemovalCleanups() + .filterNot { cleanup -> cleanup.accountStorageKey == pendingCleanup.accountStorageKey } + .toSet() + pendingCleanup editor.putStringSet( - KEY_PENDING_ACCOUNT_REMOVAL_CLEANUP, - pendingAccountRemovalCleanupIdentities() + accountIdentity, + ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, + retained.mapTo(linkedSetOf(), ::encodeAndroidPendingAccountRemovalCleanup), ) } - private fun clearPendingAccountRemovalCleanup(accountIdentity: String) { - val remaining = pendingAccountRemovalCleanupIdentities() - accountIdentity + private fun clearPendingAccountRemovalCleanup(accountStorageKey: String) { + val remaining = pendingAccountRemovalCleanups() + .filterNot { cleanup -> cleanup.accountStorageKey == accountStorageKey } val editor = preferences.edit() - if (remaining.isEmpty()) editor.remove(KEY_PENDING_ACCOUNT_REMOVAL_CLEANUP) - else editor.putStringSet(KEY_PENDING_ACCOUNT_REMOVAL_CLEANUP, remaining) + if (remaining.isEmpty()) editor.remove(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY) + else editor.putStringSet( + ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, + remaining.mapTo(linkedSetOf(), ::encodeAndroidPendingAccountRemovalCleanup), + ) commitPreferences(editor) } @@ -616,10 +733,10 @@ internal class AndroidAccountCredentialController( editor: SharedPreferences.Editor, state: AndroidAccountCredentialState, ): SharedPreferences.Editor = editor.apply { - remove(KEY_QUARANTINED_SESSION) + remove(ANDROID_QUARANTINED_SESSION_KEY) val retainedKeys = state.sessions.keys.mapTo(hashSetOf(), ::androidAccountCredentialSlotKey) preferences.all.keys - .filter { key -> key.startsWith(KEY_ACCOUNT_CREDENTIAL_SLOT_PREFIX) && key !in retainedKeys } + .filter { key -> key.startsWith(ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX) && key !in retainedKeys } .forEach(::remove) state.sessions.forEach { (accountId, session) -> putString( @@ -633,6 +750,7 @@ internal class AndroidAccountCredentialController( code: String, operation: String, component: SupportDiagnosticComponent = SupportDiagnosticComponent.Authentication, + failure: Throwable? = null, ) { recordDiagnostic( SupportDiagnosticEventDraft( @@ -641,123 +759,26 @@ internal class AndroidAccountCredentialController( operation = operation, outcome = "failed", code = code, + exception = failure?.toSupportDiagnosticExceptionDraft(), ), ) } - private fun recordAccountRemovalCleanupFailure() = recordCredentialFailure( + private fun recordAccountRemovalCleanupFailure(failure: Exception) = recordCredentialFailure( code = "ACCOUNT_REMOVAL_CLEANUP_FAILED", operation = "account.remove-cleanup", component = SupportDiagnosticComponent.Sync, + failure = failure, ) private fun recordAccountSelectionCacheCleanupFailure() = recordCredentialFailure( code = "ACCOUNT_SELECTION_CACHE_CLEANUP_FAILED", operation = "account-selection.cache-cleanup", component = SupportDiagnosticComponent.Cache, ) + private fun recordAccountHandoffCleanupFailure(failure: Exception) = recordCredentialFailure( + code = "ACCOUNT_HANDOFF_CLEANUP_FAILED", + operation = "account.handoff-cleanup", + component = SupportDiagnosticComponent.Cache, + failure = failure, + ) } -internal sealed interface AndroidAccountCredentialStoreRead { - data class Available(val state: AndroidAccountCredentialState) : AndroidAccountCredentialStoreRead - data class Invalid(val encrypted: String) : AndroidAccountCredentialStoreRead - data object IndependentRecoveryUnavailable : AndroidAccountCredentialStoreRead - data class Unsupported(val encrypted: String, val version: Int) : AndroidAccountCredentialStoreRead -} - -private fun unsupportedCredentialStoreMutation(version: Int): Nothing = - error("The account credential store version $version is unsupported.") - -internal fun androidAccountCredentialSlotKey(accountId: NextcloudAccountId): String = - "$KEY_ACCOUNT_CREDENTIAL_SLOT_PREFIX${accountId.storageKey}" - -internal fun readAndroidAccountCredentialSlot( - accountId: NextcloudAccountId, - readEncrypted: (String) -> String?, - decrypt: (String) -> String, - decode: (String) -> NextcloudSession?, -): NextcloudSession? { - val encrypted = readEncrypted(androidAccountCredentialSlotKey(accountId)) ?: return null - return decode(decrypt(encrypted))?.takeIf { session -> session.accountId == accountId } -} - -internal fun recoverAndroidAccountCredentialSlot( - accountId: NextcloudAccountId, - registry: NextcloudAccountRegistry, - storedSlot: NextcloudSession?, - aggregate: AndroidAccountCredentialState?, -): NextcloudSession? { - val account = registry.accounts.firstOrNull { candidate -> candidate.id == accountId } ?: return null - return storedSlot?.takeIf { session -> session.accountRecord() == account } - ?: aggregate?.sessions?.get(accountId)?.takeIf { session -> session.accountRecord() == account } -} - -internal fun reconstructAndroidAccountCredentialState( - registry: NextcloudAccountRegistry, - loadSession: (NextcloudAccountId) -> NextcloudSession?, -): AndroidAccountCredentialState? { - val sessions = linkedMapOf() - val unavailableAccounts = mutableListOf() - registry.accounts.forEach { account -> - val session = loadSession(account.id)?.takeIf { loaded -> loaded.accountRecord() == account } - if (session == null) unavailableAccounts += account.id else sessions[account.id] = session - } - if (registry.activeAccountId in unavailableAccounts) return null - val retainedRegistry = unavailableAccounts.fold(registry) { retained, accountId -> retained.remove(accountId) } - return AndroidAccountCredentialState(retainedRegistry, sessions) -} - -internal fun restoreAndroidAccountCredentialStateWithoutAggregate( - encodedRegistry: String?, - loadSession: (NextcloudAccountId) -> NextcloudSession?, -): AndroidAccountCredentialState? { - val restored = encodedRegistry - ?.let { encoded -> restoreNextcloudAccountRegistry(encoded, legacySession = null) } - ?: return null - if (restored.recoveryReason != null) return null - return reconstructAndroidAccountCredentialState(restored.registry, loadSession) -} - -internal fun androidCredentialStoreAllowsSessionRestore( - read: AndroidAccountCredentialStoreRead, -): Boolean = read !is AndroidAccountCredentialStoreRead.Unsupported - -internal class AndroidAccountCredentialStoreGuard { - private val monitor = Any() - - fun serialize(action: () -> Result): Result = synchronized(monitor, action) -} - -internal fun prepareInvalidAndroidAccountCredentialRecoveryEdit( - editor: SharedPreferences.Editor, - replacementEncrypted: String?, -): SharedPreferences.Editor = editor.apply { - remove(KEY_QUARANTINED_SESSION) - if (replacementEncrypted == null) remove(KEY_SESSION) else putString(KEY_SESSION, replacementEncrypted) - remove(KEY_TEST_READ_ONLY) -} - -internal fun requireCommittedAndroidAccountCredentialEdit(editor: SharedPreferences.Editor) { - check(editor.commit()) { "The account credential store could not be committed." } -} - -internal fun resolveStoredAndroidAccountSession( - accountIdentity: String, - listAccounts: () -> List, - loadSession: (NextcloudAccountId) -> NextcloudSession?, -): NextcloudSession? { - val accountId = listAccounts().firstOrNull { account -> - NextcloudDocumentIds.accountKey( - NextcloudSession(account.serverUrl, account.loginName, appPassword = ""), - ) == accountIdentity - }?.id ?: return null - return loadSession(accountId)?.takeIf { session -> - NextcloudDocumentIds.accountKey(session) == accountIdentity - } -} - -private const val KEY_SESSION = "encrypted_session" -private const val KEY_ACCOUNT_REGISTRY = "account_registry_v1" -private const val KEY_ACCOUNT_CREDENTIAL_SLOT_PREFIX = "account_credential_v1:" -private const val KEY_QUARANTINED_SESSION = "encrypted_session_quarantine" -private const val KEY_PENDING_ACCOUNT_REMOVAL_CLEANUP = "pending_account_removal_cleanup_v1" -private val ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD = AndroidAccountCredentialStoreGuard() -private val ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX = Mutex() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt new file mode 100644 index 000000000..b165851f5 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -0,0 +1,179 @@ +package dev.obiente.nextcloudnative + +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudAccountId +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord +import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry +import kotlinx.coroutines.sync.Mutex + +internal sealed interface AndroidAccountCredentialStoreRead { + data class Available(val state: AndroidAccountCredentialState) : AndroidAccountCredentialStoreRead + data class Invalid(val encrypted: String) : AndroidAccountCredentialStoreRead + data object IndependentRecoveryUnavailable : AndroidAccountCredentialStoreRead + data class Unsupported(val encrypted: String, val version: Int) : AndroidAccountCredentialStoreRead +} + +internal sealed interface AndroidAccountCredentialSlotRead { + data object Missing : AndroidAccountCredentialSlotRead + data class Available(val session: NextcloudSession) : AndroidAccountCredentialSlotRead + data object Invalid : AndroidAccountCredentialSlotRead + data class Unsupported(val version: Int) : AndroidAccountCredentialSlotRead +} + +internal data class AndroidPendingAccountRemovalCleanup( + val accountStorageKey: String, + val workIdentity: String, +) { + init { + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(accountStorageKey)) + require(WORK_IDENTITY_PATTERN.matches(workIdentity)) + } +} + +internal fun unsupportedCredentialStoreMutation(version: Int): Nothing = + error("The account credential store version $version is unsupported.") + +internal fun androidAccountCredentialSlotKey(accountId: NextcloudAccountId): String = + "$ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX${accountId.storageKey}" + +internal fun readAndroidAccountCredentialSlot( + accountId: NextcloudAccountId, + readEncrypted: (String) -> String?, + decrypt: (String) -> String, + decode: (String) -> RestoredAndroidAccountCredentialState, +): AndroidAccountCredentialSlotRead { + val encrypted = readEncrypted(androidAccountCredentialSlotKey(accountId)) + ?: return AndroidAccountCredentialSlotRead.Missing + val restored = decode(decrypt(encrypted)) + restored.unsupportedVersion?.let { version -> return AndroidAccountCredentialSlotRead.Unsupported(version) } + val session = restored.state?.activeSession + ?.takeIf { candidate -> candidate.accountId == accountId } + ?: return AndroidAccountCredentialSlotRead.Invalid + return AndroidAccountCredentialSlotRead.Available(session) +} + +internal fun pendingAndroidAccountRemovalCleanup( + session: NextcloudSession, +): AndroidPendingAccountRemovalCleanup = AndroidPendingAccountRemovalCleanup( + accountStorageKey = session.accountId.storageKey, + workIdentity = NextcloudDocumentIds.accountKey(session), +) + +internal fun encodeAndroidPendingAccountRemovalCleanup( + cleanup: AndroidPendingAccountRemovalCleanup, +): String = "${cleanup.accountStorageKey}:${cleanup.workIdentity}" + +internal fun decodeAndroidPendingAccountRemovalCleanup( + encoded: String, +): AndroidPendingAccountRemovalCleanup? { + val accountStorageKey = encoded.substringBefore(':', missingDelimiterValue = "") + val workIdentity = encoded.substringAfter(':', missingDelimiterValue = "") + return runCatching { AndroidPendingAccountRemovalCleanup(accountStorageKey, workIdentity) }.getOrNull() +} + +internal fun pendingAndroidAccountRemovalCleanupForSession( + session: NextcloudSession, + cleanups: Collection, +): AndroidPendingAccountRemovalCleanup? { + val matching = cleanups.filter { cleanup -> + cleanup.accountStorageKey == session.accountId.storageKey + } + check(matching.size <= 1) { "The pending account cleanup journal is ambiguous." } + return matching.singleOrNull() +} + +internal fun recoverAndroidAccountCredentialSlot( + accountId: NextcloudAccountId, + registry: NextcloudAccountRegistry, + storedSlot: NextcloudSession?, + aggregate: AndroidAccountCredentialState?, +): NextcloudSession? { + val account = registry.accounts.firstOrNull { candidate -> candidate.id == accountId } ?: return null + return storedSlot?.takeIf { session -> session.accountRecord() == account } + ?: aggregate?.sessions?.get(accountId)?.takeIf { session -> session.accountRecord() == account } +} + +internal fun reconstructAndroidAccountCredentialState( + registry: NextcloudAccountRegistry, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): AndroidAccountCredentialState? { + val sessions = linkedMapOf() + val unavailableAccounts = mutableListOf() + registry.accounts.forEach { account -> + val session = loadSession(account.id)?.takeIf { loaded -> loaded.accountRecord() == account } + if (session == null) unavailableAccounts += account.id else sessions[account.id] = session + } + if (registry.activeAccountId in unavailableAccounts) return null + val retainedRegistry = unavailableAccounts.fold(registry) { retained, accountId -> retained.remove(accountId) } + return AndroidAccountCredentialState(retainedRegistry, sessions) +} + +internal fun restoreAndroidAccountCredentialStateWithoutAggregate( + encodedRegistry: String?, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): AndroidAccountCredentialState? { + val restored = encodedRegistry + ?.let { encoded -> restoreNextcloudAccountRegistry(encoded, legacySession = null) } + ?: return null + if (restored.recoveryReason != null) return null + return reconstructAndroidAccountCredentialState(restored.registry, loadSession) +} + +internal fun androidCredentialStoreAllowsSessionRestore( + read: AndroidAccountCredentialStoreRead, +): Boolean = when (read) { + is AndroidAccountCredentialStoreRead.Available -> read.state.mutationsAllowed + is AndroidAccountCredentialStoreRead.Unsupported -> false + is AndroidAccountCredentialStoreRead.Invalid, + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable, + -> true +} + +internal class AndroidAccountCredentialStoreGuard { + private val monitor = Any() + + fun serialize(action: () -> Result): Result = synchronized(monitor, action) +} + +internal fun prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor: SharedPreferences.Editor, + replacementEncrypted: String?, +): SharedPreferences.Editor = editor.apply { + remove(ANDROID_QUARANTINED_SESSION_KEY) + if (replacementEncrypted == null) remove(ANDROID_ACCOUNT_SESSION_KEY) + else putString(ANDROID_ACCOUNT_SESSION_KEY, replacementEncrypted) + remove(KEY_TEST_READ_ONLY) +} + +internal fun requireCommittedAndroidAccountCredentialEdit(editor: SharedPreferences.Editor) { + check(editor.commit()) { "The account credential store could not be committed." } +} + +internal fun resolveStoredAndroidAccountSession( + accountIdentity: String, + listAccounts: () -> List, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): NextcloudSession? { + val accountId = listAccounts().firstOrNull { account -> + NextcloudDocumentIds.accountKey( + NextcloudSession(account.serverUrl, account.loginName, appPassword = ""), + ) == accountIdentity + }?.id ?: return null + return loadSession(accountId)?.takeIf { session -> + NextcloudDocumentIds.accountKey(session) == accountIdentity + } +} + +internal const val ANDROID_ACCOUNT_SESSION_KEY = "encrypted_session" +internal const val ANDROID_ACCOUNT_REGISTRY_KEY = "account_registry_v1" +internal const val ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX = "account_credential_v1:" +internal const val ANDROID_QUARANTINED_SESSION_KEY = "encrypted_session_quarantine" +internal const val ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY = "pending_account_removal_cleanup_v2" +internal val ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD = AndroidAccountCredentialStoreGuard() +internal val ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX = Mutex() + +private val ACCOUNT_STORAGE_KEY_PATTERN = Regex("[0-9a-f]{64}") +private val WORK_IDENTITY_PATTERN = Regex("[0-9a-f]{32}") diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index a9491b702..3fb794771 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -13,6 +13,7 @@ internal class AndroidAccountOwnedStateCleanup(context: Context) { val accountIdentity = NextcloudDocumentIds.accountKey(session) runAndroidAccountRemovalCleanups( listOf( + { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(session) }, { durableUploads.removeForAccount(accountIdentity) }, @@ -21,11 +22,12 @@ internal class AndroidAccountOwnedStateCleanup(context: Context) { ) } - suspend fun retry(accountIdentity: String) { + suspend fun retry(session: NextcloudSession, accountIdentity: String) { runAndroidAccountRemovalCleanups( listOf( + { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, - { incomingShares.removeForAccount(accountIdentity) }, + { incomingShares.removeForAccount(accountIdentity, session) }, { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, ), diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index 558c8c7d3..b487ae1d4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -19,9 +19,21 @@ internal fun requireAndroidAccountRemovalWritebacksResolved(resolved: Boolean) { internal suspend fun revokeAndroidSessionAfterRemovalPreflight( preflight: suspend () -> Unit, revoke: suspend () -> Unit, + removeLocalAccount: suspend () -> Unit, ) { preflight() revoke() + removeLocalAccount() +} + +internal suspend fun revokeAndroidSessionWithAccountLease( + accountIdentity: String, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + preflight: suspend () -> Unit, + revoke: suspend () -> Unit, + removeLocalAccount: suspend () -> Unit, +) = guard.withAccount(accountIdentity) { + revokeAndroidSessionAfterRemovalPreflight(preflight, revoke, removeLocalAccount) } internal enum class AndroidAccountDocumentGrantScope(val pathSegment: String) { @@ -41,9 +53,12 @@ internal suspend fun preflightAndroidAccountRemoval(context: Context, session: N internal suspend fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) { preflightAndroidAccountRemoval(context, session) +} + +internal fun revokeAndroidAccountDocumentGrants(context: Context, accountIdentity: String) { AndroidAccountDocumentGrantScope.entries.forEach { scope -> context.revokeUriPermission( - scope.uri(nextcloudDocumentsAuthority(context.packageName), NextcloudDocumentIds.rootId(session)), + scope.uri(nextcloudDocumentsAuthority(context.packageName), NextcloudDocumentIds.rootId(accountIdentity)), NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS, ) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt index 868865657..1f68158ce 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt @@ -2,6 +2,19 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.NextcloudSession +internal fun commitAndroidAccountTransitionBeforeHandoffCleanup( + commitTransition: () -> Unit, + clearHandoffs: () -> Unit, + recordFailure: (Exception) -> Unit, +) { + commitTransition() + try { + clearHandoffs() + } catch (failure: Exception) { + runCatching { recordFailure(failure) } + } +} + internal fun clearAndroidPreviousPreviewAfterCommittedSelection( previousSession: NextcloudSession?, selectedSession: NextcloudSession, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index fef7d124b..9745c31a0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -247,7 +247,11 @@ internal class DeckAttachmentUploadWorker( target = DurableUploadState.Uploading, message = null, ) ?: return Result.success() - val services = AndroidNextcloudServices(applicationContext, localUploadPicker = picker) + val services = AndroidNextcloudServices( + applicationContext, + localUploadPicker = picker, + accountMutationLeaseHeld = true, + ) val outcome = runCatching { services.executeNextcloudMultipartUpload(session, started.request) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index fca1536fa..69e4c3d60 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -238,12 +238,24 @@ internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, account .filter { pair -> pair.accountId == accountId } .map { pair -> pair.id } if (retiredPairIds.isEmpty()) return@withLock - store.save(removeAndroidFileSyncAccountPairs(current, accountId)) val scheduler = AndroidFileSyncScheduler(context) - retiredPairIds.forEach { pairId -> scheduler.cancel(pairId) } + cancelAndroidFileSyncPairSchedulesBeforeRetirement( + pairIds = retiredPairIds, + cancelSchedule = scheduler::cancel, + persistRetirement = { store.save(removeAndroidFileSyncAccountPairs(current, accountId)) }, + ) } } +internal suspend fun cancelAndroidFileSyncPairSchedulesBeforeRetirement( + pairIds: List, + cancelSchedule: suspend (String) -> Unit, + persistRetirement: () -> Unit, +) { + pairIds.forEach { pairId -> cancelSchedule(pairId) } + persistRetirement() +} + internal suspend fun requireAndroidFileSyncAccountRemovalReady(context: Context, accountId: String) { AndroidFileSyncEngine.ENGINE_LOCK.withLock { requireAndroidFileSyncAccountRemovalReady(AndroidFileSyncStore(context).load(), accountId) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt index e9aa0cd5c..71873c417 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt @@ -24,11 +24,17 @@ internal class AndroidIncomingShareAccountCleanup(context: Context) { private val store = AndroidIncomingShareStore(appContext) suspend fun removeForAccount(session: NextcloudSession) = - removeForAccount(NextcloudDocumentIds.accountKey(session), session) + removeForAccountInternal(NextcloudDocumentIds.accountKey(session), session) - suspend fun removeForAccount(accountId: String) = removeForAccount(accountId, session = null) + suspend fun removeForAccount(accountId: String) = removeForAccountInternal(accountId, session = null) - private suspend fun removeForAccount(accountId: String, session: NextcloudSession?) = withContext(Dispatchers.IO) { + suspend fun removeForAccount(accountId: String, session: NextcloudSession) = + removeForAccountInternal(accountId, session) + + private suspend fun removeForAccountInternal( + accountId: String, + session: NextcloudSession?, + ) = withContext(Dispatchers.IO) { val workManager = WorkManager.getInstance(appContext) val webDav = session?.let { NextcloudDocumentWebDav( @@ -60,8 +66,8 @@ internal class AndroidIncomingShareAccountCleanup(context: Context) { cancellation.close() } }, - recordChunkReleaseFailure = { failure -> - Log.w(LOG_TAG, "Remote staged-share chunk cleanup deferred during account removal", failure) + recordChunkReleaseFailure = { + Log.w(LOG_TAG, "Remote staged-share chunk cleanup deferred during account removal") }, removeRequest = { requestId -> check(store.remove(requestId)) { "The staged share data could not be released." } @@ -115,6 +121,8 @@ internal suspend fun removeAndroidIncomingShareRequests( removeRequest: (String) -> Unit, ) { requests.forEach { request -> cancelWork(request.id) } + val retained = mutableSetOf() + var firstReleaseFailure: Exception? = null requests.forEach { accountRequest -> accountRequest.request?.chunkSession?.let { chunk -> try { @@ -123,10 +131,17 @@ internal suspend fun removeAndroidIncomingShareRequests( throw failure } catch (failure: Exception) { recordChunkReleaseFailure(failure) + retained += accountRequest.id + if (firstReleaseFailure == null) { + firstReleaseFailure = failure + } else { + firstReleaseFailure.addSuppressed(failure) + } } } } - requests.forEach { request -> removeRequest(request.id) } + requests.filterNot { request -> request.id in retained }.forEach { request -> removeRequest(request.id) } + firstReleaseFailure?.let { throw it } } private const val LOG_TAG = "IncomingShareCleanup" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 2e305f70e..1d73d306d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -407,6 +407,7 @@ internal class AndroidNextcloudServices( private val localUploadPicker: AndroidLocalUploadPicker? = null, private val requestPlatformPermissions: ((Array) -> Boolean)? = null, private val onThemePreferenceChanged: (ThemePreference) -> Unit = {}, + private val accountMutationLeaseHeld: Boolean = false, ) : NextcloudPlatformServices { private val appContext = context.applicationContext private val activity = context as? Activity @@ -2922,6 +2923,7 @@ internal class AndroidNextcloudServices( streamingBody = requestBody, maxResponseBytes = safeRequest.maximumResponseBytes, client = noRedirectHttpClient, + accountMutationSerialized = accountMutationLeaseHeld, ) NextcloudApiResponse( response.status, @@ -3444,14 +3446,13 @@ internal class AndroidNextcloudServices( Unit } override suspend fun revokeSession(session: NextcloudSession) = withContext(Dispatchers.IO) { - revokeAndroidSessionAfterRemovalPreflight( - preflight = { preflightAndroidAccountRemoval(appContext, session) }, - ) { + accountCredentials.revokeSession(session) { request( method = "DELETE", url = session.serverUrl + "/ocs/v2.php/core/apppassword", session = session, ocsRequest = true, + accountMutationSerialized = true, ) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt index a703d3172..a2d4d5799 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentIds.kt @@ -32,7 +32,12 @@ internal object NextcloudDocumentIds { accountDigest(session.serverUrl, session.loginName) .joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } - fun rootId(session: NextcloudSession): String = documentId(session, "") + fun rootId(session: NextcloudSession): String = rootId(accountKey(session)) + + fun rootId(accountKey: String): String { + require(accountKeyPattern.matches(accountKey)) { "Invalid document account." } + return "$PREFIX:$accountKey:" + } fun documentId(session: NextcloudSession, path: String): String { val normalizedPath = normalizePath(path) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 0d7b0accd..6cba2f2aa 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -74,6 +74,41 @@ class AndroidAccountOperationGuardTest { assertTrue(removalEntered) } + @Test + fun remoteRevocationKeepsMutationsBlockedUntilLocalRemovalCommits() = runBlocking { + val guard = AndroidAccountOperationGuard() + val remoteRevoked = CompletableDeferred() + val allowLocalRemoval = CompletableDeferred() + var localRemovalCommitted = false + var mutationObservedCommittedRemoval = false + + val removal = async { + revokeAndroidSessionWithAccountLease( + accountIdentity = "account-a", + guard = guard, + preflight = {}, + revoke = { remoteRevoked.complete(Unit) }, + removeLocalAccount = { + allowLocalRemoval.await() + localRemovalCommitted = true + }, + ) + } + remoteRevoked.await() + val mutation = async { + guard.withAccount("account-a") { + mutationObservedCommittedRemoval = localRemovalCommitted + } + } + yield() + + assertFalse(mutation.isCompleted) + allowLocalRemoval.complete(Unit) + removal.await() + mutation.await() + assertTrue(mutationObservedCommittedRemoval) + } + @Test fun fileSyncPairCreationWaitsForRemovalAndRejectsTheReauthenticatedSession() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 7efb683ab..dff53b021 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -8,6 +8,7 @@ import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.afterProcessRecovery import dev.obiente.nextcloudnative.app.localUploadFile import java.io.IOException +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index f820dc815..4cf18f25c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -544,6 +544,24 @@ class AndroidFileSyncEngineInvariantTest { assertTrue(reconciled) } + @Test + fun accountRetirementKeepsPairIdsUntilEveryScheduleCancellationCompletes() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + cancelAndroidFileSyncPairSchedulesBeforeRetirement( + pairIds = listOf("pair-a", "pair-b"), + cancelSchedule = { pairId -> + events += "cancel-$pairId" + if (pairId == "pair-b") error("synthetic WorkManager cancellation failure") + }, + persistRetirement = { events += "persist-retirement" }, + ) + } + + assertEquals(listOf("cancel-pair-a", "cancel-pair-b"), events) + } + @Test fun pairRemovalRecoveryPropagatesCancellationBeforeAnyMutation() = runBlocking { val events = mutableListOf() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt index 23d0d131f..e24285cb9 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt @@ -759,7 +759,7 @@ class AndroidIncomingShareStateTest { } @Test - fun accountRemovalPurgesLocalShareAfterRemoteChunkCleanupFails() = runBlocking { + fun accountRemovalRetainsLocalShareAfterRemoteChunkCleanupFails() = runBlocking { val staged = request(AndroidIncomingShareState.Uploading).copy( chunkSession = AndroidIncomingShareChunkSession( fileIndex = 0, @@ -769,15 +769,17 @@ class AndroidIncomingShareStateTest { ) val events = mutableListOf() - removeAndroidIncomingShareRequests( - requests = listOf(AndroidIncomingShareAccountRequest(staged.id, staged)), - cancelWork = { events += "cancel" }, - releaseChunk = { _, _ -> error("synthetic offline cleanup failure") }, - recordChunkReleaseFailure = { events += "release-failed" }, - removeRequest = { events += "remove" }, - ) + assertFailsWith { + removeAndroidIncomingShareRequests( + requests = listOf(AndroidIncomingShareAccountRequest(staged.id, staged)), + cancelWork = { events += "cancel" }, + releaseChunk = { _, _ -> error("synthetic offline cleanup failure") }, + recordChunkReleaseFailure = { events += "release-failed" }, + removeRequest = { events += "remove" }, + ) + } - assertEquals(listOf("cancel", "release-failed", "remove"), events) + assertEquals(listOf("cancel", "release-failed"), events) } @Test diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index f3b86c529..cb2a7bec8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -408,6 +408,21 @@ class AndroidPersistedSessionTest { AndroidAccountCredentialStoreRead.Invalid("encrypted-malformed-store"), ), ) + assertFalse( + androidCredentialStoreAllowsSessionRestore( + AndroidAccountCredentialStoreRead.Available( + AndroidAccountCredentialState.Empty.upsertAndSelect(firstSession()) + .copy(mutationsAllowed = false), + ), + ), + ) + assertTrue( + androidCredentialStoreAllowsSessionRestore( + AndroidAccountCredentialStoreRead.Available( + AndroidAccountCredentialState.Empty.upsertAndSelect(firstSession()), + ), + ), + ) } @Test @@ -527,10 +542,15 @@ class AndroidPersistedSessionTest { decryptedValues += encrypted "decoded-second" }, - decode = { decoded -> second.takeIf { decoded == "decoded-second" } }, + decode = { decoded -> + RestoredAndroidAccountCredentialState( + AndroidAccountCredentialState.Empty.upsertAndSelect(second) + .takeIf { decoded == "decoded-second" }, + ) + }, ) - assertEquals(second, restored) + assertEquals(AndroidAccountCredentialSlotRead.Available(second), restored) assertEquals(listOf(androidAccountCredentialSlotKey(second.accountId)), requestedKeys) assertEquals(listOf("encrypted-second"), decryptedValues) } @@ -544,10 +564,44 @@ class AndroidPersistedSessionTest { accountId = second.accountId, readEncrypted = { "encrypted-first" }, decrypt = { "decoded-first" }, - decode = { first }, + decode = { + RestoredAndroidAccountCredentialState( + AndroidAccountCredentialState.Empty.upsertAndSelect(first), + ) + }, ) - assertNull(restored) + assertEquals(AndroidAccountCredentialSlotRead.Invalid, restored) + } + + @Test + fun futureCredentialSlotBlocksAggregateFallbackAndRepair() { + val session = firstSession() + val future = JSONObject(encodeAndroidPersistedSession(session)).put("version", 3).toString() + + val restored = readAndroidAccountCredentialSlot( + accountId = session.accountId, + readEncrypted = { "encrypted-future-slot" }, + decrypt = { future }, + decode = ::decodeAndroidAccountCredentialState, + ) + + assertEquals(AndroidAccountCredentialSlotRead.Unsupported(3), restored) + } + + @Test + fun pendingCleanupMatchesCanonicalAccountAndRetainsOriginalWorkIdentity() { + val original = firstSession().copy(serverUrl = "HTTPS://CLOUD.EXAMPLE.TEST:443/") + val replacement = firstSession().copy(serverUrl = "https://cloud.example.test") + assertEquals(original.accountId, replacement.accountId) + assertFalse(NextcloudDocumentIds.accountKey(original) == NextcloudDocumentIds.accountKey(replacement)) + val encoded = encodeAndroidPendingAccountRemovalCleanup(pendingAndroidAccountRemovalCleanup(original)) + val decoded = requireNotNull(decodeAndroidPendingAccountRemovalCleanup(encoded)) + + val pending = pendingAndroidAccountRemovalCleanupForSession(replacement, listOf(decoded)) + + assertEquals(NextcloudDocumentIds.accountKey(original), requireNotNull(pending).workIdentity) + assertEquals(original.accountId.storageKey, pending.accountStorageKey) } @Test @@ -692,6 +746,40 @@ class AndroidPersistedSessionTest { assertEquals(listOf("clear-preview", "diagnose-cleanup"), events) } + @Test + fun failedAccountTransitionDoesNotClearExternalHandoffs() { + val events = mutableListOf() + + assertFailsWith { + commitAndroidAccountTransitionBeforeHandoffCleanup( + commitTransition = { + events += "commit-transition" + error("synthetic credential persistence failure") + }, + clearHandoffs = { events += "clear-handoffs" }, + recordFailure = { events += "diagnose-cleanup" }, + ) + } + + assertEquals(listOf("commit-transition"), events) + } + + @Test + fun handoffCleanupFailureDoesNotHideACommittedAccountTransition() { + val events = mutableListOf() + + commitAndroidAccountTransitionBeforeHandoffCleanup( + commitTransition = { events += "commit-transition" }, + clearHandoffs = { + events += "clear-handoffs" + error("synthetic handoff cleanup failure") + }, + recordFailure = { events += "diagnose-cleanup" }, + ) + + assertEquals(listOf("commit-transition", "clear-handoffs", "diagnose-cleanup"), events) + } + @Test fun queuedUploadResumeCancellationNotifiesBeforePropagating() { val events = mutableListOf() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt index 6d3a89b1d..13b615351 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt @@ -54,14 +54,30 @@ class NextcloudDocumentsContractTest { @Test fun `account removal preflight runs before remote credential revocation`() = runBlocking { var revoked = false + var removed = false assertFailsWith { revokeAndroidSessionAfterRemovalPreflight( preflight = { error("pending account-owned recovery") }, revoke = { revoked = true }, + removeLocalAccount = { removed = true }, ) } assertFalse(revoked) + assertFalse(removed) + } + + @Test + fun `remote revocation and local removal share one ordered operation`() = runBlocking { + val events = mutableListOf() + + revokeAndroidSessionAfterRemovalPreflight( + preflight = { events += "preflight" }, + revoke = { events += "revoke" }, + removeLocalAccount = { events += "remove-local" }, + ) + + assertEquals(listOf("preflight", "revoke", "remove-local"), events) } } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt index 7a1d8816d..9632a2903 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt @@ -288,7 +288,7 @@ private val accountRegistryVersionEnvelope = Regex( private const val ACCOUNT_REGISTRY_VERSION = 1 internal const val MAX_LOCAL_ACCOUNTS = 64 -private const val MAX_ACCOUNT_REGISTRY_BYTES = 256 * 1024 +internal const val MAX_ACCOUNT_REGISTRY_BYTES = 256 * 1024 private const val MAX_ACCOUNT_REGISTRY_VERSION_ENVELOPE_CHARACTERS = 512 internal const val MAX_ACCOUNT_SERVER_URL_LENGTH = 8 * 1024 internal const val MAX_ACCOUNT_LOGIN_NAME_LENGTH = 1024 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 616725bf2..5618aadd2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -9,8 +9,11 @@ internal class DesktopAccountCredentialPersistence( private val recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, private val flushPreferences: () -> Unit = preferences::flush, ) { + private val registryStore = DesktopAccountRegistryPreferenceStore(preferences, flushPreferences) + fun loadActiveSession(): NextcloudSession? { retryPendingCredentialSave() + retryPendingCredentialRemoval() retryPendingLegacyCredentialCleanup() val read = readRegistry() if (read.registry == null) { @@ -36,6 +39,7 @@ internal class DesktopAccountCredentialPersistence( fun loadSession(accountId: NextcloudAccountId): NextcloudSession? { retryPendingCredentialSave() + retryPendingCredentialRemoval() retryPendingLegacyCredentialCleanup() val registry = readRegistry().registry ?: return null val record = registry.accounts.firstOrNull { account -> account.id == accountId } ?: return null @@ -53,6 +57,7 @@ internal class DesktopAccountCredentialPersistence( fun saveSession(session: NextcloudSession): NextcloudSession { retryPendingCredentialSave() + retryPendingCredentialRemoval() retryPendingLegacyCredentialCleanup() val read = readRegistry() val registry = read.registry @@ -111,16 +116,20 @@ internal class DesktopAccountCredentialPersistence( fun removeAccount(accountId: NextcloudAccountId): Boolean { retryPendingCredentialSave() + retryPendingCredentialRemoval() retryPendingLegacyCredentialCleanup() val registry = readRegistry().registry ?: return false val record = registry.accounts.firstOrNull { account -> account.id == accountId } ?: return false val clearLegacyCredential = legacyMetadataMatches(record) val updated = registry.remove(accountId) - clearSecret(desktopAccountSecretReference(accountId)) - if (clearLegacyCredential) { - clearSecret(desktopSessionSecretReference(record.serverUrl, record.loginName)) - } - persistAccountState(prepareRegistry(updated), updated.activeAccount) + persistAccountState( + encodedRegistry = prepareRegistry(updated), + activeAccount = updated.activeAccount, + pendingLegacyCleanupAccount = record.takeIf { clearLegacyCredential }, + pendingCredentialRemoval = accountId, + ) + retryPendingCredentialRemoval() + if (clearLegacyCredential) retryPendingLegacyCredentialCleanup() return true } @@ -142,7 +151,7 @@ internal class DesktopAccountCredentialPersistence( persistAccountState( encodedRegistry, restored.registry.activeAccount, - pendingLegacyCleanup = legacy, + pendingLegacyCleanupAccount = legacy.accountRecord(), ) } catch (failure: Exception) { recordCredentialDiagnostic( @@ -228,6 +237,74 @@ internal class DesktopAccountCredentialPersistence( clearPendingCredentialSave() } + private fun retryPendingCredentialRemoval() { + pendingCredentialRemovalIds().forEach { accountId -> + val registry = readRegistry().registry + if (registry == null) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_REMOVAL_JOURNAL_INVALID", + "account-credentials.recover", + ) + return@forEach + } + if (registry.accounts.any { account -> account.id == accountId }) { + clearPendingCredentialRemoval(accountId) + return@forEach + } + try { + secretStore.clear(desktopAccountSecretReference(accountId)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_CLEAR_FAILED", + "account-credentials.remove", + failure, + ) + return@forEach + } + clearPendingCredentialRemoval(accountId) + } + } + + private fun pendingCredentialRemovalIds(): Set { + val encoded = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null) ?: return emptySet() + if (encoded.isBlank()) return emptySet() + return encoded.split(',').mapNotNullTo(linkedSetOf()) { storageKey -> + try { + NextcloudAccountId(storageKey) + } catch (_: IllegalArgumentException) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_REMOVAL_JOURNAL_INVALID", + "account-credentials.recover", + ) + null + } + } + } + + private fun clearPendingCredentialRemoval(accountId: NextcloudAccountId) { + val previous = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null) + val remaining = pendingCredentialRemovalIds() - accountId + try { + preferences.putOrRemove( + KEY_PENDING_CREDENTIAL_REMOVALS, + if (remaining.isEmpty()) null else remaining.joinToString(",") { pending -> pending.storageKey }, + ) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_REMOVALS, previous) + runCatching(flushPreferences) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_CLEAR_FAILED", + "account-credentials.recover", + failure, + ) + } + } + private fun persistPendingCredentialSave(session: NextcloudSession) { val previousServer = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_SERVER, null) val previousLogin = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, null) @@ -284,9 +361,11 @@ internal class DesktopAccountCredentialPersistence( return } if (expected != null && (expected.serverUrl != server || expected.loginName != login)) return - val replacementAvailable = try { + val cleanupAllowed = try { val accountId = deriveNextcloudAccountId(server, login) - loadSecret(desktopAccountSecretReference(accountId)) != null + val registry = readRegistry().registry + registry?.accounts?.none { account -> account.id == accountId } == true || + loadSecret(desktopAccountSecretReference(accountId)) != null } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { @@ -296,7 +375,7 @@ internal class DesktopAccountCredentialPersistence( ) false } - if (!replacementAvailable) return + if (!cleanupAllowed) return try { secretStore.clear(desktopSessionSecretReference(server, login)) } catch (cancelled: CancellationException) { @@ -405,7 +484,7 @@ internal class DesktopAccountCredentialPersistence( } private fun readRegistry(): DesktopRegistryRead { - val encoded = preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null) + val encoded = registryStore.read() val decoded = encoded?.let(::decodeNextcloudAccountRegistryResult) return DesktopRegistryRead( encoded = encoded, @@ -415,36 +494,40 @@ internal class DesktopAccountCredentialPersistence( } private fun prepareRegistry(registry: NextcloudAccountRegistry): String = - encodeNextcloudAccountRegistry(registry).also { encoded -> - require(encoded.length <= Preferences.MAX_VALUE_LENGTH) { - "The account registry exceeds the desktop preference value limit." - } - } + encodeNextcloudAccountRegistry(registry) private fun persistAccountState( encodedRegistry: String, activeAccount: NextcloudAccountRecord?, - pendingLegacyCleanup: NextcloudSession? = null, + pendingLegacyCleanupAccount: NextcloudAccountRecord? = null, + pendingCredentialRemoval: NextcloudAccountId? = null, ) { - require(encodedRegistry.length <= Preferences.MAX_VALUE_LENGTH) val previous = DesktopAccountPreferenceSnapshot( - registry = preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null), + registry = registryStore.read(), server = preferences.get(KEY_SERVER, null), login = preferences.get(KEY_LOGIN, null), pendingLegacyCleanupServer = preferences.get(KEY_PENDING_LEGACY_CLEANUP_SERVER, null), pendingLegacyCleanupLogin = preferences.get(KEY_PENDING_LEGACY_CLEANUP_LOGIN, null), + pendingCredentialRemovals = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null), ) try { - preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodedRegistry) + registryStore.write(encodedRegistry) preferences.putOrRemove(KEY_SERVER, activeAccount?.serverUrl) preferences.putOrRemove(KEY_LOGIN, activeAccount?.loginName) - pendingLegacyCleanup?.let { session -> - preferences.put(KEY_PENDING_LEGACY_CLEANUP_SERVER, session.serverUrl) - preferences.put(KEY_PENDING_LEGACY_CLEANUP_LOGIN, session.loginName) + pendingLegacyCleanupAccount?.let { account -> + preferences.put(KEY_PENDING_LEGACY_CLEANUP_SERVER, account.serverUrl) + preferences.put(KEY_PENDING_LEGACY_CLEANUP_LOGIN, account.loginName) + } + pendingCredentialRemoval?.let { accountId -> + val removals = pendingCredentialRemovalIds() + accountId + preferences.put( + KEY_PENDING_CREDENTIAL_REMOVALS, + removals.joinToString(",") { pending -> pending.storageKey }, + ) } flushPreferences() } catch (failure: Exception) { - previous.restore(preferences) + runCatching { previous.restore(preferences, registryStore) } runCatching(flushPreferences) recordCredentialDiagnostic( "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", @@ -493,13 +576,15 @@ internal class DesktopAccountCredentialPersistence( val login: String?, val pendingLegacyCleanupServer: String?, val pendingLegacyCleanupLogin: String?, + val pendingCredentialRemovals: String?, ) { - fun restore(preferences: Preferences) { - preferences.putOrRemove(DESKTOP_ACCOUNT_REGISTRY_KEY, registry) + fun restore(preferences: Preferences, registryStore: DesktopAccountRegistryPreferenceStore) { + registryStore.write(registry) preferences.putOrRemove(KEY_SERVER, server) preferences.putOrRemove(KEY_LOGIN, login) preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_SERVER, pendingLegacyCleanupServer) preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_LOGIN, pendingLegacyCleanupLogin) + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_REMOVALS, pendingCredentialRemovals) } } @@ -510,6 +595,7 @@ internal class DesktopAccountCredentialPersistence( const val KEY_PENDING_LEGACY_CLEANUP_LOGIN = "accountLegacyCleanupLogin" const val KEY_PENDING_CREDENTIAL_SAVE_SERVER = "accountCredentialSaveServer" const val KEY_PENDING_CREDENTIAL_SAVE_LOGIN = "accountCredentialSaveLogin" + const val KEY_PENDING_CREDENTIAL_REMOVALS = "accountCredentialRemovals" } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 7fc780507..de81e1f28 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -32,6 +32,19 @@ internal class DesktopAccountOperationGuard { suspend fun withSyncRunLock(action: suspend () -> Result): Result = syncRunMutex.withLock { action() } } +internal class DesktopSessionPublicationGuard { + private val monitor = Any() + + fun serialize(action: () -> Result): Result = synchronized(monitor, action) +} + +internal fun closeVirtualFileProviderForReplacement( + provider: AutoCloseable?, + detach: () -> Unit, +): Throwable? = runCatching { provider?.close() } + .onSuccess { detach() } + .exceptionOrNull() + internal fun desktopAccountDiagnosticFields(accountId: String?): List = accountId?.let { listOf( @@ -89,6 +102,7 @@ internal fun removeDesktopCredentialWithoutProviderReactivation( providerWasEnabled: Boolean, clearProviderPreference: () -> Unit, restoreProviderPreference: (Boolean) -> Unit, + removalCommitted: () -> Boolean = { false }, removeCredential: () -> Boolean, ): Boolean { clearProviderPreference() @@ -97,9 +111,15 @@ internal fun removeDesktopCredentialWithoutProviderReactivation( if (!removed) restoreProviderPreference(providerWasEnabled) } } catch (failure: Throwable) { - runCatching { restoreProviderPreference(providerWasEnabled) } - .exceptionOrNull() - ?.let(failure::addSuppressed) + val committed = runCatching(removalCommitted).getOrElse { statusFailure -> + failure.addSuppressed(statusFailure) + true + } + if (!committed) { + runCatching { restoreProviderPreference(providerWasEnabled) } + .exceptionOrNull() + ?.let(failure::addSuppressed) + } throw failure } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt index 24d20eb4c..c3ee677bd 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt @@ -7,7 +7,8 @@ internal fun restoreDesktopAccountRegistry( session: NextcloudSession, recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, ) { - val restored = restoreNextcloudAccountRegistry(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null), session) + val registryStore = DesktopAccountRegistryPreferenceStore(preferences) + val restored = restoreNextcloudAccountRegistry(registryStore.read(), session) restored.recoveryReason?.let { reason -> recordDiagnostic( SupportDiagnosticEventDraft( @@ -45,19 +46,14 @@ internal fun prepareDesktopAccountRegistry(session: NextcloudSession): String = prepareDesktopAccountRegistry(singleAccountRegistry(session)) internal fun prepareDesktopAccountRegistry(registry: NextcloudAccountRegistry): String = - encodeNextcloudAccountRegistry(registry).also { encoded -> - require(encoded.length <= Preferences.MAX_VALUE_LENGTH) { - "The account registry exceeds the desktop preference value limit." - } - } + encodeNextcloudAccountRegistry(registry) internal fun persistDesktopAccountRegistry(preferences: Preferences, encodedRegistry: String) { - require(encodedRegistry.length <= Preferences.MAX_VALUE_LENGTH) - preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodedRegistry) + DesktopAccountRegistryPreferenceStore(preferences).write(encodedRegistry) } internal fun clearDesktopAccountRegistry(preferences: Preferences) { - preferences.remove(DESKTOP_ACCOUNT_REGISTRY_KEY) + DesktopAccountRegistryPreferenceStore(preferences).write(null) } internal const val DESKTOP_ACCOUNT_REGISTRY_KEY = "account_registry_v1" diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPreferenceStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPreferenceStore.kt new file mode 100644 index 000000000..7ba56d23f --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPreferenceStore.kt @@ -0,0 +1,117 @@ +package dev.obiente.nextcloudnative.app + +import java.util.prefs.Preferences + +/** + * Stores account metadata without exceeding the per-value limit of [Preferences]. + * + * Small registries retain the original single-value format. Larger registries are written to an + * inactive chunk generation before one pointer switches readers to the complete new value. + */ +internal class DesktopAccountRegistryPreferenceStore( + private val preferences: Preferences, + private val flushPreferences: () -> Unit = preferences::flush, +) { + @Synchronized + fun read(): String? { + val generation = preferences.get(KEY_ACTIVE_GENERATION, null) + ?: return preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null) + if (generation != GENERATION_A && generation != GENERATION_B) return MALFORMED_REGISTRY + val chunkCount = preferences.getInt(countKey(generation), -1) + if (chunkCount !in 1..MAX_CHUNKS) return MALFORMED_REGISTRY + val encoded = buildString { + repeat(chunkCount) { index -> + val chunk = preferences.get(chunkKey(generation, index), null) + ?: return MALFORMED_REGISTRY + if (chunk.length > CHUNK_CHARACTER_LIMIT) return MALFORMED_REGISTRY + append(chunk) + } + } + return encoded.takeIf { value -> value.encodeToByteArray().size <= MAX_ACCOUNT_REGISTRY_BYTES } + ?: MALFORMED_REGISTRY + } + + @Synchronized + fun write(encoded: String?) { + if (encoded == null) { + clear() + } else if (encoded.length <= Preferences.MAX_VALUE_LENGTH) { + writeSingleValue(encoded) + } else { + writeChunked(encoded) + } + } + + private fun writeSingleValue(encoded: String) { + require(encoded.encodeToByteArray().size <= MAX_ACCOUNT_REGISTRY_BYTES) + val previousGeneration = preferences.get(KEY_ACTIVE_GENERATION, null) + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encoded) + flushPreferences() + if (previousGeneration == null) return + preferences.remove(KEY_ACTIVE_GENERATION) + flushPreferences() + clearGenerationBestEffort(GENERATION_A) + clearGenerationBestEffort(GENERATION_B) + } + + private fun writeChunked(encoded: String) { + require(encoded.encodeToByteArray().size <= MAX_ACCOUNT_REGISTRY_BYTES) + val previousGeneration = preferences.get(KEY_ACTIVE_GENERATION, null) + val targetGeneration = if (previousGeneration == GENERATION_A) GENERATION_B else GENERATION_A + val chunks = encoded.chunked(CHUNK_CHARACTER_LIMIT) + require(chunks.size in 1..MAX_CHUNKS) + + clearGeneration(targetGeneration) + chunks.forEachIndexed { index, chunk -> + preferences.put(chunkKey(targetGeneration, index), chunk) + } + preferences.putInt(countKey(targetGeneration), chunks.size) + flushPreferences() + + preferences.put(KEY_ACTIVE_GENERATION, targetGeneration) + preferences.remove(DESKTOP_ACCOUNT_REGISTRY_KEY) + flushPreferences() + + previousGeneration + ?.takeIf { generation -> generation != targetGeneration } + ?.let(::clearGenerationBestEffort) + } + + private fun clear() { + val hadActiveGeneration = preferences.get(KEY_ACTIVE_GENERATION, null) != null + val hadSingleValue = preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null) != null + if (!hadActiveGeneration && !hadSingleValue) return + preferences.remove(DESKTOP_ACCOUNT_REGISTRY_KEY) + preferences.remove(KEY_ACTIVE_GENERATION) + flushPreferences() + clearGenerationBestEffort(GENERATION_A) + clearGenerationBestEffort(GENERATION_B) + } + + private fun clearGenerationBestEffort(generation: String) { + runCatching { + clearGeneration(generation) + flushPreferences() + } + } + + private fun clearGeneration(generation: String) { + preferences.remove(countKey(generation)) + repeat(MAX_CHUNKS) { index -> preferences.remove(chunkKey(generation, index)) } + } + + private fun countKey(generation: String) = "$KEY_GENERATION_PREFIX.$generation.count" + + private fun chunkKey(generation: String, index: Int) = + "$KEY_GENERATION_PREFIX.$generation.${index.toString().padStart(2, '0')}" + + private companion object { + const val KEY_ACTIVE_GENERATION = "account_registry_v2_active" + const val KEY_GENERATION_PREFIX = "account_registry_v2" + const val GENERATION_A = "a" + const val GENERATION_B = "b" + const val CHUNK_CHARACTER_LIMIT = 8_000 + const val MAX_CHUNKS = (MAX_ACCOUNT_REGISTRY_BYTES / CHUNK_CHARACTER_LIMIT) + 1 + const val MALFORMED_REGISTRY = "{malformed-chunked-account-registry" + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 5fbafe1b2..af507dd66 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -3,6 +3,79 @@ package dev.obiente.nextcloudnative.app import java.util.prefs.Preferences import kotlinx.coroutines.CancellationException +internal enum class DesktopAccountSyncPairCleanupPhase { + Prepared, + Committed, +} + +internal data class DesktopAccountSyncPairCleanup( + val accountId: String, + val phase: DesktopAccountSyncPairCleanupPhase, +) + +internal class DesktopAccountSyncPairCleanupJournal( + private val preferences: Preferences, +) { + fun prepare(accountId: String) = persist(accountId, DesktopAccountSyncPairCleanupPhase.Prepared) + + fun commit(accountId: String) = persist(accountId, DesktopAccountSyncPairCleanupPhase.Committed) + + fun clear(accountId: String) { + validateDesktopSyncPairCleanupAccountId(accountId) + preferences.remove(cleanupKey(accountId)) + preferences.flush() + } + + fun pending(): List = preferences.keys() + .asSequence() + .filter { key -> key.startsWith(KEY_PREFIX) } + .map { key -> + val accountId = key.removePrefix(KEY_PREFIX) + validateDesktopSyncPairCleanupAccountId(accountId) + val phase = when (preferences.get(key, null)) { + PREPARED -> DesktopAccountSyncPairCleanupPhase.Prepared + COMMITTED -> DesktopAccountSyncPairCleanupPhase.Committed + else -> error("The desktop account sync cleanup journal is invalid.") + } + DesktopAccountSyncPairCleanup(accountId, phase) + } + .toList() + .also { cleanups -> + check(cleanups.size <= MAX_LOCAL_ACCOUNTS) { + "The desktop account sync cleanup journal is too large." + } + } + + private fun persist(accountId: String, phase: DesktopAccountSyncPairCleanupPhase) { + validateDesktopSyncPairCleanupAccountId(accountId) + val pending = pending() + check(pending.any { cleanup -> cleanup.accountId == accountId } || pending.size < MAX_LOCAL_ACCOUNTS) { + "The desktop account sync cleanup journal is too large." + } + preferences.put( + cleanupKey(accountId), + if (phase == DesktopAccountSyncPairCleanupPhase.Prepared) PREPARED else COMMITTED, + ) + preferences.flush() + } + + private fun cleanupKey(accountId: String): String = "$KEY_PREFIX$accountId".also { key -> + check(key.length <= Preferences.MAX_KEY_LENGTH) + } + + private companion object { + const val KEY_PREFIX = "fsac." + const val PREPARED = "prepared" + const val COMMITTED = "committed" + } +} + +private fun validateDesktopSyncPairCleanupAccountId(accountId: String) { + require(accountId.length == 64 && accountId.all { character -> + character in '0'..'9' || character in 'a'..'f' + }) { "The desktop account sync cleanup identity is invalid." } +} + internal fun requireDesktopAccountRemovalReady(accountId: String, linuxDesktop: Boolean) { if (linuxDesktop) { requireDesktopAccountRemovalWritebacksResolved( @@ -14,6 +87,7 @@ internal fun requireDesktopAccountRemovalReady(accountId: String, linuxDesktop: internal fun removeDesktopAccountCredential( preferences: Preferences, providerAccountId: String?, + credentialStillExists: () -> Boolean, removeCredential: () -> Boolean, ): Boolean { val providerKey = providerAccountId?.let(::virtualFileProviderPreferenceKey) @@ -30,19 +104,38 @@ internal fun removeDesktopAccountCredential( } preferences.flush() }, + removalCommitted = { !credentialStillExists() }, removeCredential = removeCredential, ) } internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( + accountId: String, + prepareCleanup: suspend (String) -> Unit, + commitCleanup: suspend (String) -> Unit, + clearCleanup: suspend (String) -> Unit, + accountStillExists: (String) -> Boolean, removeCredential: suspend () -> Boolean, removeSyncPairs: suspend () -> Unit, recordCleanupFailure: suspend (Exception) -> Unit, ): Boolean { - val removed = removeCredential() - if (!removed) return false + prepareCleanup(accountId) + val removed = try { + removeCredential() + } catch (failure: Throwable) { + runCatching { + if (accountStillExists(accountId)) clearCleanup(accountId) else commitCleanup(accountId) + }.exceptionOrNull()?.let(failure::addSuppressed) + throw failure + } + if (!removed) { + clearCleanup(accountId) + return false + } try { + commitCleanup(accountId) removeSyncPairs() + clearCleanup(accountId) } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { @@ -53,22 +146,69 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( accountId: String?, + cleanupJournal: DesktopAccountSyncPairCleanupJournal, + accountStillExists: (String) -> Boolean, commitRemoval: suspend () -> Unit, removeSyncPairs: suspend (String) -> Unit, recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, ) { + if (accountId == null) { + commitRemoval() + return + } removeDesktopAccountBeforeSyncPairCleanup( + accountId = accountId, + prepareCleanup = cleanupJournal::prepare, + commitCleanup = cleanupJournal::commit, + clearCleanup = cleanupJournal::clear, + accountStillExists = accountStillExists, removeCredential = { commitRemoval() true }, - removeSyncPairs = { accountId?.let { removeSyncPairs(it) } }, + removeSyncPairs = { removeSyncPairs(accountId) }, recordCleanupFailure = { failure -> - accountId?.let { recordDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(it, failure)) } + recordDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(accountId, failure)) }, ) } +internal suspend fun retryDesktopAccountSyncPairCleanup( + cleanup: DesktopAccountSyncPairCleanup, + accountStillExists: (String) -> Boolean, + removeSyncPairs: suspend (String) -> Unit, + clearCleanup: suspend (String) -> Unit, +) { + if (cleanup.phase == DesktopAccountSyncPairCleanupPhase.Prepared && accountStillExists(cleanup.accountId)) { + clearCleanup(cleanup.accountId) + return + } + removeSyncPairs(cleanup.accountId) + clearCleanup(cleanup.accountId) +} + +internal suspend fun retryPendingDesktopAccountSyncPairCleanups( + cleanupJournal: DesktopAccountSyncPairCleanupJournal, + accountStillExists: (String) -> Boolean, + removeSyncPairs: suspend (String) -> Unit, + recordCleanupFailure: (String, Exception) -> Unit, +) { + cleanupJournal.pending().forEach { cleanup -> + try { + retryDesktopAccountSyncPairCleanup( + cleanup, + accountStillExists, + removeSyncPairs, + cleanupJournal::clear, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + runCatching { recordCleanupFailure(cleanup.accountId, failure) } + } + } +} + internal fun desktopAccountSyncPairCleanupFailureDiagnostic(accountId: String, failure: Exception) = SupportDiagnosticEventDraft( severity = SupportDiagnosticSeverity.Error, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCachePolicy.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCachePolicy.kt new file mode 100644 index 000000000..b65726ed4 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCachePolicy.kt @@ -0,0 +1,52 @@ +package dev.obiente.nextcloudnative.app + +internal suspend fun executeDesktopDynamicApiGet( + accountId: String, + requestIdentity: String, + cachePolicy: NextcloudApiCachePolicy, + coalescer: DynamicApiRequestCoalescer, + loadCached: () -> NextcloudApiResponse?, + invalidateCached: () -> Unit, + executeNetwork: suspend () -> NextcloudApiResponse, + commit: (NextcloudApiResponse) -> Unit, +): NextcloudApiResponse { + when (cachePolicy) { + NextcloudApiCachePolicy.PreferCache -> loadCached()?.let { return it } + NextcloudApiCachePolicy.RefreshNetwork -> + coalescer.invalidateRequest(accountId, requestIdentity) {} + NextcloudApiCachePolicy.ForceNetwork -> + coalescer.invalidateRequest(accountId, requestIdentity, invalidateCached) + } + return coalescer.execute( + accountId = accountId, + requestIdentity = requestIdentity, + load = { + if (cachePolicy != NextcloudApiCachePolicy.PreferCache) { + executeNetwork() + } else { + loadCached() ?: executeNetwork() + } + }, + commit = commit, + ) +} + +internal fun combinedAutomaticCacheExcess( + maximumBytes: Long, + completeFileBytes: Long, + rangeBytes: Long, + windowsCachedBytes: Long, + windowsPinnedBytes: Long, +): Long { + require(maximumBytes > 0L) + require(listOf(completeFileBytes, rangeBytes, windowsCachedBytes, windowsPinnedBytes).all { it >= 0L }) + require(windowsPinnedBytes <= windowsCachedBytes) + val total = listOf( + completeFileBytes, + rangeBytes, + windowsCachedBytes - windowsPinnedBytes, + ).fold(0L) { accumulated, bytes -> + if (bytes > Long.MAX_VALUE - accumulated) Long.MAX_VALUE else accumulated + bytes + } + return (total - maximumBytes).coerceAtLeast(0L) +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt index c8bfba695..d7fc25da2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncAccountCleanup.kt @@ -1,13 +1,22 @@ package dev.obiente.nextcloudnative.app +internal fun DesktopFileSyncStore.requireDesktopFileSyncAccountRemovalReady(accountId: String) { + require(accountId.isNotBlank() && accountId.length <= 256) + withExclusiveAccess { + check( + load().coordinator.pairs + .filter { pair -> pair.accountId == accountId } + .none { pair -> fileSyncOwnedUploads(pair).isNotEmpty() }, + ) { "Owned remote upload state must be recovered before removing this account." } + } +} + internal fun DesktopFileSyncStore.removeDesktopFileSyncAccountPairs(accountId: String) { require(accountId.isNotBlank() && accountId.length <= 256) withExclusiveAccess { val current = load() val removed = current.coordinator.pairs.filter { pair -> pair.accountId == accountId } - check(removed.none { pair -> fileSyncOwnedUploads(pair).isNotEmpty() }) { - "Owned remote upload state must be recovered before removing this account." - } + check(removed.none { pair -> fileSyncOwnedUploads(pair).isNotEmpty() }) val retainedRootIds = current.coordinator.pairs.asSequence() .filterNot { pair -> pair.accountId == accountId } .mapTo(mutableSetOf(), FileSyncPair::localRootId) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt index 9887896f6..a988455d8 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt @@ -10,7 +10,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext - /** Durable manual desktop executor. The common coordinator owns all planning and conflict rules. */ internal class DesktopFileSyncEngine( private val store: DesktopFileSyncStore = DesktopFileSyncStore(), @@ -215,9 +214,10 @@ internal class DesktopFileSyncEngine( FileSyncCenterActionResult.Completed("Folder sync pair removed. No local or server files were deleted.") } } - suspend fun removeAccountPairs(accountId: String) = lock.withLock { store.removeDesktopFileSyncAccountPairs(accountId) } - + suspend fun requireAccountRemovalReady(accountId: String) = lock.withLock { + store.requireDesktopFileSyncAccountRemovalReady(accountId) + } suspend fun runPair( session: NextcloudSession, userId: String, 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 adb12fc33..0de1f17bf 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -923,70 +923,6 @@ internal fun writePrivatePendingMutationFile( } } -internal suspend fun executeDesktopDynamicApiGet( - accountId: String, - requestIdentity: String, - cachePolicy: NextcloudApiCachePolicy, - coalescer: DynamicApiRequestCoalescer, - loadCached: () -> NextcloudApiResponse?, - invalidateCached: () -> Unit, - executeNetwork: suspend () -> NextcloudApiResponse, - commit: (NextcloudApiResponse) -> Unit, -): NextcloudApiResponse { - when (cachePolicy) { - NextcloudApiCachePolicy.PreferCache -> loadCached()?.let { return it } - NextcloudApiCachePolicy.RefreshNetwork -> - coalescer.invalidateRequest(accountId, requestIdentity) {} - NextcloudApiCachePolicy.ForceNetwork -> - coalescer.invalidateRequest(accountId, requestIdentity, invalidateCached) - } - return coalescer.execute( - accountId = accountId, - requestIdentity = requestIdentity, - load = { - if (cachePolicy != NextcloudApiCachePolicy.PreferCache) { - executeNetwork() - } else { - loadCached() ?: executeNetwork() - } - }, - commit = commit, - ) -} - -internal fun combinedAutomaticCacheExcess( - maximumBytes: Long, - completeFileBytes: Long, - rangeBytes: Long, - windowsCachedBytes: Long, - windowsPinnedBytes: Long, -): Long { - require(maximumBytes > 0L) - require(listOf(completeFileBytes, rangeBytes, windowsCachedBytes, windowsPinnedBytes).all { it >= 0L }) - require(windowsPinnedBytes <= windowsCachedBytes) - val total = listOf( - completeFileBytes, - rangeBytes, - windowsCachedBytes - windowsPinnedBytes, - ).fold(0L) { accumulated, bytes -> - if (bytes > Long.MAX_VALUE - accumulated) Long.MAX_VALUE else accumulated + bytes - } - return (total - maximumBytes).coerceAtLeast(0L) -} - -internal class DesktopSessionPublicationGuard { - private val monitor = Any() - - fun serialize(action: () -> Result): Result = synchronized(monitor, action) -} - -internal fun closeVirtualFileProviderForReplacement( - provider: AutoCloseable?, - detach: () -> Unit, -): Throwable? = runCatching { provider?.close() } - .onSuccess { detach() } - .exceptionOrNull() - class DesktopNextcloudServices( private val onThemePreferenceChanged: (ThemePreference) -> Unit = {}, private val onKeepRunningInBackgroundChanged: (Boolean) -> Unit = {}, @@ -1658,6 +1594,7 @@ class DesktopNextcloudServices( ) }, ) + private val accountSyncPairCleanupJournal = DesktopAccountSyncPairCleanupJournal(preferences) private val startOnLoginController = DesktopStartOnLoginController() private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var backgroundFileSyncJob: Job? = null @@ -1692,6 +1629,9 @@ class DesktopNextcloudServices( backgroundFileSyncJob = serviceScope.launch { restoreConfirmedStartOnLoginRegistration() while (isActive) { + accountOperationGuard.serializeWhenSyncIdle { + retryPendingAccountSyncPairCleanups() + } if (!isFileSyncPaused()) { runCatching { syncAllFileSyncPairs(DesktopFileSyncRunSource.Background) } } @@ -3724,7 +3664,6 @@ class DesktopNextcloudServices( "${desktopFileCacheAccountId(session)}-$appId-$digest.json", ) } - override fun loadSession(): NextcloudSession? = sessionPublicationGuard.serialize { val session = accountCredentials.loadActiveSession() if (session == null) { @@ -3738,6 +3677,7 @@ class DesktopNextcloudServices( override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { val persistedSession = accountOperationGuard.serializeWhenSyncIdle { + retryPendingAccountSyncPairCleanup(desktopFileCacheAccountId(session)) sessionPublicationGuard.serialize { val activeAccountId = accountCredentials.activeAccountId() val activeSession = activeAccountId?.let(accountCredentials::loadSession) @@ -3762,7 +3702,6 @@ class DesktopNextcloudServices( accountSessionPublication.register(session) } } - override suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = withContext(Dispatchers.IO) { accountOperationGuard.serialize operation@{ @@ -3780,6 +3719,12 @@ class DesktopNextcloudServices( syncJob?.join() reopenDesktopSessionAfterSelection( selected = accountOperationGuard.withSyncRunLock { + val selectedRecord = sessionPublicationGuard.serialize { + accountCredentials.listAccounts().firstOrNull { account -> account.id == accountId } + } + selectedRecord?.let { record -> + retryPendingAccountSyncPairCleanup(desktopFileCacheAccountId(record)) + } sessionPublicationGuard.serialize { accountCredentials.selectAccount(accountId)?.also { session -> accountSessionPublication.publish(session) @@ -3793,7 +3738,6 @@ class DesktopNextcloudServices( ) } } - private fun hasLiveAccountResources(): Boolean = synchronized(fileRangeSessionLock) { activeFileRangeSessions.isNotEmpty() } || synchronized(virtualFolderHydrationJobs) { virtualFolderHydrationJobs.values.any { it.isActive } } || @@ -3801,7 +3745,6 @@ class DesktopNextcloudServices( linuxVirtualFileSystem != null || windowsCloudFilesProvider != null || virtualFileCacheTierMutations.isNotEmpty() } - override suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = withContext(Dispatchers.IO) { accountOperationGuard.serialize { if (activeAccountId() == accountId) { @@ -3813,9 +3756,22 @@ class DesktopNextcloudServices( val providerAccountId = desktopFileCacheAccountId(account) requireDesktopAccountRemovalReady(providerAccountId, isLinuxDesktop()) accountOperationGuard.withSyncRunLock { - removeDesktopAccountBeforeSyncPairCleanup({ sessionPublicationGuard.serialize { - removeDesktopAccountCredential(preferences, providerAccountId) { accountCredentials.removeAccount(accountId) } - } }, { fileSyncEngine.removeAccountPairs(providerAccountId) }) { + fileSyncEngine.requireAccountRemovalReady(providerAccountId) + removeDesktopAccountBeforeSyncPairCleanup( + accountId = providerAccountId, + prepareCleanup = accountSyncPairCleanupJournal::prepare, + commitCleanup = accountSyncPairCleanupJournal::commit, + clearCleanup = accountSyncPairCleanupJournal::clear, + accountStillExists = ::desktopAccountExists, + removeCredential = { sessionPublicationGuard.serialize { + removeDesktopAccountCredential(preferences, providerAccountId, { + accountCredentials.listAccounts().any { account -> account.id == accountId } + }) { + accountCredentials.removeAccount(accountId) + } + } }, + removeSyncPairs = { fileSyncEngine.removeAccountPairs(providerAccountId) }, + ) { recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(providerAccountId, it)) } } @@ -3825,8 +3781,9 @@ class DesktopNextcloudServices( override suspend fun clearSession() = withContext(Dispatchers.IO) { accountOperationGuard.serialize { clearSessionForAccountOperation() } } - - private suspend fun clearSessionForAccountOperation() { + private suspend fun clearSessionForAccountOperation( + expectedSession: NextcloudSession? = null, revokeRemoteSession: suspend (NextcloudSession) -> Unit = {}, + ) { val userHome = File(System.getProperty("user.home")) val rangeSessions = synchronized(fileRangeSessionLock) { sessionClearing = true @@ -3836,12 +3793,14 @@ class DesktopNextcloudServices( try { val activeAccountId = activeAccountId() val activeSession = activeAccountId?.let(::loadSession) + check(expectedSession == null || activeSession == expectedSession) { + "The account changed before its remote session could be revoked." + } val activeRecord = activeAccountId?.let { id -> listAccounts().firstOrNull { account -> account.id == id } } val accountId = activeSession?.let(::desktopFileCacheAccountId) ?: activeRecord?.let(::desktopFileCacheAccountId) - accountId?.let { requireDesktopAccountRemovalReady(it, isLinuxDesktop()) } val syncJob = synchronized(this) { val active = backgroundFileSyncJob backgroundFileSyncJob = null @@ -3850,6 +3809,10 @@ class DesktopNextcloudServices( syncJob?.cancel() syncJob?.join() accountOperationGuard.withSyncRunLock { + accountId + ?.also { requireDesktopAccountRemovalReady(it, isLinuxDesktop()) } + ?.let { fileSyncEngine.requireAccountRemovalReady(it) } + expectedSession?.let { session -> revokeRemoteSession(session) } val hydrationJobs = accountId?.let(::cancelAllVirtualFolderHydration).orEmpty() rangeSessions.forEach { source -> runCatching(source::close) } hydrationJobs.forEach { job -> job.join() } @@ -3928,15 +3891,28 @@ class DesktopNextcloudServices( } } mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot(phase = DesktopFileSyncTrayPhase.Idle) - clearDesktopActiveAccountBeforeSyncPairCleanup(accountId, { - sessionPublicationGuard.serialize { - check(activeAccountId == null || removeDesktopAccountCredential(preferences, accountId) { - accountCredentials.removeAccount(activeAccountId) - }) - supportDiagnostics.setActiveAccountIdentity(null) - supportIntake.setActiveAccountIdentity(null) - } - }, fileSyncEngine::removeAccountPairs, ::recordSupportDiagnostic) + clearDesktopActiveAccountBeforeSyncPairCleanup( + accountId, + accountSyncPairCleanupJournal, + ::desktopAccountExists, + { + sessionPublicationGuard.serialize { + check( + activeAccountId == null || removeDesktopAccountCredential( + preferences, + accountId, + credentialStillExists = { + accountCredentials.listAccounts().any { account -> account.id == activeAccountId } + }, + ) { accountCredentials.removeAccount(activeAccountId) }, + ) + supportDiagnostics.setActiveAccountIdentity(null) + supportIntake.setActiveAccountIdentity(null) + } + }, + fileSyncEngine::removeAccountPairs, + ::recordSupportDiagnostic, + ) cleared = true } } finally { @@ -3947,20 +3923,44 @@ class DesktopNextcloudServices( } } + private suspend fun retryPendingAccountSyncPairCleanup(accountId: String) { + val cleanup = accountSyncPairCleanupJournal.pending() + .singleOrNull { pending -> pending.accountId == accountId } + ?: return + retryDesktopAccountSyncPairCleanup( + cleanup = cleanup, + accountStillExists = ::desktopAccountExists, + removeSyncPairs = fileSyncEngine::removeAccountPairs, + clearCleanup = accountSyncPairCleanupJournal::clear, + ) + } + + private suspend fun retryPendingAccountSyncPairCleanups() { + retryPendingDesktopAccountSyncPairCleanups( + cleanupJournal = accountSyncPairCleanupJournal, + accountStillExists = ::desktopAccountExists, + removeSyncPairs = fileSyncEngine::removeAccountPairs, + recordCleanupFailure = { accountId, failure -> + recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(accountId, failure)) + }, + ) + } + + private fun desktopAccountExists(accountId: String): Boolean = sessionPublicationGuard.serialize { + accountCredentials.listAccounts().any { account -> desktopFileCacheAccountId(account) == accountId } + } override suspend fun loadDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, ): PersistedDeckCardDraft? = withContext(Dispatchers.IO) { deckCardDrafts.load(session, key) } - override suspend fun saveDeckCardDraft( session: NextcloudSession, draft: PersistedDeckCardDraft, ) = withContext(Dispatchers.IO) { deckCardDrafts.save(session, draft) } - override suspend fun clearDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, @@ -3982,11 +3982,9 @@ class DesktopNextcloudServices( runCatching { openExternalUrlNow(url) } } } - override suspend fun openLoginUrl(url: String) = withContext(Dispatchers.IO) { openExternalUrlNow(url) } - private fun openExternalUrlNow(url: String) { try { externalUrlLauncher.open(url) @@ -5617,7 +5615,6 @@ class DesktopNextcloudServices( hasMoreHistory = response.status != 304 && nextCursor != null, ) } - override suspend fun sendTalkMessage(session: NextcloudSession, token: String, message: String) = withContext(Dispatchers.IO) { val response = request( @@ -5631,12 +5628,15 @@ class DesktopNextcloudServices( check(response.status in 200..299) { "Sending the Talk message failed (HTTP ${response.status})." } Unit } - override suspend fun revokeSession(session: NextcloudSession): Unit = withContext(Dispatchers.IO) { - requireDesktopAccountRemovalReady(desktopFileCacheAccountId(session), isLinuxDesktop()) - request("DELETE", session.serverUrl + "/ocs/v2.php/core/apppassword", session, ocsRequest = true) + accountOperationGuard.serialize { + clearSessionForAccountOperation(expectedSession = session) { current -> + request("DELETE", current.serverUrl + "/ocs/v2.php/core/apppassword", current, + ocsRequest = true, accountMutationSerialized = true, + ) + } + } } - private suspend fun ocsGet(session: NextcloudSession, path: String): JSONObject { val separator = if ('?' in path) '&' else '?' val response = request("GET", session.serverUrl + path + separator + "format=json", session, ocsRequest = true) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index ee9c6e460..dde385832 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -61,6 +61,35 @@ class DesktopAccountCredentialPersistenceTest { assertEquals(session.serverUrl, preferences.get("accountCredentialSaveServer", null)) } + @Test + fun accountRemovalJournalsBothCurrentAndLegacyCredentialCleanup() = withStore { preferences, secrets -> + val first = firstSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(first) + persistence.saveSession(secondSession()) + secrets.save( + desktopSessionSecretReference(first.serverUrl, first.loginName), + first.loginName, + first.appPassword.encodeToByteArray(), + ) + preferences.put("accountLegacyCleanupServer", first.serverUrl) + preferences.put("accountLegacyCleanupLogin", first.loginName) + secrets.failClears = true + + assertTrue(persistence.removeAccount(first.accountId)) + assertFalse(persistence.listAccounts().any { account -> account.id == first.accountId }) + assertNotNull(secrets.load(desktopAccountSecretReference(first.accountId))) + assertNotNull(secrets.load(desktopSessionSecretReference(first.serverUrl, first.loginName))) + assertEquals(first.accountId.storageKey, preferences.get("accountCredentialRemovals", null)) + + secrets.failClears = false + persistence.loadActiveSession() + assertNull(secrets.load(desktopAccountSecretReference(first.accountId))) + assertNull(secrets.load(desktopSessionSecretReference(first.serverUrl, first.loginName))) + assertNull(preferences.get("accountLegacyCleanupServer", null)) + assertNull(preferences.get("accountLegacyCleanupLogin", null)) + } + @Test fun selectionFlushesRegistryAndLegacyMetadataBeforeReturning() = withStore { preferences, secrets -> var flushCount = 0 @@ -69,7 +98,7 @@ class DesktopAccountCredentialPersistenceTest { persistence.saveSession(secondSession()) assertEquals(firstSession(), persistence.selectAccount(firstSession().accountId)) - assertEquals(7, flushCount) + assertEquals(10, flushCount) assertEquals(firstSession().serverUrl, preferences.get("server", null)) assertEquals(firstSession().loginName, preferences.get("login", null)) } @@ -417,7 +446,7 @@ class DesktopAccountCredentialPersistenceTest { } @Test - fun failedCredentialDeletionKeepsTheAccountRegisteredForRetry() = withStore { preferences, secrets -> + fun failedCredentialDeletionKeepsAPostCommitRetryJournal() = withStore { preferences, secrets -> val first = firstSession() val second = secondSession() val persistence = persistence(preferences, secrets) @@ -425,29 +454,41 @@ class DesktopAccountCredentialPersistenceTest { persistence.saveSession(second) secrets.failClears = true - assertFailsWith { - persistence.removeAccount(second.accountId) - } + assertTrue(persistence.removeAccount(second.accountId)) - assertEquals(second.accountId, persistence.activeAccountId()) - assertEquals(setOf(first.accountRecord(), second.accountRecord()), persistence.listAccounts().toSet()) + assertNull(persistence.activeAccountId()) + assertEquals(listOf(first.accountRecord()), persistence.listAccounts()) assertNotNull(secrets.load(desktopAccountSecretReference(second.accountId))) + assertEquals(second.accountId.storageKey, preferences.get("accountCredentialRemovals", null)) secrets.failClears = false - assertTrue(persistence.removeAccount(second.accountId)) - assertNull(persistence(preferences, secrets).activeAccountId()) + assertNull(persistence(preferences, secrets).loadActiveSession()) assertNull(secrets.load(desktopAccountSecretReference(second.accountId))) + assertNull(preferences.get("accountCredentialRemovals", null)) + } + + @Test + fun removalJournalNeverDeletesAStillRegisteredCredential() = withStore { preferences, secrets -> + val session = firstSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(session) + preferences.put("accountCredentialRemovals", session.accountId.storageKey) + preferences.flush() + + assertEquals(session, persistence.loadActiveSession()) + assertNotNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertNull(preferences.get("accountCredentialRemovals", null)) } @Test - fun failedRegistryFlushAfterCredentialDeletionKeepsADeletionRetryPath() = + fun failedRegistryFlushLeavesTheCredentialAndAccountIntact() = withStore { preferences, secrets -> val first = firstSession() val second = secondSession() var flushAttempts = 0 val persistence = persistence(preferences, secrets) { flushAttempts += 1 - if (flushAttempts == 7) error("synthetic removal flush failure") + if (flushAttempts == 10) error("synthetic removal flush failure") preferences.flush() } persistence.saveSession(first) @@ -459,27 +500,27 @@ class DesktopAccountCredentialPersistenceTest { assertEquals(second.accountId, persistence.activeAccountId()) assertEquals(setOf(first.accountRecord(), second.accountRecord()), persistence.listAccounts().toSet()) - assertNull(secrets.load(desktopAccountSecretReference(second.accountId))) + assertNotNull(secrets.load(desktopAccountSecretReference(second.accountId))) + assertNull(preferences.get("accountCredentialRemovals", null)) assertTrue(persistence.removeAccount(second.accountId)) assertNull(persistence(preferences, secrets).activeAccountId()) } @Test - fun oversizedRegistryFailsBeforeCredentialOrMetadataWrites() = withStore { preferences, secrets -> + fun largeRegistryPersistsCredentialAndMetadataThroughPreferenceChunks() = withStore { preferences, secrets -> val session = NextcloudSession( serverUrl = "https://cloud.example.test/" + "a".repeat(8_050), loginName = "alice", appPassword = "private-app-password", ) - assertFailsWith { - persistence(preferences, secrets).saveSession(session) - } + assertEquals(session, persistence(preferences, secrets).saveSession(session)) assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) - assertNull(preferences.get("server", null)) - assertNull(preferences.get("login", null)) - assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertEquals(session.serverUrl, preferences.get("server", null)) + assertEquals(session.loginName, preferences.get("login", null)) + assertNotNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertEquals(session.accountId, decodeRegistry(preferences).activeAccountId) } @Test @@ -521,7 +562,9 @@ class DesktopAccountCredentialPersistenceTest { } private fun decodeRegistry(preferences: Preferences): NextcloudAccountRegistry = requireNotNull( - decodeNextcloudAccountRegistry(requireNotNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null))), + decodeNextcloudAccountRegistry( + requireNotNull(DesktopAccountRegistryPreferenceStore(preferences).read()), + ), ) private fun assertDiagnosticsExcludePrivateValues(diagnostics: List) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index e3ee62e71..4455b991e 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.yield import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.UUID +import java.util.prefs.Preferences import kotlin.concurrent.thread class DesktopAccountOperationGuardTest { @@ -203,6 +205,44 @@ class DesktopAccountOperationGuardTest { assertFalse(pairCreated) } + @Test + fun sessionRevocationWaitsForSyncAndBlocksMutationsUntilLocalRemoval() = runBlocking { + val guard = DesktopAccountOperationGuard() + val syncEntered = CompletableDeferred() + val releaseSync = CompletableDeferred() + val events = mutableListOf() + var localRemovalCommitted = false + + val sync = async { + guard.withSyncRunLock { + syncEntered.complete(Unit) + releaseSync.await() + } + } + syncEntered.await() + val revocation = async { + guard.serializeWhenSyncIdle { + events += "preflight" + events += "revoke" + localRemovalCommitted = true + events += "remove-local" + } + } + yield() + val laterMutation = async { + guard.serialize { localRemovalCommitted } + } + yield() + + assertFalse(revocation.isCompleted) + assertFalse(laterMutation.isCompleted) + releaseSync.complete(Unit) + sync.await() + revocation.await() + assertTrue(laterMutation.await()) + assertEquals(listOf("preflight", "revoke", "remove-local"), events) + } + @Test fun differentAccountSaveRequiresTheSelectionTransition() { val first = NextcloudSession("https://first.example.test", "alice", "one") @@ -415,11 +455,36 @@ class DesktopAccountOperationGuardTest { assertEquals(listOf("cleared", "remove"), events) } + @Test + fun committedCredentialRemovalFailureDoesNotReactivateTheProvider() { + val events = mutableListOf() + + assertFailsWith { + removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = true, + clearProviderPreference = { events += "cleared" }, + restoreProviderPreference = { enabled -> events += "restored:$enabled" }, + removalCommitted = { true }, + removeCredential = { + events += "remove" + error("synthetic post-commit credential cleanup failure") + }, + ) + } + + assertEquals(listOf("cleared", "remove"), events) + } + @Test fun committedInactiveRemovalSurvivesSyncPairCleanupFailure() = runBlocking { val events = mutableListOf() val removed = removeDesktopAccountBeforeSyncPairCleanup( + accountId = CLEANUP_ACCOUNT_ID, + prepareCleanup = { events += "prepare-cleanup" }, + commitCleanup = { events += "commit-cleanup" }, + clearCleanup = { events += "clear-cleanup" }, + accountStillExists = { false }, removeCredential = { events += "remove-credential" true @@ -432,7 +497,16 @@ class DesktopAccountOperationGuardTest { ) assertTrue(removed) - assertEquals(listOf("remove-credential", "remove-pairs", "diagnose-cleanup"), events) + assertEquals( + listOf( + "prepare-cleanup", + "remove-credential", + "commit-cleanup", + "remove-pairs", + "diagnose-cleanup", + ), + events, + ) } @Test @@ -441,6 +515,11 @@ class DesktopAccountOperationGuardTest { assertFailsWith { removeDesktopAccountBeforeSyncPairCleanup( + accountId = CLEANUP_ACCOUNT_ID, + prepareCleanup = { events += "prepare-cleanup" }, + commitCleanup = { events += "commit-cleanup" }, + clearCleanup = { events += "clear-cleanup" }, + accountStillExists = { false }, removeCredential = { events += "remove-credential" true @@ -453,25 +532,132 @@ class DesktopAccountOperationGuardTest { ) } - assertEquals(listOf("remove-credential", "remove-pairs"), events) + assertEquals( + listOf("prepare-cleanup", "remove-credential", "commit-cleanup", "remove-pairs"), + events, + ) } @Test - fun failedActiveCredentialCommitPreservesSyncPairs() = runBlocking { + fun postCommitCredentialFailureRetainsCommittedPairCleanupRecovery() = runBlocking { val events = mutableListOf() assertFailsWith { - clearDesktopActiveAccountBeforeSyncPairCleanup( - accountId = "account-old", - commitRemoval = { + removeDesktopAccountBeforeSyncPairCleanup( + accountId = CLEANUP_ACCOUNT_ID, + prepareCleanup = { events += "prepare-cleanup" }, + commitCleanup = { events += "commit-cleanup" }, + clearCleanup = { events += "clear-cleanup" }, + accountStillExists = { false }, + removeCredential = { events += "remove-credential" - error("synthetic credential commit failure") + error("synthetic post-commit credential cleanup failure") + }, + removeSyncPairs = { events += "remove-pairs" }, + recordCleanupFailure = { events += "diagnose-cleanup" }, + ) + } + + assertEquals(listOf("prepare-cleanup", "remove-credential", "commit-cleanup"), events) + } + + @Test + fun failedActiveCredentialCommitPreservesSyncPairs() = runBlocking { + val events = mutableListOf() + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + + try { + assertFailsWith { + clearDesktopActiveAccountBeforeSyncPairCleanup( + accountId = CLEANUP_ACCOUNT_ID, + cleanupJournal = DesktopAccountSyncPairCleanupJournal(preferences), + accountStillExists = { true }, + commitRemoval = { + events += "remove-credential" + error("synthetic credential commit failure") + }, + removeSyncPairs = { events += "remove-pairs-$it" }, + recordDiagnostic = { events += "diagnose-cleanup" }, + ) + } + + assertEquals(listOf("remove-credential"), events) + assertTrue(DesktopAccountSyncPairCleanupJournal(preferences).pending().isEmpty()) + } finally { + preferences.removeNode() + } + } + + @Test + fun committedPairCleanupFailureSurvivesRestartAndBlocksReactivationUntilRetry() = runBlocking { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val firstJournal = DesktopAccountSyncPairCleanupJournal(preferences) + try { + val removalEvents = mutableListOf() + assertTrue( + removeDesktopAccountBeforeSyncPairCleanup( + accountId = CLEANUP_ACCOUNT_ID, + prepareCleanup = firstJournal::prepare, + commitCleanup = firstJournal::commit, + clearCleanup = firstJournal::clear, + accountStillExists = { false }, + removeCredential = { true }, + removeSyncPairs = { error("synthetic pair cleanup failure") }, + recordCleanupFailure = { removalEvents += "diagnose" }, + ), + ) + + assertEquals(listOf("diagnose"), removalEvents) + val restored = DesktopAccountSyncPairCleanupJournal(preferences) + assertEquals( + listOf( + DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Committed, + ), + ), + restored.pending(), + ) + + val retryEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = restored.pending().single(), + accountStillExists = { true }, + removeSyncPairs = { retryEvents += "remove-pairs-$it" }, + clearCleanup = { + retryEvents += "clear-cleanup-$it" + restored.clear(it) }, - removeSyncPairs = { events += "remove-pairs-$it" }, - recordDiagnostic = { events += "diagnose-cleanup" }, ) + + assertEquals( + listOf("remove-pairs-$CLEANUP_ACCOUNT_ID", "clear-cleanup-$CLEANUP_ACCOUNT_ID"), + retryEvents, + ) + assertTrue(restored.pending().isEmpty()) + } finally { + preferences.removeNode() } + } + + @Test + fun preparedCleanupFromAnAbortedRemovalPreservesExistingPairs() = runBlocking { + val events = mutableListOf() + + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Prepared, + ), + accountStillExists = { true }, + removeSyncPairs = { events += "remove-pairs" }, + clearCleanup = { events += "clear-cleanup" }, + ) + + assertEquals(listOf("clear-cleanup"), events) + } - assertEquals(listOf("remove-credential"), events) + private companion object { + const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt index ff547f349..1eed712e7 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt @@ -61,7 +61,7 @@ class DesktopAccountRegistryPersistenceTest { } @Test - fun oversizedMigrationReportsABoundedCauseWithoutChangingPreferences() = withPreferences { preferences -> + fun largeLegacyAccountMigratesThroughChunkedPreferences() = withPreferences { preferences -> val session = NextcloudSession( serverUrl = "https://cloud.example.test/" + "a".repeat(8_050), loginName = "alice", @@ -71,17 +71,15 @@ class DesktopAccountRegistryPersistenceTest { restoreDesktopAccountRegistry(preferences, session, diagnostics::add) + val encoded = requireNotNull(DesktopAccountRegistryPreferenceStore(preferences).read()) + assertEquals(session.accountId, requireNotNull(decodeNextcloudAccountRegistry(encoded)).activeAccountId) + assertTrue(encoded.length > Preferences.MAX_VALUE_LENGTH) assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) - val diagnostic = diagnostics.single() - assertEquals("ACCOUNT_REGISTRY_MIGRATION_FAILED", diagnostic.code) - assertNotNull(diagnostic.exception) - assertNull(diagnostic.exception.message) - assertFalse(diagnostic.toString().contains(session.appPassword)) - assertFalse(diagnostic.toString().contains(session.serverUrl)) + assertTrue(diagnostics.isEmpty()) } @Test - fun desktopValueLimitIsValidatedBeforeAnyMetadataWrite() = withPreferences { preferences -> + fun preparingALargeRegistryDoesNotWriteMetadata() = withPreferences { preferences -> val session = NextcloudSession( serverUrl = "https://cloud.example.test/" + "a".repeat(8_050), loginName = "alice", @@ -90,13 +88,58 @@ class DesktopAccountRegistryPersistenceTest { preferences.put("server", "existing-server") preferences.put("login", "existing-login") - assertFailsWith { prepareDesktopAccountRegistry(session) } + val encoded = prepareDesktopAccountRegistry(session) + assertTrue(encoded.length > Preferences.MAX_VALUE_LENGTH) assertEquals("existing-server", preferences.get("server", null)) assertEquals("existing-login", preferences.get("login", null)) assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) } + @Test + fun maximumAccountCountRoundTripsAcrossBoundedPreferenceChunks() = withPreferences { preferences -> + val accounts = (0 until MAX_LOCAL_ACCOUNTS).map { index -> + NextcloudSession( + serverUrl = "https://cloud-$index.example.test/nextcloud", + loginName = "person-$index-${"x".repeat(120)}", + appPassword = "not-persisted", + ).accountRecord() + } + val registry = NextcloudAccountRegistry(accounts, accounts.last().id) + val encoded = encodeNextcloudAccountRegistry(registry) + val store = DesktopAccountRegistryPreferenceStore(preferences) + + assertTrue(encoded.length > Preferences.MAX_VALUE_LENGTH) + store.write(encoded) + + assertEquals(encoded, DesktopAccountRegistryPreferenceStore(preferences).read()) + assertTrue( + preferences.keys() + .filter { key -> key.startsWith("account_registry_v2.") } + .map { key -> requireNotNull(preferences.get(key, null)) } + .all { value -> value.length <= Preferences.MAX_VALUE_LENGTH }, + ) + } + + @Test + fun failedInactiveGenerationWriteKeepsThePreviouslyCommittedRegistry() = withPreferences { preferences -> + val session = NextcloudSession( + serverUrl = "https://cloud.example.test/${"a".repeat(8_050)}", + loginName = "alice", + appPassword = "not-persisted", + ) + val first = prepareDesktopAccountRegistry(session) + val second = prepareDesktopAccountRegistry(session.copy(loginName = "bob")) + DesktopAccountRegistryPreferenceStore(preferences).write(first) + val failingStore = DesktopAccountRegistryPreferenceStore(preferences) { + error("synthetic inactive generation flush failure") + } + + assertFailsWith { failingStore.write(second) } + + assertEquals(first, DesktopAccountRegistryPreferenceStore(preferences).read()) + } + @Test fun explicitSaveAndRemovalOwnOnlyCredentialFreeMetadata() = withPreferences { preferences -> val session = session() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt index 0dc96d5f0..932859581 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStoreTest.kt @@ -88,6 +88,7 @@ class DesktopFileSyncStoreTest { store.savePair(DesktopFileSyncPersistedState(FileSyncCoordinatorState(listOf(owned)), roots.take(1)), owned.id) store.savePair(DesktopFileSyncPersistedState(FileSyncCoordinatorState(listOf(clear)), roots.drop(1)), clear.id) + assertFails { store.requireDesktopFileSyncAccountRemovalReady("account-a") } assertFails { store.removeDesktopFileSyncAccountPairs("account-a") } val retained = store.load() From d4db556c60348815000479ddf604c6aa9b5a5a41 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 01:14:40 +0200 Subject: [PATCH 030/119] fix(accounts): harden transition recovery --- .../AndroidAccountCredentialController.kt | 18 ++++++-- .../AndroidAccountSelectionMaintenance.kt | 12 ++++++ .../AndroidPersistedSessionTest.kt | 16 ++++++++ .../DesktopAccountCredentialPersistence.kt | 26 ++++++++---- .../app/DesktopAccountOperationGuard.kt | 2 +- .../app/DesktopAccountRemoval.kt | 22 ++++++++++ .../app/DesktopNextcloudServices.kt | 7 ++-- ...DesktopAccountCredentialPersistenceTest.kt | 38 ++++++++++++++++- .../app/DesktopAccountOperationGuardTest.kt | 41 +++++++++++++++++++ 9 files changed, 167 insertions(+), 15 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 0241a9cbc..d942e7741 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -162,7 +162,7 @@ internal class AndroidAccountCredentialController( recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) if (!active) { - clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(session)) + clearRemovedAccountPreview(session) } } true @@ -286,7 +286,7 @@ internal class AndroidAccountCredentialController( state.registry.accounts.isEmpty() && state.sessions.isEmpty() }?.let(::encryptState) clearPersistedSession(encodedReplacement, replacement, pendingCleanup = pendingCleanup) - clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(activeSession)) + clearRemovedAccountPreview(activeSession) notifyDocumentRootsChanged() } @@ -349,7 +349,7 @@ internal class AndroidAccountCredentialController( suspectEncrypted, pendingCleanup, ) - activeSession?.let { session -> clearPreviewAccount(NextcloudDocumentIds.cacheAccountId(session)) } + activeSession?.let(::clearRemovedAccountPreview) notifyDocumentRootsChanged() } @@ -774,6 +774,18 @@ internal class AndroidAccountCredentialController( operation = "account-selection.cache-cleanup", component = SupportDiagnosticComponent.Cache, ) + private fun clearRemovedAccountPreview(session: NextcloudSession) = + clearAndroidPreviewAfterCommittedRemoval( + NextcloudDocumentIds.cacheAccountId(session), + clearPreviewAccount, + ::recordAccountRemovalCacheCleanupFailure, + ) + private fun recordAccountRemovalCacheCleanupFailure(failure: Exception) = recordCredentialFailure( + code = "ACCOUNT_REMOVAL_CACHE_CLEANUP_FAILED", + operation = "account.remove-cache-cleanup", + component = SupportDiagnosticComponent.Cache, + failure = failure, + ) private fun recordAccountHandoffCleanupFailure(failure: Exception) = recordCredentialFailure( code = "ACCOUNT_HANDOFF_CLEANUP_FAILED", operation = "account.handoff-cleanup", diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt index 1f68158ce..e23767e01 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt @@ -28,3 +28,15 @@ internal fun clearAndroidPreviousPreviewAfterCommittedSelection( runCatching { recordFailure(failure) } } } + +internal fun clearAndroidPreviewAfterCommittedRemoval( + accountCacheId: String, + clearPreviewAccount: (String) -> Unit, + recordFailure: (Exception) -> Unit, +) { + try { + clearPreviewAccount(accountCacheId) + } catch (failure: Exception) { + runCatching { recordFailure(failure) } + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index cb2a7bec8..f3ffe5d0c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -746,6 +746,22 @@ class AndroidPersistedSessionTest { assertEquals(listOf("clear-preview", "diagnose-cleanup"), events) } + @Test + fun previewCleanupFailureDoesNotRollBackACommittedAccountRemoval() { + val events = mutableListOf() + + clearAndroidPreviewAfterCommittedRemoval( + accountCacheId = "account-cache-id", + clearPreviewAccount = { + events += "clear-preview:$it" + error("synthetic preview cleanup failure") + }, + recordFailure = { events += "diagnose-cleanup" }, + ) + + assertEquals(listOf("clear-preview:account-cache-id", "diagnose-cleanup"), events) + } + @Test fun failedAccountTransitionDoesNotClearExternalHandoffs() { val events = mutableListOf() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 5618aadd2..86faed041 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -71,8 +71,7 @@ internal class DesktopAccountCredentialPersistence( val encodedRegistry = prepareRegistry(updatedRegistry) val secretReference = desktopAccountSecretReference(persistedSession.accountId) val previousSecret = loadSecretForRollback(secretReference) - val journalNewCredential = previousRecord == null - if (journalNewCredential) persistPendingCredentialSave(persistedSession) + persistPendingCredentialSave(persistedSession) try { saveSecret(persistedSession) persistAccountState(encodedRegistry, updatedRegistry.activeAccount) @@ -97,10 +96,10 @@ internal class DesktopAccountCredentialPersistence( rollbackFailure, ) } - if (journalNewCredential && credentialRollbackCompleted) clearPendingCredentialSave() + if (credentialRollbackCompleted) clearPendingCredentialSave() throw failure } - if (journalNewCredential) clearPendingCredentialSave() + clearPendingCredentialSave() return persistedSession } @@ -217,9 +216,8 @@ internal class DesktopAccountCredentialPersistence( ) return } - val credentialCommitted = registryRead.registry - ?.accounts - ?.any { account -> account.id == accountId } == true + val registry = registryRead.registry + val credentialCommitted = registry?.accounts?.any { account -> account.id == accountId } == true if (!credentialCommitted) { try { secretStore.clear(desktopAccountSecretReference(accountId)) @@ -233,6 +231,20 @@ internal class DesktopAccountCredentialPersistence( ) return } + } else { + val selected = requireNotNull(registry.select(accountId)) + try { + persistAccountState(prepareRegistry(selected), selected.activeAccount) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + failure, + ) + return + } } clearPendingCredentialSave() } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index de81e1f28..17b4f5336 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -105,8 +105,8 @@ internal fun removeDesktopCredentialWithoutProviderReactivation( removalCommitted: () -> Boolean = { false }, removeCredential: () -> Boolean, ): Boolean { - clearProviderPreference() return try { + clearProviderPreference() removeCredential().also { removed -> if (!removed) restoreProviderPreference(providerWasEnabled) } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index af507dd66..424c0961a 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -209,6 +209,19 @@ internal suspend fun retryPendingDesktopAccountSyncPairCleanups( } } +internal suspend fun recoverDesktopBackgroundAccountSyncPairCleanups( + retry: suspend () -> Unit, + recordFailure: (Exception) -> Unit, +) { + try { + retry() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + runCatching { recordFailure(failure) } + } +} + internal fun desktopAccountSyncPairCleanupFailureDiagnostic(accountId: String, failure: Exception) = SupportDiagnosticEventDraft( severity = SupportDiagnosticSeverity.Error, @@ -218,3 +231,12 @@ internal fun desktopAccountSyncPairCleanupFailureDiagnostic(accountId: String, f fields = desktopAccountDiagnosticFields(accountId), exception = failure.toSupportDiagnosticExceptionDraft(), ) + +internal fun desktopAccountSyncPairCleanupJournalFailureDiagnostic(failure: Exception) = + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.Sync, + operation = "account.remove-sync-cleanup-journal", + outcome = "failed", + exception = failure.toSupportDiagnosticExceptionDraft(), + ) 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 0de1f17bf..8a48e439b 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1629,9 +1629,10 @@ class DesktopNextcloudServices( backgroundFileSyncJob = serviceScope.launch { restoreConfirmedStartOnLoginRegistration() while (isActive) { - accountOperationGuard.serializeWhenSyncIdle { - retryPendingAccountSyncPairCleanups() - } + recoverDesktopBackgroundAccountSyncPairCleanups( + retry = { accountOperationGuard.serializeWhenSyncIdle { retryPendingAccountSyncPairCleanups() } }, + recordFailure = { recordSupportDiagnostic(desktopAccountSyncPairCleanupJournalFailureDiagnostic(it)) }, + ) if (!isFileSyncPaused()) { runCatching { syncAllFileSyncPairs(DesktopFileSyncRunSource.Background) } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index dde385832..4a6f45fab 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -223,6 +223,36 @@ class DesktopAccountCredentialPersistenceTest { assertEquals(original, persistence(preferences, secrets).loadActiveSession()) } + @Test + fun failedReplacementRollbackIsFinalizedFromTheCredentialJournalOnRestart() = + withStore { preferences, secrets -> + val original = firstSession() + val replacement = original.copy(appPassword = "replacement-password") + var flushCount = 0 + var failFlushOnAttempt: Int? = null + val persistence = persistence(preferences, secrets) { + flushCount += 1 + if (flushCount == failFlushOnAttempt) error("synthetic registry flush failure") + preferences.flush() + } + persistence.saveSession(original) + secrets.failSaveOnAttempt = secrets.saveCount + 2 + failFlushOnAttempt = flushCount + 2 + + assertFailsWith { persistence.saveSession(replacement) } + + assertEquals( + replacement.appPassword, + secrets.load(desktopAccountSecretReference(original.accountId))?.decodeToString(), + ) + assertEquals(original.serverUrl, preferences.get("accountCredentialSaveServer", null)) + + failFlushOnAttempt = null + assertEquals(replacement, persistence(preferences, secrets).loadActiveSession()) + assertNull(preferences.get("accountCredentialSaveServer", null)) + assertNull(preferences.get("accountCredentialSaveLogin", null)) + } + @Test fun canonicalEquivalentReauthenticationPreservesDesktopStorageIdentity() = withStore { preferences, secrets -> @@ -601,10 +631,13 @@ class DesktopAccountCredentialPersistenceTest { private class MemorySecretStore : DesktopSecretStore { private val values = mutableMapOf() var failSaves = false + var failSaveOnAttempt: Int? = null var failClears = false var loadFailure: RuntimeException? = null var loadCount = 0 private set + var saveCount = 0 + private set var clearCount = 0 private set @@ -615,7 +648,10 @@ class DesktopAccountCredentialPersistenceTest { } override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { - if (failSaves) error("private-app-password at cloud.example.test for alice") + saveCount += 1 + if (failSaves || saveCount == failSaveOnAttempt) { + error("private-app-password at cloud.example.test for alice") + } values[reference.targetName] = secret.copyOf() } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 4455b991e..829a92c77 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -436,6 +436,28 @@ class DesktopAccountOperationGuardTest { assertEquals(listOf("cleared", "remove", "restored:true"), events) } + @Test + fun failedProviderPreferenceClearRestoresThePreviousValue() { + val events = mutableListOf() + + assertFailsWith { + removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = true, + clearProviderPreference = { + events += "clear" + error("synthetic preference flush failure") + }, + restoreProviderPreference = { enabled -> events += "restore:$enabled" }, + removeCredential = { + events += "remove" + true + }, + ) + } + + assertEquals(listOf("clear", "restore:true"), events) + } + @Test fun successfulCredentialRemovalLeavesProviderPreferenceDisabled() = runBlocking { val events = mutableListOf() @@ -561,6 +583,25 @@ class DesktopAccountOperationGuardTest { assertEquals(listOf("prepare-cleanup", "remove-credential", "commit-cleanup"), events) } + @Test + fun backgroundSyncContinuesAfterCleanupJournalReadFailure() = runBlocking { + val events = mutableListOf() + + recoverDesktopBackgroundAccountSyncPairCleanups( + retry = { + events += "retry-cleanup" + error("synthetic cleanup journal read failure") + }, + recordFailure = { events += "diagnose-cleanup" }, + ) + events += "continue-background-sync" + + assertEquals( + listOf("retry-cleanup", "diagnose-cleanup", "continue-background-sync"), + events, + ) + } + @Test fun failedActiveCredentialCommitPreservesSyncPairs() = runBlocking { val events = mutableListOf() From 9ecd00c17ef3026f2d520a50554a8208562e5a60 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 01:43:12 +0200 Subject: [PATCH 031/119] fix(accounts): harden damaged state recovery --- .../AndroidAccountCredentialController.kt | 112 +++++++++--------- .../AndroidAccountCredentialRecovery.kt | 33 +++++- .../AndroidAccountRemovalCleanupJournal.kt | 60 ++++++++++ .../AndroidPersistedSession.kt | 22 +++- .../AndroidPersistedSessionTest.kt | 60 +++++++++- 5 files changed, 224 insertions(+), 63 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index d942e7741..6d104d044 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -32,6 +32,17 @@ internal class AndroidAccountCredentialController( private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String) -> Unit, ) { private val appContext = context.applicationContext + private val accountRemovalCleanupJournal = AndroidAccountRemovalCleanupJournal( + preferences = preferences, + commit = ::commitPreferences, + recordMalformed = { + recordCredentialFailure( + code = "ACCOUNT_REMOVAL_CLEANUP_JOURNAL_MALFORMED", + operation = "account.remove-cleanup.restore", + component = SupportDiagnosticComponent.Sync, + ) + }, + ) fun loadSession(): NextcloudSession? = ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.restorePersistedSession( load = { @@ -133,7 +144,7 @@ internal class AndroidAccountCredentialController( suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { - val current = requireValidState() + val current = requireValidStateForAccountRemoval(accountId) val session = current.sessions[accountId] ?: return@withLock removeUnavailableAccount(accountId, current) val accountIdentity = NextcloudDocumentIds.accountKey(session) @@ -151,14 +162,14 @@ internal class AndroidAccountCredentialController( previousSession = null, suspectEncrypted = null, ) - clearPendingAccountRemovalCleanup(accountId.storageKey) + accountRemovalCleanupJournal.clear(accountId.storageKey) }, persistInactiveRemoval = { persistState(current.remove(accountId), pendingCleanup) }, rollbackInactiveRemoval = { persistState(current) - clearPendingAccountRemovalCleanup(accountId.storageKey) + accountRemovalCleanupJournal.clear(accountId.storageKey) }, - completeCommittedCleanup = { clearPendingAccountRemovalCleanup(accountId.storageKey) }, + completeCommittedCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) if (!active) { @@ -184,12 +195,14 @@ internal class AndroidAccountCredentialController( removeQueuedUploads = { retryQueuedUploadsCleanup(unavailableSession, accountIdentity) }, clearActiveAccount = {}, rollbackActiveRemoval = {}, - persistInactiveRemoval = { persistState(recovered, pendingCleanup) }, - rollbackInactiveRemoval = { clearPendingAccountRemovalCleanup(accountId.storageKey) }, - completeCommittedCleanup = { clearPendingAccountRemovalCleanup(accountId.storageKey) }, + persistInactiveRemoval = { persistState(recovered.remove(accountId), pendingCleanup) }, + rollbackInactiveRemoval = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, + completeCommittedCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) } + clearRemovedAccountPreview(unavailableSession) + notifyDocumentRootsChanged() return true } @@ -214,12 +227,12 @@ internal class AndroidAccountCredentialController( clearActiveAccount = { clearSession(current, pendingCleanup) }, rollbackActiveRemoval = { replaceActiveStateWhileOperationsIdle(current, previousSession = null, suspectEncrypted = null) - clearPendingAccountRemovalCleanup(expectedSession.accountId.storageKey) + accountRemovalCleanupJournal.clear(expectedSession.accountId.storageKey) }, persistInactiveRemoval = {}, rollbackInactiveRemoval = {}, completeCommittedCleanup = { - clearPendingAccountRemovalCleanup(expectedSession.accountId.storageKey) + accountRemovalCleanupJournal.clear(expectedSession.accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) @@ -249,12 +262,12 @@ internal class AndroidAccountCredentialController( previousSession = null, suspectEncrypted = null, ) - clearPendingAccountRemovalCleanup(session.accountId.storageKey) + accountRemovalCleanupJournal.clear(session.accountId.storageKey) }, persistInactiveRemoval = {}, rollbackInactiveRemoval = {}, completeCommittedCleanup = { - clearPendingAccountRemovalCleanup(session.accountId.storageKey) + accountRemovalCleanupJournal.clear(session.accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) @@ -320,10 +333,10 @@ internal class AndroidAccountCredentialController( previousSession = null, suspectEncrypted = suspectEncrypted, ) - clearPendingAccountRemovalCleanup(activeSession.accountId.storageKey) + accountRemovalCleanupJournal.clear(activeSession.accountId.storageKey) }, completeCommittedCleanup = { - clearPendingAccountRemovalCleanup(activeSession.accountId.storageKey) + accountRemovalCleanupJournal.clear(activeSession.accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) @@ -384,7 +397,7 @@ internal class AndroidAccountCredentialController( encodeNextcloudAccountRegistry(replacement.registry), ).let { editor -> prepareCredentialSlotEdit(editor, replacement) } } - commitPreferences(preparePendingAccountRemovalCleanupEdit(editor, pendingCleanup)) + commitPreferences(accountRemovalCleanupJournal.prepareEdit(editor, pendingCleanup)) }, cancelAll = scheduler::cancelAll, clearPublishedAccount = { publishAccountIdentity(null) }, @@ -487,6 +500,18 @@ internal class AndroidAccountCredentialController( is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } + private fun requireValidStateForAccountRemoval(accountId: NextcloudAccountId): AndroidAccountCredentialState = + when (val read = readStore()) { + is AndroidAccountCredentialStoreRead.Available -> read.state.also { state -> + requireSupportedCredentialSlots(state.registry) + } + is AndroidAccountCredentialStoreRead.Invalid, + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable, + -> readIndependentCredentialSlotState(allowUnavailableActiveAccountId = accountId) + ?: error("The independent account credential slots could not be recovered.") + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) + } + private fun readCredentialFreeRegistry(): NextcloudAccountRegistry? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { val encoded = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) ?: return@serialize null @@ -577,13 +602,21 @@ internal class AndroidAccountCredentialController( return AndroidAccountCredentialStoreRead.Available(state) } - private fun readIndependentCredentialSlotState(): AndroidAccountCredentialState? { + private fun readIndependentCredentialSlotState( + allowUnavailableActiveAccountId: NextcloudAccountId? = null, + ): AndroidAccountCredentialState? { val encodedRegistry = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) ?: return null val registry = restoreAndroidCredentialFreeRegistry(encodedRegistry).registry ?: return null val slots = registry.accounts.associate { account -> account.id to readCredentialSlot(account.id) } if (slots.values.any { slot -> slot is AndroidAccountCredentialSlotRead.Unsupported }) return null - return reconstructAndroidAccountCredentialState(registry) { accountId -> - (slots[accountId] as? AndroidAccountCredentialSlotRead.Available)?.session + return if (allowUnavailableActiveAccountId == null) { + reconstructAndroidAccountCredentialState(registry) { accountId -> + (slots[accountId] as? AndroidAccountCredentialSlotRead.Available)?.session + } + } else { + reconstructAndroidAccountCredentialStateForRemoval(registry, allowUnavailableActiveAccountId) { accountId -> + (slots[accountId] as? AndroidAccountCredentialSlotRead.Available)?.session + } } } @@ -633,7 +666,7 @@ internal class AndroidAccountCredentialController( pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, ) = withContext(Dispatchers.IO) { commitPreferences( - preparePendingAccountRemovalCleanupEdit( + accountRemovalCleanupJournal.prepareEdit( prepareCredentialSlotEdit( preferences.edit() .putString(ANDROID_ACCOUNT_SESSION_KEY, encryptState(state)) @@ -646,10 +679,13 @@ internal class AndroidAccountCredentialController( } private suspend fun retryPendingAccountRemovalCleanup(session: NextcloudSession) { - val pending = pendingAndroidAccountRemovalCleanupForSession(session, pendingAccountRemovalCleanups()) ?: return + val pending = pendingAndroidAccountRemovalCleanupForSession( + session, + accountRemovalCleanupJournal.pending(), + ) ?: return try { retryQueuedUploadsCleanup(session, pending.workIdentity) - clearPendingAccountRemovalCleanup(pending.accountStorageKey) + accountRemovalCleanupJournal.clear(pending.accountStorageKey) } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { @@ -661,42 +697,6 @@ internal class AndroidAccountCredentialController( } } - private fun pendingAccountRemovalCleanups(): Set = - preferences.getStringSet(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, emptySet()) - ?.mapTo(linkedSetOf()) { encoded -> - requireNotNull(decodeAndroidPendingAccountRemovalCleanup(encoded)) { - "The pending account cleanup journal is invalid." - } - } - .orEmpty() - - private fun preparePendingAccountRemovalCleanupEdit( - editor: SharedPreferences.Editor, - pendingCleanup: AndroidPendingAccountRemovalCleanup?, - ): SharedPreferences.Editor = if (pendingCleanup == null) { - editor - } else { - val retained = pendingAccountRemovalCleanups() - .filterNot { cleanup -> cleanup.accountStorageKey == pendingCleanup.accountStorageKey } - .toSet() + pendingCleanup - editor.putStringSet( - ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, - retained.mapTo(linkedSetOf(), ::encodeAndroidPendingAccountRemovalCleanup), - ) - } - - private fun clearPendingAccountRemovalCleanup(accountStorageKey: String) { - val remaining = pendingAccountRemovalCleanups() - .filterNot { cleanup -> cleanup.accountStorageKey == accountStorageKey } - val editor = preferences.edit() - if (remaining.isEmpty()) editor.remove(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY) - else editor.putStringSet( - ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, - remaining.mapTo(linkedSetOf(), ::encodeAndroidPendingAccountRemovalCleanup), - ) - commitPreferences(editor) - } - private fun commitPreferences(editor: SharedPreferences.Editor) = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { try { requireCommittedAndroidAccountCredentialEdit(editor) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index b165851f5..8efef6763 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -74,6 +74,23 @@ internal fun decodeAndroidPendingAccountRemovalCleanup( return runCatching { AndroidPendingAccountRemovalCleanup(accountStorageKey, workIdentity) }.getOrNull() } +internal data class RestoredAndroidPendingAccountRemovalCleanups( + val cleanups: Set, + val malformedEntryCount: Int, +) + +internal fun restoreAndroidPendingAccountRemovalCleanups( + encoded: Set, +): RestoredAndroidPendingAccountRemovalCleanups { + val cleanups = linkedSetOf() + var malformedEntryCount = 0 + encoded.forEach { entry -> + val cleanup = decodeAndroidPendingAccountRemovalCleanup(entry) + if (cleanup == null) malformedEntryCount += 1 else cleanups += cleanup + } + return RestoredAndroidPendingAccountRemovalCleanups(cleanups, malformedEntryCount) +} + internal fun pendingAndroidAccountRemovalCleanupForSession( session: NextcloudSession, cleanups: Collection, @@ -107,8 +124,20 @@ internal fun reconstructAndroidAccountCredentialState( if (session == null) unavailableAccounts += account.id else sessions[account.id] = session } if (registry.activeAccountId in unavailableAccounts) return null - val retainedRegistry = unavailableAccounts.fold(registry) { retained, accountId -> retained.remove(accountId) } - return AndroidAccountCredentialState(retainedRegistry, sessions) + return AndroidAccountCredentialState(registry, sessions) +} + +internal fun reconstructAndroidAccountCredentialStateForRemoval( + registry: NextcloudAccountRegistry, + accountId: NextcloudAccountId, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): AndroidAccountCredentialState? { + val recoverableRegistry = if (registry.activeAccountId == accountId) { + registry.copy(activeAccountId = null) + } else { + registry + } + return reconstructAndroidAccountCredentialState(recoverableRegistry, loadSession) } internal fun restoreAndroidAccountCredentialStateWithoutAggregate( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt new file mode 100644 index 000000000..60e2e6d5f --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt @@ -0,0 +1,60 @@ +package dev.obiente.nextcloudnative + +import android.content.SharedPreferences + +internal class AndroidAccountRemovalCleanupJournal( + private val preferences: SharedPreferences, + private val commit: (SharedPreferences.Editor) -> Unit, + private val recordMalformed: () -> Unit, +) { + fun pending(): Set { + val encoded = runCatching { + preferences.getStringSet(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, emptySet()).orEmpty() + }.getOrElse { + repair(emptySet()) + return emptySet() + } + val restored = restoreAndroidPendingAccountRemovalCleanups(encoded) + if (restored.malformedEntryCount > 0) repair(restored.cleanups) + return restored.cleanups + } + + fun prepareEdit( + editor: SharedPreferences.Editor, + pendingCleanup: AndroidPendingAccountRemovalCleanup?, + ): SharedPreferences.Editor = if (pendingCleanup == null) { + editor + } else { + val retained = pending() + .filterNot { cleanup -> cleanup.accountStorageKey == pendingCleanup.accountStorageKey } + .toSet() + pendingCleanup + editor.putStringSet( + ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, + retained.mapTo(linkedSetOf(), ::encodeAndroidPendingAccountRemovalCleanup), + ) + } + + fun clear(accountStorageKey: String) { + val remaining = pending().filterNot { cleanup -> cleanup.accountStorageKey == accountStorageKey } + val editor = preferences.edit() + if (remaining.isEmpty()) editor.remove(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY) + else editor.putStringSet( + ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, + remaining.mapTo(linkedSetOf(), ::encodeAndroidPendingAccountRemovalCleanup), + ) + commit(editor) + } + + private fun repair(retained: Set) { + recordMalformed() + runCatching { + val editor = preferences.edit() + if (retained.isEmpty()) editor.remove(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY) + else editor.putStringSet( + ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, + retained.mapTo(linkedSetOf(), ::encodeAndroidPendingAccountRemovalCleanup), + ) + commit(editor) + } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index ec4750ed1..263602739 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -23,7 +23,7 @@ internal data class AndroidAccountCredentialState( ) { init { require(sessions.size <= MAX_ANDROID_ACCOUNT_CREDENTIALS) - require(sessions.size == registry.accounts.size) + require(sessions.size <= registry.accounts.size) require(sessions.all { (id, session) -> id == session.accountId && registry.accounts.any { account -> account == session.accountRecord() } }) @@ -90,12 +90,19 @@ internal fun restoreAndroidAccountCredentialStore( recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, ): RestoredAndroidAccountCredentialState { val restored = decodeAndroidAccountCredentialState(encoded) - restored.diagnosticCode?.let { code -> recordAccountCredentialDiagnostic(code, recordDiagnostic) } + restored.diagnosticCode?.let { code -> + recordAccountCredentialDiagnostic( + code = code, + outcome = restored.diagnosticOutcome(), + recordDiagnostic = recordDiagnostic, + ) + } if (restored.needsPersistence && restored.state != null) { runCatching { persistMigrated(encodeAndroidAccountCredentialState(restored.state)) } .onFailure { failure -> recordAccountCredentialDiagnostic( code = "ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED", + outcome = "failed", recordDiagnostic = recordDiagnostic, failure = failure, ) @@ -137,7 +144,7 @@ internal fun decodeAndroidAccountCredentialState(encoded: String): RestoredAndro if (claimedAccountId != session.accountId.storageKey) throw AndroidCredentialMismatchException() if (sessions.put(session.accountId, session) != null) throw AndroidCredentialMismatchException() } - if (sessions.size != registry.accounts.size || sessions.any { (_, session) -> + if (sessions.any { (_, session) -> registry.accounts.none { account -> account == session.accountRecord() } } ) { @@ -266,8 +273,15 @@ private fun malformedAndroidAccountCredentialState() = RestoredAndroidAccountCre diagnosticCode = "ACCOUNT_CREDENTIAL_STORE_MALFORMED", ) +private fun RestoredAndroidAccountCredentialState.diagnosticOutcome(): String = when { + unsupportedVersion != null || state?.mutationsAllowed == false -> "unsupported" + state == null -> "failed" + else -> "recovered" +} + private fun recordAccountCredentialDiagnostic( code: String, + outcome: String, recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, failure: Throwable? = null, ) { @@ -276,7 +290,7 @@ private fun recordAccountCredentialDiagnostic( severity = SupportDiagnosticSeverity.Warning, component = SupportDiagnosticComponent.Authentication, operation = "account-credentials.restore", - outcome = "recovered", + outcome = outcome, code = code, exception = failure?.toNonSecretSupportDiagnosticExceptionDraft(), ), diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index f3ffe5d0c..303ec1b61 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -256,6 +256,7 @@ class AndroidPersistedSessionTest { assertNull(restored) assertFalse(persisted) assertEquals(listOf("ACCOUNT_CREDENTIAL_STORE_MALFORMED"), diagnostics.mapNotNull { it.code }) + assertEquals("failed", diagnostics.single().outcome) assertDiagnosticsExcludePrivateValues(diagnostics) } @@ -276,6 +277,7 @@ class AndroidPersistedSessionTest { assertNull(restored) assertEquals(listOf("ACCOUNT_CREDENTIAL_SLOT_MISMATCH"), diagnostics.mapNotNull { it.code }) + assertEquals("failed", diagnostics.single().outcome) assertDiagnosticsExcludePrivateValues(diagnostics) } @@ -349,6 +351,7 @@ class AndroidPersistedSessionTest { assertEquals(firstSession(), requireNotNull(restored).activeSession) assertNotNull(migrated) assertEquals(listOf("ACCOUNT_REGISTRY_MALFORMED"), diagnostics.mapNotNull { it.code }) + assertEquals("recovered", diagnostics.single().outcome) assertDiagnosticsExcludePrivateValues(diagnostics) } @@ -371,6 +374,7 @@ class AndroidPersistedSessionTest { assertEquals(firstSession(), readOnly.activeSession) assertFalse(migrated) assertEquals(listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }) + assertEquals("unsupported", diagnostics.single().outcome) assertFailsWith { readOnly.upsertAndSelect(secondSession()) } assertFailsWith { readOnly.select(firstSession().accountId) } assertFailsWith { readOnly.remove(firstSession().accountId) } @@ -398,6 +402,7 @@ class AndroidPersistedSessionTest { listOf("ACCOUNT_CREDENTIAL_STORE_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }, ) + assertEquals("unsupported", diagnostics.single().outcome) assertFalse( androidCredentialStoreAllowsSessionRestore( AndroidAccountCredentialStoreRead.Unsupported("encrypted-future-store", 3), @@ -438,6 +443,7 @@ class AndroidPersistedSessionTest { assertEquals(firstSession(), requireNotNull(restored).activeSession) val diagnostic = diagnostics.single() assertEquals("ACCOUNT_CREDENTIAL_STORE_MIGRATION_FAILED", diagnostic.code) + assertEquals("failed", diagnostic.outcome) val exception = assertNotNull(diagnostic.exception) assertNull(exception.message) assertDiagnosticsExcludePrivateValues(diagnostics) @@ -604,6 +610,18 @@ class AndroidPersistedSessionTest { assertEquals(original.accountId.storageKey, pending.accountStorageKey) } + @Test + fun malformedPendingCleanupRowsAreIsolatedFromValidRecoveryWork() { + val valid = pendingAndroidAccountRemovalCleanup(firstSession()) + + val restored = restoreAndroidPendingAccountRemovalCleanups( + setOf(encodeAndroidPendingAccountRemovalCleanup(valid), "truncated-row"), + ) + + assertEquals(setOf(valid), restored.cleanups) + assertEquals(1, restored.malformedEntryCount) + } + @Test fun damagedCredentialSlotRecoversFromTheMatchingAggregateCredential() { val session = firstSession() @@ -665,8 +683,11 @@ class AndroidPersistedSessionTest { } assertEquals(mapOf(first.accountId to first), requireNotNull(restored).sessions) - assertEquals(listOf(first.accountRecord()), restored.registry.accounts) + assertEquals(listOf(first.accountRecord(), second.accountRecord()), restored.registry.accounts) assertEquals(first, restored.activeSession) + + val roundTrip = decodeAndroidAccountCredentialState(encodeAndroidAccountCredentialState(restored)).state + assertEquals(restored, roundTrip) } @Test @@ -684,6 +705,43 @@ class AndroidPersistedSessionTest { ) } + @Test + fun corruptActiveCredentialSlotCanBeRemovedWithoutDroppingOtherAccounts() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + + val recovered = reconstructAndroidAccountCredentialStateForRemoval( + registry = registry, + accountId = second.accountId, + loadSession = { accountId -> first.takeIf { accountId == first.accountId } }, + ) + + val afterRemoval = requireNotNull(recovered).remove(second.accountId) + assertNull(afterRemoval.activeSession) + assertEquals(listOf(first.accountRecord()), afterRemoval.registry.accounts) + assertEquals(mapOf(first.accountId to first), afterRemoval.sessions) + } + + @Test + fun corruptActiveCredentialSlotCannotAuthorizeRemovingAnotherAccount() { + val first = firstSession() + val second = secondSession() + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + + val recovered = reconstructAndroidAccountCredentialStateForRemoval( + registry = registry, + accountId = first.accountId, + loadSession = { accountId -> first.takeIf { accountId == first.accountId } }, + ) + + assertNull(recovered) + } + @Test fun validIndependentSlotsRecoverWhenTheAggregateKeyIsAbsent() { val first = firstSession() From b54d88d135c79dafe646d799487367d3e01bc333 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 02:06:38 +0200 Subject: [PATCH 032/119] fix(accounts): complete removal isolation --- .../NextcloudFileSyncWorker.kt | 115 ++++--- .../app/DesktopAccountOperationGuard.kt | 15 +- .../app/DesktopAccountRemoval.kt | 26 ++ .../app/DesktopNextcloudServices.kt | 289 +++++++----------- .../app/DesktopWindowsCloudFilesCleanup.kt | 125 ++++++++ .../app/DesktopAccountOperationGuardTest.kt | 30 +- ...sktopVirtualFileProviderPreferencesTest.kt | 50 +++ .../app/WindowsUninstallCleanupTest.kt | 105 +++++++ 8 files changed, 514 insertions(+), 241 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt index 0f38f29ff..74a9322ae 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt @@ -48,80 +48,75 @@ internal class NextcloudFileSyncWorker( // WorkManager may still execute short work when the OS temporarily refuses an FGS. } val engine = AndroidFileSyncEngine(applicationContext) - val result = try { + return@withContext try { ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountId) { val current = services.loadSession() if (current == null || !androidAccountOperationSessionIsCurrent(accountId, current)) { - null - } else { - engine.runPair(current, userId, pairId) + return@withAccount Result.failure() } - } ?: return@withContext Result.failure() - } catch (failure: Throwable) { - rethrowAndroidFileSyncCancellation(failure) - val disposition = backgroundSyncFailureDisposition(runAttemptCount) - services.recordSupportDiagnosticForAccountIdentity( - accountId, - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.Sync, - operation = "sync.background-run", - outcome = "failed", - fields = listOf( - SupportDiagnosticFieldDraft( - "pair", - pairId, - SupportDiagnosticValuePrivacy.Identifier, - ), - SupportDiagnosticFieldDraft("failure_scope", "run"), - SupportDiagnosticFieldDraft("work_attempt", runAttemptCount.toString()), - SupportDiagnosticFieldDraft( - "retry_scheduled", - (disposition == BackgroundSyncWorkerDisposition.Retry).toString(), - ), + val result = engine.runPair(current, userId, pairId) + val pair = engine.loadCenter(current, userId).pairs.firstOrNull { it.id == pairId } + ?: return@withAccount Result.success() + pair.conflicts.firstOrNull()?.let { conflict -> + AndroidNotificationCoordinator(applicationContext).post( + NextcloudNotificationEvent.SyncConflict( + id = stableNotificationId(pairId), + accountKey = accountId, + path = conflict.relativePath, + detail = syncConflictNotificationDetail(pair.conflictCount), ), - exception = failure.toSupportDiagnosticExceptionDraft(), - ), + ) + } + val completionDisposition = backgroundSyncCompletionDisposition( + failedCount = pair.failedCount, + resultRejected = result is FileSyncCenterActionResult.Rejected, ) - return@withContext disposition.toWorkerResult() - } - val pair = engine.loadCenter(session, userId).pairs.firstOrNull { it.id == pairId } - ?: return@withContext Result.success() - pair.conflicts.firstOrNull()?.let { conflict -> - AndroidNotificationCoordinator(applicationContext).post( - NextcloudNotificationEvent.SyncConflict( - id = stableNotificationId(pairId), - accountKey = accountId, - path = conflict.relativePath, - detail = syncConflictNotificationDetail(pair.conflictCount), - ), - ) - } - val completionDisposition = backgroundSyncCompletionDisposition( - failedCount = pair.failedCount, - resultRejected = result is FileSyncCenterActionResult.Rejected, - ) - if (completionDisposition == BackgroundSyncWorkerDisposition.WaitForNextPeriod) { + if (completionDisposition == BackgroundSyncWorkerDisposition.WaitForNextPeriod) { + services.recordSupportDiagnosticForAccountIdentity( + accountId, + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Sync, + operation = "sync.background-run", + outcome = "needs-attention", + fields = backgroundSyncCompletionDiagnosticFields( + pairId = pairId, + failedCount = pair.failedCount, + conflictCount = pair.conflictCount, + result = result, + ), + ), + ) + } + completionDisposition.toWorkerResult() + } + } catch (failure: Throwable) { + rethrowAndroidFileSyncCancellation(failure) + val disposition = backgroundSyncFailureDisposition(runAttemptCount) services.recordSupportDiagnosticForAccountIdentity( accountId, SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Warning, + severity = SupportDiagnosticSeverity.Error, component = SupportDiagnosticComponent.Sync, operation = "sync.background-run", - outcome = "needs-attention", - fields = backgroundSyncCompletionDiagnosticFields( - pairId = pairId, - failedCount = pair.failedCount, - conflictCount = pair.conflictCount, - result = result, + outcome = "failed", + fields = listOf( + SupportDiagnosticFieldDraft( + "pair", + pairId, + SupportDiagnosticValuePrivacy.Identifier, + ), + SupportDiagnosticFieldDraft("failure_scope", "run"), + SupportDiagnosticFieldDraft("work_attempt", runAttemptCount.toString()), + SupportDiagnosticFieldDraft( + "retry_scheduled", + (disposition == BackgroundSyncWorkerDisposition.Retry).toString(), + ), ), + exception = failure.toSupportDiagnosticExceptionDraft(), ), ) - // Per-item failures and attempt counts are durable coordinator state. An immediate - // WorkManager retry bypasses the periodic cadence and re-executes known failed work. - completionDisposition.toWorkerResult() - } else { - completionDisposition.toWorkerResult() + disposition.toWorkerResult() } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 17b4f5336..10788e4fb 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -103,6 +103,7 @@ internal fun removeDesktopCredentialWithoutProviderReactivation( clearProviderPreference: () -> Unit, restoreProviderPreference: (Boolean) -> Unit, removalCommitted: () -> Boolean = { false }, + finishCommittedRemoval: () -> Unit = {}, removeCredential: () -> Boolean, ): Boolean { return try { @@ -111,14 +112,20 @@ internal fun removeDesktopCredentialWithoutProviderReactivation( if (!removed) restoreProviderPreference(providerWasEnabled) } } catch (failure: Throwable) { - val committed = runCatching(removalCommitted).getOrElse { statusFailure -> + val committed = try { + removalCommitted() + } catch (statusFailure: Throwable) { failure.addSuppressed(statusFailure) - true + null } - if (!committed) { - runCatching { restoreProviderPreference(providerWasEnabled) } + when (committed) { + false -> runCatching { restoreProviderPreference(providerWasEnabled) } .exceptionOrNull() ?.let(failure::addSuppressed) + true -> runCatching(finishCommittedRemoval) + .exceptionOrNull() + ?.let(failure::addSuppressed) + null -> Unit } throw failure } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 424c0961a..20f53be29 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -88,6 +88,7 @@ internal fun removeDesktopAccountCredential( preferences: Preferences, providerAccountId: String?, credentialStillExists: () -> Boolean, + finishCommittedRemoval: () -> Unit = {}, removeCredential: () -> Boolean, ): Boolean { val providerKey = providerAccountId?.let(::virtualFileProviderPreferenceKey) @@ -105,6 +106,7 @@ internal fun removeDesktopAccountCredential( preferences.flush() }, removalCommitted = { !credentialStillExists() }, + finishCommittedRemoval = finishCommittedRemoval, removeCredential = removeCredential, ) } @@ -173,6 +175,30 @@ internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( ) } +internal suspend fun commitDesktopAccountRemovalBeforeVirtualFileTeardown( + commitRemoval: suspend () -> Unit, + teardownVirtualFiles: () -> Unit, +) { + commitRemoval() + teardownVirtualFiles() +} + +internal fun finishCommittedDesktopAccountRemoval( + markRemovalCommitted: () -> Unit, + teardownVirtualFiles: () -> Unit, + clearDiagnosticIdentity: () -> Unit, + clearIntakeIdentity: () -> Unit, +) { + markRemovalCommitted() + var firstFailure: Throwable? = null + listOf(teardownVirtualFiles, clearDiagnosticIdentity, clearIntakeIdentity).forEach { action -> + runCatching(action).onFailure { failure -> + if (firstFailure == null) firstFailure = failure else firstFailure?.addSuppressed(failure) + } + } + firstFailure?.let { throw it } +} + internal suspend fun retryDesktopAccountSyncPairCleanup( cleanup: DesktopAccountSyncPairCleanup, accountStillExists: (String) -> Boolean, 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 8a48e439b..013d7ee52 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -125,8 +125,6 @@ private const val MAX_VIRTUAL_FOLDER_DISCOVERED_ENTRIES = 100_000 private const val MAX_VIRTUAL_FOLDER_STABILITY_ATTEMPTS = 3 private const val VIRTUAL_FOLDER_REFRESH_INTERVAL_MILLIS = 6L * 60L * 60L * 1_000L private const val VIRTUAL_FOLDER_REFRESH_RETRY_MILLIS = 30L * 60L * 1_000L -private const val KEY_WINDOWS_CLOUD_FILES_ROOT = "windows-cloud-files-root" -private const val KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX = "wcfr." private const val KEY_WINDOWS_CLOUD_FILES_PRESERVED_ROOT_PREFIX = "wcfpr." private const val KEY_WINDOWS_CLOUD_FILES_RECOVERY_CURSOR = "windows-cloud-files-recovery-cursor" private const val MAX_WINDOWS_CLOUD_FILES_RECOVERY_ROOTS_PER_ATTEMPT = 16 @@ -135,7 +133,6 @@ private const val KEY_VIRTUAL_FILE_PRIMARY_CACHE_PREFIX = "vfpc-primary." private const val KEY_VIRTUAL_FILE_OVERFLOW_CACHE_PREFIX = "vfpc-overflow." private const val VIRTUAL_FILE_PRIMARY_PREFERENCE_VERSION = "v2" private const val VIRTUAL_FILE_OVERFLOW_PREFERENCE_VERSION = "v2" -private const val WINDOWS_CLOUD_FILES_ROOT_SUFFIX = "-v2" private fun isLinuxDesktop(): Boolean = System.getProperty("os.name").orEmpty().lowercase().contains("linux") @@ -520,98 +517,6 @@ internal fun pagedPersistedWindowsCloudFilesRecoveryRoots( return page } -private fun desktopLegacyWindowsCloudFilesRoot(accountId: String, userHome: File): File = - File(File(userHome, "Nextcloud Native"), accountId) - -internal fun unregisterSupersededWindowsCloudFilesRoot( - preferences: Preferences, - accountId: String, - userHome: File, - api: WindowsCloudFilesApi, -) { - require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) - val legacyRoot = validatedWindowsCloudFilesRoot(desktopLegacyWindowsCloudFilesRoot(accountId, userHome), userHome) - api.unregisterSyncRoot(legacyRoot) - clearWindowsCloudFilesRootPreferences(preferences, accountId, legacyRoot) -} - -private fun clearWindowsCloudFilesRootPreferences( - preferences: Preferences, - accountId: String, - removedRoot: Path, -) { - listOf(KEY_WINDOWS_CLOUD_FILES_ROOT, windowsCloudFilesRootPreferenceKey(accountId)).forEach { key -> - val savedRoot = preferences.get(key, null) - ?.let(::File) - ?.toPath() - ?.toAbsolutePath() - ?.normalize() - if (savedRoot == removedRoot) preferences.remove(key) - } -} - -internal fun unregisterWindowsCloudFilesRootForUninstall( - preferences: Preferences = Preferences.userRoot().node("dev/obiente/nextcloudnative"), - userHome: File = File(System.getProperty("user.home")), - apiFactory: () -> WindowsCloudFilesApi = ::JnaWindowsCloudFilesApi, -) { - val rootsByPreference = linkedMapOf>() - fun addRoot(root: File?, preferenceKey: String? = null) { - if (root == null) return - val validated = validatedWindowsCloudFilesRoot(root, userHome) - rootsByPreference.getOrPut(validated) { linkedSetOf() } - .apply { preferenceKey?.let(::add) } - } - addRoot( - preferences.get(KEY_WINDOWS_CLOUD_FILES_ROOT, null)?.let(::File), - KEY_WINDOWS_CLOUD_FILES_ROOT, - ) - preferences.keys().filter { it.startsWith(KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX) }.forEach { key -> - addRoot(preferences.get(key, null)?.let(::File), key) - } - val sessionAccountId = preferences.get("server", null)?.let { server -> - preferences.get("login", null)?.let { login -> - desktopFileCacheAccountId(NextcloudSession(server, login, "unused")) - } - } - sessionAccountId?.let { accountId -> - addRoot( - desktopWindowsCloudFilesRoot(accountId, userHome), - windowsCloudFilesRootPreferenceKey(accountId), - ) - addRoot(desktopLegacyWindowsCloudFilesRoot(accountId, userHome)) - } - if (rootsByPreference.isEmpty()) return - val api = apiFactory() - var firstFailure: Throwable? = null - try { - rootsByPreference.entries - .sortedByDescending { (root) -> root.fileName.toString().endsWith(WINDOWS_CLOUD_FILES_ROOT_SUFFIX) } - .forEach { (root, preferenceKeys) -> - runCatching { api.unregisterSyncRoot(root) } - .onSuccess { preferenceKeys.forEach(preferences::remove) } - .onFailure { failure -> if (firstFailure == null) firstFailure = failure } - } - } finally { - api.close() - } - firstFailure?.let { throw it } -} - -private fun validatedWindowsCloudFilesRoot(root: File, userHome: File): Path { - val expectedParent = File(userHome, "Nextcloud Native").toPath().toAbsolutePath().normalize() - val normalizedRoot = root.toPath().toAbsolutePath().normalize() - val name = normalizedRoot.fileName.toString() - val accountId = name.removeSuffix(WINDOWS_CLOUD_FILES_ROOT_SUFFIX) - check( - normalizedRoot.parent == expectedParent && - accountId.length == 64 && - accountId.all { it in '0'..'9' || it in 'a'..'f' } && - (name == accountId || name == accountId + WINDOWS_CLOUD_FILES_ROOT_SUFFIX), - ) { "The stored Windows Cloud Files root is invalid." } - return normalizedRoot -} - internal fun virtualFileProviderPreferenceKey(accountId: String): String { require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) return "vfp-active.$accountId".also { key -> @@ -3758,7 +3663,7 @@ class DesktopNextcloudServices( requireDesktopAccountRemovalReady(providerAccountId, isLinuxDesktop()) accountOperationGuard.withSyncRunLock { fileSyncEngine.requireAccountRemovalReady(providerAccountId) - removeDesktopAccountBeforeSyncPairCleanup( + val removed = removeDesktopAccountBeforeSyncPairCleanup( accountId = providerAccountId, prepareCleanup = accountSyncPairCleanupJournal::prepare, commitCleanup = accountSyncPairCleanupJournal::commit, @@ -3771,10 +3676,11 @@ class DesktopNextcloudServices( accountCredentials.removeAccount(accountId) } } }, - removeSyncPairs = { fileSyncEngine.removeAccountPairs(providerAccountId) }, + removeSyncPairs = { removeDesktopAccountOwnedState(providerAccountId) }, ) { recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(providerAccountId, it)) } + removed } } } @@ -3825,96 +3731,110 @@ class DesktopNextcloudServices( virtualFolderRetryAtEpochMillis.keys.removeIf { key -> key.startsWith(prefix) } } } - synchronized(virtualFileProviderLock) { - linuxVirtualFileSystem?.unmount() - linuxVirtualFileSystem = null - linuxVirtualMetadataBackend = null - linuxVirtualFileMountIdentity = null - linuxVirtualFileFailure = null - val windowsCloudFilesFailureMessage = "Could not remove the Windows Cloud Files root." - val provider = windowsCloudFilesProvider - try { - if (provider != null) { - provider.removeSyncRoot() - } else if (isWindowsDesktop()) { - unregisterWindowsCloudFilesRootForUninstall(preferences) - } - windowsCloudFilesFailure = null - } catch (failure: Throwable) { - windowsCloudFilesFailure = failure.message ?: windowsCloudFilesFailureMessage - supportDiagnostics.record( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.VirtualFiles, - operation = "cloud-files.signout-cleanup", - outcome = "failed", - fields = desktopAccountDiagnosticFields(accountId), - exception = failure.toSupportDiagnosticExceptionDraft(), - ), - ) - } finally { - runCatching { provider?.close() } - windowsCloudFilesProvider = null - windowsCloudFilesIdentity = null - preferences.remove(KEY_WINDOWS_CLOUD_FILES_ROOT) - accountId?.let { - clearWindowsCloudFilesRootPreferences( - preferences, - it, - desktopWindowsCloudFilesRoot(it, userHome).toPath(), - ) - clearWindowsCloudFilesRootPreferences( - preferences, - it, - desktopLegacyWindowsCloudFilesRoot(it, userHome).toPath(), + val teardownVirtualFiles = { + synchronized(virtualFileProviderLock) { + linuxVirtualFileSystem?.unmount() + linuxVirtualFileSystem = null + linuxVirtualMetadataBackend = null + linuxVirtualFileMountIdentity = null + linuxVirtualFileFailure = null + val windowsCloudFilesFailureMessage = "Could not remove the Windows Cloud Files root." + val provider = windowsCloudFilesProvider + try { + if (provider != null) { + provider.removeSyncRoot() + } + windowsCloudFilesFailure = null + } catch (failure: Throwable) { + windowsCloudFilesFailure = failure.message ?: windowsCloudFilesFailureMessage + supportDiagnostics.record( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.VirtualFiles, + operation = "cloud-files.signout-cleanup", + outcome = "failed", + fields = desktopAccountDiagnosticFields(accountId), + exception = failure.toSupportDiagnosticExceptionDraft(), + ), ) - } - if (isWindowsDesktop()) { - val uninstallFailure = runCatching { - unregisterWindowsCloudFilesRootForUninstall(preferences, userHome = userHome) - }.exceptionOrNull() - if (uninstallFailure != null) { - windowsCloudFilesFailure = windowsCloudFilesFailure ?: ( - uninstallFailure.message ?: windowsCloudFilesFailureMessage - ) - supportDiagnostics.record( - SupportDiagnosticEventDraft( - severity = SupportDiagnosticSeverity.Error, - component = SupportDiagnosticComponent.VirtualFiles, - operation = "cloud-files.signout-cleanup-retry", - outcome = "failed", - fields = desktopAccountDiagnosticFields(accountId), - exception = uninstallFailure.toSupportDiagnosticExceptionDraft(), - ), - ) + } finally { + runCatching { provider?.close() } + windowsCloudFilesProvider = null + windowsCloudFilesIdentity = null + if (isWindowsDesktop() && accountId != null) { + val uninstallFailure = runCatching { + unregisterWindowsCloudFilesRootsForAccountRemoval( + preferences = preferences, + accountId = accountId, + userHome = userHome, + ) + }.exceptionOrNull() + if (uninstallFailure != null) { + windowsCloudFilesFailure = windowsCloudFilesFailure ?: ( + uninstallFailure.message ?: windowsCloudFilesFailureMessage + ) + supportDiagnostics.record( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Error, + component = SupportDiagnosticComponent.VirtualFiles, + operation = "cloud-files.signout-cleanup-retry", + outcome = "failed", + fields = desktopAccountDiagnosticFields(accountId), + exception = uninstallFailure.toSupportDiagnosticExceptionDraft(), + ), + ) + } } } } } mutableFileSyncTraySnapshot.value = DesktopFileSyncTraySnapshot(phase = DesktopFileSyncTrayPhase.Idle) + val finishCommittedRemoval = { + finishCommittedDesktopAccountRemoval( + markRemovalCommitted = { cleared = true }, + teardownVirtualFiles = teardownVirtualFiles, + clearDiagnosticIdentity = { supportDiagnostics.setActiveAccountIdentity(null) }, + clearIntakeIdentity = { supportIntake.setActiveAccountIdentity(null) }, + ) + } clearDesktopActiveAccountBeforeSyncPairCleanup( accountId, accountSyncPairCleanupJournal, ::desktopAccountExists, { - sessionPublicationGuard.serialize { - check( - activeAccountId == null || removeDesktopAccountCredential( - preferences, - accountId, - credentialStillExists = { - accountCredentials.listAccounts().any { account -> account.id == activeAccountId } - }, - ) { accountCredentials.removeAccount(activeAccountId) }, - ) - supportDiagnostics.setActiveAccountIdentity(null) - supportIntake.setActiveAccountIdentity(null) - } + commitDesktopAccountRemovalBeforeVirtualFileTeardown( + commitRemoval = { + var committedFailure = false + try { + sessionPublicationGuard.serialize { + check( + activeAccountId == null || removeDesktopAccountCredential( + preferences, + accountId, + credentialStillExists = { + accountCredentials.listAccounts().any { account -> + account.id == activeAccountId + } + }, + finishCommittedRemoval = { committedFailure = true }, + ) { accountCredentials.removeAccount(activeAccountId) }, + ) + } + } catch (failure: Throwable) { + if (committedFailure) { + runCatching(finishCommittedRemoval) + .exceptionOrNull() + ?.let(failure::addSuppressed) + } + throw failure + } + }, + teardownVirtualFiles = finishCommittedRemoval, + ) }, - fileSyncEngine::removeAccountPairs, + ::removeDesktopAccountOwnedState, ::recordSupportDiagnostic, ) - cleared = true } } finally { if (!cleared) { @@ -3931,7 +3851,7 @@ class DesktopNextcloudServices( retryDesktopAccountSyncPairCleanup( cleanup = cleanup, accountStillExists = ::desktopAccountExists, - removeSyncPairs = fileSyncEngine::removeAccountPairs, + removeSyncPairs = ::removeDesktopAccountOwnedState, clearCleanup = accountSyncPairCleanupJournal::clear, ) } @@ -3940,13 +3860,32 @@ class DesktopNextcloudServices( retryPendingDesktopAccountSyncPairCleanups( cleanupJournal = accountSyncPairCleanupJournal, accountStillExists = ::desktopAccountExists, - removeSyncPairs = fileSyncEngine::removeAccountPairs, + removeSyncPairs = ::removeDesktopAccountOwnedState, recordCleanupFailure = { accountId, failure -> recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(accountId, failure)) }, ) } + private suspend fun removeDesktopAccountOwnedState(accountId: String) { + fileSyncEngine.removeAccountPairs(accountId) + if (!isWindowsDesktop()) return + try { + unregisterWindowsCloudFilesRootsForAccountRemoval( + preferences = preferences, + accountId = accountId, + ) + } catch (failure: Throwable) { + recordVirtualFileFailure( + operation = "cloud-files.account-removal-cleanup", + accountId = accountId, + root = desktopWindowsCloudFilesRoot(accountId).toPath(), + failure = failure, + ) + throw failure + } + } + private fun desktopAccountExists(accountId: String): Boolean = sessionPublicationGuard.serialize { accountCredentials.listAccounts().any { account -> desktopFileCacheAccountId(account) == accountId } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt new file mode 100644 index 000000000..5efab37c0 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt @@ -0,0 +1,125 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.nio.file.Path +import java.util.prefs.Preferences + +internal const val KEY_WINDOWS_CLOUD_FILES_ROOT = "windows-cloud-files-root" +internal const val KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX = "wcfr." +internal const val WINDOWS_CLOUD_FILES_ROOT_SUFFIX = "-v2" + +internal fun desktopLegacyWindowsCloudFilesRoot(accountId: String, userHome: File): File = + File(File(userHome, "Nextcloud Native"), accountId) + +internal fun unregisterSupersededWindowsCloudFilesRoot( + preferences: Preferences, + accountId: String, + userHome: File, + api: WindowsCloudFilesApi, +) { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + val legacyRoot = validatedWindowsCloudFilesRoot(desktopLegacyWindowsCloudFilesRoot(accountId, userHome), userHome) + api.unregisterSyncRoot(legacyRoot) + clearWindowsCloudFilesRootPreferences(preferences, accountId, legacyRoot) +} + +internal fun clearWindowsCloudFilesRootPreferences( + preferences: Preferences, + accountId: String, + removedRoot: Path, +) { + listOf(KEY_WINDOWS_CLOUD_FILES_ROOT, windowsCloudFilesRootPreferenceKey(accountId)).forEach { key -> + val savedRoot = preferences.get(key, null) + ?.let(::File) + ?.toPath() + ?.toAbsolutePath() + ?.normalize() + if (savedRoot == removedRoot) preferences.remove(key) + } +} + +internal fun unregisterWindowsCloudFilesRootForUninstall( + preferences: Preferences = Preferences.userRoot().node("dev/obiente/nextcloudnative"), + userHome: File = File(System.getProperty("user.home")), + apiFactory: () -> WindowsCloudFilesApi = ::JnaWindowsCloudFilesApi, +) { + val rootsByPreference = linkedMapOf>() + fun addRoot(root: File?, preferenceKey: String? = null) { + if (root == null) return + val validated = validatedWindowsCloudFilesRoot(root, userHome) + rootsByPreference.getOrPut(validated) { linkedSetOf() } + .apply { preferenceKey?.let(::add) } + } + addRoot( + preferences.get(KEY_WINDOWS_CLOUD_FILES_ROOT, null)?.let(::File), + KEY_WINDOWS_CLOUD_FILES_ROOT, + ) + preferences.keys().filter { it.startsWith(KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX) }.forEach { key -> + addRoot(preferences.get(key, null)?.let(::File), key) + } + val sessionAccountId = preferences.get("server", null)?.let { server -> + preferences.get("login", null)?.let { login -> + desktopFileCacheAccountId(NextcloudSession(server, login, "unused")) + } + } + sessionAccountId?.let { accountId -> + addRoot( + desktopWindowsCloudFilesRoot(accountId, userHome), + windowsCloudFilesRootPreferenceKey(accountId), + ) + addRoot(desktopLegacyWindowsCloudFilesRoot(accountId, userHome)) + } + if (rootsByPreference.isEmpty()) return + val api = apiFactory() + var firstFailure: Throwable? = null + try { + rootsByPreference.entries + .sortedByDescending { (root) -> root.fileName.toString().endsWith(WINDOWS_CLOUD_FILES_ROOT_SUFFIX) } + .forEach { (root, preferenceKeys) -> + runCatching { api.unregisterSyncRoot(root) } + .onSuccess { preferenceKeys.forEach(preferences::remove) } + .onFailure { failure -> if (firstFailure == null) firstFailure = failure } + } + } finally { + api.close() + } + firstFailure?.let { throw it } +} + +internal fun unregisterWindowsCloudFilesRootsForAccountRemoval( + preferences: Preferences, + accountId: String, + userHome: File = File(System.getProperty("user.home")), + apiFactory: () -> WindowsCloudFilesApi = ::JnaWindowsCloudFilesApi, +) { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + val currentRoot = validatedWindowsCloudFilesRoot(desktopWindowsCloudFilesRoot(accountId, userHome), userHome) + val legacyRoot = validatedWindowsCloudFilesRoot(desktopLegacyWindowsCloudFilesRoot(accountId, userHome), userHome) + val roots = listOf(currentRoot, legacyRoot) + val api = apiFactory() + var firstFailure: Throwable? = null + try { + roots.forEach { root -> + runCatching { api.unregisterSyncRoot(root) } + .onSuccess { clearWindowsCloudFilesRootPreferences(preferences, accountId, root) } + .onFailure { failure -> if (firstFailure == null) firstFailure = failure } + } + } finally { + api.close() + } + firstFailure?.let { throw it } +} + +internal fun validatedWindowsCloudFilesRoot(root: File, userHome: File): Path { + val expectedParent = File(userHome, "Nextcloud Native").toPath().toAbsolutePath().normalize() + val normalizedRoot = root.toPath().toAbsolutePath().normalize() + val name = normalizedRoot.fileName.toString() + val accountId = name.removeSuffix(WINDOWS_CLOUD_FILES_ROOT_SUFFIX) + check( + normalizedRoot.parent == expectedParent && + accountId.length == 64 && + accountId.all { it in '0'..'9' || it in 'a'..'f' } && + (name == accountId || name == accountId + WINDOWS_CLOUD_FILES_ROOT_SUFFIX), + ) { "The stored Windows Cloud Files root is invalid." } + return normalizedRoot +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 829a92c77..7415f8864 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -478,7 +478,7 @@ class DesktopAccountOperationGuardTest { } @Test - fun committedCredentialRemovalFailureDoesNotReactivateTheProvider() { + fun committedCredentialRemovalFailureFinishesProviderTeardownWithoutReactivation() { val events = mutableListOf() assertFailsWith { @@ -487,6 +487,7 @@ class DesktopAccountOperationGuardTest { clearProviderPreference = { events += "cleared" }, restoreProviderPreference = { enabled -> events += "restored:$enabled" }, removalCommitted = { true }, + finishCommittedRemoval = { events += "finish" }, removeCredential = { events += "remove" error("synthetic post-commit credential cleanup failure") @@ -494,7 +495,32 @@ class DesktopAccountOperationGuardTest { ) } - assertEquals(listOf("cleared", "remove"), events) + assertEquals(listOf("cleared", "remove", "finish"), events) + } + + @Test + fun unknownCredentialCommitStatusNeitherReactivatesNorTearsDownTheProvider() { + val events = mutableListOf() + + val failure = assertFailsWith { + removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = true, + clearProviderPreference = { events += "cleared" }, + restoreProviderPreference = { enabled -> events += "restored:$enabled" }, + removalCommitted = { + events += "probe" + error("synthetic registry read failure") + }, + finishCommittedRemoval = { events += "finish" }, + removeCredential = { + events += "remove" + error("synthetic credential removal failure") + }, + ) + } + + assertEquals(listOf("cleared", "remove", "probe"), events) + assertEquals(1, failure.suppressedExceptions.size) } @Test diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt index 5f268c393..7acb5374e 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt @@ -1,8 +1,10 @@ package dev.obiente.nextcloudnative.app import java.util.prefs.Preferences +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertSame @@ -48,4 +50,52 @@ class DesktopVirtualFileProviderPreferencesTest { assertTrue(detached) assertEquals(null, returnedFailure) } + + @Test + fun `aborted account removal leaves virtual file providers attached`() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + commitDesktopAccountRemovalBeforeVirtualFileTeardown( + commitRemoval = { + events += "remove" + error("credential removal failed") + }, + teardownVirtualFiles = { events += "teardown" }, + ) + } + + assertEquals(listOf("remove"), events) + } + + @Test + fun `committed account removal tears down virtual file providers afterward`() = runBlocking { + val events = mutableListOf() + + commitDesktopAccountRemovalBeforeVirtualFileTeardown( + commitRemoval = { events += "remove" }, + teardownVirtualFiles = { events += "teardown" }, + ) + + assertEquals(listOf("remove", "teardown"), events) + } + + @Test + fun `committed removal clears support identities even when provider teardown fails`() { + val events = mutableListOf() + + assertFailsWith { + finishCommittedDesktopAccountRemoval( + markRemovalCommitted = { events += "committed" }, + teardownVirtualFiles = { + events += "teardown" + error("synthetic unmount failure") + }, + clearDiagnosticIdentity = { events += "diagnostics" }, + clearIntakeIdentity = { events += "intake" }, + ) + } + + assertEquals(listOf("committed", "teardown", "diagnostics", "intake"), events) + } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt index b5634e97d..5faca0630 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt @@ -5,6 +5,7 @@ import java.nio.file.Files import java.nio.file.Path import java.util.UUID import java.util.prefs.Preferences +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -241,6 +242,106 @@ class WindowsUninstallCleanupTest { } } + @Test + fun inactiveAccountRemovalUnregistersOnlyThatAccountsCloudFilesRoots() { + val preferences = Preferences.userRoot().node("windows-account-removal-test-${UUID.randomUUID()}") + val home = Files.createTempDirectory("windows-account-removal-home").toFile() + val removedAccountId = "7".repeat(64) + val retainedAccountId = "8".repeat(64) + val removedRoot = desktopWindowsCloudFilesRoot(removedAccountId, home) + val retainedRoot = desktopWindowsCloudFilesRoot(retainedAccountId, home) + val api = RecordingWindowsCloudFilesApi() + try { + preferences.put(windowsCloudFilesRootPreferenceKey(removedAccountId), removedRoot.absolutePath) + preferences.put(windowsCloudFilesRootPreferenceKey(retainedAccountId), retainedRoot.absolutePath) + preferences.put("windows-cloud-files-root", retainedRoot.absolutePath) + + unregisterWindowsCloudFilesRootsForAccountRemoval( + preferences = preferences, + accountId = removedAccountId, + userHome = home, + apiFactory = { api }, + ) + + assertEquals( + listOf( + removedRoot.toPath(), + home.resolve("Nextcloud Native").resolve(removedAccountId).toPath(), + ), + api.unregisteredRoots, + ) + assertEquals(null, preferences.get(windowsCloudFilesRootPreferenceKey(removedAccountId), null)) + assertEquals( + retainedRoot.absolutePath, + preferences.get(windowsCloudFilesRootPreferenceKey(retainedAccountId), null), + ) + assertEquals(retainedRoot.absolutePath, preferences.get("windows-cloud-files-root", null)) + assertTrue(api.closed) + } finally { + preferences.removeNode() + home.deleteRecursively() + } + } + + @Test + fun partialAccountRootCleanupRemainsJournaledUntilEveryRootIsUnregistered() = runBlocking { + val preferences = Preferences.userRoot().node("windows-account-cleanup-retry-${UUID.randomUUID()}") + val home = Files.createTempDirectory("windows-account-cleanup-retry-home").toFile() + val accountId = "9".repeat(64) + val currentRoot = desktopWindowsCloudFilesRoot(accountId, home).toPath() + val legacyRoot = desktopLegacyWindowsCloudFilesRoot(accountId, home).toPath() + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + val firstApi = RecordingWindowsCloudFilesApi().apply { failingRoot = legacyRoot } + try { + preferences.put(windowsCloudFilesRootPreferenceKey(accountId), currentRoot.toString()) + val removed = removeDesktopAccountBeforeSyncPairCleanup( + accountId = accountId, + prepareCleanup = journal::prepare, + commitCleanup = journal::commit, + clearCleanup = journal::clear, + accountStillExists = { false }, + removeCredential = { true }, + removeSyncPairs = { + unregisterWindowsCloudFilesRootsForAccountRemoval( + preferences = preferences, + accountId = accountId, + userHome = home, + apiFactory = { firstApi }, + ) + }, + recordCleanupFailure = {}, + ) + + assertTrue(removed) + assertEquals(listOf(currentRoot, legacyRoot), firstApi.unregisterAttempts) + assertEquals( + listOf(DesktopAccountSyncPairCleanup(accountId, DesktopAccountSyncPairCleanupPhase.Committed)), + journal.pending(), + ) + + val retryApi = RecordingWindowsCloudFilesApi() + retryDesktopAccountSyncPairCleanup( + cleanup = journal.pending().single(), + accountStillExists = { false }, + removeSyncPairs = { + unregisterWindowsCloudFilesRootsForAccountRemoval( + preferences = preferences, + accountId = accountId, + userHome = home, + apiFactory = { retryApi }, + ) + }, + clearCleanup = journal::clear, + ) + + assertEquals(listOf(currentRoot, legacyRoot), retryApi.unregisterAttempts) + assertTrue(journal.pending().isEmpty()) + } finally { + preferences.removeNode() + home.deleteRecursively() + } + } + @Test fun uninstallCanUseThePersistedRootAfterSessionMetadataIsGone() { val preferences = Preferences.userRoot().node("windows-uninstall-root-test-${UUID.randomUUID()}") @@ -410,13 +511,17 @@ class WindowsUninstallCleanupTest { } private class RecordingWindowsCloudFilesApi : WindowsCloudFilesApi { + val unregisterAttempts = mutableListOf() val unregisteredRoots = mutableListOf() val unregisteredRoot: Path? get() = unregisteredRoots.lastOrNull() var prerequisiteRoot: Path? = null var dependentRoot: Path? = null + var failingRoot: Path? = null var closed = false override fun unregisterSyncRoot(root: Path) { + unregisterAttempts += root + if (root == failingRoot) error("Synthetic Cloud Files unregister failure") if (root == dependentRoot && prerequisiteRoot !in unregisteredRoots) { error("The stable registration still points at another candidate root.") } From f721f919262eea7a744c3423954749abc1a8c561 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 02:15:36 +0200 Subject: [PATCH 033/119] test(accounts): verify durable root cleanup --- .../dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt | 2 +- .../obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 20f53be29..830700403 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -193,7 +193,7 @@ internal fun finishCommittedDesktopAccountRemoval( var firstFailure: Throwable? = null listOf(teardownVirtualFiles, clearDiagnosticIdentity, clearIntakeIdentity).forEach { action -> runCatching(action).onFailure { failure -> - if (firstFailure == null) firstFailure = failure else firstFailure?.addSuppressed(failure) + if (firstFailure == null) firstFailure = failure else firstFailure.addSuppressed(failure) } } firstFailure?.let { throw it } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt index 5faca0630..1be27d5b0 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt @@ -520,7 +520,7 @@ class WindowsUninstallCleanupTest { var closed = false override fun unregisterSyncRoot(root: Path) { - unregisterAttempts += root + unregisterAttempts.add(root) if (root == failingRoot) error("Synthetic Cloud Files unregister failure") if (root == dependentRoot && prerequisiteRoot !in unregisteredRoots) { error("The stable registration still points at another candidate root.") From f198eb08223b21745f0fe6e69563e0062dc12cad Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 02:29:23 +0200 Subject: [PATCH 034/119] fix(accounts): harden removal recovery --- .../AndroidAccountCredentialController.kt | 10 +++- .../AndroidAccountCredentialTransitions.kt | 9 +++ .../AndroidPersistedSessionTest.kt | 40 +++++++++++++ .../app/DesktopAccountRemoval.kt | 56 +++++++++++++------ .../app/DesktopNextcloudServices.kt | 4 +- .../app/DesktopAccountOperationGuardTest.kt | 37 ++++++++++++ 6 files changed, 135 insertions(+), 21 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 6d104d044..4a221317f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -193,10 +193,14 @@ internal class AndroidAccountCredentialController( active = false, prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, removeQueuedUploads = { retryQueuedUploadsCleanup(unavailableSession, accountIdentity) }, - clearActiveAccount = {}, - rollbackActiveRemoval = {}, + clearActiveAccount = {}, rollbackActiveRemoval = {}, persistInactiveRemoval = { persistState(recovered.remove(accountId), pendingCleanup) }, - rollbackInactiveRemoval = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, + rollbackInactiveRemoval = { + rollbackUnavailableAndroidAccountRemoval( + recovered = recovered, persistRecovered = { state -> persistState(state) }, + clearCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, + ) + }, completeCommittedCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index dee10acc2..651306cd2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -8,6 +8,15 @@ internal fun removeActiveAndroidAccountCredentialState( state: AndroidAccountCredentialState, ): AndroidAccountCredentialState = state.registry.activeAccountId?.let(state::remove) ?: state +internal suspend fun rollbackUnavailableAndroidAccountRemoval( + recovered: AndroidAccountCredentialState, + persistRecovered: suspend (AndroidAccountCredentialState) -> Unit, + clearCleanup: suspend () -> Unit, +) { + persistRecovered(recovered) + clearCleanup() +} + internal suspend fun resumeAndroidQueuedUploadsAfterSelection( resume: suspend () -> Unit, notifyDocumentRootsChanged: () -> Unit, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 303ec1b61..d33fbe0f9 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -1027,6 +1027,46 @@ class AndroidPersistedSessionTest { assertEquals(listOf("clear-account", "rollback-active"), events) } + @Test + fun failedUnavailableAccountRemovalRestoresRecoveredStateBeforeClearingCleanup() = runBlocking { + val recovered = AndroidAccountCredentialState.Empty + .upsertAndSelect(firstSession()) + .upsertAndSelect(secondSession()) + val removed = recovered.remove(firstSession().accountId) + var persisted = recovered + val events = mutableListOf() + + assertFailsWith { + removeAndroidAccountCredentialData( + active = false, + removeQueuedUploads = { events += "remove-uploads" }, + clearActiveAccount = { events += "clear-account" }, + rollbackActiveRemoval = { events += "rollback-active" }, + persistInactiveRemoval = { + persisted = removed + events += "persist-removal" + error("synthetic commit result failure") + }, + rollbackInactiveRemoval = { + rollbackUnavailableAndroidAccountRemoval( + recovered = recovered, + persistRecovered = { state -> + persisted = state + events += "restore-recovered" + }, + clearCleanup = { events += "clear-cleanup" }, + ) + }, + ) + } + + assertEquals(recovered, persisted) + assertEquals( + listOf("persist-removal", "restore-recovered", "clear-cleanup"), + events, + ) + } + @Test fun cancelledInactiveAccountCleanupKeepsTheCredentialRemovalCommitted() = runBlocking { val cleanupEntered = CompletableDeferred() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 830700403..c8f18ddd5 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -15,6 +15,7 @@ internal data class DesktopAccountSyncPairCleanup( internal class DesktopAccountSyncPairCleanupJournal( private val preferences: Preferences, + private val recordMalformed: () -> Unit = {}, ) { fun prepare(accountId: String) = persist(accountId, DesktopAccountSyncPairCleanupPhase.Prepared) @@ -26,25 +27,38 @@ internal class DesktopAccountSyncPairCleanupJournal( preferences.flush() } - fun pending(): List = preferences.keys() - .asSequence() - .filter { key -> key.startsWith(KEY_PREFIX) } - .map { key -> - val accountId = key.removePrefix(KEY_PREFIX) - validateDesktopSyncPairCleanupAccountId(accountId) - val phase = when (preferences.get(key, null)) { - PREPARED -> DesktopAccountSyncPairCleanupPhase.Prepared - COMMITTED -> DesktopAccountSyncPairCleanupPhase.Committed - else -> error("The desktop account sync cleanup journal is invalid.") - } - DesktopAccountSyncPairCleanup(accountId, phase) - } - .toList() - .also { cleanups -> - check(cleanups.size <= MAX_LOCAL_ACCOUNTS) { - "The desktop account sync cleanup journal is too large." + fun pending(): List { + val malformedKeys = mutableListOf() + val cleanups = preferences.keys() + .asSequence() + .filter { key -> key.startsWith(KEY_PREFIX) } + .mapNotNull { key -> + val accountId = key.removePrefix(KEY_PREFIX) + val cleanup = runCatching { + validateDesktopSyncPairCleanupAccountId(accountId) + val phase = when (preferences.get(key, null)) { + PREPARED -> DesktopAccountSyncPairCleanupPhase.Prepared + COMMITTED -> DesktopAccountSyncPairCleanupPhase.Committed + else -> error("The desktop account sync cleanup journal is invalid.") + } + DesktopAccountSyncPairCleanup(accountId, phase) + }.getOrNull() + if (cleanup == null) malformedKeys += key + cleanup } + .toList() + if (malformedKeys.isNotEmpty()) quarantineMalformed(malformedKeys) + check(cleanups.size <= MAX_LOCAL_ACCOUNTS) { + "The desktop account sync cleanup journal is too large." } + return cleanups + } + + private fun quarantineMalformed(keys: List) { + keys.forEach { key -> runCatching { preferences.remove(key) } } + runCatching(preferences::flush) + runCatching(recordMalformed) + } private fun persist(accountId: String, phase: DesktopAccountSyncPairCleanupPhase) { validateDesktopSyncPairCleanupAccountId(accountId) @@ -266,3 +280,11 @@ internal fun desktopAccountSyncPairCleanupJournalFailureDiagnostic(failure: Exce outcome = "failed", exception = failure.toSupportDiagnosticExceptionDraft(), ) + +internal fun desktopAccountSyncPairCleanupJournalMalformedDiagnostic() = + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Sync, + operation = "account.remove-sync-cleanup-journal", + outcome = "malformed-entry-quarantined", + ) 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 013d7ee52..1f26c340f 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1499,7 +1499,9 @@ class DesktopNextcloudServices( ) }, ) - private val accountSyncPairCleanupJournal = DesktopAccountSyncPairCleanupJournal(preferences) + private val accountSyncPairCleanupJournal = DesktopAccountSyncPairCleanupJournal( + preferences, + ) { recordSupportDiagnostic(desktopAccountSyncPairCleanupJournalMalformedDiagnostic()) } private val startOnLoginController = DesktopStartOnLoginController() private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var backgroundFileSyncJob: Job? = null diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 7415f8864..226fc9809 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -655,6 +655,43 @@ class DesktopAccountOperationGuardTest { } } + @Test + fun malformedCleanupEntryDoesNotHideValidTombstonesOrBlockNewRemoval() { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val malformedAccountId = "1".repeat(64) + val validAccountId = "2".repeat(64) + val newAccountId = "3".repeat(64) + var malformedCount = 0 + try { + preferences.put("fsac.$malformedAccountId", "future-phase") + preferences.put("fsac.$validAccountId", "committed") + val journal = DesktopAccountSyncPairCleanupJournal(preferences) { malformedCount += 1 } + + assertEquals( + listOf( + DesktopAccountSyncPairCleanup( + validAccountId, + DesktopAccountSyncPairCleanupPhase.Committed, + ), + ), + journal.pending(), + ) + assertNull(preferences.get("fsac.$malformedAccountId", null)) + assertEquals("committed", preferences.get("fsac.$validAccountId", null)) + assertEquals(1, malformedCount) + + journal.prepare(newAccountId) + + assertEquals( + setOf(validAccountId, newAccountId), + journal.pending().mapTo(linkedSetOf(), DesktopAccountSyncPairCleanup::accountId), + ) + assertEquals(1, malformedCount) + } finally { + preferences.removeNode() + } + } + @Test fun committedPairCleanupFailureSurvivesRestartAndBlocksReactivationUntilRetry() = runBlocking { val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") From 8b1183101efb3b0aeeb5da30be64476f928b59f7 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 02:59:34 +0200 Subject: [PATCH 035/119] fix(accounts): fail closed on recovery tombstones --- .../AndroidAccountCredentialController.kt | 15 ++++--- .../AndroidAccountCredentialTransitions.kt | 20 ++++++++++ .../AndroidPersistedSessionTest.kt | 39 +++++++++++++++++++ .../app/DesktopAccountRemoval.kt | 23 +++++------ .../app/DesktopAccountOperationGuardTest.kt | 7 +++- 5 files changed, 83 insertions(+), 21 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 4a221317f..6c2b339c0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -684,20 +684,19 @@ internal class AndroidAccountCredentialController( private suspend fun retryPendingAccountRemovalCleanup(session: NextcloudSession) { val pending = pendingAndroidAccountRemovalCleanupForSession( - session, - accountRemovalCleanupJournal.pending(), + session, accountRemovalCleanupJournal.pending(), ) ?: return try { - retryQueuedUploadsCleanup(session, pending.workIdentity) - accountRemovalCleanupJournal.clear(pending.accountStorageKey) + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = readCredentialFreeRegistry()?.accounts?.any { it.id == session.accountId }, + removeAccountOwnedWork = { retryQueuedUploadsCleanup(session, pending.workIdentity) }, + clearCleanup = { accountRemovalCleanupJournal.clear(pending.accountStorageKey) }, + ) } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { recordAccountRemovalCleanupFailure(failure) - throw IllegalStateException( - "Previous account cleanup must finish before this account can be added again.", - failure, - ) + throw androidAccountRemovalCleanupRetryFailure(failure) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index 651306cd2..78ca56905 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -17,6 +17,26 @@ internal suspend fun rollbackUnavailableAndroidAccountRemoval( clearCleanup() } +internal suspend fun retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry: Boolean?, + removeAccountOwnedWork: suspend () -> Unit, + clearCleanup: suspend () -> Unit, +) { + when (accountOwnedByRegistry) { + true -> clearCleanup() + false -> { + removeAccountOwnedWork() + clearCleanup() + } + null -> error("Account ownership is unavailable; pending cleanup cannot run safely.") + } +} + +internal fun androidAccountRemovalCleanupRetryFailure(failure: Exception) = IllegalStateException( + "Previous account cleanup must finish before this account can be added again.", + failure, +) + internal suspend fun resumeAndroidQueuedUploadsAfterSelection( resume: suspend () -> Unit, notifyDocumentRootsChanged: () -> Unit, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index d33fbe0f9..225c7b032 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -1067,6 +1067,45 @@ class AndroidPersistedSessionTest { ) } + @Test + fun restoredAccountRetriesMarkerClearWithoutDeletingOwnedWork() = runBlocking { + var failClear = true + val events = mutableListOf() + val retry = suspend { + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = true, + removeAccountOwnedWork = { events += "remove-owned-work" }, + clearCleanup = { + events += "clear-cleanup" + if (failClear) { + failClear = false + error("synthetic cleanup marker commit failure") + } + }, + ) + } + + assertFailsWith { retry() } + retry() + + assertEquals(listOf("clear-cleanup", "clear-cleanup"), events) + } + + @Test + fun unknownAccountOwnershipFailsClosedBeforeCleanup() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = null, + removeAccountOwnedWork = { events += "remove-owned-work" }, + clearCleanup = { events += "clear-cleanup" }, + ) + } + + assertTrue(events.isEmpty()) + } + @Test fun cancelledInactiveAccountCleanupKeepsTheCredentialRemovalCommitted() = runBlocking { val cleanupEntered = CompletableDeferred() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index c8f18ddd5..98f881d56 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative.app import java.util.prefs.Preferences +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException internal enum class DesktopAccountSyncPairCleanupPhase { @@ -17,6 +18,8 @@ internal class DesktopAccountSyncPairCleanupJournal( private val preferences: Preferences, private val recordMalformed: () -> Unit = {}, ) { + private val malformedReported = AtomicBoolean() + fun prepare(accountId: String) = persist(accountId, DesktopAccountSyncPairCleanupPhase.Prepared) fun commit(accountId: String) = persist(accountId, DesktopAccountSyncPairCleanupPhase.Committed) @@ -28,7 +31,7 @@ internal class DesktopAccountSyncPairCleanupJournal( } fun pending(): List { - val malformedKeys = mutableListOf() + var malformedEntryFound = false val cleanups = preferences.keys() .asSequence() .filter { key -> key.startsWith(KEY_PREFIX) } @@ -43,31 +46,29 @@ internal class DesktopAccountSyncPairCleanupJournal( } DesktopAccountSyncPairCleanup(accountId, phase) }.getOrNull() - if (cleanup == null) malformedKeys += key + if (cleanup == null) malformedEntryFound = true cleanup } .toList() - if (malformedKeys.isNotEmpty()) quarantineMalformed(malformedKeys) + if (malformedEntryFound && malformedReported.compareAndSet(false, true)) runCatching(recordMalformed) check(cleanups.size <= MAX_LOCAL_ACCOUNTS) { "The desktop account sync cleanup journal is too large." } return cleanups } - private fun quarantineMalformed(keys: List) { - keys.forEach { key -> runCatching { preferences.remove(key) } } - runCatching(preferences::flush) - runCatching(recordMalformed) - } - private fun persist(accountId: String, phase: DesktopAccountSyncPairCleanupPhase) { validateDesktopSyncPairCleanupAccountId(accountId) + val key = cleanupKey(accountId) + check(preferences.get(key, null) in setOf(null, PREPARED, COMMITTED)) { + "The desktop account sync cleanup journal phase is unsupported." + } val pending = pending() check(pending.any { cleanup -> cleanup.accountId == accountId } || pending.size < MAX_LOCAL_ACCOUNTS) { "The desktop account sync cleanup journal is too large." } preferences.put( - cleanupKey(accountId), + key, if (phase == DesktopAccountSyncPairCleanupPhase.Prepared) PREPARED else COMMITTED, ) preferences.flush() @@ -286,5 +287,5 @@ internal fun desktopAccountSyncPairCleanupJournalMalformedDiagnostic() = severity = SupportDiagnosticSeverity.Warning, component = SupportDiagnosticComponent.Sync, operation = "account.remove-sync-cleanup-journal", - outcome = "malformed-entry-quarantined", + outcome = "unknown-entry-preserved", ) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 226fc9809..da10744c8 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -656,7 +656,7 @@ class DesktopAccountOperationGuardTest { } @Test - fun malformedCleanupEntryDoesNotHideValidTombstonesOrBlockNewRemoval() { + fun futureCleanupEntryIsPreservedWithoutHidingValidTombstonesOrBlockingNewRemoval() { val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") val malformedAccountId = "1".repeat(64) val validAccountId = "2".repeat(64) @@ -676,7 +676,7 @@ class DesktopAccountOperationGuardTest { ), journal.pending(), ) - assertNull(preferences.get("fsac.$malformedAccountId", null)) + assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) assertEquals("committed", preferences.get("fsac.$validAccountId", null)) assertEquals(1, malformedCount) @@ -686,6 +686,9 @@ class DesktopAccountOperationGuardTest { setOf(validAccountId, newAccountId), journal.pending().mapTo(linkedSetOf(), DesktopAccountSyncPairCleanup::accountId), ) + assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) + assertFailsWith { journal.prepare(malformedAccountId) } + assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) assertEquals(1, malformedCount) } finally { preferences.removeNode() From 10157c71bc11f3164bf2bc0169c14bbbddce822d Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 03:02:51 +0200 Subject: [PATCH 036/119] test(accounts): split removal recovery coverage --- .../AndroidAccountRemovalRecoveryTest.kt | 48 +++++++++++++++++++ .../AndroidPersistedSessionTest.kt | 39 --------------- 2 files changed, 48 insertions(+), 39 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt new file mode 100644 index 000000000..c0eb5ce5b --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt @@ -0,0 +1,48 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AndroidAccountRemovalRecoveryTest { + @Test + fun restoredAccountRetriesMarkerClearWithoutDeletingOwnedWork() = runBlocking { + var failClear = true + val events = mutableListOf() + val retry = suspend { + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = true, + removeAccountOwnedWork = { events += "remove-owned-work" }, + clearCleanup = { + events += "clear-cleanup" + if (failClear) { + failClear = false + error("synthetic cleanup marker commit failure") + } + }, + ) + } + + assertFailsWith { retry() } + retry() + + assertEquals(listOf("clear-cleanup", "clear-cleanup"), events) + } + + @Test + fun unknownAccountOwnershipFailsClosedBeforeCleanup() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = null, + removeAccountOwnedWork = { events += "remove-owned-work" }, + clearCleanup = { events += "clear-cleanup" }, + ) + } + + assertTrue(events.isEmpty()) + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 225c7b032..d33fbe0f9 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -1067,45 +1067,6 @@ class AndroidPersistedSessionTest { ) } - @Test - fun restoredAccountRetriesMarkerClearWithoutDeletingOwnedWork() = runBlocking { - var failClear = true - val events = mutableListOf() - val retry = suspend { - retryAndroidAccountRemovalCleanup( - accountOwnedByRegistry = true, - removeAccountOwnedWork = { events += "remove-owned-work" }, - clearCleanup = { - events += "clear-cleanup" - if (failClear) { - failClear = false - error("synthetic cleanup marker commit failure") - } - }, - ) - } - - assertFailsWith { retry() } - retry() - - assertEquals(listOf("clear-cleanup", "clear-cleanup"), events) - } - - @Test - fun unknownAccountOwnershipFailsClosedBeforeCleanup() = runBlocking { - val events = mutableListOf() - - assertFailsWith { - retryAndroidAccountRemovalCleanup( - accountOwnedByRegistry = null, - removeAccountOwnedWork = { events += "remove-owned-work" }, - clearCleanup = { events += "clear-cleanup" }, - ) - } - - assertTrue(events.isEmpty()) - } - @Test fun cancelledInactiveAccountCleanupKeepsTheCredentialRemovalCommitted() = runBlocking { val cleanupEntered = CompletableDeferred() From fbc87648821d21198cd0a8aa660760f9dbd3b410 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 03:47:37 +0200 Subject: [PATCH 037/119] fix(accounts): quiesce Linux writes before removal --- .../app/DesktopAccountOperationGuard.kt | 24 +++ .../app/DesktopAccountRemoval.kt | 39 ++++- .../app/DesktopLinuxProviderCleanup.kt | 39 +++++ .../app/DesktopNextcloudServices.kt | 95 +++++++++--- .../nextcloudnative/app/LinuxFuseLifecycle.kt | 59 ++++++++ .../app/LinuxVirtualFileSystem.kt | 116 ++++++--------- .../app/LinuxVirtualMutationGate.kt | 52 +++++++ .../app/DesktopAccountOperationGuardTest.kt | 21 ++- .../app/DesktopLinuxProviderCleanupTest.kt | 80 ++++++++++ ...sktopVirtualFileProviderPreferencesTest.kt | 31 ++++ .../app/LinuxVirtualMutationGateTest.kt | 138 ++++++++++++++++++ 11 files changed, 600 insertions(+), 94 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxFuseLifecycle.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGate.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 10788e4fb..86a3b87b6 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -103,12 +103,14 @@ internal fun removeDesktopCredentialWithoutProviderReactivation( clearProviderPreference: () -> Unit, restoreProviderPreference: (Boolean) -> Unit, removalCommitted: () -> Boolean = { false }, + commitStatusObserved: (Boolean?) -> Unit = {}, finishCommittedRemoval: () -> Unit = {}, removeCredential: () -> Boolean, ): Boolean { return try { clearProviderPreference() removeCredential().also { removed -> + commitStatusObserved(removed) if (!removed) restoreProviderPreference(providerWasEnabled) } } catch (failure: Throwable) { @@ -118,6 +120,7 @@ internal fun removeDesktopCredentialWithoutProviderReactivation( failure.addSuppressed(statusFailure) null } + commitStatusObserved(committed) when (committed) { false -> runCatching { restoreProviderPreference(providerWasEnabled) } .exceptionOrNull() @@ -131,6 +134,27 @@ internal fun removeDesktopCredentialWithoutProviderReactivation( } } +internal fun shouldResumeDesktopWritesAfterRemovalFailure( + removalCommitted: Boolean, + remoteRevocationAttempted: Boolean, + credentialRemovalStatus: Boolean?, +): Boolean = !removalCommitted && !remoteRevocationAttempted && credentialRemovalStatus == false + +internal fun recoverDesktopAccountAfterPrecommitFailure( + restoreProviderPreference: () -> Unit, + resumeVirtualFileSystem: () -> Unit, + reopenSession: () -> Unit, + restartLifecycle: () -> Unit, +): Throwable? { + var recoveryFailure: Throwable? = null + listOf(restoreProviderPreference, resumeVirtualFileSystem, reopenSession, restartLifecycle).forEach { action -> + runCatching(action).exceptionOrNull()?.let { failure -> + recoveryFailure?.addSuppressed(failure) ?: run { recoveryFailure = failure } + } + } + return recoveryFailure +} + internal fun requireDesktopSessionSaveAllowed( allowed: Boolean, recordBlocked: (SupportDiagnosticEventDraft) -> Unit, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 98f881d56..7fb8fecc2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -4,6 +4,19 @@ import java.util.prefs.Preferences import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException +internal const val DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE = + "This account has cleanup state written by a newer app version." + +internal fun unknownCleanupStateRejection() = + VirtualFileStorageActionResult.Rejected(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE) + +internal fun requireDesktopAccountActivationAllowed(blockedByUnknownCleanup: Boolean) { + check(!blockedByUnknownCleanup) { DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE } +} + +internal fun DesktopAccountSyncPairCleanupJournal.requireAccountActivationAllowed(record: NextcloudAccountRecord) = + requireDesktopAccountActivationAllowed(blocksAccountActivation(desktopFileCacheAccountId(record))) + internal enum class DesktopAccountSyncPairCleanupPhase { Prepared, Committed, @@ -30,6 +43,14 @@ internal class DesktopAccountSyncPairCleanupJournal( preferences.flush() } + fun blocksAccountActivation(accountId: String): Boolean { + validateDesktopSyncPairCleanupAccountId(accountId) + val phase = preferences.get(cleanupKey(accountId), null) + val blocked = phase != null && phase != PREPARED && phase != COMMITTED + if (blocked) recordMalformedOnce() + return blocked + } + fun pending(): List { var malformedEntryFound = false val cleanups = preferences.keys() @@ -50,7 +71,7 @@ internal class DesktopAccountSyncPairCleanupJournal( cleanup } .toList() - if (malformedEntryFound && malformedReported.compareAndSet(false, true)) runCatching(recordMalformed) + if (malformedEntryFound) recordMalformedOnce() check(cleanups.size <= MAX_LOCAL_ACCOUNTS) { "The desktop account sync cleanup journal is too large." } @@ -74,6 +95,10 @@ internal class DesktopAccountSyncPairCleanupJournal( preferences.flush() } + private fun recordMalformedOnce() { + if (malformedReported.compareAndSet(false, true)) runCatching(recordMalformed) + } + private fun cleanupKey(accountId: String): String = "$KEY_PREFIX$accountId".also { key -> check(key.length <= Preferences.MAX_KEY_LENGTH) } @@ -103,6 +128,7 @@ internal fun removeDesktopAccountCredential( preferences: Preferences, providerAccountId: String?, credentialStillExists: () -> Boolean, + commitStatusObserved: (Boolean?) -> Unit = {}, finishCommittedRemoval: () -> Unit = {}, removeCredential: () -> Boolean, ): Boolean { @@ -121,11 +147,22 @@ internal fun removeDesktopAccountCredential( preferences.flush() }, removalCommitted = { !credentialStillExists() }, + commitStatusObserved = commitStatusObserved, finishCommittedRemoval = finishCommittedRemoval, removeCredential = removeCredential, ) } +internal fun setDesktopVirtualFileProviderPreference( + preferences: Preferences, + accountId: String, + enabled: Boolean, +) { + val key = virtualFileProviderPreferenceKey(accountId) + if (enabled) preferences.putBoolean(key, true) else preferences.remove(key) + preferences.flush() +} + internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( accountId: String, prepareCleanup: suspend (String) -> Unit, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt new file mode 100644 index 000000000..718eea72b --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt @@ -0,0 +1,39 @@ +package dev.obiente.nextcloudnative.app + +internal data class DetachedDesktopLinuxProvider( + val fileSystem: LinuxNextcloudVirtualFileSystem, + val metadataBackend: CachingLinuxVirtualFileBackend?, + val accountId: String?, +) + +internal fun detachedDesktopLinuxProvider( + fileSystem: LinuxNextcloudVirtualFileSystem?, + metadataBackend: CachingLinuxVirtualFileBackend?, + accountId: String?, +): DetachedDesktopLinuxProvider? = fileSystem?.let { + DetachedDesktopLinuxProvider(it, metadataBackend, accountId) +} + +internal class DesktopLinuxProviderCleanupSlot { + private val lock = Any() + private var pending: DetachedDesktopLinuxProvider? = null + + fun unmountOrRetain(provider: DetachedDesktopLinuxProvider) { + try { + provider.fileSystem.unmount() + } catch (failure: Throwable) { + synchronized(lock) { + check(pending == null) + pending = provider + } + throw failure + } + } + + fun retry() { + val provider = synchronized(lock) { pending.also { pending = null } } ?: return + unmountOrRetain(provider) + } + + fun pendingForTest(): DetachedDesktopLinuxProvider? = synchronized(lock) { pending } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 1f26c340f..b586f4775 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -909,6 +909,7 @@ class DesktopNextcloudServices( private var linuxVirtualMetadataBackend: CachingLinuxVirtualFileBackend? = null private var linuxVirtualFileMountIdentity: String? = null private var linuxVirtualFileFailure: String? = null + private val linuxProviderCleanup = DesktopLinuxProviderCleanupSlot() @Volatile private var windowsCloudFilesProvider: WindowsCloudFilesProvider? = null @Volatile @@ -961,6 +962,7 @@ class DesktopNextcloudServices( accountId: String, cache: DesktopVirtualRangeCache, ) { + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return if (sessionClearing) return if (synchronized(virtualFileProviderLock) { accountId in virtualFileCacheTierMutations }) return if (cache.hasUnavailableRetainedOverflowRecords(accountId, relativePath)) return @@ -1478,6 +1480,7 @@ class DesktopNextcloudServices( if (!isLinuxDesktop()) return session ?: return val accountId = desktopFileCacheAccountId(session) + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return val cache = virtualRangeCache(accountId) val kept = cache.loadFolderRetention(accountId).rules.filter { rule -> rule.retention == VirtualFolderRetention.KeepOnDevice @@ -1816,6 +1819,10 @@ class DesktopNextcloudServices( ) } val accountId = desktopFileCacheAccountId(session) + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return unknownCleanupStateRejection() + runCatching(linuxProviderCleanup::retry).exceptionOrNull()?.let { + return VirtualFileStorageActionResult.Rejected(it.message ?: "The earlier Linux mount is still active.") + } var windowsCloudFilesRecoveryNotice = if (isWindowsDesktop()) { persistedWindowsCloudFilesRecoveryNotice(preferences, accountId) } else { @@ -2502,6 +2509,7 @@ class DesktopNextcloudServices( // A retained-metadata persistence callback can briefly enter virtualFileProviderLock. // Closing its backend while holding the same lock reverses that order and deadlocks. runCatching { providersToClose.first?.unmount() } + runCatching(linuxProviderCleanup::retry) runCatching { providersToClose.second?.close() } supportIntake.close() supportDiagnostics.close() @@ -2840,6 +2848,9 @@ class DesktopNextcloudServices( ?: return@syncRun FileSyncCenterActionResult.Rejected("Sign in before syncing folders.") val accountId = desktopFileCacheAccountId(session) diagnosticAccountId = accountId + if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) { + return@syncRun FileSyncCenterActionResult.Rejected(DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE) + } val userId = runCatching { loadServerInfo(session).userId }.getOrElse { failure -> return@syncRun FileSyncCenterActionResult.Rejected( failure.message ?: "Could not load the signed-in account.", @@ -3613,7 +3624,11 @@ class DesktopNextcloudServices( override suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = withContext(Dispatchers.IO) { accountOperationGuard.serialize operation@{ - if (activeAccountId() == accountId) return@operation loadSession(accountId) + if (activeAccountId() == accountId) { + listAccounts().firstOrNull { it.id == accountId } + ?.let(accountSyncPairCleanupJournal::requireAccountActivationAllowed) + return@operation loadSession(accountId) + } if (hasLiveAccountResources()) { recordSupportDiagnostic(desktopAccountSelectionBlockedDiagnostic()) return@operation null @@ -3699,6 +3714,13 @@ class DesktopNextcloudServices( activeFileRangeSessions.toList() } var cleared = false + var quiescedLinuxFileSystem: LinuxNextcloudVirtualFileSystem? = null + var linuxFileSystemQuiesced = false + var providerPreferenceAccountId: String? = null + var providerWasEnabledBeforeRemoval = false + var remoteRevocationAttempted = false + var credentialRemovalStatus: Boolean? = false + var removalFailure: Throwable? = null try { val activeAccountId = activeAccountId() val activeSession = activeAccountId?.let(::loadSession) @@ -3718,10 +3740,26 @@ class DesktopNextcloudServices( syncJob?.cancel() syncJob?.join() accountOperationGuard.withSyncRunLock { + quiescedLinuxFileSystem = synchronized(virtualFileProviderLock) { + linuxVirtualFileSystem?.takeIf { linuxVirtualFileMountIdentity == accountId } + } + linuxFileSystemQuiesced = quiescedLinuxFileSystem?.quiesceWrites() == true + check(quiescedLinuxFileSystem == null || linuxFileSystemQuiesced) { + "Close files being edited through the Linux virtual filesystem before removing this account." + } + accountId?.let { currentAccountId -> + providerPreferenceAccountId = currentAccountId + val key = virtualFileProviderPreferenceKey(currentAccountId) + providerWasEnabledBeforeRemoval = preferences.getBoolean(key, false) + setDesktopVirtualFileProviderPreference(preferences, currentAccountId, enabled = false) + } accountId ?.also { requireDesktopAccountRemovalReady(it, isLinuxDesktop()) } ?.let { fileSyncEngine.requireAccountRemovalReady(it) } - expectedSession?.let { session -> revokeRemoteSession(session) } + expectedSession?.let { session -> + remoteRevocationAttempted = true + revokeRemoteSession(session) + } val hydrationJobs = accountId?.let(::cancelAllVirtualFolderHydration).orEmpty() rangeSessions.forEach { source -> runCatching(source::close) } hydrationJobs.forEach { job -> job.join() } @@ -3734,12 +3772,18 @@ class DesktopNextcloudServices( } } val teardownVirtualFiles = { + val linuxProvider = synchronized(virtualFileProviderLock) { + detachedDesktopLinuxProvider( + linuxVirtualFileSystem, linuxVirtualMetadataBackend, linuxVirtualFileMountIdentity, + ).also { + linuxVirtualFileSystem = null + linuxVirtualMetadataBackend = null + linuxVirtualFileMountIdentity = null + linuxVirtualFileFailure = null + } + } + linuxProvider?.let(linuxProviderCleanup::unmountOrRetain) synchronized(virtualFileProviderLock) { - linuxVirtualFileSystem?.unmount() - linuxVirtualFileSystem = null - linuxVirtualMetadataBackend = null - linuxVirtualFileMountIdentity = null - linuxVirtualFileFailure = null val windowsCloudFilesFailureMessage = "Could not remove the Windows Cloud Files root." val provider = windowsCloudFilesProvider try { @@ -3818,6 +3862,7 @@ class DesktopNextcloudServices( account.id == activeAccountId } }, + commitStatusObserved = { credentialRemovalStatus = it }, finishCommittedRemoval = { committedFailure = true }, ) { accountCredentials.removeAccount(activeAccountId) }, ) @@ -3838,10 +3883,22 @@ class DesktopNextcloudServices( ::recordSupportDiagnostic, ) } - } finally { - if (!cleared) { - synchronized(fileRangeSessionLock) { sessionClearing = false } - if (desktopStoredSessionAccountId(preferences) != null) startDesktopSyncLifecycle() + } catch (failure: Throwable) { removalFailure = failure; throw failure } finally { + val reopen = shouldResumeDesktopWritesAfterRemovalFailure( + cleared, remoteRevocationAttempted, credentialRemovalStatus, + ) + if (reopen) { + val recoveryFailure = recoverDesktopAccountAfterPrecommitFailure( + restoreProviderPreference = { providerPreferenceAccountId?.let { + setDesktopVirtualFileProviderPreference(preferences, it, providerWasEnabledBeforeRemoval) + } }, + resumeVirtualFileSystem = { if (linuxFileSystemQuiesced) quiescedLinuxFileSystem?.resumeWrites() }, + reopenSession = { synchronized(fileRangeSessionLock) { sessionClearing = false } }, + restartLifecycle = { + if (desktopStoredSessionAccountId(preferences) != null) startDesktopSyncLifecycle() + }, + ) + recoveryFailure?.let { removalFailure?.addSuppressed(it) ?: throw it } } } } @@ -3849,13 +3906,15 @@ class DesktopNextcloudServices( private suspend fun retryPendingAccountSyncPairCleanup(accountId: String) { val cleanup = accountSyncPairCleanupJournal.pending() .singleOrNull { pending -> pending.accountId == accountId } - ?: return - retryDesktopAccountSyncPairCleanup( - cleanup = cleanup, - accountStillExists = ::desktopAccountExists, - removeSyncPairs = ::removeDesktopAccountOwnedState, - clearCleanup = accountSyncPairCleanupJournal::clear, - ) + if (cleanup != null) { + retryDesktopAccountSyncPairCleanup( + cleanup = cleanup, + accountStillExists = ::desktopAccountExists, + removeSyncPairs = ::removeDesktopAccountOwnedState, + clearCleanup = accountSyncPairCleanupJournal::clear, + ) + } + requireDesktopAccountActivationAllowed(accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) } private suspend fun retryPendingAccountSyncPairCleanups() { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxFuseLifecycle.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxFuseLifecycle.kt new file mode 100644 index 000000000..6c3ffd5e2 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxFuseLifecycle.kt @@ -0,0 +1,59 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.ByteBuffer +import java.nio.channels.SeekableByteChannel +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import jnr.posix.POSIXFactory + +internal fun linuxEffectiveProcessUid(): Long = Integer.toUnsignedLong(POSIXFactory.getPOSIX().geteuid()) + +internal fun linuxEffectiveProcessGid(): Long = Integer.toUnsignedLong(POSIXFactory.getPOSIX().getegid()) + +internal fun linuxFuseConnectionIdForMount( + mountPoint: Path, + mountInfo: String = runCatching { Files.readString(Path.of("/proc/self/mountinfo")) }.getOrDefault(""), +): Int? { + val encodedMountPoint = mountPoint.toAbsolutePath().normalize().toString() + .replace("\\", "\\134") + .replace(" ", "\\040") + .replace("\t", "\\011") + .replace("\n", "\\012") + return mountInfo.lineSequence().firstNotNullOfOrNull { line -> + val fields = line.split(' ') + val separator = fields.indexOf("-") + if ( + fields.size < 7 || separator < 6 || separator + 2 >= fields.size || + fields[4] != encodedMountPoint || + fields[separator + 1].let { type -> type != "fuse" && !type.startsWith("fuse.") } || + fields[separator + 2] != "nextcloud-native" + ) return@firstNotNullOfOrNull null + fields[2].substringAfter(':', "").toIntOrNull() + } +} + +internal fun openLinuxFuseAbortHandle(connectionId: Int): LinuxFuseAbortHandle? { + require(connectionId >= 0) + return openLinuxFuseAbortHandle(Path.of("/sys/fs/fuse/connections", connectionId.toString(), "abort")) +} + +internal fun openLinuxFuseAbortHandle(path: Path): LinuxFuseAbortHandle? = runCatching { + ChannelLinuxFuseAbortHandle(Files.newByteChannel(path, StandardOpenOption.WRITE)) +}.getOrNull() + +internal interface LinuxFuseAbortHandle : AutoCloseable { + fun abortBestEffort() +} + +private class ChannelLinuxFuseAbortHandle( + private val channel: SeekableByteChannel, +) : LinuxFuseAbortHandle { + override fun abortBestEffort() { + runCatching { channel.write(ByteBuffer.wrap("1\n".encodeToByteArray())) } + } + + override fun close() = channel.close() +} + +internal const val MAX_UNSIGNED_UNIX_ID = 0xffff_ffffL diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt index 2dc9b5724..bbac4d820 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt @@ -1,10 +1,7 @@ package dev.obiente.nextcloudnative.app -import java.nio.ByteBuffer -import java.nio.channels.SeekableByteChannel import java.nio.file.Files import java.nio.file.Path -import java.nio.file.StandardOpenOption import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ExecutorService @@ -13,7 +10,6 @@ import java.util.concurrent.Semaphore import java.util.concurrent.atomic.AtomicLong import jnr.ffi.Pointer import jnr.ffi.Platform -import jnr.posix.POSIXFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import ru.serce.jnrfuse.ErrorCodes @@ -1100,6 +1096,9 @@ internal class LinuxNextcloudVirtualFileSystem( private val maximumOpenDirectoryEntries: Int = DEFAULT_MAX_OPEN_DIRECTORY_ENTRIES, private val beforeDirectoryHandleRemoval: () -> Unit = {}, private val unmountOperation: (LinuxNextcloudVirtualFileSystem) -> Unit = { fileSystem -> fileSystem.umount() }, + private val fuseAbortHandleProvider: (Path?) -> LinuxFuseAbortHandle? = { mountPoint -> + mountPoint?.let(::linuxFuseConnectionIdForMount)?.let(::openLinuxFuseAbortHandle) + }, private val mountOwnerUid: Long = linuxEffectiveProcessUid(), private val mountOwnerGid: Long = linuxEffectiveProcessGid(), ) : FuseStubFS() { @@ -1116,6 +1115,7 @@ internal class LinuxNextcloudVirtualFileSystem( private var openDirectoryEntries = 0L private val pendingCreatedFiles = ConcurrentHashMap() private val namespaceLock = Any() + private val mutationGate = LinuxVirtualMutationGate() init { require(maximumOpenDirectoryEntries > 0) require(mountOwnerUid in 0L..MAX_UNSIGNED_UNIX_ID) @@ -1177,7 +1177,7 @@ internal class LinuxNextcloudVirtualFileSystem( 0 } - override fun open(path: String, fileInfo: FuseFileInfo): Int = fuseResult { + override fun open(path: String, fileInfo: FuseFileInfo): Int = fuseMutationResult { val normalized = path.linuxVirtualPath() val flags = fileInfo.flags.intValue() val writeAccess = flags and OPEN_ACCESS_MASK != OPEN_READ_ONLY @@ -1233,12 +1233,19 @@ internal class LinuxNextcloudVirtualFileSystem( override fun release(path: String, fileInfo: FuseFileInfo): Int = fuseResult { val id = fileInfo.fh.get() - if (id != EMPTY_FILE_HANDLE) { - synchronized(namespaceLock) { - readHandlePaths.remove(id) - readHandles.remove(id)?.close() + val writeRelease = writeHandles.containsKey(id) + val releaseStarted = !writeRelease || mutationGate.beginRelease() + if (!releaseStarted) return 0 + try { + if (id != EMPTY_FILE_HANDLE) { + synchronized(namespaceLock) { + readHandlePaths.remove(id) + readHandles.remove(id)?.close() + } + releaseWriteHandle(id) } - releaseWriteHandle(id) + } finally { + if (writeRelease) mutationGate.end() } 0 } @@ -1248,7 +1255,7 @@ internal class LinuxNextcloudVirtualFileSystem( if (pendingCreatedFiles.containsKey(normalized) || visibleNode(normalized) != null) 0 else -ErrorCodes.ENOENT() } - override fun create(path: String, mode: Long, fi: FuseFileInfo?): Int = fuseResult { + override fun create(path: String, mode: Long, fi: FuseFileInfo?): Int = fuseMutationResult { val fileInfo = fi ?: return -ErrorCodes.EINVAL() val normalized = path.linuxVirtualPath() val parent = visibleNode(normalized.substringBeforeLast('/', "")) @@ -1268,7 +1275,7 @@ internal class LinuxNextcloudVirtualFileSystem( 0 } - override fun mkdir(path: String, mode: Long): Int = fuseResult { + override fun mkdir(path: String, mode: Long): Int = fuseMutationResult { val normalized = path.linuxVirtualPath() val parent = visibleNode(normalized.substringBeforeLast('/', "")) ?: return -ErrorCodes.ENOENT() @@ -1282,7 +1289,7 @@ internal class LinuxNextcloudVirtualFileSystem( override fun rmdir(path: String): Int = deletePath(path, expectDirectory = true) - override fun rename(oldPath: String, newPath: String): Int = fuseResult { + override fun rename(oldPath: String, newPath: String): Int = fuseMutationResult { synchronized(namespaceLock) { val sourcePath = oldPath.linuxVirtualPath() val destination = newPath.linuxVirtualPath() @@ -1316,7 +1323,7 @@ internal class LinuxNextcloudVirtualFileSystem( } } - override fun truncate(path: String, size: Long): Int = fuseResult { + override fun truncate(path: String, size: Long): Int = fuseMutationResult { val normalized = path.linuxVirtualPath() pendingCreatedFiles[normalized]?.let { pending -> pending.delegate.truncate(size) @@ -1332,7 +1339,7 @@ internal class LinuxNextcloudVirtualFileSystem( 0 } - override fun write(path: String, buf: Pointer, size: Long, offset: Long, fi: FuseFileInfo): Int = fuseResult { + override fun write(path: String, buf: Pointer, size: Long, offset: Long, fi: FuseFileInfo): Int = fuseMutationResult { if (offset < 0L || size < 0L || size > Int.MAX_VALUE) return -ErrorCodes.EINVAL() val reference = writeHandles[fi.fh.get()] ?: return -ErrorCodes.EBADF() if (!reference.writable) return -ErrorCodes.EBADF() @@ -1341,7 +1348,7 @@ internal class LinuxNextcloudVirtualFileSystem( reference.shared.delegate.write(offset, bytes) } - override fun flush(path: String, fi: FuseFileInfo): Int = fuseResult { + override fun flush(path: String, fi: FuseFileInfo): Int = fuseMutationResult { writeHandles[fi.fh.get()]?.shared?.delegate?.flush() 0 } @@ -1372,15 +1379,20 @@ internal class LinuxNextcloudVirtualFileSystem( mountedAt = mountPoint.toAbsolutePath().normalize() } + internal fun quiesceWrites(): Boolean = mutationGate.tryQuiesce { + writeHandles.isEmpty() && pendingCreatedFiles.isEmpty() + } + + internal fun resumeWrites() = mutationGate.resume() + fun unmount() { var detached = false - val fuseConnectionId = mountedAt?.let(::linuxFuseConnectionIdForMount) - val fuseAbortHandle = fuseConnectionId?.let(::openLinuxFuseAbortHandle) + val fuseAbortHandle = fuseAbortHandleProvider(mountedAt) try { unmountOperation(this) detached = true - fuseAbortHandle?.abortBestEffort() } finally { + fuseAbortHandle?.abortBestEffort() runCatching { fuseAbortHandle?.close() } readHandles.values.forEach { runCatching(it::close) } writeHandles.values.map(LinuxOpenWriteReference::shared).distinct().forEach { shared -> @@ -1569,7 +1581,7 @@ internal class LinuxNextcloudVirtualFileSystem( private fun visibleNode(path: String): LinuxVirtualFileNode? = backend.resolve(path) - private fun deletePath(path: String, expectDirectory: Boolean): Int = fuseResult { + private fun deletePath(path: String, expectDirectory: Boolean): Int = fuseMutationResult { synchronized(namespaceLock) { val normalized = path.linuxVirtualPath() if (pendingCreatedFiles.containsKey(normalized)) return -ErrorCodes.EBUSY() @@ -1604,6 +1616,15 @@ internal class LinuxNextcloudVirtualFileSystem( -ErrorCodes.EIO() } + private inline fun fuseMutationResult(operation: () -> Int): Int = fuseResult { + mutationGate.begin() + try { + operation() + } finally { + mutationGate.end() + } + } + private companion object { const val DIRECTORY_PERMISSIONS = 0b111101101 // 0755 const val FILE_PERMISSIONS = 0b110100100 // 0644 @@ -1616,59 +1637,6 @@ internal class LinuxNextcloudVirtualFileSystem( } } -private fun linuxEffectiveProcessUid(): Long = Integer.toUnsignedLong(POSIXFactory.getPOSIX().geteuid()) - -private fun linuxEffectiveProcessGid(): Long = Integer.toUnsignedLong(POSIXFactory.getPOSIX().getegid()) - -internal fun linuxFuseConnectionIdForMount( - mountPoint: Path, - mountInfo: String = runCatching { Files.readString(Path.of("/proc/self/mountinfo")) }.getOrDefault(""), -): Int? { - val encodedMountPoint = mountPoint.toAbsolutePath().normalize().toString() - .replace("\\", "\\134") - .replace(" ", "\\040") - .replace("\t", "\\011") - .replace("\n", "\\012") - return mountInfo.lineSequence().firstNotNullOfOrNull { line -> - val fields = line.split(' ') - val separator = fields.indexOf("-") - if ( - fields.size < 7 || - separator < 6 || - separator + 2 >= fields.size || - fields[4] != encodedMountPoint || - fields[separator + 1].let { type -> type != "fuse" && !type.startsWith("fuse.") } || - fields[separator + 2] != "nextcloud-native" - ) { - return@firstNotNullOfOrNull null - } - fields[2].substringAfter(':', "").toIntOrNull() - } -} - -private fun openLinuxFuseAbortHandle(connectionId: Int): LinuxFuseAbortHandle? { - require(connectionId >= 0) - return openLinuxFuseAbortHandle( - Path.of("/sys/fs/fuse/connections", connectionId.toString(), "abort"), - ) -} - -internal fun openLinuxFuseAbortHandle(path: Path): LinuxFuseAbortHandle? = runCatching { - LinuxFuseAbortHandle(Files.newByteChannel(path, StandardOpenOption.WRITE)) -}.getOrNull() - -internal class LinuxFuseAbortHandle( - private val channel: SeekableByteChannel, -) : AutoCloseable { - fun abortBestEffort() { - runCatching { channel.write(ByteBuffer.wrap("1\n".encodeToByteArray())) } - } - - override fun close() = channel.close() -} - -private const val MAX_UNSIGNED_UNIX_ID = 0xffff_ffffL - /** Stable across refreshes and app restarts so file managers can reconcile large directory models. */ internal fun stableLinuxVirtualInode(path: String): Long { var hash = -0x340d631b7bdddcdbL @@ -1690,7 +1658,7 @@ private data class LinuxOpenDirectoryEntry( val node: LinuxVirtualFileNode?, ) -private class LinuxVirtualFileSystemException(val errorCode: Int) : RuntimeException() +internal class LinuxVirtualFileSystemException(val errorCode: Int) : RuntimeException() private class LinuxSharedWriteHandle( val delegate: LinuxVirtualFileWriteHandle, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGate.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGate.kt new file mode 100644 index 000000000..39339a8d0 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGate.kt @@ -0,0 +1,52 @@ +package dev.obiente.nextcloudnative.app + +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import ru.serce.jnrfuse.ErrorCodes + +internal class LinuxVirtualMutationGate { + private enum class State { Open, Draining, Quiesced } + + private val lock = ReentrantLock() + private val drained = lock.newCondition() + private var state = State.Open + private var active = 0 + + fun begin() = lock.withLock { + if (state != State.Open) throw LinuxVirtualFileSystemException(ErrorCodes.EBUSY()) + active += 1 + } + + fun beginRelease(): Boolean = lock.withLock { + if (state == State.Quiesced) return false + active += 1 + true + } + + fun end() = lock.withLock { + check(active > 0) + active -= 1 + if (active == 0) drained.signalAll() + } + + fun tryQuiesce(canQuiesce: () -> Boolean): Boolean = lock.withLock { + if (state == State.Quiesced) return true + check(state == State.Open) + state = State.Draining + while (active > 0) drained.awaitUninterruptibly() + if (canQuiesce()) { + state = State.Quiesced + true + } else { + state = State.Open + false + } + } + + fun resume() = lock.withLock { + check(state == State.Quiesced) + state = State.Open + } + + fun isAcceptingNewOperations(): Boolean = lock.withLock { state == State.Open } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index da10744c8..834d15941 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -511,6 +511,7 @@ class DesktopAccountOperationGuardTest { events += "probe" error("synthetic registry read failure") }, + commitStatusObserved = { events += "status:$it" }, finishCommittedRemoval = { events += "finish" }, removeCredential = { events += "remove" @@ -519,10 +520,24 @@ class DesktopAccountOperationGuardTest { ) } - assertEquals(listOf("cleared", "remove", "probe"), events) + assertEquals(listOf("cleared", "remove", "probe", "status:null"), events) assertEquals(1, failure.suppressedExceptions.size) } + @Test + fun linuxWritesResumeOnlyAfterPositivelyKnownPrecommitFailure() { + assertTrue( + shouldResumeDesktopWritesAfterRemovalFailure( + removalCommitted = false, + remoteRevocationAttempted = false, + credentialRemovalStatus = false, + ), + ) + assertFalse(shouldResumeDesktopWritesAfterRemovalFailure(false, false, null)) + assertFalse(shouldResumeDesktopWritesAfterRemovalFailure(false, true, false)) + assertFalse(shouldResumeDesktopWritesAfterRemovalFailure(true, false, true)) + } + @Test fun committedInactiveRemovalSurvivesSyncPairCleanupFailure() = runBlocking { val events = mutableListOf() @@ -678,6 +693,9 @@ class DesktopAccountOperationGuardTest { ) assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) assertEquals("committed", preferences.get("fsac.$validAccountId", null)) + assertTrue(journal.blocksAccountActivation(malformedAccountId)) + assertFailsWith { requireDesktopAccountActivationAllowed(true) } + assertFalse(journal.blocksAccountActivation(validAccountId)) assertEquals(1, malformedCount) journal.prepare(newAccountId) @@ -686,6 +704,7 @@ class DesktopAccountOperationGuardTest { setOf(validAccountId, newAccountId), journal.pending().mapTo(linkedSetOf(), DesktopAccountSyncPairCleanup::accountId), ) + assertFalse(journal.blocksAccountActivation(newAccountId)) assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) assertFailsWith { journal.prepare(malformedAccountId) } assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt new file mode 100644 index 000000000..51eca4e7c --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt @@ -0,0 +1,80 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame +import kotlin.test.assertTrue +import ru.serce.jnrfuse.ErrorCodes + +class DesktopLinuxProviderCleanupTest { + @Test + fun `failed unmount aborts fuse and retains the exact quiesced provider`() { + var aborted = false + var abortHandleClosed = false + val fileSystem = LinuxNextcloudVirtualFileSystem( + backend = CleanupBackend, + unmountOperation = { error("synthetic unmount failure") }, + fuseAbortHandleProvider = { + object : LinuxFuseAbortHandle { + override fun abortBestEffort() { + aborted = true + } + + override fun close() { + abortHandleClosed = true + } + } + }, + ) + assertTrue(fileSystem.quiesceWrites()) + val provider = DetachedDesktopLinuxProvider(fileSystem, null, "account") + val cleanup = DesktopLinuxProviderCleanupSlot() + + assertFailsWith { cleanup.unmountOrRetain(provider) } + + assertTrue(aborted) + assertTrue(abortHandleClosed) + assertSame(provider, cleanup.pendingForTest()) + assertEquals(-ErrorCodes.EBUSY(), fileSystem.mkdir("/blocked", 0L)) + } + + @Test + fun `failed unmount without an abort handle still retains a quiesced provider`() { + val fileSystem = LinuxNextcloudVirtualFileSystem( + backend = CleanupBackend, + unmountOperation = { error("synthetic unmount failure") }, + fuseAbortHandleProvider = { null }, + ) + assertTrue(fileSystem.quiesceWrites()) + val provider = DetachedDesktopLinuxProvider(fileSystem, null, "account") + val cleanup = DesktopLinuxProviderCleanupSlot() + + assertFailsWith { cleanup.unmountOrRetain(provider) } + + assertSame(provider, cleanup.pendingForTest()) + assertEquals(-ErrorCodes.EBUSY(), fileSystem.mkdir("/blocked", 0L)) + } +} + +private object CleanupBackend : LinuxVirtualFileBackend { + override fun resolve(path: String): LinuxVirtualFileNode? = null + override fun list(path: String): List = emptyList() + override fun open(node: LinuxVirtualFileNode): LinuxVirtualFileReadHandle = error("Not used") + override fun openWrite( + path: String, + existing: LinuxVirtualFileNode?, + truncate: Boolean, + ): LinuxVirtualFileWriteHandle = error("Not used") + + override fun createDirectory(path: String) = Unit + override fun delete(node: LinuxVirtualFileNode) = Unit + override fun move(node: LinuxVirtualFileNode, destinationPath: String) = Unit + override fun moveReplacing( + node: LinuxVirtualFileNode, + destination: LinuxVirtualFileNode, + destinationPath: String, + ) = Unit + + override fun close() = Unit +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt index 7acb5374e..9d349bc0f 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt @@ -51,6 +51,37 @@ class DesktopVirtualFileProviderPreferencesTest { assertEquals(null, returnedFailure) } + @Test + fun `remote revocation attempt never restores a pre-disabled provider`() { + var providerEnabled = false + val removed = removeDesktopCredentialWithoutProviderReactivation( + providerWasEnabled = false, + clearProviderPreference = { providerEnabled = false }, + restoreProviderPreference = { enabled -> providerEnabled = enabled }, + removeCredential = { false }, + ) + + assertFalse(removed) + assertFalse(providerEnabled) + assertFalse(shouldResumeDesktopWritesAfterRemovalFailure(false, true, false)) + } + + @Test + fun `provider restore failure does not prevent in-memory account recovery`() { + val events = mutableListOf() + val restoreFailure = IllegalStateException("synthetic preference flush failure") + + val recoveryFailure = recoverDesktopAccountAfterPrecommitFailure( + restoreProviderPreference = { events += "restore"; throw restoreFailure }, + resumeVirtualFileSystem = { events += "resume" }, + reopenSession = { events += "reopen" }, + restartLifecycle = { events += "restart" }, + ) + + assertSame(restoreFailure, recoveryFailure) + assertEquals(listOf("restore", "resume", "reopen", "restart"), events) + } + @Test fun `aborted account removal leaves virtual file providers attached`() = runBlocking { val events = mutableListOf() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt new file mode 100644 index 000000000..22717889c --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt @@ -0,0 +1,138 @@ +package dev.obiente.nextcloudnative.app + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import jnr.ffi.Runtime +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import ru.serce.jnrfuse.ErrorCodes +import ru.serce.jnrfuse.struct.FuseFileInfo + +class LinuxVirtualMutationGateTest { + @Test + fun `quiescence blocks new mutations and drains an active callback`() { + val gate = LinuxVirtualMutationGate() + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val workers = Executors.newFixedThreadPool(2) + try { + val mutation = workers.submit { + gate.begin() + try { + entered.countDown() + check(release.await(5, TimeUnit.SECONDS)) + } finally { + gate.end() + } + } + check(entered.await(5, TimeUnit.SECONDS)) + val quiescence = workers.submit { gate.tryQuiesce { true } } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) + while (gate.isAcceptingNewOperations() && System.nanoTime() < deadline) Thread.yield() + + assertFalse(gate.isAcceptingNewOperations()) + assertFailsWith { gate.begin() } + assertFalse(quiescence.isDone) + release.countDown() + mutation.get(5, TimeUnit.SECONDS) + assertTrue(quiescence.get(5, TimeUnit.SECONDS)) + + gate.resume() + gate.begin() + gate.end() + } finally { + release.countDown() + workers.shutdownNow() + } + } + + @Test + fun `failed quiescence reopens automatically so an unreleased writer can close`() { + val fileSystem = LinuxNextcloudVirtualFileSystem(QuiescenceBackend()) + val fileInfo = FuseFileInfo.of(Runtime.getSystemRuntime().memoryManager.allocateDirect(256)).apply { + flags.set(1L) + } + + assertEquals(0, fileSystem.open("/draft.txt", fileInfo)) + assertFalse(fileSystem.quiesceWrites()) + assertEquals(0, fileSystem.release("/draft.txt", fileInfo)) + assertTrue(fileSystem.quiesceWrites()) + assertEquals(-ErrorCodes.EBUSY(), fileSystem.mkdir("/still-blocked", 0L)) + fileSystem.resumeWrites() + } + + @Test + fun `quiescence drains final pending file close through a read alias release`() { + val closeStarted = CountDownLatch(1) + val allowClose = CountDownLatch(1) + val fileSystem = LinuxNextcloudVirtualFileSystem( + QuiescenceBackend { + closeStarted.countDown() + check(allowClose.await(5, TimeUnit.SECONDS)) + }, + ) + val runtime = Runtime.getSystemRuntime() + val writer = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) + val reader = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) + val workers = Executors.newFixedThreadPool(2) + try { + assertEquals(0, fileSystem.create("/pending.txt", 0L, writer)) + assertEquals(0, fileSystem.open("/pending.txt", reader)) + assertEquals(0, fileSystem.release("/pending.txt", writer)) + + val release = workers.submit { fileSystem.release("/pending.txt", reader) } + assertTrue(closeStarted.await(5, TimeUnit.SECONDS)) + val quiescence = workers.submit { fileSystem.quiesceWrites() } + assertFalse(quiescence.isDone) + + allowClose.countDown() + assertEquals(0, release.get(5, TimeUnit.SECONDS)) + assertTrue(quiescence.get(5, TimeUnit.SECONDS)) + assertEquals(-ErrorCodes.EBUSY(), fileSystem.open("/draft.txt", reader)) + fileSystem.resumeWrites() + } finally { + allowClose.countDown() + workers.shutdownNow() + } + } +} + +private class QuiescenceBackend( + private val onWriteClose: () -> Unit = {}, +) : LinuxVirtualFileBackend { + private val file = LinuxVirtualFileNode("draft.txt", "draft.txt", false, 5L, "etag") + + override fun resolve(path: String): LinuxVirtualFileNode? = when (path.trim('/')) { + "" -> LinuxVirtualFileNode("", "", true, 0L, "root") + "draft.txt" -> file + else -> null + } + + override fun list(path: String): List = + if (path.trim('/').isEmpty()) listOf(file) else emptyList() + + override fun open(node: LinuxVirtualFileNode): LinuxVirtualFileReadHandle = error("Not used") + override fun openWrite(path: String, existing: LinuxVirtualFileNode?, truncate: Boolean) = + object : LinuxVirtualFileWriteHandle { + override val size: Long = 5L + override fun read(offset: Long, length: Int) = ByteArray(length) + override fun write(offset: Long, bytes: ByteArray) = bytes.size + override fun truncate(size: Long) = Unit + override fun flush() = Unit + override fun close() = onWriteClose() + } + + override fun createDirectory(path: String) = Unit + override fun delete(node: LinuxVirtualFileNode) = Unit + override fun move(node: LinuxVirtualFileNode, destinationPath: String) = Unit + override fun moveReplacing( + node: LinuxVirtualFileNode, + destination: LinuxVirtualFileNode, + destinationPath: String, + ) = Unit + override fun close() = Unit +} From 5e6da1bbcdbb7fbd80309d97987f4c8dd790322f Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 04:09:10 +0200 Subject: [PATCH 038/119] fix(accounts): persist desktop cleanup markers first --- .../DesktopAccountCredentialPersistence.kt | 7 ++-- ...DesktopAccountCredentialPersistenceTest.kt | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 86faed041..6219d8e7f 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -523,9 +523,6 @@ internal class DesktopAccountCredentialPersistence( pendingCredentialRemovals = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null), ) try { - registryStore.write(encodedRegistry) - preferences.putOrRemove(KEY_SERVER, activeAccount?.serverUrl) - preferences.putOrRemove(KEY_LOGIN, activeAccount?.loginName) pendingLegacyCleanupAccount?.let { account -> preferences.put(KEY_PENDING_LEGACY_CLEANUP_SERVER, account.serverUrl) preferences.put(KEY_PENDING_LEGACY_CLEANUP_LOGIN, account.loginName) @@ -537,6 +534,10 @@ internal class DesktopAccountCredentialPersistence( removals.joinToString(",") { pending -> pending.storageKey }, ) } + if (pendingLegacyCleanupAccount != null || pendingCredentialRemoval != null) flushPreferences() + registryStore.write(encodedRegistry) + preferences.putOrRemove(KEY_SERVER, activeAccount?.serverUrl) + preferences.putOrRemove(KEY_LOGIN, activeAccount?.loginName) flushPreferences() } catch (failure: Exception) { runCatching { previous.restore(preferences, registryStore) } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index 4a6f45fab..810951c7b 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -497,6 +497,42 @@ class DesktopAccountCredentialPersistenceTest { assertNull(preferences.get("accountCredentialRemovals", null)) } + @Test + fun removalMarkersSurviveProcessExitAfterTheRegistryCommit() = withStore { preferences, secrets -> + val first = firstSession() + val removed = secondSession() + var crashDuringRemoval = false + val persistence = persistence(preferences, secrets) { + preferences.flush() + if (crashDuringRemoval && decodeRegistry(preferences).accounts.none { it.id == removed.accountId }) { + throw SimulatedProcessExit() + } + } + persistence.saveSession(first) + persistence.saveSession(removed) + secrets.save( + desktopSessionSecretReference(removed.serverUrl, removed.loginName), + removed.loginName, + removed.appPassword.encodeToByteArray(), + ) + crashDuringRemoval = true + + assertFailsWith { persistence.removeAccount(removed.accountId) } + + assertFalse(decodeRegistry(preferences).accounts.any { it.id == removed.accountId }) + assertEquals(removed.accountId.storageKey, preferences.get("accountCredentialRemovals", null)) + assertEquals(removed.serverUrl, preferences.get("accountLegacyCleanupServer", null)) + assertNotNull(secrets.load(desktopAccountSecretReference(removed.accountId))) + assertNotNull(secrets.load(desktopSessionSecretReference(removed.serverUrl, removed.loginName))) + + crashDuringRemoval = false + persistence(preferences, secrets).loadActiveSession() + assertNull(secrets.load(desktopAccountSecretReference(removed.accountId))) + assertNull(secrets.load(desktopSessionSecretReference(removed.serverUrl, removed.loginName))) + assertNull(preferences.get("accountCredentialRemovals", null)) + assertNull(preferences.get("accountLegacyCleanupServer", null)) + } + @Test fun removalJournalNeverDeletesAStillRegisteredCredential() = withStore { preferences, secrets -> val session = firstSession() @@ -667,3 +703,5 @@ class DesktopAccountCredentialPersistenceTest { } } } + +private class SimulatedProcessExit : Error() From 0eb5e5d6033118b8decbcde4efcb6eb8e3e764c1 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 04:09:10 +0200 Subject: [PATCH 039/119] fix(accounts): recover Android accounts before enumeration --- .../AndroidAccountCredentialController.kt | 2 +- .../AndroidPersistedSessionTest.kt | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 6c2b339c0..44a6d6499 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -56,7 +56,7 @@ internal class AndroidAccountCredentialController( }, ) - fun listAccounts(): List = readCredentialFreeRegistry()?.accounts.orEmpty() + fun listAccounts(): List = readRegistryForCredentialLoad()?.accounts.orEmpty() fun activeAccountId(): NextcloudAccountId? = readCredentialFreeRegistry()?.activeAccountId diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index d33fbe0f9..bfc690294 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -96,6 +96,27 @@ class AndroidPersistedSessionTest { assertEquals(second, resolved) } + @Test + fun workerAccountResolutionRecoversALegacyAggregateBeforeEnumeration() { + val session = firstSession() + val registry = NextcloudAccountRegistry.Empty.upsertAndSelect(session.accountRecord()) + var aggregateRecoveryCount = 0 + + val resolved = resolveStoredAndroidAccountSession( + accountIdentity = NextcloudDocumentIds.accountKey(session), + listAccounts = { + recoverAndroidCredentialFreeRegistryForCredentialLoad(restored = null) { + aggregateRecoveryCount += 1 + registry + }?.accounts.orEmpty() + }, + loadSession = { accountId -> session.takeIf { it.accountId == accountId } }, + ) + + assertEquals(session, resolved) + assertEquals(1, aggregateRecoveryCount) + } + @Test fun clearingRecoveredIndependentStateRemovesOnlyItsActiveAccount() { val first = firstSession() From 97e9e1351843e084d2933526c1807cc9c397c854 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 04:09:10 +0200 Subject: [PATCH 040/119] fix(accounts): retry Android removal cleanup --- .../AndroidAccountOwnedStateCleanup.kt | 12 ++ ...ndroidAccountRemovalCleanupRecoveryWork.kt | 111 ++++++++++++++++++ .../AndroidIncomingShareAccountCleanup.kt | 16 ++- .../NextcloudNativeApplication.kt | 8 ++ ...idAccountRemovalCleanupRecoveryWorkTest.kt | 66 +++++++++++ .../AndroidIncomingShareStateTest.kt | 22 ++++ 6 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index 3fb794771..472d27f9a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -33,4 +33,16 @@ internal class AndroidAccountOwnedStateCleanup(context: Context) { ), ) } + + suspend fun retryWithoutCredentials(accountIdentity: String) { + runAndroidAccountRemovalCleanups( + listOf( + { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, + { fileOffline.removeForAccount(accountIdentity) }, + { incomingShares.removeForAccount(accountIdentity) }, + { durableUploads.removeForAccount(accountIdentity) }, + { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + ), + ) + } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt new file mode 100644 index 000000000..dbcd4b22b --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt @@ -0,0 +1,111 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.withLock + +internal fun installAndroidAccountRemovalCleanupRecovery( + context: Context, +): SharedPreferences.OnSharedPreferenceChangeListener { + val appContext = context.applicationContext + val preferences = appContext.getSharedPreferences(ANDROID_ACCOUNT_PREFERENCES, Context.MODE_PRIVATE) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY) { + AndroidAccountRemovalCleanupRecoveryWork.schedule(appContext, preferences) + } + } + preferences.registerOnSharedPreferenceChangeListener(listener) + AndroidAccountRemovalCleanupRecoveryWork.schedule(appContext, preferences) + return listener +} + +internal object AndroidAccountRemovalCleanupRecoveryWork { + private const val UNIQUE_WORK = "nextcloud-native-account-removal-cleanup" + + fun schedule(context: Context, preferences: SharedPreferences) { + if (!preferences.contains(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY)) return + val request = OneTimeWorkRequestBuilder() + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) + .build() + WorkManager.getInstance(context.applicationContext).enqueueUniqueWork( + UNIQUE_WORK, + ANDROID_ACCOUNT_REMOVAL_CLEANUP_WORK_POLICY, + request, + ) + } +} + +internal class AndroidAccountRemovalCleanupRecoveryWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { + val preferences = applicationContext.getSharedPreferences( + ANDROID_ACCOUNT_PREFERENCES, + Context.MODE_PRIVATE, + ) + val journal = AndroidAccountRemovalCleanupJournal( + preferences = preferences, + commit = { editor -> ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + requireCommittedAndroidAccountCredentialEdit(editor) + } }, + recordMalformed = { Log.w(LOG_TAG, "Malformed account-removal cleanup journal repaired") }, + ) + val registry = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) + ?.let(::restoreAndroidCredentialFreeRegistry) + ?.registry + val cleanup = AndroidAccountOwnedStateCleanup(applicationContext) + val completed = recoverPendingAndroidAccountRemovalCleanups( + pending = journal.pending(), + accountOwnedByRegistry = { accountStorageKey -> + registry?.accounts?.any { account -> account.id.storageKey == accountStorageKey } + }, + removeAccountOwnedWork = { pending -> + ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(pending.workIdentity) { + cleanup.retryWithoutCredentials(pending.workIdentity) + } + }, + clearCleanup = journal::clear, + recordFailure = { Log.w(LOG_TAG, "Account-removal cleanup recovery deferred", it) }, + ) + if (completed) Result.success() else Result.retry() + } +} + +internal suspend fun recoverPendingAndroidAccountRemovalCleanups( + pending: Collection, + accountOwnedByRegistry: (String) -> Boolean?, + removeAccountOwnedWork: suspend (AndroidPendingAccountRemovalCleanup) -> Unit, + clearCleanup: suspend (String) -> Unit, + recordFailure: (Exception) -> Unit, +): Boolean { + var completed = true + pending.forEach { cleanup -> + try { + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = accountOwnedByRegistry(cleanup.accountStorageKey), + removeAccountOwnedWork = { removeAccountOwnedWork(cleanup) }, + clearCleanup = { clearCleanup(cleanup.accountStorageKey) }, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + completed = false + recordFailure(failure) + } + } + return completed +} + +private const val ANDROID_ACCOUNT_PREFERENCES = "nextcloud_native" +private const val LOG_TAG = "AccountCleanupRecovery" +internal val ANDROID_ACCOUNT_REMOVAL_CLEANUP_WORK_POLICY = ExistingWorkPolicy.APPEND_OR_REPLACE diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt index 71873c417..eb371e61b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareAccountCleanup.kt @@ -54,8 +54,7 @@ internal class AndroidIncomingShareAccountCleanup(context: Context) { workManager.cancelUniqueWork(workName).await() } }, - releaseChunk = { request, uploadId -> - if (session == null || webDav == null) return@removeAndroidIncomingShareRequests + releaseChunk = if (session == null || webDav == null) null else { request, uploadId -> val userId = requireNotNull(request.userId?.takeIf(String::isNotBlank)) { "The staged share chunk is missing its account owner." } @@ -69,6 +68,9 @@ internal class AndroidIncomingShareAccountCleanup(context: Context) { recordChunkReleaseFailure = { Log.w(LOG_TAG, "Remote staged-share chunk cleanup deferred during account removal") }, + recordChunkAbandonment = { _, _ -> + Log.w(LOG_TAG, "Remote staged-share chunk abandoned after credential removal") + }, removeRequest = { requestId -> check(store.remove(requestId)) { "The staged share data could not be released." } NotificationManagerCompat.from(appContext).apply { @@ -116,17 +118,21 @@ internal fun incomingShareAccountWorkNames(requestId: String): List = li internal suspend fun removeAndroidIncomingShareRequests( requests: List, cancelWork: suspend (String) -> Unit, - releaseChunk: suspend (AndroidIncomingShareRequest, String) -> Unit, + releaseChunk: (suspend (AndroidIncomingShareRequest, String) -> Unit)?, recordChunkReleaseFailure: (Throwable) -> Unit = {}, + recordChunkAbandonment: (AndroidIncomingShareRequest, String) -> Unit = { _, _ -> }, removeRequest: (String) -> Unit, ) { requests.forEach { request -> cancelWork(request.id) } val retained = mutableSetOf() var firstReleaseFailure: Exception? = null requests.forEach { accountRequest -> - accountRequest.request?.chunkSession?.let { chunk -> + accountRequest.request?.chunkSession?.takeIf { releaseChunk == null }?.let { chunk -> + recordChunkAbandonment(accountRequest.request, chunk.uploadId) + } + accountRequest.request?.chunkSession?.takeIf { releaseChunk != null }?.let { chunk -> try { - releaseChunk(accountRequest.request, chunk.uploadId) + requireNotNull(releaseChunk)(accountRequest.request, chunk.uploadId) } catch (failure: kotlinx.coroutines.CancellationException) { throw failure } catch (failure: Exception) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt index 6d6998e52..d5ca61bcc 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudNativeApplication.kt @@ -2,10 +2,18 @@ package dev.obiente.nextcloudnative import android.app.Application import android.content.Context +import android.content.SharedPreferences class NextcloudNativeApplication : Application() { + private var accountCleanupListener: SharedPreferences.OnSharedPreferenceChangeListener? = null + override fun attachBaseContext(base: Context) { super.attachBaseContext(base) installAndroidUncaughtDiagnosticHandler(base) } + + override fun onCreate() { + super.onCreate() + accountCleanupListener = installAndroidAccountRemovalCleanupRecovery(this) + } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt new file mode 100644 index 000000000..b5897fb0f --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt @@ -0,0 +1,66 @@ +package dev.obiente.nextcloudnative + +import androidx.work.ExistingWorkPolicy +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidAccountRemovalCleanupRecoveryWorkTest { + @Test + fun newCleanupMarkersAppendBehindAStillRunningRecovery() { + assertEquals( + ExistingWorkPolicy.APPEND_OR_REPLACE, + ANDROID_ACCOUNT_REMOVAL_CLEANUP_WORK_POLICY, + ) + } + + @Test + fun removedAccountsAreCleanedWithoutBeingSavedAgain() = runBlocking { + val removed = cleanup("a", "1") + val restored = cleanup("b", "2") + val events = mutableListOf() + + val completed = recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(removed, restored), + accountOwnedByRegistry = { key -> key == restored.accountStorageKey }, + removeAccountOwnedWork = { events += "remove:${it.workIdentity}" }, + clearCleanup = { events += "clear:$it" }, + recordFailure = { events += "failure" }, + ) + + assertTrue(completed) + assertEquals( + listOf( + "remove:${removed.workIdentity}", + "clear:${removed.accountStorageKey}", + "clear:${restored.accountStorageKey}", + ), + events, + ) + } + + @Test + fun unreadableRegistryDefersCleanupWithoutDeletingAccountOwnedState() = runBlocking { + val pending = cleanup("a", "1") + val events = mutableListOf() + + val completed = recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(pending), + accountOwnedByRegistry = { null }, + removeAccountOwnedWork = { events += "remove" }, + clearCleanup = { events += "clear" }, + recordFailure = { events += "failure" }, + ) + + assertFalse(completed) + assertEquals(listOf("failure"), events) + } + + private fun cleanup(accountCharacter: String, workCharacter: String) = + AndroidPendingAccountRemovalCleanup( + accountStorageKey = accountCharacter.repeat(64), + workIdentity = workCharacter.repeat(32), + ) +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt index e24285cb9..3f07430a4 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt @@ -782,6 +782,28 @@ class AndroidIncomingShareStateTest { assertEquals(listOf("cancel", "release-failed"), events) } + @Test + fun credentiallessRecoveryAbandonsRemoteChunkAndRemovesPrivateStagingOnce() = runBlocking { + val staged = request(AndroidIncomingShareState.Uploading).copy( + chunkSession = AndroidIncomingShareChunkSession( + fileIndex = 0, + targetName = "first.txt", + uploadId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + ), + ) + val events = mutableListOf() + + removeAndroidIncomingShareRequests( + requests = listOf(AndroidIncomingShareAccountRequest(staged.id, staged)), + cancelWork = { events += "cancel" }, + releaseChunk = null, + recordChunkAbandonment = { _, _ -> events += "abandon-remote" }, + removeRequest = { events += "remove" }, + ) + + assertEquals(listOf("cancel", "abandon-remote", "remove"), events) + } + @Test fun accountRemovalPreservesChunkCleanupCancellation() = runBlocking { val staged = request(AndroidIncomingShareState.Uploading).copy( From a84703718eb4919a3b931149d068c3fd72b337f4 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 04:25:58 +0200 Subject: [PATCH 041/119] test(desktop): inject FUSE ownership in lifecycle tests --- .../app/DesktopLinuxProviderCleanupTest.kt | 7 +++++++ .../app/LinuxVirtualMutationGateTest.kt | 13 +++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt index 51eca4e7c..823ff93a4 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt @@ -26,6 +26,8 @@ class DesktopLinuxProviderCleanupTest { } } }, + mountOwnerUid = TEST_MOUNT_OWNER_UID, + mountOwnerGid = TEST_MOUNT_OWNER_GID, ) assertTrue(fileSystem.quiesceWrites()) val provider = DetachedDesktopLinuxProvider(fileSystem, null, "account") @@ -45,6 +47,8 @@ class DesktopLinuxProviderCleanupTest { backend = CleanupBackend, unmountOperation = { error("synthetic unmount failure") }, fuseAbortHandleProvider = { null }, + mountOwnerUid = TEST_MOUNT_OWNER_UID, + mountOwnerGid = TEST_MOUNT_OWNER_GID, ) assertTrue(fileSystem.quiesceWrites()) val provider = DetachedDesktopLinuxProvider(fileSystem, null, "account") @@ -57,6 +61,9 @@ class DesktopLinuxProviderCleanupTest { } } +private const val TEST_MOUNT_OWNER_UID = 2_001L +private const val TEST_MOUNT_OWNER_GID = 2_002L + private object CleanupBackend : LinuxVirtualFileBackend { override fun resolve(path: String): LinuxVirtualFileNode? = null override fun list(path: String): List = emptyList() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt index 22717889c..08881f7ed 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt @@ -52,7 +52,11 @@ class LinuxVirtualMutationGateTest { @Test fun `failed quiescence reopens automatically so an unreleased writer can close`() { - val fileSystem = LinuxNextcloudVirtualFileSystem(QuiescenceBackend()) + val fileSystem = LinuxNextcloudVirtualFileSystem( + backend = QuiescenceBackend(), + mountOwnerUid = TEST_MOUNT_OWNER_UID, + mountOwnerGid = TEST_MOUNT_OWNER_GID, + ) val fileInfo = FuseFileInfo.of(Runtime.getSystemRuntime().memoryManager.allocateDirect(256)).apply { flags.set(1L) } @@ -70,10 +74,12 @@ class LinuxVirtualMutationGateTest { val closeStarted = CountDownLatch(1) val allowClose = CountDownLatch(1) val fileSystem = LinuxNextcloudVirtualFileSystem( - QuiescenceBackend { + backend = QuiescenceBackend { closeStarted.countDown() check(allowClose.await(5, TimeUnit.SECONDS)) }, + mountOwnerUid = TEST_MOUNT_OWNER_UID, + mountOwnerGid = TEST_MOUNT_OWNER_GID, ) val runtime = Runtime.getSystemRuntime() val writer = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) @@ -101,6 +107,9 @@ class LinuxVirtualMutationGateTest { } } +private const val TEST_MOUNT_OWNER_UID = 2_001L +private const val TEST_MOUNT_OWNER_GID = 2_002L + private class QuiescenceBackend( private val onWriteClose: () -> Unit = {}, ) : LinuxVirtualFileBackend { From 83e6b7b8a02834af51d4b843ddc53e015b275af4 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 05:00:21 +0200 Subject: [PATCH 042/119] fix(accounts): harden Android removal recovery --- .../AndroidAccountCredentialController.kt | 12 +-- .../AndroidAccountCredentialTransitions.kt | 23 +++++ .../AndroidAccountRemovalCleanupJournal.kt | 43 ++++---- ...ndroidAccountRemovalCleanupRecoveryWork.kt | 38 +++++-- .../AndroidNextcloudServices.kt | 1 + ...idAccountRemovalCleanupRecoveryWorkTest.kt | 74 ++++++++++++++ .../AndroidAccountRemovalRecoveryTest.kt | 99 +++++++++++++++++++ 7 files changed, 260 insertions(+), 30 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 44a6d6499..fa0e86a5b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -30,6 +30,7 @@ internal class AndroidAccountCredentialController( private val prepareAccountRemoval: suspend (NextcloudSession) -> Unit, private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String) -> Unit, + private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String) -> Unit, ) { private val appContext = context.applicationContext private val accountRemovalCleanupJournal = AndroidAccountRemovalCleanupJournal( @@ -189,13 +190,12 @@ internal class AndroidAccountCredentialController( val accountIdentity = NextcloudDocumentIds.accountKey(unavailableSession) val pendingCleanup = pendingAndroidAccountRemovalCleanup(unavailableSession) ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { - removeAndroidAccountCredentialData( - active = false, + removeUnavailableAndroidAccountCredentialData( + accountIdentity = accountIdentity, prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, - removeQueuedUploads = { retryQueuedUploadsCleanup(unavailableSession, accountIdentity) }, - clearActiveAccount = {}, rollbackActiveRemoval = {}, - persistInactiveRemoval = { persistState(recovered.remove(accountId), pendingCleanup) }, - rollbackInactiveRemoval = { + removeAccountOwnedWorkWithoutCredentials = retryQueuedUploadsCleanupWithoutCredentials, + persistRemoval = { persistState(recovered.remove(accountId), pendingCleanup) }, + rollbackRemoval = { rollbackUnavailableAndroidAccountRemoval( recovered = recovered, persistRecovered = { state -> persistState(state) }, clearCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index 78ca56905..184b49200 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -99,6 +99,29 @@ internal suspend fun removeAndroidAccountCredentialData( ) } +internal suspend fun removeUnavailableAndroidAccountCredentialData( + accountIdentity: String, + prepareAccountRemoval: suspend () -> Unit, + removeAccountOwnedWorkWithoutCredentials: suspend (String) -> Unit, + persistRemoval: suspend () -> Unit, + rollbackRemoval: suspend () -> Unit, + completeCommittedCleanup: suspend () -> Unit = {}, + recordCommittedCleanupFailure: (Exception) -> Unit = {}, +) { + require(accountIdentity.isNotBlank()) + removeAndroidAccountCredentialData( + active = false, + prepareAccountRemoval = prepareAccountRemoval, + removeQueuedUploads = { removeAccountOwnedWorkWithoutCredentials(accountIdentity) }, + clearActiveAccount = {}, + rollbackActiveRemoval = {}, + persistInactiveRemoval = persistRemoval, + rollbackInactiveRemoval = rollbackRemoval, + completeCommittedCleanup = completeCommittedCleanup, + recordCommittedCleanupFailure = recordCommittedCleanupFailure, + ) +} + private suspend fun finishCommittedAndroidAccountRemovalCleanup( removeQueuedUploads: suspend () -> Unit, completeCommittedCleanup: suspend () -> Unit, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt index 60e2e6d5f..c782e3cc4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt @@ -8,15 +8,16 @@ internal class AndroidAccountRemovalCleanupJournal( private val recordMalformed: () -> Unit, ) { fun pending(): Set { - val encoded = runCatching { + val encoded = try { preferences.getStringSet(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, emptySet()).orEmpty() - }.getOrElse { - repair(emptySet()) - return emptySet() + } catch (failure: Exception) { + runCatching(recordMalformed) + throw AndroidAccountRemovalCleanupJournalException( + "The account-removal cleanup journal is unreadable.", + failure, + ) } - val restored = restoreAndroidPendingAccountRemovalCleanups(encoded) - if (restored.malformedEntryCount > 0) repair(restored.cleanups) - return restored.cleanups + return requireValidAndroidAccountRemovalCleanupJournal(encoded, recordMalformed) } fun prepareEdit( @@ -44,17 +45,23 @@ internal class AndroidAccountRemovalCleanupJournal( ) commit(editor) } +} - private fun repair(retained: Set) { - recordMalformed() - runCatching { - val editor = preferences.edit() - if (retained.isEmpty()) editor.remove(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY) - else editor.putStringSet( - ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, - retained.mapTo(linkedSetOf(), ::encodeAndroidPendingAccountRemovalCleanup), - ) - commit(editor) - } +internal class AndroidAccountRemovalCleanupJournalException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) + +internal fun requireValidAndroidAccountRemovalCleanupJournal( + encoded: Set, + recordMalformed: () -> Unit, +): Set { + val restored = restoreAndroidPendingAccountRemovalCleanups(encoded) + if (restored.malformedEntryCount > 0) { + runCatching(recordMalformed) + throw AndroidAccountRemovalCleanupJournalException( + "The account-removal cleanup journal contains a malformed tombstone.", + ) } + return restored.cleanups } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt index dbcd4b22b..c858db501 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt @@ -58,14 +58,20 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( commit = { editor -> ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { requireCommittedAndroidAccountCredentialEdit(editor) } }, - recordMalformed = { Log.w(LOG_TAG, "Malformed account-removal cleanup journal repaired") }, + recordMalformed = { Log.w(LOG_TAG, "Malformed account-removal cleanup journal retained") }, ) val registry = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) ?.let(::restoreAndroidCredentialFreeRegistry) ?.registry val cleanup = AndroidAccountOwnedStateCleanup(applicationContext) + val pending = readPendingAndroidAccountRemovalCleanups( + readPending = journal::pending, + recordFailure = { + logAndroidAccountRemovalCleanupRecoveryDeferred { message -> Log.w(LOG_TAG, message) } + }, + ) ?: return@withLock Result.retry() val completed = recoverPendingAndroidAccountRemovalCleanups( - pending = journal.pending(), + pending = pending, accountOwnedByRegistry = { accountStorageKey -> registry?.accounts?.any { account -> account.id.storageKey == accountStorageKey } }, @@ -75,7 +81,9 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( } }, clearCleanup = journal::clear, - recordFailure = { Log.w(LOG_TAG, "Account-removal cleanup recovery deferred", it) }, + recordFailure = { + logAndroidAccountRemovalCleanupRecoveryDeferred { message -> Log.w(LOG_TAG, message) } + }, ) if (completed) Result.success() else Result.retry() } @@ -86,7 +94,7 @@ internal suspend fun recoverPendingAndroidAccountRemovalCleanups( accountOwnedByRegistry: (String) -> Boolean?, removeAccountOwnedWork: suspend (AndroidPendingAccountRemovalCleanup) -> Unit, clearCleanup: suspend (String) -> Unit, - recordFailure: (Exception) -> Unit, + recordFailure: () -> Unit, ): Boolean { var completed = true pending.forEach { cleanup -> @@ -98,14 +106,32 @@ internal suspend fun recoverPendingAndroidAccountRemovalCleanups( ) } catch (cancelled: CancellationException) { throw cancelled - } catch (failure: Exception) { + } catch (_: Exception) { completed = false - recordFailure(failure) + recordFailure() } } return completed } +internal fun readPendingAndroidAccountRemovalCleanups( + readPending: () -> Collection, + recordFailure: () -> Unit, +): Collection? = try { + readPending() +} catch (cancelled: CancellationException) { + throw cancelled +} catch (_: Exception) { + recordFailure() + null +} + +internal fun logAndroidAccountRemovalCleanupRecoveryDeferred( + logWarning: (String) -> Unit, +) { + logWarning("Account-removal cleanup recovery deferred") +} + private const val ANDROID_ACCOUNT_PREFERENCES = "nextcloud_native" private const val LOG_TAG = "AccountCleanupRecovery" internal val ANDROID_ACCOUNT_REMOVAL_CLEANUP_WORK_POLICY = ExistingWorkPolicy.APPEND_OR_REPLACE diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 1d73d306d..850d56a8d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -500,6 +500,7 @@ internal class AndroidNextcloudServices( prepareAccountRemoval = { session -> prepareAndroidAccountRemoval(appContext, session) }, removeQueuedUploads = accountOwnedStateCleanup::remove, retryQueuedUploadsCleanup = accountOwnedStateCleanup::retry, + retryQueuedUploadsCleanupWithoutCredentials = accountOwnedStateCleanup::retryWithoutCredentials, ) init { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt index b5897fb0f..44748f63c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative import androidx.work.ExistingWorkPolicy +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -58,6 +59,79 @@ class AndroidAccountRemovalCleanupRecoveryWorkTest { assertEquals(listOf("failure"), events) } + @Test + fun cleanupCancellationIsNotReportedAsARecoverableFailure() = runBlocking { + val pending = cleanup("a", "1") + val events = mutableListOf() + + kotlin.test.assertFailsWith { + recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(pending), + accountOwnedByRegistry = { false }, + removeAccountOwnedWork = { + events += "remove" + throw CancellationException("synthetic cancellation") + }, + clearCleanup = { events += "clear" }, + recordFailure = { events += "failure" }, + ) + } + + assertEquals(listOf("remove"), events) + } + + @Test + fun recoveryFailureLogDoesNotExposeTheFailureMessage() = runBlocking { + val pending = cleanup("a", "1") + val messages = mutableListOf() + + val completed = recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(pending), + accountOwnedByRegistry = { false }, + removeAccountOwnedWork = { + error("private/path/account-secret") + }, + clearCleanup = {}, + recordFailure = { + logAndroidAccountRemovalCleanupRecoveryDeferred(messages::add) + }, + ) + + assertFalse(completed) + assertEquals(listOf("Account-removal cleanup recovery deferred"), messages) + assertFalse(messages.single().contains("private/path/account-secret")) + } + + @Test + fun unreadableCleanupJournalDefersRecoveryWithABoundedMessage() { + val messages = mutableListOf() + + val pending = readPendingAndroidAccountRemovalCleanups( + readPending = { error("private/path/account-secret") }, + recordFailure = { + logAndroidAccountRemovalCleanupRecoveryDeferred(messages::add) + }, + ) + + assertEquals(null, pending) + assertEquals(listOf("Account-removal cleanup recovery deferred"), messages) + assertFalse(messages.single().contains("private/path/account-secret")) + } + + @Test + fun cleanupJournalReadCancellationIsPropagated() { + var recorded = false + + kotlin.test.assertFailsWith { + readPendingAndroidAccountRemovalCleanups( + readPending = { throw CancellationException("synthetic cancellation") }, + recordFailure = { recorded = true }, + ) + } + + assertFalse(recorded) + } + private fun cleanup(accountCharacter: String, workCharacter: String) = AndroidPendingAccountRemovalCleanup( accountStorageKey = accountCharacter.repeat(64), diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt index c0eb5ce5b..b73cc4627 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt @@ -1,5 +1,8 @@ package dev.obiente.nextcloudnative +import android.content.SharedPreferences +import java.lang.reflect.Proxy +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -7,6 +10,48 @@ import kotlin.test.assertFailsWith import kotlin.test.assertTrue class AndroidAccountRemovalRecoveryTest { + @Test + fun unavailableCredentialRemovalCleansCommittedStateByIdentity() = runBlocking { + val events = mutableListOf() + + removeUnavailableAndroidAccountCredentialData( + accountIdentity = "account-identity", + prepareAccountRemoval = { events += "prepare" }, + removeAccountOwnedWorkWithoutCredentials = { identity -> events += "remove:$identity" }, + persistRemoval = { events += "persist" }, + rollbackRemoval = { events += "rollback" }, + completeCommittedCleanup = { events += "clear" }, + recordCommittedCleanupFailure = { events += "failure" }, + ) + + assertEquals( + listOf("prepare", "persist", "remove:account-identity", "clear"), + events, + ) + } + + @Test + fun unavailableCredentialRemovalPreservesCleanupCancellation() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + removeUnavailableAndroidAccountCredentialData( + accountIdentity = "account-identity", + prepareAccountRemoval = {}, + removeAccountOwnedWorkWithoutCredentials = { + events += "remove" + throw CancellationException("synthetic cancellation") + }, + persistRemoval = { events += "persist" }, + rollbackRemoval = { events += "rollback" }, + completeCommittedCleanup = { events += "clear" }, + recordCommittedCleanupFailure = { events += "failure" }, + ) + } + + assertEquals(listOf("persist", "remove"), events) + } + @Test fun restoredAccountRetriesMarkerClearWithoutDeletingOwnedWork() = runBlocking { var failClear = true @@ -45,4 +90,58 @@ class AndroidAccountRemovalRecoveryTest { assertTrue(events.isEmpty()) } + + @Test + fun malformedCleanupTombstonesRemainBlockingRecoveryState() { + val valid = AndroidPendingAccountRemovalCleanup( + accountStorageKey = "a".repeat(64), + workIdentity = "1".repeat(32), + ) + val encoded = linkedSetOf(encodeAndroidPendingAccountRemovalCleanup(valid), "truncated-row") + var malformedRecorded = false + + assertFailsWith { + requireValidAndroidAccountRemovalCleanupJournal(encoded) { + malformedRecorded = true + } + } + + assertTrue(malformedRecorded) + assertTrue("truncated-row" in encoded) + } + + @Test + fun malformedCleanupJournalDoesNotRewriteStoredTombstones() { + val valid = AndroidPendingAccountRemovalCleanup( + accountStorageKey = "a".repeat(64), + workIdentity = "1".repeat(32), + ) + val encoded = linkedSetOf(encodeAndroidPendingAccountRemovalCleanup(valid), "truncated-row") + var editCalls = 0 + var commitCalls = 0 + val preferences = Proxy.newProxyInstance( + SharedPreferences::class.java.classLoader, + arrayOf(SharedPreferences::class.java), + ) { _, method, _ -> + when (method.name) { + "getStringSet" -> encoded + "edit" -> { + editCalls += 1 + error("Malformed cleanup recovery must not edit preferences") + } + else -> error("Unexpected SharedPreferences call: ${method.name}") + } + } as SharedPreferences + val journal = AndroidAccountRemovalCleanupJournal( + preferences = preferences, + commit = { commitCalls += 1 }, + recordMalformed = {}, + ) + + assertFailsWith { journal.pending() } + + assertEquals(0, editCalls) + assertEquals(0, commitCalls) + assertEquals(linkedSetOf(encodeAndroidPendingAccountRemovalCleanup(valid), "truncated-row"), encoded) + } } From d6dfd46b1ada70204d27feb671a8f166d0900b52 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 05:00:28 +0200 Subject: [PATCH 043/119] fix(uploads): preserve retained account queues --- .../AndroidDurableMultipartUploads.kt | 16 ++++++++-------- .../AndroidDurableMultipartUploadPolicyTest.kt | 9 +++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 9745c31a0..c2d680a4d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -18,6 +18,7 @@ import dev.obiente.nextcloudnative.app.DurableUploadStatus import dev.obiente.nextcloudnative.app.LocalUploadFile import dev.obiente.nextcloudnative.app.MAX_DURABLE_UPLOAD_MESSAGE_CHARACTERS import dev.obiente.nextcloudnative.app.MultipartTextField +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import dev.obiente.nextcloudnative.app.NextcloudApiMethod import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest import dev.obiente.nextcloudnative.app.NextcloudSession @@ -190,12 +191,7 @@ internal class DeckAttachmentUploadWorker( val accountServices = AndroidNextcloudServices(applicationContext) val session = accountServices.loadSession() if (session == null || NextcloudDocumentIds.accountKey(session) != initial.accountId) { - val retainedSession = resolveStoredAndroidAccountSession( - accountIdentity = initial.accountId, - listAccounts = accountServices::listAccounts, - loadSession = { accountId -> accountServices.loadSession(accountId) }, - ) - if (durableUploadAccountMismatchOutcome(initial.accountId, retainedSession) == + if (durableUploadAccountMismatchOutcome(initial.accountId, accountServices.listAccounts()) == DurableUploadAccountMismatchOutcome.DeferRetainedAccount ) { recordUploadDiagnostic( @@ -348,9 +344,13 @@ internal enum class DurableUploadAccountMismatchOutcome { internal fun durableUploadAccountMismatchOutcome( expectedAccountId: String, - retainedSession: NextcloudSession?, + retainedAccounts: List, ): DurableUploadAccountMismatchOutcome = - if (retainedSession != null && NextcloudDocumentIds.accountKey(retainedSession) == expectedAccountId) { + if ( + retainedAccounts.any { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == expectedAccountId + } + ) { DurableUploadAccountMismatchOutcome.DeferRetainedAccount } else { DurableUploadAccountMismatchOutcome.AccountUnavailable diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index dff53b021..585d9f8de 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -5,6 +5,7 @@ import dev.obiente.nextcloudnative.app.DurableUploadState import dev.obiente.nextcloudnative.app.NextcloudApiMethod import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord import dev.obiente.nextcloudnative.app.afterProcessRecovery import dev.obiente.nextcloudnative.app.localUploadFile import java.io.IOException @@ -314,7 +315,7 @@ class AndroidDurableMultipartUploadPolicyTest { } @Test - fun `retained background account is deferred instead of becoming unavailable`() { + fun `retained background account is deferred without reading its credential`() { val retainedSession = NextcloudSession( serverUrl = "https://cloud.example.test/nextcloud", loginName = "alice", @@ -324,17 +325,17 @@ class AndroidDurableMultipartUploadPolicyTest { assertEquals( DurableUploadAccountMismatchOutcome.DeferRetainedAccount, - durableUploadAccountMismatchOutcome(accountId, retainedSession), + durableUploadAccountMismatchOutcome(accountId, listOf(retainedSession.accountRecord())), ) assertEquals( DurableUploadAccountMismatchOutcome.AccountUnavailable, - durableUploadAccountMismatchOutcome(accountId, null), + durableUploadAccountMismatchOutcome(accountId, emptyList()), ) assertEquals( DurableUploadAccountMismatchOutcome.AccountUnavailable, durableUploadAccountMismatchOutcome( accountId, - retainedSession.copy(loginName = "another-account"), + listOf(retainedSession.copy(loginName = "another-account").accountRecord()), ), ) } From 8c370d307a4aaecf3a86004c97c99203efa8df45 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 05:00:28 +0200 Subject: [PATCH 044/119] fix(accounts): journal desktop credential transitions --- .../DesktopAccountCredentialPersistence.kt | 91 +++++++------- .../DesktopLegacyCredentialCleanupJournal.kt | 118 ++++++++++++++++++ ...DesktopAccountCredentialPersistenceTest.kt | 64 +++++++++- 3 files changed, 225 insertions(+), 48 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLegacyCredentialCleanupJournal.kt diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 6219d8e7f..2ff26d3fc 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -10,6 +10,10 @@ internal class DesktopAccountCredentialPersistence( private val flushPreferences: () -> Unit = preferences::flush, ) { private val registryStore = DesktopAccountRegistryPreferenceStore(preferences, flushPreferences) + private val legacyCleanupJournal = DesktopLegacyCredentialCleanupJournal( + preferences, + flushPreferences, + ) { recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", "account-credentials.migrate") } fun loadActiveSession(): NextcloudSession? { retryPendingCredentialSave() @@ -74,6 +78,7 @@ internal class DesktopAccountCredentialPersistence( persistPendingCredentialSave(persistedSession) try { saveSecret(persistedSession) + markPendingCredentialSaveSecretWritten() persistAccountState(encodedRegistry, updatedRegistry.activeAccount) } catch (failure: Exception) { var credentialRollbackCompleted = false @@ -190,6 +195,7 @@ internal class DesktopAccountCredentialPersistence( private fun retryPendingCredentialSave() { val server = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_SERVER, null) val login = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, null) + val phase = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_PHASE, null) if (server == null && login == null) return if (server.isNullOrBlank() || login.isNullOrBlank()) { recordCredentialDiagnostic( @@ -231,7 +237,7 @@ internal class DesktopAccountCredentialPersistence( ) return } - } else { + } else if (phase == CREDENTIAL_SAVE_SECRET_WRITTEN) { val selected = requireNotNull(registry.select(accountId)) try { persistAccountState(prepareRegistry(selected), selected.activeAccount) @@ -320,15 +326,18 @@ internal class DesktopAccountCredentialPersistence( private fun persistPendingCredentialSave(session: NextcloudSession) { val previousServer = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_SERVER, null) val previousLogin = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, null) + val previousPhase = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_PHASE, null) try { preferences.put(KEY_PENDING_CREDENTIAL_SAVE_SERVER, session.serverUrl) preferences.put(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, session.loginName) + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_PHASE, CREDENTIAL_SAVE_PREPARED) flushPreferences() } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_SERVER, previousServer) preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, previousLogin) + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_PHASE, previousPhase) runCatching(flushPreferences) recordCredentialDiagnostic( "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", @@ -339,19 +348,27 @@ internal class DesktopAccountCredentialPersistence( } } + private fun markPendingCredentialSaveSecretWritten() { + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_PHASE, CREDENTIAL_SAVE_SECRET_WRITTEN) + flushPreferences() + } + private fun clearPendingCredentialSave() { val server = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_SERVER, null) val login = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, null) + val phase = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_PHASE, null) if (server == null && login == null) return try { preferences.remove(KEY_PENDING_CREDENTIAL_SAVE_SERVER) preferences.remove(KEY_PENDING_CREDENTIAL_SAVE_LOGIN) + preferences.remove(KEY_PENDING_CREDENTIAL_SAVE_PHASE) flushPreferences() } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_SERVER, server) preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, login) + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_PHASE, phase) runCatching(flushPreferences) recordCredentialDiagnostic( "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", @@ -362,19 +379,16 @@ internal class DesktopAccountCredentialPersistence( } private fun retryPendingLegacyCredentialCleanup(expected: NextcloudSession? = null) { - val server = preferences.get(KEY_PENDING_LEGACY_CLEANUP_SERVER, null) - val login = preferences.get(KEY_PENDING_LEGACY_CLEANUP_LOGIN, null) - if (server == null && login == null) return - if (server.isNullOrBlank() || login.isNullOrBlank()) { - recordCredentialDiagnostic( - "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", - "account-credentials.migrate", - ) - return - } - if (expected != null && (expected.serverUrl != server || expected.loginName != login)) return + legacyCleanupJournal.pending() + .filter { cleanup -> expected == null || + expected.serverUrl == cleanup.serverUrl && expected.loginName == cleanup.loginName + } + .forEach(::retryPendingLegacyCredentialCleanup) + } + + private fun retryPendingLegacyCredentialCleanup(cleanup: DesktopPendingLegacyCredentialCleanup) { val cleanupAllowed = try { - val accountId = deriveNextcloudAccountId(server, login) + val accountId = deriveNextcloudAccountId(cleanup.serverUrl, cleanup.loginName) val registry = readRegistry().registry registry?.accounts?.none { account -> account.id == accountId } == true || loadSecret(desktopAccountSecretReference(accountId)) != null @@ -389,7 +403,7 @@ internal class DesktopAccountCredentialPersistence( } if (!cleanupAllowed) return try { - secretStore.clear(desktopSessionSecretReference(server, login)) + secretStore.clear(desktopSessionSecretReference(cleanup.serverUrl, cleanup.loginName)) } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { @@ -400,21 +414,10 @@ internal class DesktopAccountCredentialPersistence( return } try { - preferences.remove(KEY_PENDING_LEGACY_CLEANUP_SERVER) - preferences.remove(KEY_PENDING_LEGACY_CLEANUP_LOGIN) - flushPreferences() + legacyCleanupJournal.clear(cleanup) } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { - preferences.put(KEY_PENDING_LEGACY_CLEANUP_SERVER, server) - preferences.put(KEY_PENDING_LEGACY_CLEANUP_LOGIN, login) - try { - flushPreferences() - } catch (cancelled: CancellationException) { - throw cancelled - } catch (_: Exception) { - // The in-memory marker remains available for another retry in this process. - } recordCredentialDiagnostic( "ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", "account-credentials.migrate", @@ -423,17 +426,14 @@ internal class DesktopAccountCredentialPersistence( } private fun persistPendingLegacyCredentialCleanup(session: NextcloudSession) { - val previousServer = preferences.get(KEY_PENDING_LEGACY_CLEANUP_SERVER, null) - val previousLogin = preferences.get(KEY_PENDING_LEGACY_CLEANUP_LOGIN, null) + val previous = legacyCleanupJournal.snapshot() try { - preferences.put(KEY_PENDING_LEGACY_CLEANUP_SERVER, session.serverUrl) - preferences.put(KEY_PENDING_LEGACY_CLEANUP_LOGIN, session.loginName) + legacyCleanupJournal.prepareAdd(DesktopPendingLegacyCredentialCleanup(session.serverUrl, session.loginName)) flushPreferences() } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { - preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_SERVER, previousServer) - preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_LOGIN, previousLogin) + legacyCleanupJournal.restore(previous) try { flushPreferences() } catch (cancelled: CancellationException) { @@ -518,14 +518,14 @@ internal class DesktopAccountCredentialPersistence( registry = registryStore.read(), server = preferences.get(KEY_SERVER, null), login = preferences.get(KEY_LOGIN, null), - pendingLegacyCleanupServer = preferences.get(KEY_PENDING_LEGACY_CLEANUP_SERVER, null), - pendingLegacyCleanupLogin = preferences.get(KEY_PENDING_LEGACY_CLEANUP_LOGIN, null), + pendingLegacyCleanups = legacyCleanupJournal.snapshot(), pendingCredentialRemovals = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null), ) try { pendingLegacyCleanupAccount?.let { account -> - preferences.put(KEY_PENDING_LEGACY_CLEANUP_SERVER, account.serverUrl) - preferences.put(KEY_PENDING_LEGACY_CLEANUP_LOGIN, account.loginName) + legacyCleanupJournal.prepareAdd( + DesktopPendingLegacyCredentialCleanup(account.serverUrl, account.loginName), + ) } pendingCredentialRemoval?.let { accountId -> val removals = pendingCredentialRemovalIds() + accountId @@ -540,7 +540,7 @@ internal class DesktopAccountCredentialPersistence( preferences.putOrRemove(KEY_LOGIN, activeAccount?.loginName) flushPreferences() } catch (failure: Exception) { - runCatching { previous.restore(preferences, registryStore) } + runCatching { previous.restore(preferences, registryStore, legacyCleanupJournal) } runCatching(flushPreferences) recordCredentialDiagnostic( "ACCOUNT_CREDENTIAL_STORE_WRITE_FAILED", @@ -587,16 +587,18 @@ internal class DesktopAccountCredentialPersistence( val registry: String?, val server: String?, val login: String?, - val pendingLegacyCleanupServer: String?, - val pendingLegacyCleanupLogin: String?, + val pendingLegacyCleanups: DesktopLegacyCredentialCleanupSnapshot, val pendingCredentialRemovals: String?, ) { - fun restore(preferences: Preferences, registryStore: DesktopAccountRegistryPreferenceStore) { + fun restore( + preferences: Preferences, + registryStore: DesktopAccountRegistryPreferenceStore, + legacyCleanupJournal: DesktopLegacyCredentialCleanupJournal, + ) { registryStore.write(registry) preferences.putOrRemove(KEY_SERVER, server) preferences.putOrRemove(KEY_LOGIN, login) - preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_SERVER, pendingLegacyCleanupServer) - preferences.putOrRemove(KEY_PENDING_LEGACY_CLEANUP_LOGIN, pendingLegacyCleanupLogin) + legacyCleanupJournal.restore(pendingLegacyCleanups) preferences.putOrRemove(KEY_PENDING_CREDENTIAL_REMOVALS, pendingCredentialRemovals) } } @@ -604,11 +606,12 @@ internal class DesktopAccountCredentialPersistence( private companion object { const val KEY_SERVER = "server" const val KEY_LOGIN = "login" - const val KEY_PENDING_LEGACY_CLEANUP_SERVER = "accountLegacyCleanupServer" - const val KEY_PENDING_LEGACY_CLEANUP_LOGIN = "accountLegacyCleanupLogin" const val KEY_PENDING_CREDENTIAL_SAVE_SERVER = "accountCredentialSaveServer" const val KEY_PENDING_CREDENTIAL_SAVE_LOGIN = "accountCredentialSaveLogin" + const val KEY_PENDING_CREDENTIAL_SAVE_PHASE = "accountCredentialSavePhase" const val KEY_PENDING_CREDENTIAL_REMOVALS = "accountCredentialRemovals" + const val CREDENTIAL_SAVE_PREPARED = "prepared" + const val CREDENTIAL_SAVE_SECRET_WRITTEN = "secret-written" } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLegacyCredentialCleanupJournal.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLegacyCredentialCleanupJournal.kt new file mode 100644 index 000000000..97570a66f --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLegacyCredentialCleanupJournal.kt @@ -0,0 +1,118 @@ +package dev.obiente.nextcloudnative.app + +import java.util.prefs.Preferences +import java.util.concurrent.atomic.AtomicBoolean + +internal data class DesktopPendingLegacyCredentialCleanup( + val serverUrl: String, + val loginName: String, +) + +internal data class DesktopLegacyCredentialCleanupSnapshot( + val slots: List>, + val legacyServer: String?, + val legacyLogin: String?, +) + +internal class DesktopLegacyCredentialCleanupJournal( + private val preferences: Preferences, + private val flush: () -> Unit, + private val recordMalformed: () -> Unit, +) { + private val malformedReported = AtomicBoolean(false) + + fun pending(): List { + var malformed = false + val cleanups = buildList { + repeat(MAX_LOCAL_ACCOUNTS) { index -> + val serverKey = serverKey(index) + val loginKey = loginKey(index) + val cleanup = decode(serverKey, loginKey) + if (cleanup != null) { + add(cleanup) + } else if (preferences.get(serverKey, null) != null || preferences.get(loginKey, null) != null) { + malformed = true + } + } + val legacyCleanup = decode(LEGACY_SERVER_KEY, LEGACY_LOGIN_KEY) + if (legacyCleanup != null) { + add(legacyCleanup) + } else if (preferences.get(LEGACY_SERVER_KEY, null) != null || + preferences.get(LEGACY_LOGIN_KEY, null) != null + ) { + malformed = true + } + }.distinctBy { cleanup -> deriveNextcloudAccountId(cleanup.serverUrl, cleanup.loginName) } + if (malformed && malformedReported.compareAndSet(false, true)) runCatching(recordMalformed) + return cleanups + } + + fun prepareAdd(cleanup: DesktopPendingLegacyCredentialCleanup) { + val accountId = deriveNextcloudAccountId(cleanup.serverUrl, cleanup.loginName) + if (pending().any { existing -> + deriveNextcloudAccountId(existing.serverUrl, existing.loginName) == accountId + } + ) return + val slot = (0 until MAX_LOCAL_ACCOUNTS).firstOrNull { index -> + preferences.get(serverKey(index), null) == null && preferences.get(loginKey(index), null) == null + } ?: error("The legacy credential cleanup journal is full.") + preferences.put(serverKey(slot), cleanup.serverUrl) + preferences.put(loginKey(slot), cleanup.loginName) + } + + fun clear(cleanup: DesktopPendingLegacyCredentialCleanup) { + val previous = snapshot() + try { + repeat(MAX_LOCAL_ACCOUNTS) { index -> + if (decode(serverKey(index), loginKey(index)) == cleanup) { + preferences.remove(serverKey(index)) + preferences.remove(loginKey(index)) + } + } + if (decode(LEGACY_SERVER_KEY, LEGACY_LOGIN_KEY) == cleanup) { + preferences.remove(LEGACY_SERVER_KEY) + preferences.remove(LEGACY_LOGIN_KEY) + } + flush() + } catch (failure: Exception) { + restore(previous) + runCatching(flush) + throw failure + } + } + + fun snapshot() = DesktopLegacyCredentialCleanupSnapshot( + slots = (0 until MAX_LOCAL_ACCOUNTS).map { index -> + preferences.get(serverKey(index), null) to preferences.get(loginKey(index), null) + }, + legacyServer = preferences.get(LEGACY_SERVER_KEY, null), + legacyLogin = preferences.get(LEGACY_LOGIN_KEY, null), + ) + + fun restore(snapshot: DesktopLegacyCredentialCleanupSnapshot) { + snapshot.slots.forEachIndexed { index, (server, login) -> + preferences.putOrRemove(serverKey(index), server) + preferences.putOrRemove(loginKey(index), login) + } + preferences.putOrRemove(LEGACY_SERVER_KEY, snapshot.legacyServer) + preferences.putOrRemove(LEGACY_LOGIN_KEY, snapshot.legacyLogin) + } + + private fun decode(serverKey: String, loginKey: String): DesktopPendingLegacyCredentialCleanup? { + val server = preferences.get(serverKey, null)?.takeIf(String::isNotBlank) ?: return null + val login = preferences.get(loginKey, null)?.takeIf(String::isNotBlank) ?: return null + return runCatching { + deriveNextcloudAccountId(server, login) + DesktopPendingLegacyCredentialCleanup(server, login) + }.getOrNull() + } + + private fun serverKey(index: Int) = "$SLOT_PREFIX.$index.server" + private fun loginKey(index: Int) = "$SLOT_PREFIX.$index.login" + + private companion object { + const val SLOT_PREFIX = "accountLegacyCleanupV2" + const val LEGACY_SERVER_KEY = "accountLegacyCleanupServer" + const val LEGACY_LOGIN_KEY = "accountLegacyCleanupLogin" + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index 810951c7b..1c2c6739f 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -98,7 +98,7 @@ class DesktopAccountCredentialPersistenceTest { persistence.saveSession(secondSession()) assertEquals(firstSession(), persistence.selectAccount(firstSession().accountId)) - assertEquals(10, flushCount) + assertEquals(12, flushCount) assertEquals(firstSession().serverUrl, preferences.get("server", null)) assertEquals(firstSession().loginName, preferences.get("login", null)) } @@ -181,6 +181,27 @@ class DesktopAccountCredentialPersistenceTest { assertNull(preferences.get("accountCredentialSaveLogin", null)) } + @Test + fun preparedReauthenticationCrashKeepsTheCurrentSelectionAndOldSecret() = + withStore { preferences, secrets -> + val inactive = firstSession() + val active = secondSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(inactive) + persistence.saveSession(active) + secrets.crashSaveOnAttempt = secrets.saveCount + 1 + + assertFailsWith { + persistence.saveSession(inactive.copy(appPassword = "replacement-password")) + } + assertEquals("prepared", preferences.get("accountCredentialSavePhase", null)) + + val restarted = persistence(preferences, secrets) + assertEquals(active, restarted.loadActiveSession()) + assertEquals(inactive, restarted.loadSession(inactive.accountId)) + assertNull(preferences.get("accountCredentialSavePhase", null)) + } + @Test fun startupRecoveryPreservesPendingCredentialWhenRegistryVersionIsUnreadable() = withStore { preferences, secrets -> @@ -385,6 +406,39 @@ class DesktopAccountCredentialPersistenceTest { assertNull(secrets.load(legacyReference)) } + @Test + fun failedLegacyCleanupForOneAccountDoesNotGetOverwrittenByAnotherRemoval() = + withStore { preferences, secrets -> + val first = firstSession() + val second = secondSession() + val firstLegacy = desktopSessionSecretReference(first.serverUrl, first.loginName) + val secondLegacy = desktopSessionSecretReference(second.serverUrl, second.loginName) + val persistence = persistence(preferences, secrets) + persistence.saveSession(first) + persistence.saveSession(second) + secrets.save(firstLegacy, first.loginName, first.appPassword.encodeToByteArray()) + secrets.save(secondLegacy, second.loginName, second.appPassword.encodeToByteArray()) + preferences.put("accountLegacyCleanupServer", first.serverUrl) + preferences.put("accountLegacyCleanupLogin", first.loginName) + preferences.flush() + secrets.failClears = true + + assertTrue(persistence.removeAccount(second.accountId)) + + assertEquals(first.serverUrl, preferences.get("accountLegacyCleanupServer", null)) + assertEquals(second.serverUrl, preferences.get("accountLegacyCleanupV2.0.server", null)) + assertNotNull(secrets.load(firstLegacy)) + assertNotNull(secrets.load(secondLegacy)) + + secrets.failClears = false + persistence(preferences, secrets).loadActiveSession() + + assertNull(secrets.load(firstLegacy)) + assertNull(secrets.load(secondLegacy)) + assertNull(preferences.get("accountLegacyCleanupServer", null)) + assertNull(preferences.get("accountLegacyCleanupV2.0.server", null)) + } + @Test fun secureStoreReadFailureIsNotReportedAsMissingCredentials() = withStore { preferences, secrets -> val persistence = persistence(preferences, secrets) @@ -521,7 +575,7 @@ class DesktopAccountCredentialPersistenceTest { assertFalse(decodeRegistry(preferences).accounts.any { it.id == removed.accountId }) assertEquals(removed.accountId.storageKey, preferences.get("accountCredentialRemovals", null)) - assertEquals(removed.serverUrl, preferences.get("accountLegacyCleanupServer", null)) + assertEquals(removed.serverUrl, preferences.get("accountLegacyCleanupV2.0.server", null)) assertNotNull(secrets.load(desktopAccountSecretReference(removed.accountId))) assertNotNull(secrets.load(desktopSessionSecretReference(removed.serverUrl, removed.loginName))) @@ -530,7 +584,7 @@ class DesktopAccountCredentialPersistenceTest { assertNull(secrets.load(desktopAccountSecretReference(removed.accountId))) assertNull(secrets.load(desktopSessionSecretReference(removed.serverUrl, removed.loginName))) assertNull(preferences.get("accountCredentialRemovals", null)) - assertNull(preferences.get("accountLegacyCleanupServer", null)) + assertNull(preferences.get("accountLegacyCleanupV2.0.server", null)) } @Test @@ -554,7 +608,7 @@ class DesktopAccountCredentialPersistenceTest { var flushAttempts = 0 val persistence = persistence(preferences, secrets) { flushAttempts += 1 - if (flushAttempts == 10) error("synthetic removal flush failure") + if (flushAttempts == 12) error("synthetic removal flush failure") preferences.flush() } persistence.saveSession(first) @@ -668,6 +722,7 @@ class DesktopAccountCredentialPersistenceTest { private val values = mutableMapOf() var failSaves = false var failSaveOnAttempt: Int? = null + var crashSaveOnAttempt: Int? = null var failClears = false var loadFailure: RuntimeException? = null var loadCount = 0 @@ -685,6 +740,7 @@ class DesktopAccountCredentialPersistenceTest { override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { saveCount += 1 + if (saveCount == crashSaveOnAttempt) throw SimulatedProcessExit() if (failSaves || saveCount == failSaveOnAttempt) { error("private-app-password at cloud.example.test for alice") } From c4052235ac092a0fc2a942ac1e0df75cd62bcd1b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 05:00:37 +0200 Subject: [PATCH 045/119] refactor(desktop): decouple Linux cleanup lifecycle --- .../app/DesktopLinuxProviderCleanup.kt | 6 +- .../nextcloudnative/app/LinuxFuseLifecycle.kt | 16 +++ .../app/LinuxVirtualFileSystem.kt | 34 +++--- .../app/LinuxVirtualMutationGate.kt | 19 ++++ .../app/DesktopLinuxProviderCleanupTest.kt | 74 +++++------- .../app/LinuxVirtualMutationGateTest.kt | 106 +++++------------- 6 files changed, 112 insertions(+), 143 deletions(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt index 718eea72b..ca23e9f9f 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt @@ -1,11 +1,15 @@ package dev.obiente.nextcloudnative.app internal data class DetachedDesktopLinuxProvider( - val fileSystem: LinuxNextcloudVirtualFileSystem, + val fileSystem: DesktopLinuxProviderFileSystem, val metadataBackend: CachingLinuxVirtualFileBackend?, val accountId: String?, ) +internal interface DesktopLinuxProviderFileSystem { + fun unmount() +} + internal fun detachedDesktopLinuxProvider( fileSystem: LinuxNextcloudVirtualFileSystem?, metadataBackend: CachingLinuxVirtualFileBackend?, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxFuseLifecycle.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxFuseLifecycle.kt index 6c3ffd5e2..a22e01dc3 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxFuseLifecycle.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxFuseLifecycle.kt @@ -46,6 +46,22 @@ internal interface LinuxFuseAbortHandle : AutoCloseable { fun abortBestEffort() } +internal fun runLinuxFuseUnmountLifecycle( + abortHandle: LinuxFuseAbortHandle?, + detach: () -> Unit, + cleanup: (detached: Boolean) -> Unit, +) { + var detached = false + try { + detach() + detached = true + } finally { + abortHandle?.abortBestEffort() + runCatching { abortHandle?.close() } + cleanup(detached) + } +} + private class ChannelLinuxFuseAbortHandle( private val channel: SeekableByteChannel, ) : LinuxFuseAbortHandle { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt index bbac4d820..f0ff54099 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt @@ -1101,7 +1101,7 @@ internal class LinuxNextcloudVirtualFileSystem( }, private val mountOwnerUid: Long = linuxEffectiveProcessUid(), private val mountOwnerGid: Long = linuxEffectiveProcessGid(), -) : FuseStubFS() { +) : FuseStubFS(), DesktopLinuxProviderFileSystem { @Volatile private var mountedAt: Path? = null private val nextHandle = AtomicLong(1L) @@ -1115,7 +1115,10 @@ internal class LinuxNextcloudVirtualFileSystem( private var openDirectoryEntries = 0L private val pendingCreatedFiles = ConcurrentHashMap() private val namespaceLock = Any() - private val mutationGate = LinuxVirtualMutationGate() + private val writeLifecycle = LinuxVirtualWriteLifecycle( + hasOpenWriteHandles = { writeHandles.isNotEmpty() }, + hasPendingCreatedFiles = { pendingCreatedFiles.isNotEmpty() }, + ) init { require(maximumOpenDirectoryEntries > 0) require(mountOwnerUid in 0L..MAX_UNSIGNED_UNIX_ID) @@ -1234,7 +1237,7 @@ internal class LinuxNextcloudVirtualFileSystem( override fun release(path: String, fileInfo: FuseFileInfo): Int = fuseResult { val id = fileInfo.fh.get() val writeRelease = writeHandles.containsKey(id) - val releaseStarted = !writeRelease || mutationGate.beginRelease() + val releaseStarted = !writeRelease || writeLifecycle.beginRelease() if (!releaseStarted) return 0 try { if (id != EMPTY_FILE_HANDLE) { @@ -1245,7 +1248,7 @@ internal class LinuxNextcloudVirtualFileSystem( releaseWriteHandle(id) } } finally { - if (writeRelease) mutationGate.end() + if (writeRelease) writeLifecycle.endOperation() } 0 } @@ -1379,21 +1382,16 @@ internal class LinuxNextcloudVirtualFileSystem( mountedAt = mountPoint.toAbsolutePath().normalize() } - internal fun quiesceWrites(): Boolean = mutationGate.tryQuiesce { - writeHandles.isEmpty() && pendingCreatedFiles.isEmpty() - } + internal fun quiesceWrites(): Boolean = writeLifecycle.tryQuiesce() - internal fun resumeWrites() = mutationGate.resume() + internal fun resumeWrites() = writeLifecycle.resume() - fun unmount() { - var detached = false + override fun unmount() { val fuseAbortHandle = fuseAbortHandleProvider(mountedAt) - try { - unmountOperation(this) - detached = true - } finally { - fuseAbortHandle?.abortBestEffort() - runCatching { fuseAbortHandle?.close() } + runLinuxFuseUnmountLifecycle( + abortHandle = fuseAbortHandle, + detach = { unmountOperation(this) }, + ) { detached -> readHandles.values.forEach { runCatching(it::close) } writeHandles.values.map(LinuxOpenWriteReference::shared).distinct().forEach { shared -> runCatching(shared.delegate::close) @@ -1617,11 +1615,11 @@ internal class LinuxNextcloudVirtualFileSystem( } private inline fun fuseMutationResult(operation: () -> Int): Int = fuseResult { - mutationGate.begin() + writeLifecycle.beginMutation() try { operation() } finally { - mutationGate.end() + writeLifecycle.endOperation() } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGate.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGate.kt index 39339a8d0..543d17af0 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGate.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGate.kt @@ -50,3 +50,22 @@ internal class LinuxVirtualMutationGate { fun isAcceptingNewOperations(): Boolean = lock.withLock { state == State.Open } } + +internal class LinuxVirtualWriteLifecycle( + private val hasOpenWriteHandles: () -> Boolean, + private val hasPendingCreatedFiles: () -> Boolean, +) { + private val gate = LinuxVirtualMutationGate() + + fun beginMutation() = gate.begin() + + fun beginRelease(): Boolean = gate.beginRelease() + + fun endOperation() = gate.end() + + fun tryQuiesce(): Boolean = gate.tryQuiesce { + !hasOpenWriteHandles() && !hasPendingCreatedFiles() + } + + fun resume() = gate.resume() +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt index 823ff93a4..339014f5b 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt @@ -1,35 +1,26 @@ package dev.obiente.nextcloudnative.app import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertSame import kotlin.test.assertTrue -import ru.serce.jnrfuse.ErrorCodes class DesktopLinuxProviderCleanupTest { @Test fun `failed unmount aborts fuse and retains the exact quiesced provider`() { var aborted = false var abortHandleClosed = false - val fileSystem = LinuxNextcloudVirtualFileSystem( - backend = CleanupBackend, - unmountOperation = { error("synthetic unmount failure") }, - fuseAbortHandleProvider = { - object : LinuxFuseAbortHandle { - override fun abortBestEffort() { - aborted = true - } + val fileSystem = RecordingLinuxProviderFileSystem( + abortHandle = object : LinuxFuseAbortHandle { + override fun abortBestEffort() { + aborted = true + } - override fun close() { - abortHandleClosed = true - } + override fun close() { + abortHandleClosed = true } }, - mountOwnerUid = TEST_MOUNT_OWNER_UID, - mountOwnerGid = TEST_MOUNT_OWNER_GID, ) - assertTrue(fileSystem.quiesceWrites()) val provider = DetachedDesktopLinuxProvider(fileSystem, null, "account") val cleanup = DesktopLinuxProviderCleanupSlot() @@ -38,50 +29,35 @@ class DesktopLinuxProviderCleanupTest { assertTrue(aborted) assertTrue(abortHandleClosed) assertSame(provider, cleanup.pendingForTest()) - assertEquals(-ErrorCodes.EBUSY(), fileSystem.mkdir("/blocked", 0L)) + assertFailsWith(fileSystem::beginMutation) } @Test fun `failed unmount without an abort handle still retains a quiesced provider`() { - val fileSystem = LinuxNextcloudVirtualFileSystem( - backend = CleanupBackend, - unmountOperation = { error("synthetic unmount failure") }, - fuseAbortHandleProvider = { null }, - mountOwnerUid = TEST_MOUNT_OWNER_UID, - mountOwnerGid = TEST_MOUNT_OWNER_GID, - ) - assertTrue(fileSystem.quiesceWrites()) + val fileSystem = RecordingLinuxProviderFileSystem(abortHandle = null) val provider = DetachedDesktopLinuxProvider(fileSystem, null, "account") val cleanup = DesktopLinuxProviderCleanupSlot() assertFailsWith { cleanup.unmountOrRetain(provider) } assertSame(provider, cleanup.pendingForTest()) - assertEquals(-ErrorCodes.EBUSY(), fileSystem.mkdir("/blocked", 0L)) + assertFailsWith(fileSystem::beginMutation) } } -private const val TEST_MOUNT_OWNER_UID = 2_001L -private const val TEST_MOUNT_OWNER_GID = 2_002L - -private object CleanupBackend : LinuxVirtualFileBackend { - override fun resolve(path: String): LinuxVirtualFileNode? = null - override fun list(path: String): List = emptyList() - override fun open(node: LinuxVirtualFileNode): LinuxVirtualFileReadHandle = error("Not used") - override fun openWrite( - path: String, - existing: LinuxVirtualFileNode?, - truncate: Boolean, - ): LinuxVirtualFileWriteHandle = error("Not used") - - override fun createDirectory(path: String) = Unit - override fun delete(node: LinuxVirtualFileNode) = Unit - override fun move(node: LinuxVirtualFileNode, destinationPath: String) = Unit - override fun moveReplacing( - node: LinuxVirtualFileNode, - destination: LinuxVirtualFileNode, - destinationPath: String, - ) = Unit - - override fun close() = Unit +private class RecordingLinuxProviderFileSystem( + private val abortHandle: LinuxFuseAbortHandle?, +) : DesktopLinuxProviderFileSystem { + private val writeLifecycle = LinuxVirtualWriteLifecycle( + hasOpenWriteHandles = { false }, + hasPendingCreatedFiles = { false }, + ).also { check(it.tryQuiesce()) } + + override fun unmount() = runLinuxFuseUnmountLifecycle( + abortHandle = abortHandle, + detach = { error("synthetic unmount failure") }, + cleanup = {}, + ) + + fun beginMutation() = writeLifecycle.beginMutation() } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt index 08881f7ed..3ff9c0a53 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt @@ -3,14 +3,10 @@ package dev.obiente.nextcloudnative.app import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.TimeUnit -import jnr.ffi.Runtime import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue -import ru.serce.jnrfuse.ErrorCodes -import ru.serce.jnrfuse.struct.FuseFileInfo class LinuxVirtualMutationGateTest { @Test @@ -52,96 +48,56 @@ class LinuxVirtualMutationGateTest { @Test fun `failed quiescence reopens automatically so an unreleased writer can close`() { - val fileSystem = LinuxNextcloudVirtualFileSystem( - backend = QuiescenceBackend(), - mountOwnerUid = TEST_MOUNT_OWNER_UID, - mountOwnerGid = TEST_MOUNT_OWNER_GID, + var hasOpenWriteHandle = true + val lifecycle = LinuxVirtualWriteLifecycle( + hasOpenWriteHandles = { hasOpenWriteHandle }, + hasPendingCreatedFiles = { false }, ) - val fileInfo = FuseFileInfo.of(Runtime.getSystemRuntime().memoryManager.allocateDirect(256)).apply { - flags.set(1L) - } - assertEquals(0, fileSystem.open("/draft.txt", fileInfo)) - assertFalse(fileSystem.quiesceWrites()) - assertEquals(0, fileSystem.release("/draft.txt", fileInfo)) - assertTrue(fileSystem.quiesceWrites()) - assertEquals(-ErrorCodes.EBUSY(), fileSystem.mkdir("/still-blocked", 0L)) - fileSystem.resumeWrites() + assertFalse(lifecycle.tryQuiesce()) + assertTrue(lifecycle.beginRelease()) + hasOpenWriteHandle = false + lifecycle.endOperation() + assertTrue(lifecycle.tryQuiesce()) + assertFailsWith(lifecycle::beginMutation) + lifecycle.resume() } @Test fun `quiescence drains final pending file close through a read alias release`() { val closeStarted = CountDownLatch(1) val allowClose = CountDownLatch(1) - val fileSystem = LinuxNextcloudVirtualFileSystem( - backend = QuiescenceBackend { - closeStarted.countDown() - check(allowClose.await(5, TimeUnit.SECONDS)) - }, - mountOwnerUid = TEST_MOUNT_OWNER_UID, - mountOwnerGid = TEST_MOUNT_OWNER_GID, + var hasOpenWriteHandle = true + var hasPendingCreatedFile = true + val lifecycle = LinuxVirtualWriteLifecycle( + hasOpenWriteHandles = { hasOpenWriteHandle }, + hasPendingCreatedFiles = { hasPendingCreatedFile }, ) - val runtime = Runtime.getSystemRuntime() - val writer = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) - val reader = FuseFileInfo.of(runtime.memoryManager.allocateDirect(256)) val workers = Executors.newFixedThreadPool(2) try { - assertEquals(0, fileSystem.create("/pending.txt", 0L, writer)) - assertEquals(0, fileSystem.open("/pending.txt", reader)) - assertEquals(0, fileSystem.release("/pending.txt", writer)) - - val release = workers.submit { fileSystem.release("/pending.txt", reader) } + val release = workers.submit { + check(lifecycle.beginRelease()) + try { + closeStarted.countDown() + check(allowClose.await(5, TimeUnit.SECONDS)) + hasOpenWriteHandle = false + hasPendingCreatedFile = false + } finally { + lifecycle.endOperation() + } + } assertTrue(closeStarted.await(5, TimeUnit.SECONDS)) - val quiescence = workers.submit { fileSystem.quiesceWrites() } + val quiescence = workers.submit { lifecycle.tryQuiesce() } assertFalse(quiescence.isDone) allowClose.countDown() - assertEquals(0, release.get(5, TimeUnit.SECONDS)) + release.get(5, TimeUnit.SECONDS) assertTrue(quiescence.get(5, TimeUnit.SECONDS)) - assertEquals(-ErrorCodes.EBUSY(), fileSystem.open("/draft.txt", reader)) - fileSystem.resumeWrites() + assertFailsWith(lifecycle::beginMutation) + lifecycle.resume() } finally { allowClose.countDown() workers.shutdownNow() } } } - -private const val TEST_MOUNT_OWNER_UID = 2_001L -private const val TEST_MOUNT_OWNER_GID = 2_002L - -private class QuiescenceBackend( - private val onWriteClose: () -> Unit = {}, -) : LinuxVirtualFileBackend { - private val file = LinuxVirtualFileNode("draft.txt", "draft.txt", false, 5L, "etag") - - override fun resolve(path: String): LinuxVirtualFileNode? = when (path.trim('/')) { - "" -> LinuxVirtualFileNode("", "", true, 0L, "root") - "draft.txt" -> file - else -> null - } - - override fun list(path: String): List = - if (path.trim('/').isEmpty()) listOf(file) else emptyList() - - override fun open(node: LinuxVirtualFileNode): LinuxVirtualFileReadHandle = error("Not used") - override fun openWrite(path: String, existing: LinuxVirtualFileNode?, truncate: Boolean) = - object : LinuxVirtualFileWriteHandle { - override val size: Long = 5L - override fun read(offset: Long, length: Int) = ByteArray(length) - override fun write(offset: Long, bytes: ByteArray) = bytes.size - override fun truncate(size: Long) = Unit - override fun flush() = Unit - override fun close() = onWriteClose() - } - - override fun createDirectory(path: String) = Unit - override fun delete(node: LinuxVirtualFileNode) = Unit - override fun move(node: LinuxVirtualFileNode, destinationPath: String) = Unit - override fun moveReplacing( - node: LinuxVirtualFileNode, - destination: LinuxVirtualFileNode, - destinationPath: String, - ) = Unit - override fun close() = Unit -} From 908507d9f2da9b7834449e90d0c7e09558a97f5a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 05:00:37 +0200 Subject: [PATCH 046/119] fix(desktop): clear recovered Cloud Files failures --- .../app/DesktopNextcloudServices.kt | 6 ++-- .../app/DesktopWindowsCloudFilesCleanup.kt | 8 +++++ .../app/WindowsUninstallCleanupTest.kt | 36 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) 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 b586f4775..e7b8811af 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3815,10 +3815,10 @@ class DesktopNextcloudServices( userHome = userHome, ) }.exceptionOrNull() + windowsCloudFilesFailure = windowsCloudFilesFailureAfterFallbackCleanup( + windowsCloudFilesFailure, uninstallFailure, windowsCloudFilesFailureMessage, + ) if (uninstallFailure != null) { - windowsCloudFilesFailure = windowsCloudFilesFailure ?: ( - uninstallFailure.message ?: windowsCloudFilesFailureMessage - ) supportDiagnostics.record( SupportDiagnosticEventDraft( severity = SupportDiagnosticSeverity.Error, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt index 5efab37c0..36fcb2151 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt @@ -4,6 +4,14 @@ import java.io.File import java.nio.file.Path import java.util.prefs.Preferences +internal fun windowsCloudFilesFailureAfterFallbackCleanup( + providerFailure: String?, + fallbackFailure: Throwable?, + defaultMessage: String, +): String? = fallbackFailure?.let { failure -> + providerFailure ?: failure.message ?: defaultMessage +} + internal const val KEY_WINDOWS_CLOUD_FILES_ROOT = "windows-cloud-files-root" internal const val KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX = "wcfr." internal const val WINDOWS_CLOUD_FILES_ROOT_SUFFIX = "-v2" diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt index 1be27d5b0..cc7ed8b5b 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt @@ -12,6 +12,42 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class WindowsUninstallCleanupTest { + @Test + fun successfulFallbackCleanupClearsAnEarlierProviderFailure() { + assertEquals( + null, + windowsCloudFilesFailureAfterFallbackCleanup( + providerFailure = "provider removal failed", + fallbackFailure = null, + defaultMessage = "cleanup failed", + ), + ) + } + + @Test + fun failedFallbackCleanupPreservesTheEarlierProviderFailure() { + assertEquals( + "provider removal failed", + windowsCloudFilesFailureAfterFallbackCleanup( + providerFailure = "provider removal failed", + fallbackFailure = IllegalStateException("fallback failed"), + defaultMessage = "cleanup failed", + ), + ) + } + + @Test + fun failedFallbackCleanupPublishesItsOwnFailureWhenTheProviderSucceeded() { + assertEquals( + "fallback failed", + windowsCloudFilesFailureAfterFallbackCleanup( + providerFailure = null, + fallbackFailure = IllegalStateException("fallback failed"), + defaultMessage = "cleanup failed", + ), + ) + } + @Test fun preservedRootRecordSurvivesReloadUntilAcknowledged() { val nodeName = "windows-preserved-root-test-${UUID.randomUUID()}" From 5b536d7ab9489f9b05771695492e647686c2169f Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 05:05:27 +0200 Subject: [PATCH 047/119] fix(accounts): preserve distinct legacy cleanup keys --- tools/kotlin-file-size-baseline.txt | 2 +- .../DesktopLegacyCredentialCleanupJournal.kt | 20 +++---- ...DesktopAccountCredentialPersistenceTest.kt | 58 +++++++++++++++++++ 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 108a1c5d0..4f9d90cea 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -55,7 +55,7 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteT ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStore.kt|808 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6269 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2762 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1731 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1085 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt|2373 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCacheTest.kt|2479 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLegacyCredentialCleanupJournal.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLegacyCredentialCleanupJournal.kt index 97570a66f..ec19a29e8 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLegacyCredentialCleanupJournal.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLegacyCredentialCleanupJournal.kt @@ -42,17 +42,13 @@ internal class DesktopLegacyCredentialCleanupJournal( ) { malformed = true } - }.distinctBy { cleanup -> deriveNextcloudAccountId(cleanup.serverUrl, cleanup.loginName) } + }.distinct() if (malformed && malformedReported.compareAndSet(false, true)) runCatching(recordMalformed) return cleanups } fun prepareAdd(cleanup: DesktopPendingLegacyCredentialCleanup) { - val accountId = deriveNextcloudAccountId(cleanup.serverUrl, cleanup.loginName) - if (pending().any { existing -> - deriveNextcloudAccountId(existing.serverUrl, existing.loginName) == accountId - } - ) return + if (cleanup in pending()) return val slot = (0 until MAX_LOCAL_ACCOUNTS).firstOrNull { index -> preferences.get(serverKey(index), null) == null && preferences.get(loginKey(index), null) == null } ?: error("The legacy credential cleanup journal is full.") @@ -91,11 +87,11 @@ internal class DesktopLegacyCredentialCleanupJournal( fun restore(snapshot: DesktopLegacyCredentialCleanupSnapshot) { snapshot.slots.forEachIndexed { index, (server, login) -> - preferences.putOrRemove(serverKey(index), server) - preferences.putOrRemove(loginKey(index), login) + preferences.restoreString(serverKey(index), server) + preferences.restoreString(loginKey(index), login) } - preferences.putOrRemove(LEGACY_SERVER_KEY, snapshot.legacyServer) - preferences.putOrRemove(LEGACY_LOGIN_KEY, snapshot.legacyLogin) + preferences.restoreString(LEGACY_SERVER_KEY, snapshot.legacyServer) + preferences.restoreString(LEGACY_LOGIN_KEY, snapshot.legacyLogin) } private fun decode(serverKey: String, loginKey: String): DesktopPendingLegacyCredentialCleanup? { @@ -116,3 +112,7 @@ internal class DesktopLegacyCredentialCleanupJournal( const val LEGACY_LOGIN_KEY = "accountLegacyCleanupLogin" } } + +private fun Preferences.restoreString(key: String, value: String?) { + if (value == null) remove(key) else put(key, value) +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index 1c2c6739f..158256652 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -439,6 +439,64 @@ class DesktopAccountCredentialPersistenceTest { assertNull(preferences.get("accountLegacyCleanupV2.0.server", null)) } + @Test + fun legacyCleanupJournalKeepsDistinctRawSecretReferencesForOneCanonicalAccount() = + withStore { preferences, _ -> + val diagnostics = mutableListOf() + val journal = DesktopLegacyCredentialCleanupJournal(preferences, preferences::flush) { + diagnostics += "malformed" + } + val withoutSlash = DesktopPendingLegacyCredentialCleanup("https://cloud.example.test", "alice") + val withSlash = DesktopPendingLegacyCredentialCleanup("https://cloud.example.test/", "alice") + + journal.prepareAdd(withoutSlash) + journal.prepareAdd(withSlash) + + assertEquals(listOf(withoutSlash, withSlash), journal.pending()) + assertTrue(diagnostics.isEmpty()) + } + + @Test + fun fullLegacyCleanupJournalRejectsAnotherTargetWithoutOverwritingEntries() = + withStore { preferences, _ -> + repeat(MAX_LOCAL_ACCOUNTS) { index -> + preferences.put("accountLegacyCleanupV2.$index.server", "https://cloud$index.example.test") + preferences.put("accountLegacyCleanupV2.$index.login", "user$index") + } + val journal = DesktopLegacyCredentialCleanupJournal(preferences, preferences::flush) {} + + assertFailsWith { + journal.prepareAdd(DesktopPendingLegacyCredentialCleanup("https://overflow.example.test", "alice")) + } + + assertEquals("https://cloud0.example.test", preferences.get("accountLegacyCleanupV2.0.server", null)) + assertEquals( + "https://cloud63.example.test", + preferences.get("accountLegacyCleanupV2.63.server", null), + ) + } + + @Test + fun malformedLegacyCleanupSlotIsPreservedAndDoesNotHideValidTargets() = + withStore { preferences, _ -> + preferences.put("accountLegacyCleanupV2.0.server", "https://malformed.example.test") + var malformedReports = 0 + val journal = DesktopLegacyCredentialCleanupJournal(preferences, preferences::flush) { + malformedReports += 1 + } + val valid = DesktopPendingLegacyCredentialCleanup("https://cloud.example.test", "alice") + + journal.prepareAdd(valid) + + assertEquals(listOf(valid), journal.pending()) + assertEquals(1, malformedReports) + assertEquals( + "https://malformed.example.test", + preferences.get("accountLegacyCleanupV2.0.server", null), + ) + assertEquals(valid.serverUrl, preferences.get("accountLegacyCleanupV2.1.server", null)) + } + @Test fun secureStoreReadFailureIsNotReportedAsMissingCredentials() = withStore { preferences, secrets -> val persistence = persistence(preferences, secrets) From 48f99dedc17f33e54c7cc264b92e249fed46cb51 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 05:10:21 +0200 Subject: [PATCH 048/119] test(desktop): invoke lifecycle failures explicitly --- .../nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt | 4 ++-- .../nextcloudnative/app/LinuxVirtualMutationGateTest.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt index 339014f5b..584aca9fe 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt @@ -29,7 +29,7 @@ class DesktopLinuxProviderCleanupTest { assertTrue(aborted) assertTrue(abortHandleClosed) assertSame(provider, cleanup.pendingForTest()) - assertFailsWith(fileSystem::beginMutation) + assertFailsWith { fileSystem.beginMutation() } } @Test @@ -41,7 +41,7 @@ class DesktopLinuxProviderCleanupTest { assertFailsWith { cleanup.unmountOrRetain(provider) } assertSame(provider, cleanup.pendingForTest()) - assertFailsWith(fileSystem::beginMutation) + assertFailsWith { fileSystem.beginMutation() } } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt index 3ff9c0a53..0ca9928a8 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualMutationGateTest.kt @@ -59,7 +59,7 @@ class LinuxVirtualMutationGateTest { hasOpenWriteHandle = false lifecycle.endOperation() assertTrue(lifecycle.tryQuiesce()) - assertFailsWith(lifecycle::beginMutation) + assertFailsWith { lifecycle.beginMutation() } lifecycle.resume() } @@ -93,7 +93,7 @@ class LinuxVirtualMutationGateTest { allowClose.countDown() release.get(5, TimeUnit.SECONDS) assertTrue(quiescence.get(5, TimeUnit.SECONDS)) - assertFailsWith(lifecycle::beginMutation) + assertFailsWith { lifecycle.beginMutation() } lifecycle.resume() } finally { allowClose.countDown() From 84298c4cd696ffaedd02a8f6097d67027943b9fe Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 05:48:56 +0200 Subject: [PATCH 049/119] fix(desktop): count lazy hydration as live --- .../app/DesktopNextcloudServices.kt | 6 ++--- .../DesktopVirtualFolderHydrationLifecycle.kt | 8 +++++++ ...ktopVirtualFolderHydrationLifecycleTest.kt | 22 +++++++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycle.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycleTest.kt 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 e7b8811af..a9f1343c7 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3663,7 +3663,9 @@ class DesktopNextcloudServices( } private fun hasLiveAccountResources(): Boolean = synchronized(fileRangeSessionLock) { activeFileRangeSessions.isNotEmpty() } || - synchronized(virtualFolderHydrationJobs) { virtualFolderHydrationJobs.values.any { it.isActive } } || + synchronized(virtualFolderHydrationJobs) { + hasLiveVirtualFolderHydrationJobs(virtualFolderHydrationJobs.values) + } || synchronized(virtualFileProviderLock) { linuxVirtualFileSystem != null || windowsCloudFilesProvider != null || virtualFileCacheTierMutations.isNotEmpty() @@ -6171,8 +6173,6 @@ internal fun requireVirtualFolderListingCapacity( internal fun isCompleteRetainedTreeListing(listingPath: String, retainedRoot: String): Boolean = listingPath == retainedRoot || listingPath.startsWith("$retainedRoot/") -internal fun Job?.occupiesVirtualFolderHydrationSlot(): Boolean = this != null && !isCompleted - internal fun removeVirtualFolderHydrationJobIfOwned( jobs: MutableMap, key: String, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycle.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycle.kt new file mode 100644 index 000000000..a42d37958 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycle.kt @@ -0,0 +1,8 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.Job + +internal fun Job?.occupiesVirtualFolderHydrationSlot(): Boolean = this != null && !isCompleted + +internal fun hasLiveVirtualFolderHydrationJobs(jobs: Iterable): Boolean = + jobs.any { job -> job.occupiesVirtualFolderHydrationSlot() } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycleTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycleTest.kt new file mode 100644 index 000000000..8529d96d8 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFolderHydrationLifecycleTest.kt @@ -0,0 +1,22 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopVirtualFolderHydrationLifecycleTest { + @Test + fun `account resource preflight counts a registered lazy hydration job`() = runBlocking { + val registeredJob = launch(start = CoroutineStart.LAZY) {} + + assertFalse(registeredJob.isActive) + assertTrue(hasLiveVirtualFolderHydrationJobs(listOf(registeredJob))) + + registeredJob.cancelAndJoin() + assertFalse(hasLiveVirtualFolderHydrationJobs(listOf(registeredJob))) + } +} From 260d2bda9eae70251ace784c3259dbfa74b4be73 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 05:48:56 +0200 Subject: [PATCH 050/119] fix(accounts): quarantine malformed removal journals --- .../DesktopAccountCredentialPersistence.kt | 48 +++++++++++++++---- ...DesktopAccountCredentialPersistenceTest.kt | 37 ++++++++++++++ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 2ff26d3fc..f63dae294 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative.app import java.util.prefs.Preferences +import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException internal class DesktopAccountCredentialPersistence( @@ -14,6 +15,7 @@ internal class DesktopAccountCredentialPersistence( preferences, flushPreferences, ) { recordCredentialDiagnostic("ACCOUNT_CREDENTIAL_LEGACY_CLEANUP_FAILED", "account-credentials.migrate") } + private val malformedCredentialRemovalJournalReported = AtomicBoolean(false) fun loadActiveSession(): NextcloudSession? { retryPendingCredentialSave() @@ -256,7 +258,9 @@ internal class DesktopAccountCredentialPersistence( } private fun retryPendingCredentialRemoval() { - pendingCredentialRemovalIds().forEach { accountId -> + val pending = readPendingCredentialRemovals() + if (pending.malformed) return + pending.accountIds.forEach { accountId -> val registry = readRegistry().registry if (registry == null) { recordCredentialDiagnostic( @@ -285,25 +289,34 @@ internal class DesktopAccountCredentialPersistence( } } - private fun pendingCredentialRemovalIds(): Set { - val encoded = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null) ?: return emptySet() - if (encoded.isBlank()) return emptySet() - return encoded.split(',').mapNotNullTo(linkedSetOf()) { storageKey -> + private fun readPendingCredentialRemovals(): DesktopPendingCredentialRemovals { + val encoded = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null) + ?: return DesktopPendingCredentialRemovals.Empty + val accountIds = linkedSetOf() + var malformed = encoded.isBlank() + encoded.split(',').forEach { storageKey -> try { - NextcloudAccountId(storageKey) + accountIds += NextcloudAccountId(storageKey) } catch (_: IllegalArgumentException) { + malformed = true + } + } + if (malformed && malformedCredentialRemovalJournalReported.compareAndSet(false, true)) { + runCatching { recordCredentialDiagnostic( "ACCOUNT_CREDENTIAL_REMOVAL_JOURNAL_INVALID", "account-credentials.recover", ) - null } } + return DesktopPendingCredentialRemovals(accountIds, malformed) } private fun clearPendingCredentialRemoval(accountId: NextcloudAccountId) { val previous = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null) - val remaining = pendingCredentialRemovalIds() - accountId + val pending = readPendingCredentialRemovals() + if (pending.malformed) return + val remaining = pending.accountIds - accountId try { preferences.putOrRemove( KEY_PENDING_CREDENTIAL_REMOVALS, @@ -514,6 +527,13 @@ internal class DesktopAccountCredentialPersistence( pendingLegacyCleanupAccount: NextcloudAccountRecord? = null, pendingCredentialRemoval: NextcloudAccountId? = null, ) { + val credentialRemovals = pendingCredentialRemoval?.let { accountId -> + val pending = readPendingCredentialRemovals() + check(!pending.malformed) { + "The credential removal journal is invalid and must be recovered before removing another account." + } + pending.accountIds + accountId + } val previous = DesktopAccountPreferenceSnapshot( registry = registryStore.read(), server = preferences.get(KEY_SERVER, null), @@ -527,8 +547,7 @@ internal class DesktopAccountCredentialPersistence( DesktopPendingLegacyCredentialCleanup(account.serverUrl, account.loginName), ) } - pendingCredentialRemoval?.let { accountId -> - val removals = pendingCredentialRemovalIds() + accountId + credentialRemovals?.let { removals -> preferences.put( KEY_PENDING_CREDENTIAL_REMOVALS, removals.joinToString(",") { pending -> pending.storageKey }, @@ -583,6 +602,15 @@ internal class DesktopAccountCredentialPersistence( val unsupportedVersion: Boolean, ) + private data class DesktopPendingCredentialRemovals( + val accountIds: Set, + val malformed: Boolean, + ) { + companion object { + val Empty = DesktopPendingCredentialRemovals(emptySet(), malformed = false) + } + } + private data class DesktopAccountPreferenceSnapshot( val registry: String?, val server: String?, diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index 158256652..c1fb4609e 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -609,6 +609,43 @@ class DesktopAccountCredentialPersistenceTest { assertNull(preferences.get("accountCredentialRemovals", null)) } + @Test + fun malformedCredentialRemovalJournalBlocksRecoveryAndLaterStorageRewrite() = + withStore { preferences, secrets -> + val removed = firstSession() + val retained = secondSession() + val diagnostics = mutableListOf() + var persistenceFlushes = 0 + val persistence = persistence(preferences, secrets, diagnostics) { + persistenceFlushes += 1 + preferences.flush() + } + persistence.saveSession(removed) + persistence.saveSession(retained) + DesktopAccountRegistryPreferenceStore(preferences).write( + encodeNextcloudAccountRegistry(decodeRegistry(preferences).remove(removed.accountId)), + ) + val malformedJournal = "${removed.accountId.storageKey},truncated" + preferences.put("accountCredentialRemovals", malformedJournal) + preferences.flush() + val flushesBeforeRecovery = persistenceFlushes + + assertEquals(retained, persistence.loadActiveSession()) + assertNotNull(secrets.load(desktopAccountSecretReference(removed.accountId))) + assertEquals(malformedJournal, preferences.get("accountCredentialRemovals", null)) + + assertFailsWith { persistence.removeAccount(retained.accountId) } + + assertEquals(retained.accountId, persistence.activeAccountId()) + assertNotNull(secrets.load(desktopAccountSecretReference(retained.accountId))) + assertEquals(malformedJournal, preferences.get("accountCredentialRemovals", null)) + assertEquals(flushesBeforeRecovery, persistenceFlushes) + assertEquals( + listOf("ACCOUNT_CREDENTIAL_REMOVAL_JOURNAL_INVALID"), + diagnostics.mapNotNull { it.code }, + ) + } + @Test fun removalMarkersSurviveProcessExitAfterTheRegistryCommit() = withStore { preferences, secrets -> val first = firstSession() From 1daa24912930c37b80265143587f47a923e18aae Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 06:19:21 +0200 Subject: [PATCH 051/119] fix(accounts): close Android removal races --- .../AndroidAccountCredentialController.kt | 13 ++--- .../AndroidAccountOperationGuard.kt | 31 +++++++++++ .../nextcloudnative/AndroidAccountRemoval.kt | 47 ++++++++++++++--- ...ndroidAccountRemovalCleanupRecoveryWork.kt | 35 +++++++++++-- .../AndroidAccountOperationGuardTest.kt | 31 +++++++++++ ...idAccountRemovalCleanupRecoveryWorkTest.kt | 52 ++++++++++++++++++- .../NextcloudDocumentsContractTest.kt | 47 ++++++++++++++++- 7 files changed, 238 insertions(+), 18 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index fa0e86a5b..c8fa238ed 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -148,9 +148,8 @@ internal class AndroidAccountCredentialController( val current = requireValidStateForAccountRemoval(accountId) val session = current.sessions[accountId] ?: return@withLock removeUnavailableAccount(accountId, current) - val accountIdentity = NextcloudDocumentIds.accountKey(session) val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) - ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session)) { val active = current.registry.activeAccountId == accountId removeAndroidAccountCredentialData( active = active, @@ -189,7 +188,7 @@ internal class AndroidAccountCredentialController( val unavailableSession = NextcloudSession(record.serverUrl, record.loginName, appPassword = "") val accountIdentity = NextcloudDocumentIds.accountKey(unavailableSession) val pendingCleanup = pendingAndroidAccountRemovalCleanup(unavailableSession) - ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + withAndroidAccountRemovalLease(accountIdentity) { removeUnavailableAndroidAccountCredentialData( accountIdentity = accountIdentity, prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, @@ -254,7 +253,7 @@ internal class AndroidAccountCredentialController( } else { val accountIdentity = NextcloudDocumentIds.accountKey(session) val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) - ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + withAndroidAccountRemovalLease(accountIdentity) { removeAndroidAccountCredentialData( active = true, prepareAccountRemoval = { prepareAccountRemoval(session) }, @@ -324,7 +323,7 @@ internal class AndroidAccountCredentialController( if (activeSession != null) { val accountIdentity = NextcloudDocumentIds.accountKey(activeSession) val pendingCleanup = pendingAndroidAccountRemovalCleanup(activeSession) - ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(accountIdentity) { + withAndroidAccountRemovalLease(accountIdentity) { removeRecoveredAndroidAccountCredentialData( prepareAccountRemoval = { prepareAccountRemoval(activeSession) }, removeQueuedUploads = { removeQueuedUploads(activeSession) }, @@ -688,7 +687,9 @@ internal class AndroidAccountCredentialController( ) ?: return try { retryAndroidAccountRemovalCleanup( - accountOwnedByRegistry = readCredentialFreeRegistry()?.accounts?.any { it.id == session.accountId }, + accountOwnedByRegistry = androidAccountRemovalCleanupOwnedByRegistry( + pending, readCredentialFreeRegistry()?.accounts, + ), removeAccountOwnedWork = { retryQueuedUploadsCleanup(session, pending.workIdentity) }, clearCleanup = { accountRemovalCleanupJournal.clear(pending.accountStorageKey) }, ) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index 6e367f633..f699b46c9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -1,6 +1,8 @@ package dev.obiente.nextcloudnative import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -17,6 +19,20 @@ internal class AndroidAccountOperationGuard { } } + suspend fun tryWithAccount( + accountId: String, + unavailable: suspend () -> Result, + action: suspend () -> Result, + ): Result { + currentCoroutineContext().ensureActive() + val lease = tryAcquire(accountId) ?: return unavailable() + return try { + action() + } finally { + lease.close() + } + } + suspend fun withAccounts(accountIds: Collection, action: suspend () -> Result): Result { val leases = mutableListOf() try { @@ -70,6 +86,21 @@ internal class AndroidAccountOperationGuard { } } + private fun tryAcquire(accountId: String): AndroidAccountOperationLease? { + require(accountId.isNotBlank()) + val lease = synchronized(monitor) { + accountLeases.getOrPut(accountId) { AccountLease() }.also { it.references += 1 } + } + if (!lease.mutex.tryLock()) { + releaseReference(accountId, lease) + return null + } + return AndroidAccountOperationLease { + lease.mutex.unlock() + releaseReference(accountId, lease) + } + } + private fun releaseReference(accountId: String, lease: AccountLease) { synchronized(monitor) { lease.references -= 1 diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index b487ae1d4..6d55c4a83 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -4,6 +4,9 @@ import android.content.Context import android.content.Intent import android.provider.DocumentsContract import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext internal val NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or @@ -11,19 +14,51 @@ internal val NEXTCLOUD_DOCUMENTS_URI_GRANT_FLAGS: Int = Intent.FLAG_GRANT_PREFIX_URI_PERMISSION internal fun requireAndroidAccountRemovalWritebacksResolved(resolved: Boolean) { - check(resolved) { - "Finish or discard pending document changes before removing this account." - } + if (!resolved) rejectAndroidAccountRemovalForPendingDocumentChanges() } +internal fun rejectAndroidAccountRemovalForPendingDocumentChanges(): Nothing = + error("Finish or discard pending document changes before removing this account.") + +internal suspend fun withAndroidAccountRemovalLease( + accountIdentity: String, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + action: suspend () -> Result, +): Result = guard.tryWithAccount( + accountId = accountIdentity, + unavailable = { rejectAndroidAccountRemovalForPendingDocumentChanges() }, + action = action, +) + internal suspend fun revokeAndroidSessionAfterRemovalPreflight( preflight: suspend () -> Unit, revoke: suspend () -> Unit, removeLocalAccount: suspend () -> Unit, ) { preflight() - revoke() - removeLocalAccount() + val revocationFailure: Exception? = try { + revoke() + null + } catch (cancelled: CancellationException) { + cancelled + } catch (failure: Exception) { + failure + } + val localRemovalFailure = try { + withContext(NonCancellable) { removeLocalAccount() } + null + } catch (failure: Exception) { + failure + } + if (revocationFailure is CancellationException) { + localRemovalFailure?.let(revocationFailure::addSuppressed) + throw revocationFailure + } + if (localRemovalFailure != null) { + revocationFailure?.let(localRemovalFailure::addSuppressed) + throw localRemovalFailure + } + revocationFailure?.let { throw it } } internal suspend fun revokeAndroidSessionWithAccountLease( @@ -32,7 +67,7 @@ internal suspend fun revokeAndroidSessionWithAccountLease( preflight: suspend () -> Unit, revoke: suspend () -> Unit, removeLocalAccount: suspend () -> Unit, -) = guard.withAccount(accountIdentity) { +) = withAndroidAccountRemovalLease(accountIdentity, guard) { revokeAndroidSessionAfterRemovalPreflight(preflight, revoke, removeLocalAccount) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt index c858db501..bea5a9758 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt @@ -9,6 +9,7 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import java.util.concurrent.TimeUnit import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.withLock @@ -72,8 +73,8 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( ) ?: return@withLock Result.retry() val completed = recoverPendingAndroidAccountRemovalCleanups( pending = pending, - accountOwnedByRegistry = { accountStorageKey -> - registry?.accounts?.any { account -> account.id.storageKey == accountStorageKey } + accountOwnedByRegistry = { pendingCleanup -> + androidAccountRemovalCleanupOwnedByRegistry(pendingCleanup, registry?.accounts) }, removeAccountOwnedWork = { pending -> ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(pending.workIdentity) { @@ -91,7 +92,7 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( internal suspend fun recoverPendingAndroidAccountRemovalCleanups( pending: Collection, - accountOwnedByRegistry: (String) -> Boolean?, + accountOwnedByRegistry: (AndroidPendingAccountRemovalCleanup) -> Boolean?, removeAccountOwnedWork: suspend (AndroidPendingAccountRemovalCleanup) -> Unit, clearCleanup: suspend (String) -> Unit, recordFailure: () -> Unit, @@ -100,7 +101,7 @@ internal suspend fun recoverPendingAndroidAccountRemovalCleanups( pending.forEach { cleanup -> try { retryAndroidAccountRemovalCleanup( - accountOwnedByRegistry = accountOwnedByRegistry(cleanup.accountStorageKey), + accountOwnedByRegistry = accountOwnedByRegistry(cleanup), removeAccountOwnedWork = { removeAccountOwnedWork(cleanup) }, clearCleanup = { clearCleanup(cleanup.accountStorageKey) }, ) @@ -114,6 +115,32 @@ internal suspend fun recoverPendingAndroidAccountRemovalCleanups( return completed } +internal fun androidAccountRemovalCleanupOwnedByRegistry( + cleanup: AndroidPendingAccountRemovalCleanup, + retainedAccounts: List?, +): Boolean? { + retainedAccounts ?: return null + val storageOwner = retainedAccounts.firstOrNull { account -> + account.id.storageKey == cleanup.accountStorageKey + } + if (storageOwner != null) { + val storageOwnerWorkIdentity = NextcloudDocumentIds.accountKey( + storageOwner.serverUrl, + storageOwner.loginName, + ) + check(storageOwnerWorkIdentity == cleanup.workIdentity) { + "The account-removal cleanup identities do not match." + } + return true + } + check(retainedAccounts.none { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == cleanup.workIdentity + }) { + "The account-removal cleanup identity belongs to a retained account." + } + return false +} + internal fun readPendingAndroidAccountRemovalCleanups( readPending: () -> Collection, recordFailure: () -> Unit, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 6cba2f2aa..cd7743807 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -184,6 +184,37 @@ class AndroidAccountOperationGuardTest { assertTrue(transitionEntered) } + @Test + fun writableDescriptorLeaseRejectsAccountRemovalWithoutWaitingForClose() = runBlocking { + val guard = AndroidAccountOperationGuard() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val accountIdentity = NextcloudDocumentIds.accountKey(session) + val descriptorLease = acquireAndroidDocumentMutationAccountLease(session, { session }, guard) + var removalEntered = false + + val failure = try { + assertFailsWith { + withTimeout(1_000L) { + withAndroidAccountRemovalLease(accountIdentity, guard) { + removalEntered = true + } + } + } + } finally { + descriptorLease.close() + } + + assertEquals( + "Finish or discard pending document changes before removing this account.", + failure.message, + ) + assertFalse(removalEntered) + withTimeout(1_000L) { + withAndroidAccountRemovalLease(accountIdentity, guard) { removalEntered = true } + } + assertTrue(removalEntered) + } + @Test fun directDocumentMutationLeaseRejectsReauthenticatedSessionAndReleasesTheGuard() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt index 44748f63c..4bffeee0a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt @@ -1,6 +1,8 @@ package dev.obiente.nextcloudnative import androidx.work.ExistingWorkPolicy +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking import kotlin.test.Test @@ -25,7 +27,7 @@ class AndroidAccountRemovalCleanupRecoveryWorkTest { val completed = recoverPendingAndroidAccountRemovalCleanups( pending = listOf(removed, restored), - accountOwnedByRegistry = { key -> key == restored.accountStorageKey }, + accountOwnedByRegistry = { cleanup -> cleanup.accountStorageKey == restored.accountStorageKey }, removeAccountOwnedWork = { events += "remove:${it.workIdentity}" }, clearCleanup = { events += "clear:$it" }, recordFailure = { events += "failure" }, @@ -132,6 +134,54 @@ class AndroidAccountRemovalCleanupRecoveryWorkTest { assertFalse(recorded) } + @Test + fun crossedCleanupIdentityCannotDeleteARetainedAccountsState() = runBlocking { + val retained = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "retained-user", + appPassword = "fixture-password", + ) + val removed = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "removed-user", + appPassword = "fixture-password", + ) + val crossed = pendingAndroidAccountRemovalCleanup(removed).copy( + workIdentity = NextcloudDocumentIds.accountKey(retained), + ) + val events = mutableListOf() + + val completed = recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(crossed), + accountOwnedByRegistry = { cleanup -> + androidAccountRemovalCleanupOwnedByRegistry(cleanup, listOf(retained.accountRecord())) + }, + removeAccountOwnedWork = { events += "remove" }, + clearCleanup = { events += "clear" }, + recordFailure = { events += "failure" }, + ) + + assertFalse(completed) + assertEquals(listOf("failure"), events) + } + + @Test + fun matchingCleanupIdentityRecognizesItsRetainedAccount() { + val retained = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "retained-user", + appPassword = "fixture-password", + ) + + assertEquals( + true, + androidAccountRemovalCleanupOwnedByRegistry( + pendingAndroidAccountRemovalCleanup(retained), + listOf(retained.accountRecord()), + ), + ) + } + private fun cleanup(accountCharacter: String, workCharacter: String) = AndroidPendingAccountRemovalCleanup( accountStorageKey = accountCharacter.repeat(64), diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt index 13b615351..b0d6c7465 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsContractTest.kt @@ -1,11 +1,15 @@ package dev.obiente.nextcloudnative +import java.io.IOException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue -import kotlinx.coroutines.runBlocking class NextcloudDocumentsContractTest { @Test @@ -80,4 +84,45 @@ class NextcloudDocumentsContractTest { assertEquals(listOf("preflight", "revoke", "remove-local"), events) } + + @Test + fun `remote revocation ambiguity still completes local removal`() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + revokeAndroidSessionAfterRemovalPreflight( + preflight = { events += "preflight" }, + revoke = { + events += "revoke" + throw IOException("synthetic ambiguous response") + }, + removeLocalAccount = { events += "remove-local" }, + ) + } + + assertEquals(listOf("preflight", "revoke", "remove-local"), events) + } + + @Test + fun `cancellation after remote revocation starts still completes local removal`() = runBlocking { + val revokeStarted = CompletableDeferred() + val removalCompleted = CompletableDeferred() + + val operation = launch { + revokeAndroidSessionAfterRemovalPreflight( + preflight = {}, + revoke = { + revokeStarted.complete(Unit) + awaitCancellation() + }, + removeLocalAccount = { removalCompleted.complete(Unit) }, + ) + } + revokeStarted.await() + operation.cancel() + operation.join() + + assertTrue(operation.isCancelled) + assertTrue(removalCompleted.isCompleted) + } } From 2d6fcb6b6fe9e81938df50e73c4ca670eecc8a11 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 06:19:21 +0200 Subject: [PATCH 052/119] fix(desktop): finish sign-out after revocation --- .../app/DesktopAccountRemoval.kt | 29 +++++++++ .../app/DesktopNextcloudServices.kt | 6 +- ...esktopAccountRevocationCancellationTest.kt | 60 +++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRevocationCancellationTest.kt diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 7fb8fecc2..504021621 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -3,6 +3,10 @@ package dev.obiente.nextcloudnative.app import java.util.prefs.Preferences import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext internal const val DESKTOP_UNKNOWN_CLEANUP_STATE_MESSAGE = "This account has cleanup state written by a newer app version." @@ -235,6 +239,31 @@ internal suspend fun commitDesktopAccountRemovalBeforeVirtualFileTeardown( teardownVirtualFiles() } +internal suspend fun completeDesktopSignOutAfterRemoteRevocation( + session: Session?, + revokeRemoteSession: suspend (Session) -> Unit, + completeLocalRemoval: suspend () -> Unit, +) { + if (session == null) { + completeLocalRemoval() + return + } + var revocationCancellation: CancellationException? = null + try { + revokeRemoteSession(session) + } catch (cancelled: CancellationException) { + revocationCancellation = cancelled + } + try { + withContext(NonCancellable) { completeLocalRemoval() } + } catch (failure: Throwable) { + revocationCancellation?.let(failure::addSuppressed) + throw failure + } + revocationCancellation?.let { throw it } + currentCoroutineContext().ensureActive() +} + internal fun finishCommittedDesktopAccountRemoval( markRemovalCommitted: () -> Unit, teardownVirtualFiles: () -> Unit, 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 a9f1343c7..61950e223 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3758,10 +3758,10 @@ class DesktopNextcloudServices( accountId ?.also { requireDesktopAccountRemovalReady(it, isLinuxDesktop()) } ?.let { fileSyncEngine.requireAccountRemovalReady(it) } - expectedSession?.let { session -> + completeDesktopSignOutAfterRemoteRevocation(expectedSession, { session -> remoteRevocationAttempted = true revokeRemoteSession(session) - } + }) { val hydrationJobs = accountId?.let(::cancelAllVirtualFolderHydration).orEmpty() rangeSessions.forEach { source -> runCatching(source::close) } hydrationJobs.forEach { job -> job.join() } @@ -3884,6 +3884,7 @@ class DesktopNextcloudServices( ::removeDesktopAccountOwnedState, ::recordSupportDiagnostic, ) + } } } catch (failure: Throwable) { removalFailure = failure; throw failure } finally { val reopen = shouldResumeDesktopWritesAfterRemovalFailure( @@ -3904,7 +3905,6 @@ class DesktopNextcloudServices( } } } - private suspend fun retryPendingAccountSyncPairCleanup(accountId: String) { val cleanup = accountSyncPairCleanupJournal.pending() .singleOrNull { pending -> pending.accountId == accountId } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRevocationCancellationTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRevocationCancellationTest.kt new file mode 100644 index 000000000..f76ce3242 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRevocationCancellationTest.kt @@ -0,0 +1,60 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking + +class DesktopAccountRevocationCancellationTest { + @Test + fun cancellationReturningFromRemoteRevocationStillCompletesLocalRemoval() = runBlocking { + val events = mutableListOf() + + assertFailsWith { + completeDesktopSignOutAfterRemoteRevocation( + session = "account", + revokeRemoteSession = { + events += "remote-revoked" + throw CancellationException("cancelled while returning from revocation") + }, + completeLocalRemoval = { events += "local-removed" }, + ) + } + + assertEquals(listOf("remote-revoked", "local-removed"), events) + } + + @Test + fun cancellationWhileJoiningHydrationStillCompletesLocalRemoval() = runBlocking { + val hydrationJoinStarted = CompletableDeferred() + val hydrationCanFinish = CompletableDeferred() + val localRemovalFinished = CompletableDeferred() + val events = mutableListOf() + val signOut = async { + completeDesktopSignOutAfterRemoteRevocation( + session = "account", + revokeRemoteSession = { events += "remote-revoked" }, + completeLocalRemoval = { + events += "join-hydration" + hydrationJoinStarted.complete(Unit) + hydrationCanFinish.await() + events += "local-removed" + localRemovalFinished.complete(Unit) + }, + ) + } + hydrationJoinStarted.await() + + signOut.cancel(CancellationException("cancelled while joining hydration")) + hydrationCanFinish.complete(Unit) + localRemovalFinished.await() + + assertFailsWith { signOut.await() } + assertTrue(signOut.isCancelled) + assertEquals(listOf("remote-revoked", "join-hydration", "local-removed"), events) + } +} From 9d9e98ffae76b003e754be084544299f21b08bbf Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 06:50:13 +0200 Subject: [PATCH 053/119] fix(sync): retire account SAF state safely --- .../AndroidFileSyncExecutionCoordination.kt | 54 +++++++++--- .../AndroidFileSyncEngineInvariantTest.kt | 87 +++++++++++++++++-- 2 files changed, 123 insertions(+), 18 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index 69e4c3d60..3b593c331 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.net.Uri import dev.obiente.nextcloudnative.app.FileSyncDirection import dev.obiente.nextcloudnative.app.FileSyncOperation +import dev.obiente.nextcloudnative.app.FileSyncPair import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -234,26 +235,53 @@ internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, account AndroidFileSyncEngine.ENGINE_LOCK.withLock { val store = AndroidFileSyncStore(context) val current = store.load() - val retiredPairIds = current.coordinator.pairs - .filter { pair -> pair.accountId == accountId } - .map { pair -> pair.id } - if (retiredPairIds.isEmpty()) return@withLock + val (retiredPairs, retainedPairs) = current.coordinator.pairs.partition { pair -> + pair.accountId == accountId + } + if (retiredPairs.isEmpty()) return@withLock val scheduler = AndroidFileSyncScheduler(context) - cancelAndroidFileSyncPairSchedulesBeforeRetirement( - pairIds = retiredPairIds, - cancelSchedule = scheduler::cancel, + retireConfiguredFileSyncAccountPairs( + retiredPairs = retiredPairs, + retainedPairs = retainedPairs, + reconcileLocalDownloads = { pair -> + reconcileSafDownloadsBeforePairRemoval(context, pair.localRootId) + }, + cancelSchedule = { pair -> scheduler.cancel(pair.id) }, persistRetirement = { store.save(removeAndroidFileSyncAccountPairs(current, accountId)) }, + releaseLocalGrant = { localRootId -> + releaseSafGrantAfterPairRemoval(context, localRootId, releasesLocalGrant = true) + }, ) } } -internal suspend fun cancelAndroidFileSyncPairSchedulesBeforeRetirement( - pairIds: List, - cancelSchedule: suspend (String) -> Unit, - persistRetirement: () -> Unit, +internal suspend fun retireConfiguredFileSyncAccountPairs( + retiredPairs: List, + retainedPairs: List, + reconcileLocalDownloads: suspend (FileSyncPair) -> Boolean, + cancelSchedule: suspend (FileSyncPair) -> Unit, + persistRetirement: suspend () -> Unit, + releaseLocalGrant: suspend (String) -> Unit, ) { - pairIds.forEach { pairId -> cancelSchedule(pairId) } - persistRetirement() + retiredPairs.forEach { pair -> + check(reconcileLocalDownloads(pair)) { + "A local download still needs safe recovery. Run this folder sync before removing the account." + } + currentCoroutineContext().ensureActive() + } + retiredPairs.forEach { pair -> cancelSchedule(pair) } + currentCoroutineContext().ensureActive() + + val retainedLocalRoots = retainedPairs.mapTo(hashSetOf()) { pair -> pair.localRootId } + val releasedLocalRoots = retiredPairs.asSequence() + .map { pair -> pair.localRootId } + .filter { localRootId -> localRootId.startsWith("content://") && localRootId !in retainedLocalRoots } + .distinct() + .toList() + withContext(NonCancellable) { + persistRetirement() + releasedLocalRoots.forEach { localRootId -> releaseLocalGrant(localRootId) } + } } internal suspend fun requireAndroidFileSyncAccountRemovalReady(context: Context, accountId: String) { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index 4cf18f25c..e0d3385e2 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -544,18 +544,83 @@ class AndroidFileSyncEngineInvariantTest { assertTrue(reconciled) } + @Test + fun accountRetirementReconcilesBeforePersistingAndReleasesOnlyUnsharedSafGrants() = runBlocking { + val sharedRoot = "content://documents/shared" + val retiredRoot = "content://documents/retired" + val retiredPairs = listOf( + fileSyncPair("retired-a", "removed-account", sharedRoot), + fileSyncPair("retired-b", "removed-account", retiredRoot), + fileSyncPair("retired-c", "removed-account", retiredRoot), + ) + val retainedPairs = listOf(fileSyncPair("retained", "retained-account", sharedRoot)) + val events = mutableListOf() + + retireConfiguredFileSyncAccountPairs( + retiredPairs = retiredPairs, + retainedPairs = retainedPairs, + reconcileLocalDownloads = { pair -> events += "reconcile-${pair.id}"; true }, + cancelSchedule = { pair -> events += "cancel-${pair.id}" }, + persistRetirement = { events += "persist-retirement" }, + releaseLocalGrant = { localRootId -> events += "release-$localRootId" }, + ) + + assertEquals( + listOf( + "reconcile-retired-a", + "reconcile-retired-b", + "reconcile-retired-c", + "cancel-retired-a", + "cancel-retired-b", + "cancel-retired-c", + "persist-retirement", + "release-$retiredRoot", + ), + events, + ) + } + + @Test + fun accountRetirementKeepsPairsAndGrantsWhenLocalRecoveryIsUnavailable() = runBlocking { + val retiredPairs = listOf( + fileSyncPair("retired-a", "removed-account", "content://documents/first"), + fileSyncPair("retired-b", "removed-account", "content://documents/second"), + ) + val events = mutableListOf() + + assertFailsWith { + retireConfiguredFileSyncAccountPairs( + retiredPairs = retiredPairs, + retainedPairs = emptyList(), + reconcileLocalDownloads = { pair -> events += "reconcile-${pair.id}"; pair.id == "retired-a" }, + cancelSchedule = { pair -> events += "cancel-${pair.id}" }, + persistRetirement = { events += "persist-retirement" }, + releaseLocalGrant = { localRootId -> events += "release-$localRootId" }, + ) + } + + assertEquals(listOf("reconcile-retired-a", "reconcile-retired-b"), events) + } + @Test fun accountRetirementKeepsPairIdsUntilEveryScheduleCancellationCompletes() = runBlocking { + val retiredPairs = listOf( + fileSyncPair("pair-a", "removed-account", "first-root"), + fileSyncPair("pair-b", "removed-account", "second-root"), + ) val events = mutableListOf() assertFailsWith { - cancelAndroidFileSyncPairSchedulesBeforeRetirement( - pairIds = listOf("pair-a", "pair-b"), - cancelSchedule = { pairId -> - events += "cancel-$pairId" - if (pairId == "pair-b") error("synthetic WorkManager cancellation failure") + retireConfiguredFileSyncAccountPairs( + retiredPairs = retiredPairs, + retainedPairs = emptyList(), + reconcileLocalDownloads = { true }, + cancelSchedule = { pair -> + events += "cancel-${pair.id}" + if (pair.id == "pair-b") error("synthetic WorkManager cancellation failure") }, persistRetirement = { events += "persist-retirement" }, + releaseLocalGrant = { localRootId -> events += "release-$localRootId" }, ) } @@ -1051,6 +1116,18 @@ class AndroidFileSyncEngineInvariantTest { assertTrue(guard.capture("account-new") != null) } + private fun fileSyncPair( + id: String, + accountId: String, + localRootId: String, + ) = FileSyncPair( + id = id, + accountId = accountId, + localRootId = localRootId, + remoteRootPath = "Documents", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) + private fun assertThreadBlocked(thread: Thread) { val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5) while ( From dc2635c7e501a432cf9230fee0c3b0f5cacced05 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 06:51:21 +0200 Subject: [PATCH 054/119] fix(sharing): defer uploads for unreadable accounts --- .../AndroidIncomingShareUploadWorker.kt | 13 +++++++++- .../AndroidIncomingShareStateTest.kt | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt index 5a79a32c0..0bde00538 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt @@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import dev.obiente.nextcloudnative.app.useAndroidNextcloudCertificateTrust import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -102,12 +103,16 @@ internal class AndroidIncomingShareUploadWorker( ): Result { var request = initialRequest val services = AndroidNextcloudServices(applicationContext) + val retainedAccounts = services.listAccounts() val session = resolveStoredAndroidAccountSession( accountIdentity = accountIdentity, - listAccounts = services::listAccounts, + listAccounts = { retainedAccounts }, loadSession = { accountId -> services.loadSession(accountId) }, ) if (session == null) { + if (shouldDeferIncomingShareForMissingSession(accountIdentity, retainedAccounts)) { + return Result.success() + } return failUnavailableAccount(store, requestId) } AndroidNotificationCoordinator(applicationContext).ensureChannels() @@ -332,6 +337,12 @@ internal class AndroidIncomingShareUploadWorker( } } +internal fun shouldDeferIncomingShareForMissingSession( + accountIdentity: String, + retainedAccounts: List, +): Boolean = durableUploadAccountMismatchOutcome(accountIdentity, retainedAccounts) == + DurableUploadAccountMismatchOutcome.DeferRetainedAccount + internal fun Throwable.incomingShareRetryNotBeforeEpochMillis(nowEpochMillis: Long): Long? { require(nowEpochMillis >= 0L) val retryAfterSeconds = (this as? DocumentWebDavException) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt index 3f07430a4..8f990bca8 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt @@ -1,8 +1,10 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.MAX_NEXTCLOUD_UPLOAD_CHUNKS +import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.NextcloudUploadTransferPlan import dev.obiente.nextcloudnative.app.RemoteFolderSelectionAccess +import dev.obiente.nextcloudnative.app.accountRecord import dev.obiente.nextcloudnative.app.nextcloudUploadTransferPlan import java.nio.file.Files import java.security.MessageDigest @@ -17,6 +19,30 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking class AndroidIncomingShareStateTest { + @Test + fun retainedAccountWithUnreadableCredentialsDefersIncomingShareUpload() { + val retainedSession = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "alice", + appPassword = "fixture-password", + ) + val accountIdentity = NextcloudDocumentIds.accountKey(retainedSession) + + assertTrue( + shouldDeferIncomingShareForMissingSession( + accountIdentity, + listOf(retainedSession.accountRecord()), + ), + ) + assertFalse(shouldDeferIncomingShareForMissingSession(accountIdentity, emptyList())) + assertFalse( + shouldDeferIncomingShareForMissingSession( + accountIdentity, + listOf(retainedSession.copy(loginName = "another-account").accountRecord()), + ), + ) + } + @Test fun staleWorkerTransitionCannotOverwriteCancellation() { val canceled = request(AndroidIncomingShareState.Canceled) From 99ab7beb7c258a4432afd878b8cd49300e76ce4a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 07:05:47 +0200 Subject: [PATCH 055/119] fix(desktop): quiesce Cloud Files on sign-out --- tools/kotlin-file-size-baseline.txt | 4 +- .../app/DesktopAccountOperationGuard.kt | 5 +- .../app/DesktopNextcloudServices.kt | 27 +- .../app/DesktopWindowsCloudFilesCleanup.kt | 15 + .../app/WindowsCloudFilesPath.kt | 65 +++++ .../app/WindowsCloudFilesProvider.kt | 270 ++++++++---------- .../app/WindowsCloudFilesRemovalQuiescence.kt | 130 +++++++++ ...sktopVirtualFileProviderPreferencesTest.kt | 5 +- ...sCloudFilesAccountRemovalQuiescenceTest.kt | 127 ++++++++ .../app/WindowsCloudFilesProviderTest.kt | 7 +- 10 files changed, 488 insertions(+), 167 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesPath.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRemovalQuiescence.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesAccountRemovalQuiescenceTest.kt diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 4f9d90cea..5475c60f7 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -53,11 +53,11 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.k ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt|884 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|883 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStore.kt|808 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6269 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6280 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2762 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1085 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt|2373 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt|2355 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCacheTest.kt|2479 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntakeTest.kt|2890 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystemTest.kt|2149 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 86a3b87b6..fabcd59c9 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -143,11 +143,14 @@ internal fun shouldResumeDesktopWritesAfterRemovalFailure( internal fun recoverDesktopAccountAfterPrecommitFailure( restoreProviderPreference: () -> Unit, resumeVirtualFileSystem: () -> Unit, + resumeWindowsCloudFiles: () -> Unit = {}, reopenSession: () -> Unit, restartLifecycle: () -> Unit, ): Throwable? { var recoveryFailure: Throwable? = null - listOf(restoreProviderPreference, resumeVirtualFileSystem, reopenSession, restartLifecycle).forEach { action -> + listOf( + restoreProviderPreference, resumeVirtualFileSystem, resumeWindowsCloudFiles, reopenSession, restartLifecycle, + ).forEach { action -> runCatching(action).exceptionOrNull()?.let { failure -> recoveryFailure?.addSuppressed(failure) ?: run { recoveryFailure = failure } } 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 61950e223..9d59392b2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -382,21 +382,6 @@ internal fun virtualFileLocationActionMessage(prefix: String, targetPath: String return "$prefix$displayedTarget." } -internal fun desktopWindowsCloudFilesRoot( - accountId: String, - userHome: File = File(System.getProperty("user.home")), -): File { - require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) - return File(File(userHome, "Nextcloud Native"), accountId + WINDOWS_CLOUD_FILES_ROOT_SUFFIX) -} - -internal fun windowsCloudFilesRootPreferenceKey(accountId: String): String { - require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) - return "$KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX$accountId".also { key -> - check(key.length <= Preferences.MAX_KEY_LENGTH) - } -} - internal fun windowsCloudFilesPreservedRootPreferenceKey(accountId: String): String { require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) return "$KEY_WINDOWS_CLOUD_FILES_PRESERVED_ROOT_PREFIX$accountId".also { key -> @@ -3717,7 +3702,9 @@ class DesktopNextcloudServices( } var cleared = false var quiescedLinuxFileSystem: LinuxNextcloudVirtualFileSystem? = null + var quiescedWindowsCloudFiles: WindowsCloudFilesProvider? = null var linuxFileSystemQuiesced = false + var windowsCloudFilesQuiesced = false var providerPreferenceAccountId: String? = null var providerWasEnabledBeforeRemoval = false var remoteRevocationAttempted = false @@ -3749,6 +3736,13 @@ class DesktopNextcloudServices( check(quiescedLinuxFileSystem == null || linuxFileSystemQuiesced) { "Close files being edited through the Linux virtual filesystem before removing this account." } + quiescedWindowsCloudFiles = synchronized(virtualFileProviderLock) { + windowsCloudFilesProvider?.takeIf { windowsCloudFilesIdentity == accountId } + } + windowsCloudFilesQuiesced = quiescedWindowsCloudFiles?.quiesceWritesForAccountRemoval() == true + check(quiescedWindowsCloudFiles == null || windowsCloudFilesQuiesced) { + "Finish local Windows Cloud Files changes before removing this account." + } accountId?.let { currentAccountId -> providerPreferenceAccountId = currentAccountId val key = virtualFileProviderPreferenceKey(currentAccountId) @@ -3896,6 +3890,9 @@ class DesktopNextcloudServices( setDesktopVirtualFileProviderPreference(preferences, it, providerWasEnabledBeforeRemoval) } }, resumeVirtualFileSystem = { if (linuxFileSystemQuiesced) quiescedLinuxFileSystem?.resumeWrites() }, + resumeWindowsCloudFiles = { + if (windowsCloudFilesQuiesced) quiescedWindowsCloudFiles?.resumeWritesAfterAccountRemovalFailure() + }, reopenSession = { synchronized(fileRangeSessionLock) { sessionClearing = false } }, restartLifecycle = { if (desktopStoredSessionAccountId(preferences) != null) startDesktopSyncLifecycle() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt index 36fcb2151..7ed1378b2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt @@ -16,6 +16,21 @@ internal const val KEY_WINDOWS_CLOUD_FILES_ROOT = "windows-cloud-files-root" internal const val KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX = "wcfr." internal const val WINDOWS_CLOUD_FILES_ROOT_SUFFIX = "-v2" +internal fun desktopWindowsCloudFilesRoot( + accountId: String, + userHome: File = File(System.getProperty("user.home")), +): File { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + return File(File(userHome, "Nextcloud Native"), accountId + WINDOWS_CLOUD_FILES_ROOT_SUFFIX) +} + +internal fun windowsCloudFilesRootPreferenceKey(accountId: String): String { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + return "$KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX$accountId".also { key -> + check(key.length <= Preferences.MAX_KEY_LENGTH) + } +} + internal fun desktopLegacyWindowsCloudFilesRoot(accountId: String, userHome: File): File = File(File(userHome, "Nextcloud Native"), accountId) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesPath.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesPath.kt new file mode 100644 index 000000000..c98057291 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesPath.kt @@ -0,0 +1,65 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path + +internal fun requireWindowsCloudCallbackPath(root: Path, normalizedPath: String, identityPath: String) { + // Windows can report the same directory through a long path in CFAPI while java.io.tmpdir or a + // configured root still contains an 8.3 component such as RUNNER~1. Compare real filesystem + // paths so the containment check does not reject that legitimate alias. + val absoluteRoot = root.windowsCloudRealPath() + val callbackTarget = Path.of(normalizedPath).windowsCloudRealPath() + require(callbackTarget.startsWith(absoluteRoot)) { "The Cloud Files callback escaped its sync root." } + val relative = if (callbackTarget == absoluteRoot) { + "" + } else { + absoluteRoot.relativize(callbackTarget).joinToString("/") { it.toString() }.windowsCloudPath() + } + require(relative == identityPath) { "The Cloud Files callback path does not match its identity." } +} + +private fun Path.windowsCloudRealPath(): Path { + val absolute = toAbsolutePath().normalize() + var existing = absolute + while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) { + existing = requireNotNull(existing.parent) { "The Cloud Files callback path has no existing ancestor." } + } + val realAncestor = existing.toRealPath(LinkOption.NOFOLLOW_LINKS) + return if (existing == absolute) realAncestor else realAncestor.resolve(existing.relativize(absolute)).normalize() +} + +internal fun windowsWildcardMatches(pattern: String, name: String): Boolean { + if (pattern == "*" || pattern == "*.*") return true + var patternIndex = 0 + var nameIndex = 0 + var starIndex = -1 + var retryNameIndex = -1 + while (nameIndex < name.length) { + if ( + patternIndex < pattern.length && + (pattern[patternIndex] == '?' || pattern[patternIndex].equals(name[nameIndex], true)) + ) { + patternIndex += 1 + nameIndex += 1 + } else if (patternIndex < pattern.length && pattern[patternIndex] == '*') { + starIndex = patternIndex++ + retryNameIndex = nameIndex + } else if (starIndex >= 0) { + patternIndex = starIndex + 1 + nameIndex = ++retryNameIndex + } else { + return false + } + } + while (patternIndex < pattern.length && pattern[patternIndex] == '*') patternIndex += 1 + return patternIndex == pattern.length +} + +internal fun String.windowsCloudPath(): String { + val normalized = trim('/', '\\').replace('\\', '/') + if (normalized.isEmpty()) return "" + require(normalized.split('/').none { it.isEmpty() || it == "." || it == ".." }) + require('\u0000' !in normalized) + return normalized +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt index 679fce48f..d25c1aba4 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt @@ -356,6 +356,7 @@ internal class WindowsCloudFilesProvider( private val writebackAttempts = ConcurrentHashMap() private val namespaceMutationLock = Any() private val callbacksPaused = AtomicBoolean(false) + private val accountRemovalPaused = AtomicBoolean(false) private val corruptRootRecoveryLifecycleLock = Any() private val corruptRootStableAccessLock = Any() private val corruptRootRecoveryClaimed = AtomicBoolean(false) @@ -380,6 +381,9 @@ internal class WindowsCloudFilesProvider( private set @Volatile private var watchService: WatchService? = null @Volatile private var watcherThread: Thread? = null + private val accountRemovalQuiescence = WindowsCloudFilesRemovalQuiescence( + ::pauseCallbacksForAccountRemoval, ::accountRemovalMutationState, ::resumeCallbacksAndReplayLocalChanges, + ) fun start() { check(connection.get() == 0L) { "The Windows Cloud Files provider is already connected." } @@ -390,7 +394,10 @@ internal class WindowsCloudFilesProvider( val rootIdentity = WindowsCloudFileIdentity(backend.accountId, "", "root", 0L, true) val encodedRootIdentity = WindowsCloudFileIdentityCodec.encode(rootIdentity) api.registerSyncRoot(root, backend.displayName, encodedRootIdentity) - connection.set(connectWithRegistrationRecovery(encodedRootIdentity)) + connection.set(connectWindowsCloudFilesWithRegistrationRecovery(root, this, api) { + prepareRootDirectory() + api.registerSyncRoot(root, backend.displayName, encodedRootIdentity) + }) try { try { populateDirectory("", root) @@ -433,6 +440,7 @@ internal class WindowsCloudFilesProvider( val claimDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(quiescenceTimeoutSeconds) while (!claimCorruptRootRecovery()) { check(!runtimeStopping.get()) { "Windows Cloud Files is stopping." } + check(!accountRemovalPaused.get()) { "Windows Cloud Files is paused for account removal." } runtimeRecoveryFailure.get()?.let { throw it } if (corruptRootRecoveryGeneration.get() != expectedGeneration) return check(System.nanoTime() < claimDeadline) { @@ -490,8 +498,9 @@ internal class WindowsCloudFilesProvider( "The Windows Cloud Files connection changed during corrupt-root recovery." } } - awaitPathOperationQuiescence( + awaitWindowsCloudFilesPathOperationQuiescence( System.nanoTime() + TimeUnit.SECONDS.toNanos(quiescenceTimeoutSeconds), + ::accountRemovalMutationState, ) api.unregisterSyncRoot(root) val preserved = try { @@ -625,7 +634,7 @@ internal class WindowsCloudFilesProvider( internal fun isCorruptRootRecoveryInProgress(): Boolean = corruptRootRecoveryClaimed.get() private fun claimCorruptRootRecovery(): Boolean = synchronized(corruptRootRecoveryLifecycleLock) { - !runtimeStopping.get() && corruptRootRecoveryClaimed.compareAndSet(false, true) + !runtimeStopping.get() && !accountRemovalPaused.get() && corruptRootRecoveryClaimed.compareAndSet(false, true) } private fun scheduleCorruptRootRecoveryAfterStartup( @@ -697,22 +706,6 @@ internal class WindowsCloudFilesProvider( ?.let(failure::addSuppressed) } - private fun connectWithRegistrationRecovery(syncRootIdentity: ByteArray): Long = - try { - api.connect(root, this) - } catch (firstFailure: WindowsCloudFilesOperationException) { - if (!isWindowsCloudFilesRegistrationMissingResult(firstFailure.hResult)) throw firstFailure - api.unregisterSyncRoot(root) - prepareRootDirectory() - api.registerSyncRoot(root, backend.displayName, syncRootIdentity) - try { - api.connect(root, this) - } catch (retryFailure: Throwable) { - retryFailure.addSuppressed(firstFailure) - throw retryFailure - } - } - private fun prepareRootDirectory() { Files.createDirectories(root) check(!Files.isSymbolicLink(root)) { "The Windows Cloud Files root cannot be a symlink." } @@ -754,7 +747,7 @@ internal class WindowsCloudFilesProvider( else -> throw failure } val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds) - awaitWritebackRecovery(deadline) + awaitWindowsCloudFilesWritebackRecovery(deadline, ::accountRemovalMutationState) } deferredCorruption?.let { corruption -> val expectedGeneration = requireNotNull(deferredCorruptionGeneration) @@ -831,37 +824,6 @@ internal class WindowsCloudFilesProvider( } } - private fun awaitPathOperationQuiescence(deadline: Long) { - while ( - (destructiveCallbackOperations.get() > 0 || pathOperations.isNotEmpty() || - synchronized(queuedPathOperations) { queuedPathOperations.isNotEmpty() }) && - System.nanoTime() < deadline - ) { - Thread.sleep(25L) - } - check( - destructiveCallbackOperations.get() == 0 && pathOperations.isEmpty() && - synchronized(queuedPathOperations) { queuedPathOperations.isEmpty() }, - ) { "Timed out while quiescing callbacks and local edits before Windows Cloud Files recovery." } - } - - private fun awaitWritebackRecovery(deadline: Long) { - while ( - (pendingWritebacks.isNotEmpty() || pathOperations.isNotEmpty() || - synchronized(queuedPathOperations) { queuedPathOperations.isNotEmpty() }) && - System.nanoTime() < deadline - ) { - Thread.sleep(25L) - } - check(failedWritebacks.isEmpty()) { - "Local edits in the legacy Windows Cloud Files root could not be uploaded safely." - } - check( - pendingWritebacks.isEmpty() && pathOperations.isEmpty() && - synchronized(queuedPathOperations) { queuedPathOperations.isEmpty() }, - ) { "Timed out while uploading local edits from the legacy Windows Cloud Files root." } - } - override fun fetchData(info: WindowsCloudCallbackInfo, requiredOffset: Long, requiredLength: Long) { if (callbacksPaused.get()) return val cancellation = AtomicBoolean(false) @@ -1050,7 +1012,10 @@ internal class WindowsCloudFilesProvider( if (!Files.exists(normalized) || api.placeholderState(normalized) != WindowsCloudPlaceholderState.Absent) return val relative = root.toAbsolutePath().normalize().relativize(normalized) .joinToString("/") { it.toString() }.windowsCloudPath() - submitPathOperation(relative) { + submitPathOperation( + relative, + deferredWhenPaused = { if (!runtimeStopping.get()) deferredLocalChanges.add(normalized) }, + ) { if (Files.isDirectory(normalized)) uploadLocalTree(normalized) else uploadLocalEntry(normalized, relative) } } @@ -1169,6 +1134,10 @@ internal class WindowsCloudFilesProvider( closeApi() } + internal fun quiesceWritesForAccountRemoval(timeoutSeconds: Long = DEFAULT_CORRUPT_ROOT_QUIESCENCE_TIMEOUT_SECONDS) = + accountRemovalQuiescence.tryQuiesce(timeoutSeconds) + internal fun resumeWritesAfterAccountRemovalFailure() = resumeCallbacksAndReplayLocalChanges() + override fun close() { stopRuntime() closeApi() @@ -1177,6 +1146,7 @@ internal class WindowsCloudFilesProvider( private fun stopRuntime() { synchronized(corruptRootRecoveryLifecycleLock) { runtimeStopping.set(true) + accountRemovalPaused.set(false) callbacksPaused.set(true) } awaitCorruptRootRecoveryCompletion( @@ -1223,18 +1193,76 @@ internal class WindowsCloudFilesProvider( } private fun resumeCallbacksAndReplayLocalChanges() { - val replay = synchronized(namespaceMutationLock) { - if (runtimeStopping.get()) return - callbacksPaused.set(false) - deferredLocalChanges.toList().also(deferredLocalChanges::removeAll) + if (connection.get() == 0L && !runtimeStopping.get()) connection.set(api.connect(root, this)) + if (watchService == null && initialPopulationSucceeded && !runtimeStopping.get()) startLocalWatcher() + val replay = synchronized(corruptRootRecoveryLifecycleLock) { + synchronized(namespaceMutationLock) { + if (runtimeStopping.get()) return + accountRemovalPaused.set(false) + callbacksPaused.set(false) + deferredLocalChanges.toList().also(deferredLocalChanges::removeAll) + } } replay.forEach(::scheduleLocalChange) } + private fun pauseCallbacksForAccountRemoval(): Boolean { + val paused = synchronized(corruptRootRecoveryLifecycleLock) { + if ( + runtimeStopping.get() || corruptRootRecoveryClaimed.get() || runtimeRecoveryFailure.get() != null + ) return@synchronized false + synchronized(namespaceMutationLock) { + if (callbacksPaused.get()) false else true.also { + accountRemovalPaused.set(it) + callbacksPaused.set(it) + } + } + } + if (!paused) return false + val key = connection.get() + if (key != 0L) { + api.disconnect(key) + check(connection.compareAndSet(key, 0L)) { + "The Windows Cloud Files connection changed during account removal." + } + } + stopLocalWatcherForAccountRemoval() + val deadline = System.nanoTime() + + TimeUnit.SECONDS.toNanos(DEFAULT_CORRUPT_ROOT_QUIESCENCE_TIMEOUT_SECONDS) + awaitWindowsCloudFilesPathOperationQuiescence(deadline, ::accountRemovalMutationState) + recoverLocalPlaceholders(failClosed = true, allowWhilePaused = true) + awaitWindowsCloudFilesWritebackRecovery(deadline, ::accountRemovalMutationState) + recoverUnmanagedLocalEntries(failClosed = true) + synchronized(namespaceMutationLock) { deferredLocalChanges.clear() } + true + } + + private fun accountRemovalMutationState() = synchronized(queuedPathOperations) { + WindowsCloudFilesMutationState( + pendingWritebacks.size, failedWritebacks.size, pathOperations.size, + queuedPathOperations.size, destructiveCallbackOperations.get(), + pendingLocalChanges.size, deferredLocalChanges.size, + ) + } + + private fun stopLocalWatcherForAccountRemoval() { + runCatching { watchService?.close() } + watcherThread?.interrupt() + watcherThread = null + watchService = null + val scheduled = synchronized(namespaceMutationLock) { + pendingLocalChanges.values.toList().also { pendingLocalChanges.clear() } + } + scheduled.forEach { it.cancel(false) } + localChangeScheduler.submit(Runnable {}).get( + DEFAULT_CORRUPT_ROOT_QUIESCENCE_TIMEOUT_SECONDS, + TimeUnit.SECONDS, + ) + } + private fun closeApi() { if (apiClosed.compareAndSet(false, true)) api.close() } - private fun populateDirectory(relativePath: String, localDirectory: Path) { val identities = backend.list(relativePath) val missing = ArrayList() @@ -1843,14 +1871,23 @@ internal class WindowsCloudFilesProvider( return absoluteRoot.relativize(target).joinToString("/") { it.toString() }.windowsCloudPath() } - private fun submitPathOperation(path: String, block: () -> Unit) { - if (callbacksPaused.get()) return - failedWritebacks -= path - writebackAttempts.remove(path) - val shouldSchedule = synchronized(queuedPathOperations) { - if (callbacksPaused.get()) return - queuedPathOperations[path] = block - pathOperations.add(path) + private fun submitPathOperation( + path: String, + deferredWhenPaused: () -> Unit = {}, + allowWhilePaused: Boolean = false, + block: () -> Unit, + ) { + val shouldSchedule = synchronized(namespaceMutationLock) { + if (callbacksPaused.get() && !allowWhilePaused) { + deferredWhenPaused() + return + } + failedWritebacks -= path + writebackAttempts.remove(path) + synchronized(queuedPathOperations) { + queuedPathOperations[path] = block + pathOperations.add(path) + } } if (shouldSchedule) schedulePathOperationDrain(path) } @@ -1962,15 +1999,21 @@ internal class WindowsCloudFilesProvider( } private fun scheduleLocalChange(path: Path) { - pendingLocalChanges.remove(path)?.cancel(false) - pendingLocalChanges[path] = localChangeScheduler.schedule( - { - pendingLocalChanges.remove(path) - runCatching { localEntryChanged(path) } - }, - LOCAL_CHANGE_SETTLE_MILLIS, - TimeUnit.MILLISECONDS, - ) + synchronized(namespaceMutationLock) { + if (callbacksPaused.get()) { + if (!runtimeStopping.get()) deferredLocalChanges.add(path) + return + } + pendingLocalChanges.remove(path)?.cancel(false) + pendingLocalChanges[path] = localChangeScheduler.schedule( + { + pendingLocalChanges.remove(path) + runCatching { localEntryChanged(path) } + }, + LOCAL_CHANGE_SETTLE_MILLIS, + TimeUnit.MILLISECONDS, + ) + } } private fun recoverLocalChanges() { @@ -2043,7 +2086,10 @@ internal class WindowsCloudFilesProvider( } } - private fun recoverLocalPlaceholders(failClosed: Boolean = false) { + private fun recoverLocalPlaceholders( + failClosed: Boolean = false, + allowWhilePaused: Boolean = false, + ) { val recover = { Files.walk(root).use { paths -> paths.filter { path -> path != root && !Files.isSymbolicLink(path) }.forEach { local -> @@ -2066,7 +2112,11 @@ internal class WindowsCloudFilesProvider( knownIdentities[original.path] = original if (state != WindowsCloudPlaceholderState.Dirty || original.directory) return@forEach if (!pendingWritebacks.add(original.path)) return@forEach - submitPathOperation(original.path) { + submitPathOperation( + original.path, + deferredWhenPaused = { if (!runtimeStopping.get()) deferredLocalChanges.add(local) }, + allowWhilePaused = allowWhilePaused, + ) { val current = requireNotNull(api.placeholderIdentity(local)) { "The dirty Windows placeholder has no recoverable identity." }.let(WindowsCloudFileIdentityCodec::decode) @@ -2278,74 +2328,6 @@ internal class WindowsCloudFilesProvider( } } -private class AtomicLongState { - @Volatile private var value: Long = 0L - @Synchronized fun get(): Long = value - @Synchronized fun set(next: Long) { value = next } - @Synchronized fun compareAndSet(expected: Long, next: Long): Boolean { - if (value != expected) return false - value = next - return true - } -} - -internal fun requireWindowsCloudCallbackPath(root: Path, normalizedPath: String, identityPath: String) { - // Windows can report the same directory through a long path in CFAPI while java.io.tmpdir or a - // configured root still contains an 8.3 component such as RUNNER~1. Compare real filesystem - // paths so the containment check does not reject that legitimate alias. - val absoluteRoot = root.windowsCloudRealPath() - val callbackTarget = Path.of(normalizedPath).windowsCloudRealPath() - require(callbackTarget.startsWith(absoluteRoot)) { "The Cloud Files callback escaped its sync root." } - val relative = if (callbackTarget == absoluteRoot) { - "" - } else { - absoluteRoot.relativize(callbackTarget).joinToString("/") { it.toString() }.windowsCloudPath() - } - require(relative == identityPath) { "The Cloud Files callback path does not match its identity." } -} - -private fun Path.windowsCloudRealPath(): Path { - val absolute = toAbsolutePath().normalize() - var existing = absolute - while (!Files.exists(existing, LinkOption.NOFOLLOW_LINKS)) { - existing = requireNotNull(existing.parent) { "The Cloud Files callback path has no existing ancestor." } - } - val realAncestor = existing.toRealPath(LinkOption.NOFOLLOW_LINKS) - return if (existing == absolute) realAncestor else realAncestor.resolve(existing.relativize(absolute)).normalize() -} - -private fun windowsWildcardMatches(pattern: String, name: String): Boolean { - if (pattern == "*" || pattern == "*.*") return true - var patternIndex = 0 - var nameIndex = 0 - var starIndex = -1 - var retryNameIndex = -1 - while (nameIndex < name.length) { - if (patternIndex < pattern.length && (pattern[patternIndex] == '?' || pattern[patternIndex].equals(name[nameIndex], true))) { - patternIndex += 1 - nameIndex += 1 - } else if (patternIndex < pattern.length && pattern[patternIndex] == '*') { - starIndex = patternIndex++ - retryNameIndex = nameIndex - } else if (starIndex >= 0) { - patternIndex = starIndex + 1 - nameIndex = ++retryNameIndex - } else { - return false - } - } - while (patternIndex < pattern.length && pattern[patternIndex] == '*') patternIndex += 1 - return patternIndex == pattern.length -} - -private fun String.windowsCloudPath(): String { - val normalized = trim('/', '\\').replace('\\', '/') - if (normalized.isEmpty()) return "" - require(normalized.split('/').none { it.isEmpty() || it == "." || it == ".." }) - require('\u0000' !in normalized) - return normalized -} - private const val WINDOWS_CLOUD_ALIGNMENT = 4 * 1024L private const val MAX_WINDOWS_WRITEBACK_ATTEMPTS = 5 private const val MAX_WINDOWS_DIRECTORY_REFRESH_ATTEMPTS = 4 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRemovalQuiescence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRemovalQuiescence.kt new file mode 100644 index 000000000..7c603dc64 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRemovalQuiescence.kt @@ -0,0 +1,130 @@ +package dev.obiente.nextcloudnative.app + +import java.util.concurrent.TimeUnit + +internal data class WindowsCloudFilesMutationState( + val pendingWritebackCount: Int, + val failedWritebackCount: Int, + val pathOperationCount: Int, + val queuedPathOperationCount: Int, + val destructiveCallbackCount: Int, + val pendingLocalChangeCount: Int = 0, + val deferredLocalChangeCount: Int = 0, +) { + val idle: Boolean + get() = pendingWritebackCount == 0 && pathOperationCount == 0 && + queuedPathOperationCount == 0 && destructiveCallbackCount == 0 && + pendingLocalChangeCount == 0 && deferredLocalChangeCount == 0 + + val writebackFailedWithoutRetry: Boolean + get() = failedWritebackCount > 0 && pathOperationCount == 0 && queuedPathOperationCount == 0 +} + +internal class WindowsCloudFilesRemovalQuiescence( + private val pauseCallbacks: () -> Boolean, + private val mutationState: () -> WindowsCloudFilesMutationState, + private val resumeCallbacks: () -> Unit, + private val nanoTime: () -> Long = System::nanoTime, + private val awaitProgress: () -> Unit = { Thread.sleep(POLL_MILLIS) }, +) { + fun tryQuiesce(timeoutSeconds: Long): Boolean { + require(timeoutSeconds > 0L) + try { + if (!pauseCallbacks()) return false + val deadline = nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds) + var state = mutationState() + while (!state.idle && !state.writebackFailedWithoutRetry && nanoTime() < deadline) { + awaitProgress() + state = mutationState() + } + check(state.failedWritebackCount == 0) { + "Local edits in the Windows Cloud Files root could not be uploaded safely." + } + check(state.idle) { + "Timed out while uploading local edits and finishing Windows Cloud Files operations." + } + return true + } catch (failure: Throwable) { + if (failure is InterruptedException) Thread.currentThread().interrupt() + runCatching(resumeCallbacks).exceptionOrNull()?.let(failure::addSuppressed) + throw failure + } + } + + private companion object { + const val POLL_MILLIS = 25L + } +} + +internal fun awaitWindowsCloudFilesPathOperationQuiescence( + deadline: Long, + mutationState: () -> WindowsCloudFilesMutationState, +) { + var state = mutationState() + while ( + (state.destructiveCallbackCount > 0 || state.pathOperationCount > 0 || state.queuedPathOperationCount > 0) && + System.nanoTime() < deadline + ) { + Thread.sleep(25L) + state = mutationState() + } + check(state.destructiveCallbackCount == 0 && state.pathOperationCount == 0 && state.queuedPathOperationCount == 0) { + "Timed out while quiescing callbacks and local edits before Windows Cloud Files recovery." + } +} + +internal fun awaitWindowsCloudFilesWritebackRecovery( + deadline: Long, + mutationState: () -> WindowsCloudFilesMutationState, +) { + var state = mutationState() + while ( + (state.pendingWritebackCount > 0 || state.pathOperationCount > 0 || state.queuedPathOperationCount > 0) && + !state.writebackFailedWithoutRetry && + System.nanoTime() < deadline + ) { + Thread.sleep(25L) + state = mutationState() + } + check(state.failedWritebackCount == 0) { + "Local edits in the legacy Windows Cloud Files root could not be uploaded safely." + } + check(state.pendingWritebackCount == 0 && state.pathOperationCount == 0 && state.queuedPathOperationCount == 0) { + "Timed out while uploading local edits from the legacy Windows Cloud Files root." + } +} + +internal class AtomicLongState { + @Volatile private var value: Long = 0L + + @Synchronized fun get(): Long = value + + @Synchronized fun set(next: Long) { + value = next + } + + @Synchronized fun compareAndSet(expected: Long, next: Long): Boolean { + if (value != expected) return false + value = next + return true + } +} + +internal fun connectWindowsCloudFilesWithRegistrationRecovery( + root: java.nio.file.Path, + callbacks: WindowsCloudFilesCallbacks, + api: WindowsCloudFilesApi, + recoverRegistration: () -> Unit, +): Long = try { + api.connect(root, callbacks) +} catch (firstFailure: WindowsCloudFilesOperationException) { + if (!isWindowsCloudFilesRegistrationMissingResult(firstFailure.hResult)) throw firstFailure + api.unregisterSyncRoot(root) + recoverRegistration() + try { + api.connect(root, callbacks) + } catch (retryFailure: Throwable) { + retryFailure.addSuppressed(firstFailure) + throw retryFailure + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt index 9d349bc0f..978a369f8 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualFileProviderPreferencesTest.kt @@ -73,13 +73,14 @@ class DesktopVirtualFileProviderPreferencesTest { val recoveryFailure = recoverDesktopAccountAfterPrecommitFailure( restoreProviderPreference = { events += "restore"; throw restoreFailure }, - resumeVirtualFileSystem = { events += "resume" }, + resumeVirtualFileSystem = { events += "resume-linux" }, + resumeWindowsCloudFiles = { events += "resume-windows" }, reopenSession = { events += "reopen" }, restartLifecycle = { events += "restart" }, ) assertSame(restoreFailure, recoveryFailure) - assertEquals(listOf("restore", "resume", "reopen", "restart"), events) + assertEquals(listOf("restore", "resume-linux", "resume-windows", "reopen", "restart"), events) } @Test diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesAccountRemovalQuiescenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesAccountRemovalQuiescenceTest.kt new file mode 100644 index 000000000..abaed4994 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesAccountRemovalQuiescenceTest.kt @@ -0,0 +1,127 @@ +package dev.obiente.nextcloudnative.app + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeBytes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WindowsCloudFilesAccountRemovalQuiescenceTest { + @Test + fun `dirty close crossing the pause is uploaded before account removal continues`() { + val root = createTempDirectory("windows-cloud-account-removal-") + val local = root.resolve("draft.txt") + val bytes = "edit closed while sign-out starts".encodeToByteArray() + val unmanaged = root.resolve("new-note.txt") + val unmanagedBytes = "edit whose watcher debounce crossed sign-out".encodeToByteArray() + val identity = WindowsCloudFileIdentity("account-01", "draft.txt", "etag-1", bytes.size.toLong(), false) + val backend = WindowsCloudFilesProviderTest.FakeBackend(ByteArray(0), expectedUploads = 2) + val api = WindowsCloudFilesProviderTest.FakeApi(expectedConversions = 2) + lateinit var provider: WindowsCloudFilesProvider + try { + provider = WindowsCloudFilesProvider(root, backend, api) + provider.start() + provider.recoverAfterStartup(timeoutSeconds = 5L) + local.writeBytes(bytes) + unmanaged.writeBytes(unmanagedBytes) + api.beforeDisconnect = { + api.seed(local, WindowsCloudPlaceholderState.Dirty, identity) + provider.closed(callbackInfo(local, identity), deleted = false) + provider.localEntryChanged(unmanaged) + } + + assertTrue(provider.quiesceWritesForAccountRemoval(timeoutSeconds = 5L)) + + assertTrue(backend.awaitUploads()) + assertEquals( + setOf(bytes.toList(), unmanagedBytes.toList()), + backend.uploadedBytes.map(ByteArray::toList).toSet(), + ) + assertEquals(listOf(1L), api.disconnectAttempts) + assertEquals(null, api.unregisteredRoot) + provider.removeSyncRoot() + assertEquals(root, api.unregisteredRoot) + } finally { + if (!api.closed) runCatching { provider.close() } + root.toFile().deleteRecursively() + } + } + + @Test + fun `quiescence drains an admitted destructive callback and rejects later callbacks`() { + val root = createTempDirectory("windows-cloud-account-removal-delete-") + val identity = WindowsCloudFileIdentity("account-01", "note.txt", "etag-2", 0L, false) + val backend = WindowsCloudFilesProviderTest.FakeBackend( + ByteArray(0), + listed = listOf(identity), + blockFirstDelete = true, + ) + val api = WindowsCloudFilesProviderTest.FakeApi() + val provider = WindowsCloudFilesProvider(root, backend, api) + try { + provider.start() + provider.recoverAfterStartup(timeoutSeconds = 5L) + val info = callbackInfo(root.resolve(identity.path), identity) + provider.deleteRequested(info) + assertTrue(backend.awaitFirstDeleteStarted()) + val disconnected = CountDownLatch(1) + api.beforeDisconnect = { disconnected.countDown() } + val failure = AtomicReference() + val quiescence = Thread { + try { + provider.quiesceWritesForAccountRemoval(timeoutSeconds = 5L) + } catch (thrown: Throwable) { + failure.set(thrown) + } + } + quiescence.start() + assertTrue(disconnected.await(5L, TimeUnit.SECONDS)) + assertTrue(quiescence.isAlive) + backend.releaseFirstDelete() + quiescence.join(5_000L) + + assertFalse(quiescence.isAlive) + assertEquals(null, failure.get()) + provider.deleteRequested(info) + assertEquals(listOf("delete:note.txt"), backend.operations) + provider.resumeWritesAfterAccountRemovalFailure() + assertEquals(2, api.lifecycleEvents.count { it == "connect" }) + } finally { + backend.releaseFirstDelete() + provider.close() + root.toFile().deleteRecursively() + } + } + + @Test + fun `unrecoverable writeback reopens callbacks instead of continuing removal`() { + var resumed = false + val quiescence = WindowsCloudFilesRemovalQuiescence( + pauseCallbacks = { true }, + mutationState = { + WindowsCloudFilesMutationState(1, 1, 0, 0, 0) + }, + resumeCallbacks = { resumed = true }, + ) + + assertFailsWith { quiescence.tryQuiesce(timeoutSeconds = 1L) } + + assertTrue(resumed) + } + + private fun callbackInfo(local: java.nio.file.Path, identity: WindowsCloudFileIdentity) = + WindowsCloudCallbackInfo( + connectionKey = 1L, + transferKey = 2L, + requestKey = 3L, + normalizedPath = local.toString(), + fileIdentity = WindowsCloudFileIdentityCodec.encode(identity), + fileSize = identity.size, + priorityHint = 0, + ) +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt index 4add627e0..ccfa13b2b 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProviderTest.kt @@ -2894,7 +2894,7 @@ class WindowsCloudFilesProviderTest { return payload + MessageDigest.getInstance("SHA-256").digest(payload) } - private class FakeBackend( + internal class FakeBackend( private val source: ByteArray, private val listed: List = emptyList(), expectedUploads: Int = 0, @@ -2921,7 +2921,6 @@ class WindowsCloudFilesProviderTest { private val remoteIdentities = listed.associateBy { identity -> identity.path }.toMutableMap() private val remoteContents = mutableMapOf() private val scriptedLists = ArrayDeque>() - override fun resolve(path: String): WindowsCloudFileIdentity? = synchronized(this) { resolvedPaths += path remoteIdentities[path] @@ -3019,7 +3018,7 @@ class WindowsCloudFilesProviderTest { } } - private class FakeApi( + internal class FakeApi( expectedTransfers: Int = 0, expectedConversions: Int = 0, expectedRenames: Int = 0, @@ -3048,6 +3047,7 @@ class WindowsCloudFilesProviderTest { val connectFailures = mutableListOf() val disconnectAttempts = mutableListOf() var disconnectFailure: RuntimeException? = null + var beforeDisconnect: (() -> Unit)? = null var createPlaceholdersHook: ((Path, List) -> Unit)? = null var updatePlaceholderFailure: WindowsCloudFilesOperationException? = null var updatePlaceholderFailuresRemaining: Int = Int.MAX_VALUE @@ -3071,6 +3071,7 @@ class WindowsCloudFilesProviderTest { return 1L } override fun disconnect(connectionKey: Long) { + beforeDisconnect?.invoke() disconnectAttempts += connectionKey disconnectFailure?.let { throw it } } From 1f03da9ec5e7222d6a90f9c75898ae3cd8139f22 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 07:09:07 +0200 Subject: [PATCH 056/119] refactor(accounts): share retained identity policy --- .../obiente/nextcloudnative/AndroidAccountRetention.kt | 10 ++++++++++ .../nextcloudnative/AndroidDurableMultipartUploads.kt | 6 +----- .../AndroidIncomingShareUploadWorker.kt | 3 +-- 3 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt new file mode 100644 index 000000000..71514aee9 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt @@ -0,0 +1,10 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudAccountRecord + +internal fun androidAccountIdentityIsRetained( + accountIdentity: String, + retainedAccounts: List, +): Boolean = retainedAccounts.any { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == accountIdentity +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index c2d680a4d..94f36cb7b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -346,11 +346,7 @@ internal fun durableUploadAccountMismatchOutcome( expectedAccountId: String, retainedAccounts: List, ): DurableUploadAccountMismatchOutcome = - if ( - retainedAccounts.any { account -> - NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == expectedAccountId - } - ) { + if (androidAccountIdentityIsRetained(expectedAccountId, retainedAccounts)) { DurableUploadAccountMismatchOutcome.DeferRetainedAccount } else { DurableUploadAccountMismatchOutcome.AccountUnavailable diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt index 0bde00538..a29a8d000 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt @@ -340,8 +340,7 @@ internal class AndroidIncomingShareUploadWorker( internal fun shouldDeferIncomingShareForMissingSession( accountIdentity: String, retainedAccounts: List, -): Boolean = durableUploadAccountMismatchOutcome(accountIdentity, retainedAccounts) == - DurableUploadAccountMismatchOutcome.DeferRetainedAccount +): Boolean = androidAccountIdentityIsRetained(accountIdentity, retainedAccounts) internal fun Throwable.incomingShareRetryNotBeforeEpochMillis(nowEpochMillis: Long): Long? { require(nowEpochMillis >= 0L) From eb003fbc64da2328f085be6ee5b41c8125cdda5b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 07:15:09 +0200 Subject: [PATCH 057/119] fix(desktop): return quiescence outcome --- .../obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt index d25c1aba4..6778e7fd1 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt @@ -1234,7 +1234,7 @@ internal class WindowsCloudFilesProvider( awaitWindowsCloudFilesWritebackRecovery(deadline, ::accountRemovalMutationState) recoverUnmanagedLocalEntries(failClosed = true) synchronized(namespaceMutationLock) { deferredLocalChanges.clear() } - true + return true } private fun accountRemovalMutationState() = synchronized(queuedPathOperations) { From d8bb7afb12086db794e1a3aba29fc676e8e195f9 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 07:17:54 +0200 Subject: [PATCH 058/119] test(desktop): prove callback drain ordering --- .../app/WindowsCloudFilesAccountRemovalQuiescenceTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesAccountRemovalQuiescenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesAccountRemovalQuiescenceTest.kt index abaed4994..190c6c9d4 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesAccountRemovalQuiescenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesAccountRemovalQuiescenceTest.kt @@ -80,9 +80,10 @@ class WindowsCloudFilesAccountRemovalQuiescenceTest { } } quiescence.start() - assertTrue(disconnected.await(5L, TimeUnit.SECONDS)) + assertFalse(disconnected.await(250L, TimeUnit.MILLISECONDS)) assertTrue(quiescence.isAlive) backend.releaseFirstDelete() + assertTrue(disconnected.await(5L, TimeUnit.SECONDS)) quiescence.join(5_000L) assertFalse(quiescence.isAlive) From 3008c67ae67e113abcea1db56bfa28398afb6e18 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 08:29:39 +0200 Subject: [PATCH 059/119] fix(desktop): disable reads after failed FUSE unmount --- .../app/DesktopLinuxProviderCleanup.kt | 2 + .../app/LinuxVirtualFileSystem.kt | 28 ++++----- .../app/DesktopLinuxProviderCleanupTest.kt | 60 +++++++++++++++++++ 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt index ca23e9f9f..c0c7914de 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanup.kt @@ -7,6 +7,7 @@ internal data class DetachedDesktopLinuxProvider( ) internal interface DesktopLinuxProviderFileSystem { + fun disableReads() fun unmount() } @@ -26,6 +27,7 @@ internal class DesktopLinuxProviderCleanupSlot { try { provider.fileSystem.unmount() } catch (failure: Throwable) { + runCatching(provider.fileSystem::disableReads).exceptionOrNull()?.let(failure::addSuppressed) synchronized(lock) { check(pending == null) pending = provider diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt index f0ff54099..31cebcd8b 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt @@ -1115,6 +1115,7 @@ internal class LinuxNextcloudVirtualFileSystem( private var openDirectoryEntries = 0L private val pendingCreatedFiles = ConcurrentHashMap() private val namespaceLock = Any() + private val readsEnabled = java.util.concurrent.atomic.AtomicBoolean(true) private val writeLifecycle = LinuxVirtualWriteLifecycle( hasOpenWriteHandles = { writeHandles.isNotEmpty() }, hasPendingCreatedFiles = { pendingCreatedFiles.isNotEmpty() }, @@ -1125,7 +1126,7 @@ internal class LinuxNextcloudVirtualFileSystem( require(mountOwnerGid in 0L..MAX_UNSIGNED_UNIX_ID) } - override fun getattr(path: String, stat: FileStat): Int = fuseResult { + override fun getattr(path: String, stat: FileStat): Int = fuseReadResult { val normalized = path.linuxVirtualPath() val pending = pendingCreatedFiles[normalized]?.delegate val node = visibleNode(normalized) @@ -1135,7 +1136,7 @@ internal class LinuxNextcloudVirtualFileSystem( 0 } - override fun opendir(path: String, fileInfo: FuseFileInfo): Int = fuseResult { + override fun opendir(path: String, fileInfo: FuseFileInfo): Int = fuseReadResult { val id = openAndRegisterDirectorySnapshot(path) fileInfo.fh.set(id) 0 @@ -1147,7 +1148,7 @@ internal class LinuxNextcloudVirtualFileSystem( filler: FuseFillDir, offset: Long, fileInfo: FuseFileInfo, - ): Int = fuseResult { + ): Int = fuseReadResult { val normalized = path.linuxVirtualPath() val handleId = fileInfo.fh.get() val existingHandle = directoryHandles[handleId]?.takeIf { it.path == normalized } @@ -1184,6 +1185,7 @@ internal class LinuxNextcloudVirtualFileSystem( val normalized = path.linuxVirtualPath() val flags = fileInfo.flags.intValue() val writeAccess = flags and OPEN_ACCESS_MASK != OPEN_READ_ONLY + if (!writeAccess && !readsEnabled.get()) return -ErrorCodes.EIO() pendingCreatedFiles[normalized]?.let { pending -> if (writeAccess && flags and OPEN_TRUNCATE != 0) pending.delegate.truncate(0L) fileInfo.fh.set(registerWriteHandle(pending, writable = writeAccess)) @@ -1218,7 +1220,7 @@ internal class LinuxNextcloudVirtualFileSystem( requestedSize: Long, offset: Long, fileInfo: FuseFileInfo, - ): Int = fuseResult { + ): Int = fuseReadResult { if (offset < 0L || requestedSize < 0L || requestedSize > Int.MAX_VALUE) return -ErrorCodes.EINVAL() val id = fileInfo.fh.get() if (id == EMPTY_FILE_HANDLE) return 0 @@ -1253,7 +1255,7 @@ internal class LinuxNextcloudVirtualFileSystem( 0 } - override fun access(path: String, mask: Int): Int = fuseResult { + override fun access(path: String, mask: Int): Int = fuseReadResult { val normalized = path.linuxVirtualPath() if (pendingCreatedFiles.containsKey(normalized) || visibleNode(normalized) != null) 0 else -ErrorCodes.ENOENT() } @@ -1386,6 +1388,7 @@ internal class LinuxNextcloudVirtualFileSystem( internal fun resumeWrites() = writeLifecycle.resume() + override fun disableReads() = readsEnabled.set(false) override fun unmount() { val fuseAbortHandle = fuseAbortHandleProvider(mountedAt) runLinuxFuseUnmountLifecycle( @@ -1614,6 +1617,9 @@ internal class LinuxNextcloudVirtualFileSystem( -ErrorCodes.EIO() } + private inline fun fuseReadResult(operation: () -> Int): Int = + if (readsEnabled.get()) fuseResult(operation) else -ErrorCodes.EIO() + private inline fun fuseMutationResult(operation: () -> Int): Int = fuseResult { writeLifecycle.beginMutation() try { @@ -1638,9 +1644,7 @@ internal class LinuxNextcloudVirtualFileSystem( /** Stable across refreshes and app restarts so file managers can reconcile large directory models. */ internal fun stableLinuxVirtualInode(path: String): Long { var hash = -0x340d631b7bdddcdbL - path.forEach { character -> - hash = (hash xor character.code.toLong()) * 0x100000001b3L - } + path.forEach { character -> hash = (hash xor character.code.toLong()) * 0x100000001b3L } return (hash and Long.MAX_VALUE).coerceAtLeast(2L) } @@ -1685,12 +1689,8 @@ private fun String.linuxVirtualPath(): String { if (character != '/') continue require(index > segmentStart) val segmentLength = index - segmentStart - require( - segmentLength != 1 || this[segmentStart] != '.', - ) - require( - segmentLength != 2 || this[segmentStart] != '.' || this[segmentStart + 1] != '.', - ) + require(segmentLength != 1 || this[segmentStart] != '.') + require(segmentLength != 2 || this[segmentStart] != '.' || this[segmentStart + 1] != '.') segmentStart = index + 1 } return if (start == 0 && end == length) this else substring(start, end) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt index 584aca9fe..5cb90aba3 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLinuxProviderCleanupTest.kt @@ -1,9 +1,12 @@ package dev.obiente.nextcloudnative.app +import org.junit.Assume.assumeTrue import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertSame import kotlin.test.assertTrue +import ru.serce.jnrfuse.ErrorCodes class DesktopLinuxProviderCleanupTest { @Test @@ -28,6 +31,7 @@ class DesktopLinuxProviderCleanupTest { assertTrue(aborted) assertTrue(abortHandleClosed) + assertTrue(fileSystem.readsDisabled) assertSame(provider, cleanup.pendingForTest()) assertFailsWith { fileSystem.beginMutation() } } @@ -40,19 +44,46 @@ class DesktopLinuxProviderCleanupTest { assertFailsWith { cleanup.unmountOrRetain(provider) } + assertTrue(fileSystem.readsDisabled) assertSame(provider, cleanup.pendingForTest()) assertFailsWith { fileSystem.beginMutation() } } + + @Test + fun `failed unmount rejects retained filesystem reads without reaching its backend`() { + assumeTrue(System.getProperty("os.name").startsWith("Linux", ignoreCase = true)) + val backend = ReadCountingLinuxBackend() + val fileSystem = LinuxNextcloudVirtualFileSystem( + backend = backend, + unmountOperation = { error("synthetic unmount failure") }, + ) + val provider = DetachedDesktopLinuxProvider(fileSystem, null, "account") + val cleanup = DesktopLinuxProviderCleanupSlot() + assertEquals(0, fileSystem.access("/", 0)) + assertEquals(1, backend.resolveCalls) + + assertFailsWith { cleanup.unmountOrRetain(provider) } + + assertEquals(-ErrorCodes.EIO(), fileSystem.access("/", 0)) + assertEquals(1, backend.resolveCalls) + assertSame(provider, cleanup.pendingForTest()) + } } private class RecordingLinuxProviderFileSystem( private val abortHandle: LinuxFuseAbortHandle?, ) : DesktopLinuxProviderFileSystem { + var readsDisabled = false + private set private val writeLifecycle = LinuxVirtualWriteLifecycle( hasOpenWriteHandles = { false }, hasPendingCreatedFiles = { false }, ).also { check(it.tryQuiesce()) } + override fun disableReads() { + readsDisabled = true + } + override fun unmount() = runLinuxFuseUnmountLifecycle( abortHandle = abortHandle, detach = { error("synthetic unmount failure") }, @@ -61,3 +92,32 @@ private class RecordingLinuxProviderFileSystem( fun beginMutation() = writeLifecycle.beginMutation() } + +private class ReadCountingLinuxBackend : LinuxVirtualFileBackend { + var resolveCalls = 0 + private set + + override fun resolve(path: String): LinuxVirtualFileNode? { + resolveCalls += 1 + return LinuxVirtualFileNode("", "Nextcloud", true, 0L, "root") + } + + override fun list(path: String): List = error("Not used.") + override fun open(node: LinuxVirtualFileNode): LinuxVirtualFileReadHandle = error("Not used.") + + override fun openWrite( + path: String, + existing: LinuxVirtualFileNode?, + truncate: Boolean, + ): LinuxVirtualFileWriteHandle = error("Not used.") + + override fun createDirectory(path: String) = error("Not used.") + override fun delete(node: LinuxVirtualFileNode) = error("Not used.") + override fun move(node: LinuxVirtualFileNode, destinationPath: String) = error("Not used.") + + override fun moveReplacing( + node: LinuxVirtualFileNode, + destination: LinuxVirtualFileNode, + destinationPath: String, + ) = error("Not used.") +} From 9963216b7f58c36cc34183aa1ec847d7f1b0b2b6 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 08:33:45 +0200 Subject: [PATCH 060/119] fix(android): recover credential slots before selection --- .../AndroidAccountCredentialController.kt | 19 +++++------ .../AndroidAccountCredentialRecovery.kt | 32 +++++++++++++++++++ ...dAccountSelectionCredentialRecoveryTest.kt | 31 ++++++++++++++++++ 3 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionCredentialRecoveryTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index c8fa238ed..a3bf9602b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -135,11 +135,14 @@ internal class AndroidAccountCredentialController( suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { - val current = requireValidState() + val (current, suspectEncrypted) = recoverAndroidAccountCredentialStateForSelection( + readStore(), ::readIndependentCredentialSlotState, + ) + requireSupportedCredentialSlots(current.registry) val selected = current.select(accountId) ?: return@withLock null val session = requireNotNull(selected.activeSession) registerSessionPrivateValues(session) - replaceActiveState(selected, current.activeSession) + replaceActiveState(selected, current.activeSession, suspectEncrypted) session } @@ -493,15 +496,9 @@ internal class AndroidAccountCredentialController( ) } - private fun requireValidState(): AndroidAccountCredentialState = when (val read = readStore()) { - is AndroidAccountCredentialStoreRead.Available -> read.state.also { state -> - requireSupportedCredentialSlots(state.registry) - } - is AndroidAccountCredentialStoreRead.Invalid -> error("The account credential store is invalid.") - AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> - error("The independent account credential slots could not be recovered.") - is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) - } + private fun requireValidState(): AndroidAccountCredentialState = requireValidAndroidAccountCredentialState( + readStore(), ::requireSupportedCredentialSlots, + ) private fun requireValidStateForAccountRemoval(accountId: NextcloudAccountId): AndroidAccountCredentialState = when (val read = readStore()) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index 8efef6763..7b3c98998 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -23,6 +23,38 @@ internal sealed interface AndroidAccountCredentialSlotRead { data class Unsupported(val version: Int) : AndroidAccountCredentialSlotRead } +internal data class AndroidAccountCredentialSelectionRecovery( + val state: AndroidAccountCredentialState, + val suspectEncrypted: String?, +) + +internal fun recoverAndroidAccountCredentialStateForSelection( + read: AndroidAccountCredentialStoreRead, + recoverIndependent: () -> AndroidAccountCredentialState?, +): AndroidAccountCredentialSelectionRecovery = when (read) { + is AndroidAccountCredentialStoreRead.Available -> AndroidAccountCredentialSelectionRecovery(read.state, null) + is AndroidAccountCredentialStoreRead.Invalid -> AndroidAccountCredentialSelectionRecovery( + recoverIndependent() ?: error("The independent account credential slots could not be recovered."), + read.encrypted, + ) + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> AndroidAccountCredentialSelectionRecovery( + recoverIndependent() ?: error("The independent account credential slots could not be recovered."), + null, + ) + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) +} + +internal fun requireValidAndroidAccountCredentialState( + read: AndroidAccountCredentialStoreRead, + requireSupportedSlots: (NextcloudAccountRegistry) -> Unit, +): AndroidAccountCredentialState = when (read) { + is AndroidAccountCredentialStoreRead.Available -> read.state.also { requireSupportedSlots(it.registry) } + is AndroidAccountCredentialStoreRead.Invalid -> error("The account credential store is invalid.") + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> + error("The independent account credential slots could not be recovered.") + is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) +} + internal data class AndroidPendingAccountRemovalCleanup( val accountStorageKey: String, val workIdentity: String, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionCredentialRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionCredentialRecoveryTest.kt new file mode 100644 index 000000000..104eabad5 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionCredentialRecoveryTest.kt @@ -0,0 +1,31 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class AndroidAccountSelectionCredentialRecoveryTest { + @Test + fun `valid credential slots recover account selection around a malformed aggregate`() { + val first = NextcloudSession("https://one.example.test", "alice", "first-secret") + val second = NextcloudSession("https://two.example.test", "bob", "second-secret") + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + val slots = mapOf(first.accountId to first, second.accountId to second) + + val recovery = recoverAndroidAccountCredentialStateForSelection( + AndroidAccountCredentialStoreRead.Invalid("malformed-encrypted-aggregate"), + ) { + reconstructAndroidAccountCredentialState(registry, slots::get) + } + val selected = assertNotNull(recovery.state.select(first.accountId)) + + assertEquals(first, selected.activeSession) + assertEquals(slots, selected.sessions) + assertEquals("malformed-encrypted-aggregate", recovery.suspectEncrypted) + } +} From ee428b08f8e79e3cd39330e7012bcab1600aa4de Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 09:34:43 +0200 Subject: [PATCH 061/119] fix(android): complete account retirement cleanup --- .../AndroidAccountCredentialController.kt | 5 +-- .../AndroidAccountOwnedStateCleanup.kt | 15 ++++++++- .../AndroidAccountPrivateCacheDeletion.kt | 15 +++++++++ .../AndroidAccountRetention.kt | 18 +++++++++++ .../nextcloudnative/AndroidFileReadCache.kt | 3 ++ .../AndroidFileSyncExecutionCoordination.kt | 10 +++++- .../AndroidIncomingShareUploadWorker.kt | 24 ++++++-------- .../AndroidNextcloudServices.kt | 10 +++--- .../nextcloudnative/AndroidNotifications.kt | 4 +++ .../AndroidVirtualFileCache.kt | 4 +++ .../NextcloudFileSyncWorker.kt | 10 +++--- .../AndroidFileReadCacheTest.kt | 14 +++++++++ .../AndroidFileSyncEngineInvariantTest.kt | 31 ++++++++++++++++++- .../AndroidIncomingShareStateTest.kt | 25 +++++++++++---- tools/kotlin-file-size-baseline.txt | 2 +- 15 files changed, 154 insertions(+), 36 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountPrivateCacheDeletion.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index a3bf9602b..9397b8dd5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -3,7 +3,6 @@ package dev.obiente.nextcloudnative import android.content.Context import android.content.SharedPreferences import dev.obiente.nextcloudnative.app.NextcloudAccountId -import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent @@ -57,7 +56,9 @@ internal class AndroidAccountCredentialController( }, ) - fun listAccounts(): List = readRegistryForCredentialLoad()?.accounts.orEmpty() + fun accountRetentionSnapshot(): AndroidAccountRetentionSnapshot = readRegistryForCredentialLoad() + ?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts) } + ?: AndroidAccountRetentionSnapshot.Unavailable fun activeAccountId(): NextcloudAccountId? = readCredentialFreeRegistry()?.activeAccountId diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index 472d27f9a..7a01dddaf 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -2,8 +2,15 @@ package dev.obiente.nextcloudnative import android.content.Context import dev.obiente.nextcloudnative.app.NextcloudSession +import java.io.File -internal class AndroidAccountOwnedStateCleanup(context: Context) { +internal class AndroidAccountOwnedStateCleanup( + context: Context, + private val fileReadCache: AndroidFileReadCache = AndroidFileReadCache( + File(context.applicationContext.cacheDir, "files-read-v1"), + ), + private val virtualFileCache: AndroidVirtualFileCache = AndroidVirtualFileCache(context.applicationContext), +) { private val appContext = context.applicationContext private val fileOffline = AndroidFileOfflineAccountCleanup(appContext) private val incomingShares = AndroidIncomingShareAccountCleanup(appContext) @@ -18,6 +25,8 @@ internal class AndroidAccountOwnedStateCleanup(context: Context) { { incomingShares.removeForAccount(session) }, { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { fileReadCache.clearAccount(accountIdentity) }, + { virtualFileCache.clearAccount(accountIdentity) }, ), ) } @@ -30,6 +39,8 @@ internal class AndroidAccountOwnedStateCleanup(context: Context) { { incomingShares.removeForAccount(accountIdentity, session) }, { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { fileReadCache.clearAccount(accountIdentity) }, + { virtualFileCache.clearAccount(accountIdentity) }, ), ) } @@ -42,6 +53,8 @@ internal class AndroidAccountOwnedStateCleanup(context: Context) { { incomingShares.removeForAccount(accountIdentity) }, { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { fileReadCache.clearAccount(accountIdentity) }, + { virtualFileCache.clearAccount(accountIdentity) }, ), ) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountPrivateCacheDeletion.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountPrivateCacheDeletion.kt new file mode 100644 index 000000000..9d581a9b5 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountPrivateCacheDeletion.kt @@ -0,0 +1,15 @@ +package dev.obiente.nextcloudnative + +import java.io.File + +internal fun deleteAndroidAccountPrivateCache(root: File, accountId: String) { + require(accountId.length == 32 && accountId.all { character -> + character in '0'..'9' || character in 'a'..'f' + }) { "Private cache account identity is invalid." } + val canonicalRoot = root.canonicalFile + val accountDirectory = File(canonicalRoot, accountId).canonicalFile + check(accountDirectory.parentFile == canonicalRoot) { "Unsafe private account cache path." } + check(!accountDirectory.exists() || accountDirectory.deleteRecursively() && !accountDirectory.exists()) { + "Could not remove this account's private cache." + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt index 71514aee9..1fa18ea63 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt @@ -2,9 +2,27 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.NextcloudAccountRecord +internal sealed interface AndroidAccountRetentionSnapshot { + data class Available(val accounts: List) : AndroidAccountRetentionSnapshot + + data object Unavailable : AndroidAccountRetentionSnapshot +} + +internal fun AndroidAccountRetentionSnapshot.accountsOrEmpty(): List = + (this as? AndroidAccountRetentionSnapshot.Available)?.accounts.orEmpty() + internal fun androidAccountIdentityIsRetained( accountIdentity: String, retainedAccounts: List, ): Boolean = retainedAccounts.any { account -> NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == accountIdentity } + +internal fun shouldRetryIncomingShareForMissingSession( + accountIdentity: String, + snapshot: AndroidAccountRetentionSnapshot, +): Boolean = when (snapshot) { + is AndroidAccountRetentionSnapshot.Available -> + androidAccountIdentityIsRetained(accountIdentity, snapshot.accounts) + AndroidAccountRetentionSnapshot.Unavailable -> true +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCache.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCache.kt index 393c43e18..fcd27ffd2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCache.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCache.kt @@ -104,6 +104,9 @@ internal class AndroidFileReadCache( ) } + @Synchronized + fun clearAccount(accountId: String) = deleteAndroidAccountPrivateCache(root, accountId) + private fun CacheState.bounded(): CacheState { var retainedEntries = 0 val retained = listings diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index 3b593c331..b30fa6706 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -240,6 +240,7 @@ internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, account } if (retiredPairs.isEmpty()) return@withLock val scheduler = AndroidFileSyncScheduler(context) + val notifications = AndroidNotificationCoordinator(context) retireConfiguredFileSyncAccountPairs( retiredPairs = retiredPairs, retainedPairs = retainedPairs, @@ -247,6 +248,9 @@ internal suspend fun retireAndroidFileSyncAccountPairs(context: Context, account reconcileSafDownloadsBeforePairRemoval(context, pair.localRootId) }, cancelSchedule = { pair -> scheduler.cancel(pair.id) }, + cancelNotification = { pair -> + notifications.cancel(pair.accountId, androidFileSyncNotificationId(pair.id)) + }, persistRetirement = { store.save(removeAndroidFileSyncAccountPairs(current, accountId)) }, releaseLocalGrant = { localRootId -> releaseSafGrantAfterPairRemoval(context, localRootId, releasesLocalGrant = true) @@ -260,6 +264,7 @@ internal suspend fun retireConfiguredFileSyncAccountPairs( retainedPairs: List, reconcileLocalDownloads: suspend (FileSyncPair) -> Boolean, cancelSchedule: suspend (FileSyncPair) -> Unit, + cancelNotification: suspend (FileSyncPair) -> Unit, persistRetirement: suspend () -> Unit, releaseLocalGrant: suspend (String) -> Unit, ) { @@ -269,7 +274,10 @@ internal suspend fun retireConfiguredFileSyncAccountPairs( } currentCoroutineContext().ensureActive() } - retiredPairs.forEach { pair -> cancelSchedule(pair) } + retiredPairs.forEach { pair -> + cancelSchedule(pair) + cancelNotification(pair) + } currentCoroutineContext().ensureActive() val retainedLocalRoots = retainedPairs.mapTo(hashSetOf()) { pair -> pair.localRootId } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt index a29a8d000..f02be0886 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareUploadWorker.kt @@ -14,7 +14,6 @@ import androidx.core.content.ContextCompat import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters -import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import dev.obiente.nextcloudnative.app.useAndroidNextcloudCertificateTrust import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -103,15 +102,17 @@ internal class AndroidIncomingShareUploadWorker( ): Result { var request = initialRequest val services = AndroidNextcloudServices(applicationContext) - val retainedAccounts = services.listAccounts() - val session = resolveStoredAndroidAccountSession( - accountIdentity = accountIdentity, - listAccounts = { retainedAccounts }, - loadSession = { accountId -> services.loadSession(accountId) }, - ) + val accountSnapshot = services.accountRetentionSnapshot() + val session = (accountSnapshot as? AndroidAccountRetentionSnapshot.Available)?.let { available -> + resolveStoredAndroidAccountSession( + accountIdentity = accountIdentity, + listAccounts = { available.accounts }, + loadSession = { accountId -> services.loadSession(accountId) }, + ) + } if (session == null) { - if (shouldDeferIncomingShareForMissingSession(accountIdentity, retainedAccounts)) { - return Result.success() + if (shouldRetryIncomingShareForMissingSession(accountIdentity, accountSnapshot)) { + return Result.retry() } return failUnavailableAccount(store, requestId) } @@ -337,11 +338,6 @@ internal class AndroidIncomingShareUploadWorker( } } -internal fun shouldDeferIncomingShareForMissingSession( - accountIdentity: String, - retainedAccounts: List, -): Boolean = androidAccountIdentityIsRetained(accountIdentity, retainedAccounts) - internal fun Throwable.incomingShareRetryNotBeforeEpochMillis(nowEpochMillis: Long): Long? { require(nowEpochMillis >= 0L) val retryAfterSeconds = (this as? DocumentWebDavException) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 850d56a8d..1d784171c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -449,9 +449,9 @@ internal class AndroidNextcloudServices( private val dynamicDiscoveryCacheDirectory = File(appContext.filesDir, "contracts/discoveries-v1") private val pendingDynamicMutationDirectory = File(appContext.filesDir, "mutations/dynamic-v1") private val fileOfflineRepository = AndroidFileOfflineRepository(appContext) - private val accountOwnedStateCleanup = AndroidAccountOwnedStateCleanup(appContext) private val fileReadCache = AndroidFileReadCache(File(appContext.cacheDir, "files-read-v1")) private val virtualFileCache = AndroidVirtualFileCache(appContext) + private val accountOwnedStateCleanup = AndroidAccountOwnedStateCleanup(appContext, fileReadCache, virtualFileCache) private val dynamicApiReadCache = DynamicApiResponseCache(File(appContext.cacheDir, "dynamic-api-v1")) private val nativeMediaPreviewCache = AndroidNativeMediaPreviewCache( File(appContext.cacheDir, "native-media-previews-v1"), @@ -942,14 +942,14 @@ internal class AndroidNextcloudServices( override fun loadSession(): NextcloudSession? = accountCredentials.loadSession() override suspend fun prepareDeckCardDraftRecovery(session: NextcloudSession) = - withContext(Dispatchers.IO) { - deckCardDrafts.migrateLegacyEntries(session) - } + withContext(Dispatchers.IO) { deckCardDrafts.migrateLegacyEntries(session) } override suspend fun saveSession(session: NextcloudSession): NextcloudSession = accountCredentials.saveSession(session) - override fun listAccounts() = accountCredentials.listAccounts() + internal fun accountRetentionSnapshot() = accountCredentials.accountRetentionSnapshot() + + override fun listAccounts() = accountRetentionSnapshot().accountsOrEmpty() override fun activeAccountId() = accountCredentials.activeAccountId() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNotifications.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNotifications.kt index 5350cacba..22b11fb20 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNotifications.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNotifications.kt @@ -268,6 +268,10 @@ internal class AndroidNotificationCoordinator(private val context: Context) { } } + fun cancel(accountKey: String, notificationId: Int) { + NotificationManagerCompat.from(context).cancel(accountKey, notificationId) + } + private fun openAppIntent( action: String, requestCode: Int, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCache.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCache.kt index f200a6073..3bda06b3e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCache.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileCache.kt @@ -267,6 +267,10 @@ internal class AndroidVirtualFileCache(context: Context) { } } + fun clearAccount(accountId: String) = synchronized(STORE_LOCK) { + deleteAndroidAccountPrivateCache(root, accountId) + } + fun loadPolicy(): VirtualFileCachePolicy = VirtualFileCachePolicy( automaticCleanup = preferences.getBoolean(KEY_AUTOMATIC, true), maximumCacheBytes = preferences.getLong( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt index 74a9322ae..fd634b87c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt @@ -60,7 +60,7 @@ internal class NextcloudFileSyncWorker( pair.conflicts.firstOrNull()?.let { conflict -> AndroidNotificationCoordinator(applicationContext).post( NextcloudNotificationEvent.SyncConflict( - id = stableNotificationId(pairId), + id = androidFileSyncNotificationId(pairId), accountKey = accountId, path = conflict.relativePath, detail = syncConflictNotificationDetail(pair.conflictCount), @@ -145,7 +145,7 @@ internal class NextcloudFileSyncWorker( .setOnlyAlertOnce(true) .setProgress(0, 0, true) .build() - val id = stableNotificationId(pairId) + val id = androidFileSyncNotificationId(pairId) return if (Build.VERSION.SDK_INT >= 29) { ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } else { @@ -153,9 +153,6 @@ internal class NextcloudFileSyncWorker( } } - private fun stableNotificationId(pairId: String): Int = - pairId.hashCode().let { if (it == Int.MIN_VALUE) 1 else kotlin.math.abs(it) }.coerceAtLeast(1) - private fun isForegroundServiceStartNotAllowed(error: IllegalStateException): Boolean = Build.VERSION.SDK_INT >= 31 && isForegroundServiceStartNotAllowedApi31(error) @@ -170,6 +167,9 @@ internal class NextcloudFileSyncWorker( } } +internal fun androidFileSyncNotificationId(pairId: String): Int = + pairId.hashCode().let { if (it == Int.MIN_VALUE) 1 else kotlin.math.abs(it) }.coerceAtLeast(1) + internal class AndroidFileSyncScheduleRestorationWorker( appContext: Context, params: WorkerParameters, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt index d75e81d9b..e95393e54 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt @@ -5,6 +5,7 @@ import java.io.File import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue @@ -47,6 +48,19 @@ class AndroidFileReadCacheTest { assertEquals(listOf(bob), cache.cachedListing(ACCOUNT_B, "Notes")?.files) } + @Test + fun accountRemovalDeletesOnlyItsPrivateListingCache() = withCache { root, cache -> + val alice = file("Notes/alice.md", "\"a\"") + val bob = file("Notes/bob.md", "\"b\"") + cache.storeListing(ACCOUNT_A, "Notes", listOf(alice), 10) + cache.storeListing(ACCOUNT_B, "Notes", listOf(bob), 20) + + cache.clearAccount(ACCOUNT_A) + + assertFalse(File(root, ACCOUNT_A).exists()) + assertEquals(listOf(bob), cache.cachedListing(ACCOUNT_B, "Notes")?.files) + } + @Test fun newestListingsWinBoundedMetadataQuota() = withCache( maximumListings = 3, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index e0d3385e2..459a31582 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -561,6 +561,7 @@ class AndroidFileSyncEngineInvariantTest { retainedPairs = retainedPairs, reconcileLocalDownloads = { pair -> events += "reconcile-${pair.id}"; true }, cancelSchedule = { pair -> events += "cancel-${pair.id}" }, + cancelNotification = { pair -> events += "cancel-notification-${pair.id}" }, persistRetirement = { events += "persist-retirement" }, releaseLocalGrant = { localRootId -> events += "release-$localRootId" }, ) @@ -571,8 +572,11 @@ class AndroidFileSyncEngineInvariantTest { "reconcile-retired-b", "reconcile-retired-c", "cancel-retired-a", + "cancel-notification-retired-a", "cancel-retired-b", + "cancel-notification-retired-b", "cancel-retired-c", + "cancel-notification-retired-c", "persist-retirement", "release-$retiredRoot", ), @@ -594,6 +598,7 @@ class AndroidFileSyncEngineInvariantTest { retainedPairs = emptyList(), reconcileLocalDownloads = { pair -> events += "reconcile-${pair.id}"; pair.id == "retired-a" }, cancelSchedule = { pair -> events += "cancel-${pair.id}" }, + cancelNotification = { pair -> events += "cancel-notification-${pair.id}" }, persistRetirement = { events += "persist-retirement" }, releaseLocalGrant = { localRootId -> events += "release-$localRootId" }, ) @@ -619,12 +624,36 @@ class AndroidFileSyncEngineInvariantTest { events += "cancel-${pair.id}" if (pair.id == "pair-b") error("synthetic WorkManager cancellation failure") }, + cancelNotification = { pair -> events += "cancel-notification-${pair.id}" }, persistRetirement = { events += "persist-retirement" }, releaseLocalGrant = { localRootId -> events += "release-$localRootId" }, ) } - assertEquals(listOf("cancel-pair-a", "cancel-pair-b"), events) + assertEquals(listOf("cancel-pair-a", "cancel-notification-pair-a", "cancel-pair-b"), events) + } + + @Test + fun accountRetirementKeepsPairStateWhenConflictNotificationCannotBeCanceled() = runBlocking { + val pair = fileSyncPair("pair", "removed-account", "root") + val events = mutableListOf() + + assertFailsWith { + retireConfiguredFileSyncAccountPairs( + retiredPairs = listOf(pair), + retainedPairs = emptyList(), + reconcileLocalDownloads = { true }, + cancelSchedule = { events += "cancel-schedule" }, + cancelNotification = { + events += "cancel-notification" + error("synthetic notification cancellation failure") + }, + persistRetirement = { events += "persist-retirement" }, + releaseLocalGrant = { events += "release-grant" }, + ) + } + + assertEquals(listOf("cancel-schedule", "cancel-notification"), events) } @Test diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt index 8f990bca8..8e5b5d1f6 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIncomingShareStateTest.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.runBlocking class AndroidIncomingShareStateTest { @Test - fun retainedAccountWithUnreadableCredentialsDefersIncomingShareUpload() { + fun retainedOrUnknownAccountWithUnreadableCredentialsRetriesIncomingShareUpload() { val retainedSession = NextcloudSession( serverUrl = "https://cloud.example.test/nextcloud", loginName = "alice", @@ -29,16 +29,29 @@ class AndroidIncomingShareStateTest { val accountIdentity = NextcloudDocumentIds.accountKey(retainedSession) assertTrue( - shouldDeferIncomingShareForMissingSession( + shouldRetryIncomingShareForMissingSession( accountIdentity, - listOf(retainedSession.accountRecord()), + AndroidAccountRetentionSnapshot.Available(listOf(retainedSession.accountRecord())), + ), + ) + assertTrue( + shouldRetryIncomingShareForMissingSession( + accountIdentity, + AndroidAccountRetentionSnapshot.Unavailable, ), ) - assertFalse(shouldDeferIncomingShareForMissingSession(accountIdentity, emptyList())) assertFalse( - shouldDeferIncomingShareForMissingSession( + shouldRetryIncomingShareForMissingSession( accountIdentity, - listOf(retainedSession.copy(loginName = "another-account").accountRecord()), + AndroidAccountRetentionSnapshot.Available(emptyList()), + ), + ) + assertFalse( + shouldRetryIncomingShareForMissingSession( + accountIdentity, + AndroidAccountRetentionSnapshot.Available( + listOf(retainedSession.copy(loginName = "another-account").accountRecord()), + ), ), ) } diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 5475c60f7..8380ed145 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|4241 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4260 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|1003 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1224 From 7f374552854d0b360104e940f219c620301fff79 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 09:36:53 +0200 Subject: [PATCH 062/119] fix(desktop): harden account removal recovery --- tools/kotlin-file-size-baseline.txt | 2 +- .../app/DesktopAccountCacheRemoval.kt | 74 +++++++++++++++++ .../DesktopAccountCredentialPersistence.kt | 27 +++++- .../app/DesktopAccountRemoval.kt | 46 ++++++---- .../app/DesktopFileReadCache.kt | 24 +++--- .../app/DesktopNextcloudServices.kt | 14 ++-- .../app/DesktopVirtualRangeCache.kt | 55 ++++++------ .../app/DesktopAccountCacheRemovalTest.kt | 83 +++++++++++++++++++ ...DesktopAccountCredentialPersistenceTest.kt | 20 +++++ .../app/DesktopAccountOperationGuardTest.kt | 31 +++++-- ...esktopAccountRevocationCancellationTest.kt | 21 +++++ .../app/WindowsUninstallCleanupTest.kt | 4 +- 12 files changed, 324 insertions(+), 77 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemovalTest.kt diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 8380ed145..4ec23d401 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -54,7 +54,7 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine. ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|883 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStore.kt|808 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6280 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2762 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2759 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1085 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesProvider.kt|2355 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt new file mode 100644 index 000000000..515da0101 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt @@ -0,0 +1,74 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.nio.channels.FileChannel +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.StandardOpenOption +import java.security.MessageDigest + +internal data class VirtualRangeRevision( + val relativePath: String, + val remoteRevision: String, + val fileSize: Long, +) { + init { + FileOfflineKey("account", relativePath) + require(remoteRevision.isNotBlank() && remoteRevision.none(Char::isISOControl)) + require(fileSize > 0L) + } +} + +internal fun desktopFileCacheAccountId(session: NextcloudSession): String = + MessageDigest.getInstance("SHA-256") + .digest("${session.serverUrl}\u0000${session.loginName}".encodeToByteArray()) + .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } + +internal fun defaultDesktopFileReadCache(): DesktopFileReadCache = + DesktopFileReadCache(File(desktopCacheRoot(), "nextcloud-native/files")) + +internal fun defaultDesktopVirtualRangeCache( + policy: () -> VirtualFileCachePolicy, +): DesktopVirtualRangeCache = DesktopVirtualRangeCache( + root = File(desktopCacheRoot(), "nextcloud-native/virtual-ranges"), + policy = policy, +) + +internal fun purgeDesktopAccountCacheDirectory(root: File, accountId: String) { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + val directory = File(root, accountId) + if (Files.notExists(directory.toPath(), LinkOption.NOFOLLOW_LINKS)) return + require(Files.isDirectory(directory.toPath(), LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(directory.toPath())) { + "The desktop cache account directory is invalid." + } + val entries = Files.newDirectoryStream(directory.toPath()).use { stream -> stream.toList() } + require(entries.all { entry -> Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS) }) { + "The desktop cache account directory contains an unsafe entry." + } + entries.forEach(Files::delete) + syncDesktopCacheDirectory(directory) + Files.delete(directory.toPath()) + syncDesktopCacheDirectory(root) +} + +internal suspend fun removeDesktopAccountPrivateStorage( + accountId: String, + syncEngine: DesktopFileSyncEngine, + files: DesktopFileReadCache, + ranges: DesktopVirtualRangeCache, +) { + syncEngine.removeAccountPairs(accountId) + files.removeAccount(accountId) + ranges.removeAccount(accountId) +} + +private fun desktopCacheRoot(): File { + val xdgCache = System.getenv("XDG_CACHE_HOME")?.takeIf(String::isNotBlank) + return xdgCache?.let(::File) ?: File(System.getProperty("user.home"), ".cache") +} + +private fun syncDesktopCacheDirectory(directory: File) { + if (!System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) { + FileChannel.open(directory.toPath(), StandardOpenOption.READ).use { channel -> channel.force(true) } + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index f63dae294..929ee7a81 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -43,6 +43,21 @@ internal class DesktopAccountCredentialPersistence( return readLegacyAccountRecord()?.id } + fun accountOwnership(accountId: String): DesktopAccountOwnership { + val read = readRegistry() + val knownAccounts = read.registry?.accounts + if (knownAccounts != null) { + return if (knownAccounts.any { account -> desktopFileCacheAccountId(account) == accountId }) { + DesktopAccountOwnership.Present + } else { + DesktopAccountOwnership.Absent + } + } + val legacyMatches = readLegacyAccountRecord()?.let(::desktopFileCacheAccountId) == accountId + if (legacyMatches) return DesktopAccountOwnership.Present + return if (read.encoded == null) DesktopAccountOwnership.Absent else DesktopAccountOwnership.Unknown + } + fun loadSession(accountId: NextcloudAccountId): NextcloudSession? { retryPendingCredentialSave() retryPendingCredentialRemoval() @@ -68,7 +83,11 @@ internal class DesktopAccountCredentialPersistence( val read = readRegistry() val registry = read.registry ?: restoreLegacySession(read)?.let { requireNotNull(readRegistry().registry) } - ?: if (read.encoded == null) NextcloudAccountRegistry.Empty else throw invalidRegistryForMutation() + ?: when { + read.encoded == null -> NextcloudAccountRegistry.Empty + read.unsupportedVersion -> throw unsupportedRegistryForMutation() + else -> NextcloudAccountRegistry.Empty + } val previousRecord = registry.accounts.firstOrNull { account -> account.id == session.accountId } val persistedSession = previousRecord ?.let { record -> session.copy(serverUrl = record.serverUrl, loginName = record.loginName) } @@ -574,9 +593,9 @@ internal class DesktopAccountCredentialPersistence( preferences.get(KEY_SERVER, null) == record.serverUrl && preferences.get(KEY_LOGIN, null) == record.loginName - private fun invalidRegistryForMutation(): IllegalStateException { - recordCredentialDiagnostic("ACCOUNT_REGISTRY_MALFORMED", "account-registry.persist") - return IllegalStateException("The local account registry is invalid.") + private fun unsupportedRegistryForMutation(): IllegalStateException { + recordCredentialDiagnostic("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED", "account-registry.persist") + return IllegalStateException("The local account registry was written by a newer app version.") } private fun recordCredentialDiagnostic( diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 504021621..8983f0f63 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -26,6 +26,12 @@ internal enum class DesktopAccountSyncPairCleanupPhase { Committed, } +internal enum class DesktopAccountOwnership { + Present, + Absent, + Unknown, +} + internal data class DesktopAccountSyncPairCleanup( val accountId: String, val phase: DesktopAccountSyncPairCleanupPhase, @@ -172,7 +178,7 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( prepareCleanup: suspend (String) -> Unit, commitCleanup: suspend (String) -> Unit, clearCleanup: suspend (String) -> Unit, - accountStillExists: (String) -> Boolean, + accountOwnership: (String) -> DesktopAccountOwnership, removeCredential: suspend () -> Boolean, removeSyncPairs: suspend () -> Unit, recordCleanupFailure: suspend (Exception) -> Unit, @@ -182,7 +188,11 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( removeCredential() } catch (failure: Throwable) { runCatching { - if (accountStillExists(accountId)) clearCleanup(accountId) else commitCleanup(accountId) + when (accountOwnership(accountId)) { + DesktopAccountOwnership.Present -> clearCleanup(accountId) + DesktopAccountOwnership.Absent -> commitCleanup(accountId) + DesktopAccountOwnership.Unknown -> Unit + } }.exceptionOrNull()?.let(failure::addSuppressed) throw failure } @@ -205,7 +215,7 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( accountId: String?, cleanupJournal: DesktopAccountSyncPairCleanupJournal, - accountStillExists: (String) -> Boolean, + accountOwnership: (String) -> DesktopAccountOwnership, commitRemoval: suspend () -> Unit, removeSyncPairs: suspend (String) -> Unit, recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, @@ -219,7 +229,7 @@ internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( prepareCleanup = cleanupJournal::prepare, commitCleanup = cleanupJournal::commit, clearCleanup = cleanupJournal::clear, - accountStillExists = accountStillExists, + accountOwnership = accountOwnership, removeCredential = { commitRemoval() true @@ -248,19 +258,19 @@ internal suspend fun completeDesktopSignOutAfterRemoteRevocation( completeLocalRemoval() return } - var revocationCancellation: CancellationException? = null + var revocationFailure: Throwable? = null try { revokeRemoteSession(session) - } catch (cancelled: CancellationException) { - revocationCancellation = cancelled + } catch (failure: Exception) { + revocationFailure = failure } try { withContext(NonCancellable) { completeLocalRemoval() } } catch (failure: Throwable) { - revocationCancellation?.let(failure::addSuppressed) + revocationFailure?.let(failure::addSuppressed) throw failure } - revocationCancellation?.let { throw it } + revocationFailure?.let { throw it } currentCoroutineContext().ensureActive() } @@ -282,13 +292,19 @@ internal fun finishCommittedDesktopAccountRemoval( internal suspend fun retryDesktopAccountSyncPairCleanup( cleanup: DesktopAccountSyncPairCleanup, - accountStillExists: (String) -> Boolean, + accountOwnership: (String) -> DesktopAccountOwnership, removeSyncPairs: suspend (String) -> Unit, clearCleanup: suspend (String) -> Unit, ) { - if (cleanup.phase == DesktopAccountSyncPairCleanupPhase.Prepared && accountStillExists(cleanup.accountId)) { - clearCleanup(cleanup.accountId) - return + if (cleanup.phase == DesktopAccountSyncPairCleanupPhase.Prepared) { + when (accountOwnership(cleanup.accountId)) { + DesktopAccountOwnership.Present -> { + clearCleanup(cleanup.accountId) + return + } + DesktopAccountOwnership.Unknown -> return + DesktopAccountOwnership.Absent -> Unit + } } removeSyncPairs(cleanup.accountId) clearCleanup(cleanup.accountId) @@ -296,7 +312,7 @@ internal suspend fun retryDesktopAccountSyncPairCleanup( internal suspend fun retryPendingDesktopAccountSyncPairCleanups( cleanupJournal: DesktopAccountSyncPairCleanupJournal, - accountStillExists: (String) -> Boolean, + accountOwnership: (String) -> DesktopAccountOwnership, removeSyncPairs: suspend (String) -> Unit, recordCleanupFailure: (String, Exception) -> Unit, ) { @@ -304,7 +320,7 @@ internal suspend fun retryPendingDesktopAccountSyncPairCleanups( try { retryDesktopAccountSyncPairCleanup( cleanup, - accountStillExists, + accountOwnership, removeSyncPairs, cleanupJournal::clear, ) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt index 7b22ae225..eacd32b42 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt @@ -142,6 +142,18 @@ internal class DesktopFileReadCache( virtualListingInvalidationPreferences.flush() } + @Synchronized + fun removeAccount(accountId: String) { + try { + purgeDesktopAccountCacheDirectory(root, accountId) + virtualListingInvalidationPreferences.remove(accountId) + virtualListingInvalidationPreferences.flush() + } finally { + loadedIndexes.remove(accountId) + failedVirtualListingInvalidations.remove(accountId) + } + } + @Synchronized fun storeListing( accountId: String, @@ -1355,18 +1367,6 @@ private data class MetadataShardIndexGroup( } } -internal fun desktopFileCacheAccountId(session: NextcloudSession): String = - sha256Hex("${session.serverUrl}\u0000${session.loginName}") - -private fun desktopFilesCacheDirectory(): File { - val xdgCache = System.getenv("XDG_CACHE_HOME")?.takeIf(String::isNotBlank) - val cacheRoot = xdgCache?.let(::File) ?: File(System.getProperty("user.home"), ".cache") - return File(cacheRoot, "nextcloud-native/files") -} - -internal fun defaultDesktopFileReadCache(): DesktopFileReadCache = - DesktopFileReadCache(desktopFilesCacheDirectory()) - private fun String.cachePath(): String { require(length <= 8_192) require(none { it == '\u0000' || it == '\n' || it == '\r' || it == '\\' }) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 9d59392b2..635e887fe 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3672,7 +3672,7 @@ class DesktopNextcloudServices( prepareCleanup = accountSyncPairCleanupJournal::prepare, commitCleanup = accountSyncPairCleanupJournal::commit, clearCleanup = accountSyncPairCleanupJournal::clear, - accountStillExists = ::desktopAccountExists, + accountOwnership = ::desktopAccountOwnership, removeCredential = { sessionPublicationGuard.serialize { removeDesktopAccountCredential(preferences, providerAccountId, { accountCredentials.listAccounts().any { account -> account.id == accountId } @@ -3842,7 +3842,7 @@ class DesktopNextcloudServices( clearDesktopActiveAccountBeforeSyncPairCleanup( accountId, accountSyncPairCleanupJournal, - ::desktopAccountExists, + ::desktopAccountOwnership, { commitDesktopAccountRemovalBeforeVirtualFileTeardown( commitRemoval = { @@ -3908,7 +3908,7 @@ class DesktopNextcloudServices( if (cleanup != null) { retryDesktopAccountSyncPairCleanup( cleanup = cleanup, - accountStillExists = ::desktopAccountExists, + accountOwnership = ::desktopAccountOwnership, removeSyncPairs = ::removeDesktopAccountOwnedState, clearCleanup = accountSyncPairCleanupJournal::clear, ) @@ -3919,7 +3919,7 @@ class DesktopNextcloudServices( private suspend fun retryPendingAccountSyncPairCleanups() { retryPendingDesktopAccountSyncPairCleanups( cleanupJournal = accountSyncPairCleanupJournal, - accountStillExists = ::desktopAccountExists, + accountOwnership = ::desktopAccountOwnership, removeSyncPairs = ::removeDesktopAccountOwnedState, recordCleanupFailure = { accountId, failure -> recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(accountId, failure)) @@ -3928,7 +3928,7 @@ class DesktopNextcloudServices( } private suspend fun removeDesktopAccountOwnedState(accountId: String) { - fileSyncEngine.removeAccountPairs(accountId) + removeDesktopAccountPrivateStorage(accountId, fileSyncEngine, fileReadCache, virtualRangeCache(accountId)) if (!isWindowsDesktop()) return try { unregisterWindowsCloudFilesRootsForAccountRemoval( @@ -3946,8 +3946,8 @@ class DesktopNextcloudServices( } } - private fun desktopAccountExists(accountId: String): Boolean = sessionPublicationGuard.serialize { - accountCredentials.listAccounts().any { account -> desktopFileCacheAccountId(account) == accountId } + private fun desktopAccountOwnership(accountId: String): DesktopAccountOwnership = sessionPublicationGuard.serialize { + accountCredentials.accountOwnership(accountId) } override suspend fun loadDeckCardDraft( session: NextcloudSession, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt index 8548be970..cb47650fc 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt @@ -36,18 +36,6 @@ internal data class DesktopVirtualRangeCacheSummary( val tierAttention: String? = null, ) -internal data class VirtualRangeRevision( - val relativePath: String, - val remoteRevision: String, - val fileSize: Long, -) { - init { - FileOfflineKey("account", relativePath) - require(remoteRevision.isNotBlank() && remoteRevision.none(Char::isISOControl)) - require(fileSize > 0L) - } -} - private data class ActiveVirtualRangeRevision( val file: FileOfflineKey, val remoteRevision: String, @@ -1216,16 +1204,36 @@ internal class DesktopVirtualRangeCache( @Synchronized fun removeCopiedPrimaryAccount(accountId: String) { check(activePaths.keys.none { it.accountId == accountId } && activeRevisions.keys.none { it.file.accountId == accountId }) - val directory = accountDirectory(accountId) - if (!directory.isDirectory || Files.isSymbolicLink(directory.toPath())) return - directory.listFiles().orEmpty() - .filter { it.isFile && !Files.isSymbolicLink(it.toPath()) } - .forEach(File::delete) - directory.delete() + purgeDesktopAccountCacheDirectory(root, accountId) loadedIndexes.remove(accountId) recoveredAccounts.remove(accountId) } + @Synchronized + fun removeAccount(accountId: String) { + check( + activePaths.keys.none { it.accountId == accountId } && + activeRevisions.keys.none { it.file.accountId == accountId }, + ) { "Close files from this account before removing its cache." } + val configuredOverflow = overflowRoot + try { + if (configuredOverflow != null) { + check(isOverflowRootAvailable(configuredOverflow)) { + "Reconnect the overflow cache drive before removing this account." + } + } + purgeDesktopAccountCacheDirectory(root, accountId) + configuredOverflow?.let { overflow -> purgeDesktopAccountCacheDirectory(overflow, accountId) } + } finally { + loadedIndexes.remove(accountId) + recoveredAccounts.remove(accountId) + recoveredOverflowAccounts.remove(accountId) + dirtyAccessTimeAccounts.remove(accountId) + lastAccessTimePersistence.remove(accountId) + deferredInvalidationRevisions.removeAll { revision -> revision.file.accountId == accountId } + } + } + @Synchronized fun freeUp(accountId: String, requestedBytes: Long): VirtualFileEvictionPlan = applyEviction(accountId, requestedBytes, System.currentTimeMillis()) @@ -2526,17 +2534,6 @@ internal class DesktopVirtualRangeCache( } } -internal fun defaultDesktopVirtualRangeCache( - policy: () -> VirtualFileCachePolicy, -): DesktopVirtualRangeCache { - val xdgCache = System.getenv("XDG_CACHE_HOME")?.takeIf(String::isNotBlank) - val cacheRoot = xdgCache?.let(::File) ?: File(System.getProperty("user.home"), ".cache") - return DesktopVirtualRangeCache( - root = File(cacheRoot, "nextcloud-native/virtual-ranges"), - policy = policy, - ) -} - @Serializable private data class RangeCacheIndex( val version: Int = 2, diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemovalTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemovalTest.kt new file mode 100644 index 000000000..775db96c0 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemovalTest.kt @@ -0,0 +1,83 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import java.util.UUID +import java.util.prefs.Preferences +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopAccountCacheRemovalTest { + @Test + fun accountRemovalPurgesPrivateFilesPersistentInvalidationsAndRangeIndexes() { + val root = Files.createTempDirectory("desktop-account-cache-removal-").toFile() + val filesRoot = root.resolve("files").apply { mkdir() } + val rangesRoot = root.resolve("ranges").apply { mkdir() } + val preferences = Preferences.userRoot().node("desktop-account-cache-removal-${UUID.randomUUID()}") + try { + val files = DesktopFileReadCache(filesRoot, preferences = preferences) + val ranges = DesktopVirtualRangeCache(rangesRoot) { testPolicy() } + files.storeContent( + ACCOUNT_ID, + "Private/secret.txt", + NextcloudFileContent("private bytes".encodeToByteArray(), "text/plain", "etag-1"), + ) + files.replaceFailedVirtualListingInvalidations(ACCOUNT_ID, setOf("Private")) + ranges.storeBlock(ACCOUNT_ID, "Private/secret.bin", "etag-2", 4L, 0L, "data".encodeToByteArray()) + + files.removeAccount(ACCOUNT_ID) + ranges.removeAccount(ACCOUNT_ID) + + assertFalse(filesRoot.resolve(ACCOUNT_ID).exists()) + assertFalse(rangesRoot.resolve(ACCOUNT_ID).exists()) + assertNull(files.cachedContent(ACCOUNT_ID, "Private/secret.txt", 64)) + assertTrue(files.failedVirtualListingInvalidations(ACCOUNT_ID).isEmpty()) + assertNull(ranges.readBlock(ACCOUNT_ID, "Private/secret.bin", "etag-2", 4L, 0L, 4)) + } finally { + preferences.removeNode() + root.deleteRecursively() + } + } + + @Test + fun unavailableOverflowKeepsPrimaryCacheForJournalRetry() { + val root = Files.createTempDirectory("desktop-account-cache-overflow-").toFile() + val primary = root.resolve("primary").apply { mkdir() } + val overflow = root.resolve("overflow").apply { mkdir() } + val disconnected = root.resolve("disconnected") + try { + val ranges = DesktopVirtualRangeCache( + root = primary, + overflowRoot = overflow, + initializeOverflowMarker = true, + policy = { testPolicy() }, + ) + ranges.storeBlock(ACCOUNT_ID, "Private/secret.bin", "etag-1", 4L, 0L, "data".encodeToByteArray()) + overflow.resolve(ACCOUNT_ID).apply { mkdir() }.resolve("private.block").writeText("private") + assertTrue(overflow.renameTo(disconnected)) + + assertFailsWith { ranges.removeAccount(ACCOUNT_ID) } + assertTrue(primary.resolve(ACCOUNT_ID).isDirectory) + assertTrue(disconnected.resolve(ACCOUNT_ID).isDirectory) + + assertTrue(disconnected.renameTo(overflow)) + ranges.removeAccount(ACCOUNT_ID) + assertFalse(primary.resolve(ACCOUNT_ID).exists()) + assertFalse(overflow.resolve(ACCOUNT_ID).exists()) + } finally { + root.deleteRecursively() + } + } + + private companion object { + const val ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + fun testPolicy() = VirtualFileCachePolicy( + automaticCleanup = false, + minimumFreeSpaceBytes = 0L, + unusedFileAgeMillis = null, + ) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index c1fb4609e..bf54f598b 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -323,6 +323,26 @@ class DesktopAccountCredentialPersistenceTest { listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }.distinct(), ) + assertEquals(DesktopAccountOwnership.Present, persistence.accountOwnership(desktopFileCacheAccountId(session))) + assertFailsWith { persistence.saveSession(secondSession()) } + assertEquals(futureRegistry, preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertNull(secrets.load(desktopAccountSecretReference(secondSession().accountId))) + assertEquals(listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }.distinct()) + } + + @Test + fun malformedRegistryWithoutLegacyCredentialIsReplacedByFreshSignIn() = withStore { preferences, secrets -> + val session = firstSession() + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, "{not-json") + val diagnostics = mutableListOf() + val persistence = persistence(preferences, secrets, diagnostics) + + assertEquals(DesktopAccountOwnership.Unknown, persistence.accountOwnership(desktopFileCacheAccountId(session))) + assertEquals(session, persistence.saveSession(session)) + + assertEquals(session.accountId, decodeRegistry(preferences).activeAccountId) + assertEquals(session, persistence(preferences, secrets).loadActiveSession()) + assertTrue(diagnostics.any { it.code == "ACCOUNT_REGISTRY_MALFORMED" }) } @Test diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 834d15941..0c8a72744 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -547,7 +547,7 @@ class DesktopAccountOperationGuardTest { prepareCleanup = { events += "prepare-cleanup" }, commitCleanup = { events += "commit-cleanup" }, clearCleanup = { events += "clear-cleanup" }, - accountStillExists = { false }, + accountOwnership = { DesktopAccountOwnership.Absent }, removeCredential = { events += "remove-credential" true @@ -582,7 +582,7 @@ class DesktopAccountOperationGuardTest { prepareCleanup = { events += "prepare-cleanup" }, commitCleanup = { events += "commit-cleanup" }, clearCleanup = { events += "clear-cleanup" }, - accountStillExists = { false }, + accountOwnership = { DesktopAccountOwnership.Absent }, removeCredential = { events += "remove-credential" true @@ -611,7 +611,7 @@ class DesktopAccountOperationGuardTest { prepareCleanup = { events += "prepare-cleanup" }, commitCleanup = { events += "commit-cleanup" }, clearCleanup = { events += "clear-cleanup" }, - accountStillExists = { false }, + accountOwnership = { DesktopAccountOwnership.Absent }, removeCredential = { events += "remove-credential" error("synthetic post-commit credential cleanup failure") @@ -653,7 +653,7 @@ class DesktopAccountOperationGuardTest { clearDesktopActiveAccountBeforeSyncPairCleanup( accountId = CLEANUP_ACCOUNT_ID, cleanupJournal = DesktopAccountSyncPairCleanupJournal(preferences), - accountStillExists = { true }, + accountOwnership = { DesktopAccountOwnership.Present }, commitRemoval = { events += "remove-credential" error("synthetic credential commit failure") @@ -726,7 +726,7 @@ class DesktopAccountOperationGuardTest { prepareCleanup = firstJournal::prepare, commitCleanup = firstJournal::commit, clearCleanup = firstJournal::clear, - accountStillExists = { false }, + accountOwnership = { DesktopAccountOwnership.Absent }, removeCredential = { true }, removeSyncPairs = { error("synthetic pair cleanup failure") }, recordCleanupFailure = { removalEvents += "diagnose" }, @@ -748,7 +748,7 @@ class DesktopAccountOperationGuardTest { val retryEvents = mutableListOf() retryDesktopAccountSyncPairCleanup( cleanup = restored.pending().single(), - accountStillExists = { true }, + accountOwnership = { DesktopAccountOwnership.Present }, removeSyncPairs = { retryEvents += "remove-pairs-$it" }, clearCleanup = { retryEvents += "clear-cleanup-$it" @@ -775,7 +775,7 @@ class DesktopAccountOperationGuardTest { CLEANUP_ACCOUNT_ID, DesktopAccountSyncPairCleanupPhase.Prepared, ), - accountStillExists = { true }, + accountOwnership = { DesktopAccountOwnership.Present }, removeSyncPairs = { events += "remove-pairs" }, clearCleanup = { events += "clear-cleanup" }, ) @@ -783,6 +783,23 @@ class DesktopAccountOperationGuardTest { assertEquals(listOf("clear-cleanup"), events) } + @Test + fun preparedCleanupPreservesPairsAndJournalWhenCredentialOwnershipIsUnknown() = runBlocking { + val events = mutableListOf() + + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Prepared, + ), + accountOwnership = { DesktopAccountOwnership.Unknown }, + removeSyncPairs = { events += "remove-pairs" }, + clearCleanup = { events += "clear-cleanup" }, + ) + + assertTrue(events.isEmpty()) + } + private companion object { const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRevocationCancellationTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRevocationCancellationTest.kt index f76ce3242..7a045e25c 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRevocationCancellationTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRevocationCancellationTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative.app +import java.io.IOException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -10,6 +11,26 @@ import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking class DesktopAccountRevocationCancellationTest { + @Test + fun ambiguousRemoteRevocationFailureStillCompletesLocalRemovalAndReturnsOriginalFailure() = runBlocking { + val events = mutableListOf() + val revocationFailure = IOException("remote response was lost") + + val thrown = assertFailsWith { + completeDesktopSignOutAfterRemoteRevocation( + session = "account", + revokeRemoteSession = { + events += "remote-revocation-attempted" + throw revocationFailure + }, + completeLocalRemoval = { events += "local-removed" }, + ) + } + + assertTrue(thrown === revocationFailure) + assertEquals(listOf("remote-revocation-attempted", "local-removed"), events) + } + @Test fun cancellationReturningFromRemoteRevocationStillCompletesLocalRemoval() = runBlocking { val events = mutableListOf() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt index cc7ed8b5b..e8b7dcf70 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt @@ -335,7 +335,7 @@ class WindowsUninstallCleanupTest { prepareCleanup = journal::prepare, commitCleanup = journal::commit, clearCleanup = journal::clear, - accountStillExists = { false }, + accountOwnership = { DesktopAccountOwnership.Absent }, removeCredential = { true }, removeSyncPairs = { unregisterWindowsCloudFilesRootsForAccountRemoval( @@ -358,7 +358,7 @@ class WindowsUninstallCleanupTest { val retryApi = RecordingWindowsCloudFilesApi() retryDesktopAccountSyncPairCleanup( cleanup = journal.pending().single(), - accountStillExists = { false }, + accountOwnership = { DesktopAccountOwnership.Absent }, removeSyncPairs = { unregisterWindowsCloudFilesRootsForAccountRemoval( preferences = preferences, From e75f752f206bd9019666a17d4c753c161b2a10ea Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 10:11:51 +0200 Subject: [PATCH 063/119] fix(accounts): preserve unreadable retained slots --- .../AndroidAccountCredentialController.kt | 2 +- .../AndroidAccountCredentialRecovery.kt | 6 ++++ ...dAccountSelectionCredentialRecoveryTest.kt | 28 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 9397b8dd5..eb351179e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -736,7 +736,7 @@ internal class AndroidAccountCredentialController( state: AndroidAccountCredentialState, ): SharedPreferences.Editor = editor.apply { remove(ANDROID_QUARANTINED_SESSION_KEY) - val retainedKeys = state.sessions.keys.mapTo(hashSetOf(), ::androidAccountCredentialSlotKey) + val retainedKeys = retainedAndroidAccountCredentialSlotKeys(state) preferences.all.keys .filter { key -> key.startsWith(ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX) && key !in retainedKeys } .forEach(::remove) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index 7b3c98998..e6420bb3d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -71,6 +71,12 @@ internal fun unsupportedCredentialStoreMutation(version: Int): Nothing = internal fun androidAccountCredentialSlotKey(accountId: NextcloudAccountId): String = "$ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX${accountId.storageKey}" +internal fun retainedAndroidAccountCredentialSlotKeys( + state: AndroidAccountCredentialState, +): Set = state.registry.accounts.mapTo(hashSetOf()) { account -> + androidAccountCredentialSlotKey(account.id) +} + internal fun readAndroidAccountCredentialSlot( accountId: NextcloudAccountId, readEncrypted: (String) -> String?, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionCredentialRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionCredentialRecoveryTest.kt index 104eabad5..40ddfc9d6 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionCredentialRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionCredentialRecoveryTest.kt @@ -6,6 +6,7 @@ import dev.obiente.nextcloudnative.app.accountRecord import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull class AndroidAccountSelectionCredentialRecoveryTest { @Test @@ -28,4 +29,31 @@ class AndroidAccountSelectionCredentialRecoveryTest { assertEquals(slots, selected.sessions) assertEquals("malformed-encrypted-aggregate", recovery.suspectEncrypted) } + + @Test + fun `recovered state preserves unreadable slots until their registry account is removed`() { + val available = NextcloudSession("https://one.example.test", "alice", "first-secret") + val unreadable = NextcloudSession("https://two.example.test", "bob", "second-secret") + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(unreadable.accountRecord()) + .upsertAndSelect(available.accountRecord()) + val recovered = assertNotNull( + reconstructAndroidAccountCredentialState(registry) { accountId -> + available.takeIf { accountId == available.accountId } + }, + ) + + assertNull(recovered.sessions[unreadable.accountId]) + assertEquals( + setOf( + androidAccountCredentialSlotKey(available.accountId), + androidAccountCredentialSlotKey(unreadable.accountId), + ), + retainedAndroidAccountCredentialSlotKeys(recovered), + ) + assertEquals( + setOf(androidAccountCredentialSlotKey(available.accountId)), + retainedAndroidAccountCredentialSlotKeys(recovered.remove(unreadable.accountId)), + ) + } } From 2253d2e5267e23881bd4f5b753f98e11046eca48 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 10:13:50 +0200 Subject: [PATCH 064/119] fix(android): quiesce account file cache reads --- .../nextcloudnative/AndroidAccountFileRead.kt | 18 +++++++ .../AndroidNextcloudServices.kt | 4 +- .../AndroidFileReadCacheTest.kt | 54 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt new file mode 100644 index 000000000..44d7eb88f --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt @@ -0,0 +1,18 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal suspend fun withRetainedAndroidAccountFileRead( + expectedSession: NextcloudSession, + resolveSession: suspend () -> NextcloudSession?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + read: suspend () -> Result, +): Result = withContext(Dispatchers.IO) { + guard.withExactAccountSession( + expectedSession = expectedSession, + resolveSession = resolveSession, + unavailable = { error("The account changed before the file read could finish.") }, + ) { read() } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 1d784171c..cd0460e47 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -1358,7 +1358,7 @@ internal class AndroidNextcloudServices( session: NextcloudSession, userId: String, path: String, - ): NextcloudFileListing = withContext(Dispatchers.IO) { + ): NextcloudFileListing = withRetainedAndroidAccountFileRead(session, { loadSession(session.accountId) }) read@{ val accountId = NextcloudDocumentIds.accountKey(session) try { val response = request( @@ -1377,7 +1377,7 @@ internal class AndroidNextcloudServices( } else { if (response.status >= 500) { fileReadCache.cachedListing(accountId, path)?.files?.let { - return@withContext NextcloudFileListing(it, NextcloudFileListingSource.Cache) + return@read NextcloudFileListing(it, NextcloudFileListingSource.Cache) } } throw NextcloudFileListingHttpException(response.status) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt index e95393e54..56d138995 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileReadCacheTest.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.NextcloudFile +import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.File import java.nio.file.Files import kotlin.test.Test @@ -8,6 +9,9 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking class AndroidFileReadCacheTest { @Test @@ -61,6 +65,56 @@ class AndroidFileReadCacheTest { assertEquals(listOf(bob), cache.cachedListing(ACCOUNT_B, "Notes")?.files) } + @Test + fun accountRemovalQuiescesListingCacheWritesBeforeCleanupCompletes() = runBlocking { + val root = Files.createTempDirectory("ncn-android-file-cache-removal-").toFile() + try { + val cache = AndroidFileReadCache(root) + val guard = AndroidAccountOperationGuard() + val session = NextcloudSession( + "https://cloud.example.test", + "alice", + "fixture-password", + ) + val accountId = NextcloudDocumentIds.accountKey(session) + val readStarted = CompletableDeferred() + val releaseRead = CompletableDeferred() + var currentSession: NextcloudSession? = session + var cleanupCompleted = false + val read = async { + withRetainedAndroidAccountFileRead(session, { currentSession }, guard) { + readStarted.complete(Unit) + releaseRead.await() + cache.storeListing(accountId, "Notes", listOf(file("Notes/a.md", "\"a\"")), 10) + } + } + readStarted.await() + + val prematureRemoval = runCatching { + withAndroidAccountRemovalLease(accountId, guard) { + currentSession = null + cache.clearAccount(accountId) + cleanupCompleted = true + } + } + assertTrue(prematureRemoval.isFailure) + assertFalse(cleanupCompleted) + + releaseRead.complete(Unit) + read.await() + withAndroidAccountRemovalLease(accountId, guard) { + currentSession = null + cache.clearAccount(accountId) + cleanupCompleted = true + } + + assertTrue(cleanupCompleted) + assertFalse(File(root, accountId).exists()) + } finally { + root.deleteRecursively() + } + } + @Test fun newestListingsWinBoundedMetadataQuota() = withCache( maximumListings = 3, From 4f13f4218138f6af0a1bc8d932c78874fce6743e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 10:13:18 +0200 Subject: [PATCH 065/119] fix(accounts): preserve desktop rollback selection --- .../DesktopAccountCredentialPersistence.kt | 16 ++++++++++ ...DesktopAccountCredentialPersistenceTest.kt | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 929ee7a81..128fa5872 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -104,6 +104,7 @@ internal class DesktopAccountCredentialPersistence( } catch (failure: Exception) { var credentialRollbackCompleted = false try { + markPendingCredentialSaveRollback() if (previousSecret == null) { secretStore.clear(secretReference) } else { @@ -385,6 +386,20 @@ internal class DesktopAccountCredentialPersistence( flushPreferences() } + private fun markPendingCredentialSaveRollback() { + val previousPhase = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_PHASE, null) + try { + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_PHASE, CREDENTIAL_SAVE_ROLLBACK) + flushPreferences() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + preferences.putOrRemove(KEY_PENDING_CREDENTIAL_SAVE_PHASE, previousPhase) + runCatching(flushPreferences) + throw failure + } + } + private fun clearPendingCredentialSave() { val server = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_SERVER, null) val login = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, null) @@ -659,6 +674,7 @@ internal class DesktopAccountCredentialPersistence( const val KEY_PENDING_CREDENTIAL_REMOVALS = "accountCredentialRemovals" const val CREDENTIAL_SAVE_PREPARED = "prepared" const val CREDENTIAL_SAVE_SECRET_WRITTEN = "secret-written" + const val CREDENTIAL_SAVE_ROLLBACK = "rollback" } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index bf54f598b..d2727bb5c 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -274,6 +274,35 @@ class DesktopAccountCredentialPersistenceTest { assertNull(preferences.get("accountCredentialSaveLogin", null)) } + @Test + fun crashDuringFailedReauthenticationRollbackDoesNotSelectTheInactiveAccount() = + withStore { preferences, secrets -> + val inactive = firstSession() + val active = secondSession() + var flushCount = 0 + var failFlushOnAttempt: Int? = null + val persistence = persistence(preferences, secrets) { + flushCount += 1 + if (flushCount == failFlushOnAttempt) error("synthetic registry flush failure") + preferences.flush() + } + persistence.saveSession(inactive) + persistence.saveSession(active) + failFlushOnAttempt = flushCount + 3 + secrets.crashSaveOnAttempt = secrets.saveCount + 2 + + assertFailsWith { + persistence.saveSession(inactive.copy(appPassword = "replacement-password")) + } + + assertEquals("rollback", preferences.get("accountCredentialSavePhase", null)) + failFlushOnAttempt = null + val restarted = persistence(preferences, secrets) + assertEquals(active, restarted.loadActiveSession()) + assertEquals(active.accountId, restarted.activeAccountId()) + assertNull(preferences.get("accountCredentialSavePhase", null)) + } + @Test fun canonicalEquivalentReauthenticationPreservesDesktopStorageIdentity() = withStore { preferences, secrets -> From aa4e460174e91c17edcf469c9fef673069c75277 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 10:17:27 +0200 Subject: [PATCH 066/119] fix(accounts): retry desktop removal cleanup --- .../app/DesktopAccountRemoval.kt | 13 +++ .../app/DesktopNextcloudServices.kt | 106 +++++++++--------- .../app/DesktopAccountCleanupRetryTest.kt | 41 +++++++ 3 files changed, 107 insertions(+), 53 deletions(-) create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRetryTest.kt diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 8983f0f63..51d4557ef 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -5,6 +5,7 @@ import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext @@ -345,6 +346,18 @@ internal suspend fun recoverDesktopBackgroundAccountSyncPairCleanups( } } +internal suspend fun retryDesktopAccountSyncPairCleanupsBounded( + maximumAttempts: Int = 3, + waitBeforeNextAttempt: suspend () -> Unit = { delay(1_000L) }, + retryPending: suspend () -> Boolean, +) { + require(maximumAttempts > 0) + repeat(maximumAttempts) { attempt -> + if (!retryPending()) return + if (attempt + 1 < maximumAttempts) waitBeforeNextAttempt() + } +} + internal fun desktopAccountSyncPairCleanupFailureDiagnostic(accountId: String, failure: Exception) = SupportDiagnosticEventDraft( severity = SupportDiagnosticSeverity.Error, 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 635e887fe..c71668606 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3839,45 +3839,40 @@ class DesktopNextcloudServices( clearIntakeIdentity = { supportIntake.setActiveAccountIdentity(null) }, ) } - clearDesktopActiveAccountBeforeSyncPairCleanup( - accountId, - accountSyncPairCleanupJournal, - ::desktopAccountOwnership, - { - commitDesktopAccountRemovalBeforeVirtualFileTeardown( - commitRemoval = { - var committedFailure = false - try { - sessionPublicationGuard.serialize { - check( - activeAccountId == null || removeDesktopAccountCredential( - preferences, - accountId, - credentialStillExists = { - accountCredentials.listAccounts().any { account -> - account.id == activeAccountId - } - }, - commitStatusObserved = { credentialRemovalStatus = it }, - finishCommittedRemoval = { committedFailure = true }, - ) { accountCredentials.removeAccount(activeAccountId) }, - ) - } - } catch (failure: Throwable) { - if (committedFailure) { - runCatching(finishCommittedRemoval) - .exceptionOrNull() - ?.let(failure::addSuppressed) + try { + clearDesktopActiveAccountBeforeSyncPairCleanup( + accountId, accountSyncPairCleanupJournal, ::desktopAccountOwnership, + { + commitDesktopAccountRemovalBeforeVirtualFileTeardown( + commitRemoval = { + var committedFailure = false + try { + sessionPublicationGuard.serialize { + check( + activeAccountId == null || removeDesktopAccountCredential( + preferences, accountId, + credentialStillExists = { + accountCredentials.listAccounts().any { it.id == activeAccountId } + }, + commitStatusObserved = { credentialRemovalStatus = it }, + finishCommittedRemoval = { committedFailure = true }, + ) { accountCredentials.removeAccount(activeAccountId) }, + ) + } + } catch (failure: Throwable) { + if (committedFailure) runCatching(finishCommittedRemoval) + .exceptionOrNull()?.let(failure::addSuppressed) + throw failure } - throw failure - } - }, - teardownVirtualFiles = finishCommittedRemoval, - ) - }, - ::removeDesktopAccountOwnedState, - ::recordSupportDiagnostic, - ) + }, + teardownVirtualFiles = finishCommittedRemoval, + ) + }, + ::removeDesktopAccountOwnedState, ::recordSupportDiagnostic, + ) + } finally { + if (cleared && accountId != null) schedulePendingAccountSyncPairCleanupRetry() + } } } } catch (failure: Throwable) { removalFailure = failure; throw failure } finally { @@ -3903,28 +3898,34 @@ class DesktopNextcloudServices( } } private suspend fun retryPendingAccountSyncPairCleanup(accountId: String) { - val cleanup = accountSyncPairCleanupJournal.pending() - .singleOrNull { pending -> pending.accountId == accountId } - if (cleanup != null) { + accountSyncPairCleanupJournal.pending().singleOrNull { it.accountId == accountId }?.let { cleanup -> retryDesktopAccountSyncPairCleanup( - cleanup = cleanup, - accountOwnership = ::desktopAccountOwnership, - removeSyncPairs = ::removeDesktopAccountOwnedState, - clearCleanup = accountSyncPairCleanupJournal::clear, + cleanup, ::desktopAccountOwnership, ::removeDesktopAccountOwnedState, accountSyncPairCleanupJournal::clear, ) } requireDesktopAccountActivationAllowed(accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) } - private suspend fun retryPendingAccountSyncPairCleanups() { + private suspend fun retryPendingAccountSyncPairCleanups() = retryPendingDesktopAccountSyncPairCleanups( - cleanupJournal = accountSyncPairCleanupJournal, - accountOwnership = ::desktopAccountOwnership, - removeSyncPairs = ::removeDesktopAccountOwnedState, - recordCleanupFailure = { accountId, failure -> + accountSyncPairCleanupJournal, ::desktopAccountOwnership, ::removeDesktopAccountOwnedState, + { accountId, failure -> recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(accountId, failure)) }, ) + + private fun schedulePendingAccountSyncPairCleanupRetry() = serviceScope.launch { + retryDesktopAccountSyncPairCleanupsBounded { + var pending = true + recoverDesktopBackgroundAccountSyncPairCleanups( + retry = { accountOperationGuard.serializeWhenSyncIdle { + retryPendingAccountSyncPairCleanups() + pending = accountSyncPairCleanupJournal.pending().isNotEmpty() + } }, + recordFailure = { recordSupportDiagnostic(desktopAccountSyncPairCleanupJournalFailureDiagnostic(it)) }, + ) + pending + } } private suspend fun removeDesktopAccountOwnedState(accountId: String) { @@ -3946,9 +3947,8 @@ class DesktopNextcloudServices( } } - private fun desktopAccountOwnership(accountId: String): DesktopAccountOwnership = sessionPublicationGuard.serialize { - accountCredentials.accountOwnership(accountId) - } + private fun desktopAccountOwnership(accountId: String): DesktopAccountOwnership = + sessionPublicationGuard.serialize { accountCredentials.accountOwnership(accountId) } override suspend fun loadDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRetryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRetryTest.kt new file mode 100644 index 000000000..43e1fbb11 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCleanupRetryTest.kt @@ -0,0 +1,41 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.runBlocking + +class DesktopAccountCleanupRetryTest { + @Test + fun retryStopsAsSoonAsTheCommittedCleanupIsFinished() = runBlocking { + var attempts = 0 + var waits = 0 + + retryDesktopAccountSyncPairCleanupsBounded( + maximumAttempts = 3, + waitBeforeNextAttempt = { waits += 1 }, + ) { + attempts += 1 + attempts < 2 + } + + assertEquals(2, attempts) + assertEquals(1, waits) + } + + @Test + fun retryStopsAtTheBoundAndLeavesDurableRecoveryToRestart() = runBlocking { + var attempts = 0 + var waits = 0 + + retryDesktopAccountSyncPairCleanupsBounded( + maximumAttempts = 3, + waitBeforeNextAttempt = { waits += 1 }, + ) { + attempts += 1 + true + } + + assertEquals(3, attempts) + assertEquals(2, waits) + } +} From 036511d5fe8d3dc2099104d56d52a4aef6a81c6b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 10:17:02 +0200 Subject: [PATCH 067/119] fix(desktop): remove accounts without loading secrets --- .../app/DesktopAccountRemoval.kt | 13 ++++++ .../app/DesktopNextcloudServices.kt | 5 +-- .../app/DesktopAccountRemovalSessionTest.kt | 43 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemovalSessionTest.kt diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 51d4557ef..27bb2e04c 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -135,6 +135,19 @@ internal fun requireDesktopAccountRemovalReady(accountId: String, linuxDesktop: } } +internal fun loadDesktopRemoteRevocationSession( + activeAccountId: NextcloudAccountId?, + expectedSession: NextcloudSession?, + loadSession: (NextcloudAccountId) -> NextcloudSession?, +): NextcloudSession? { + if (expectedSession == null) return null + val activeSession = activeAccountId?.let(loadSession) + check(activeSession == expectedSession) { + "The account changed before its remote session could be revoked." + } + return activeSession +} + internal fun removeDesktopAccountCredential( preferences: Preferences, providerAccountId: String?, 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 c71668606..e36614220 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3712,13 +3712,10 @@ class DesktopNextcloudServices( var removalFailure: Throwable? = null try { val activeAccountId = activeAccountId() - val activeSession = activeAccountId?.let(::loadSession) - check(expectedSession == null || activeSession == expectedSession) { - "The account changed before its remote session could be revoked." - } val activeRecord = activeAccountId?.let { id -> listAccounts().firstOrNull { account -> account.id == id } } + val activeSession = loadDesktopRemoteRevocationSession(activeAccountId, expectedSession, ::loadSession) val accountId = activeSession?.let(::desktopFileCacheAccountId) ?: activeRecord?.let(::desktopFileCacheAccountId) val syncJob = synchronized(this) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemovalSessionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemovalSessionTest.kt new file mode 100644 index 000000000..9bb9db759 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemovalSessionTest.kt @@ -0,0 +1,43 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class DesktopAccountRemovalSessionTest { + private val current = NextcloudSession("https://cloud.example.test", "alice", "current-secret") + private val accountId = current.accountId + + @Test + fun localRemovalDoesNotLoadTheRemoteCredential() { + assertNull( + loadDesktopRemoteRevocationSession(accountId, expectedSession = null) { + error("The unavailable secret store must not block local removal.") + }, + ) + } + + @Test + fun remoteRevocationFailsClosedWhenTheCredentialCannotBeLoaded() { + val failure = assertFailsWith { + loadDesktopRemoteRevocationSession(accountId, current) { + throw DesktopSecretStoreUnavailableException("Synthetic unavailable secret store.") + } + } + + assertEquals("Synthetic unavailable secret store.", failure.message) + } + + @Test + fun remoteRevocationRejectsAStaleCredential() { + assertFailsWith { + loadDesktopRemoteRevocationSession(accountId, current.copy(appPassword = "stale-secret")) { current } + } + } + + @Test + fun remoteRevocationUsesTheVerifiedCurrentCredential() { + assertEquals(current, loadDesktopRemoteRevocationSession(accountId, current) { current }) + } +} From 81e33d9890f0e79c0a85dab80a0e9b790aca4f94 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 10:17:02 +0200 Subject: [PATCH 068/119] fix(windows): purge removed Cloud Files roots --- .../app/DesktopWindowsCloudFilesCleanup.kt | 85 +++++++++++++++- .../app/WindowsUninstallCleanupTest.kt | 96 ++++++++++++++++++- 2 files changed, 178 insertions(+), 3 deletions(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt index 7ed1378b2..8e81178b7 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopWindowsCloudFilesCleanup.kt @@ -1,6 +1,8 @@ package dev.obiente.nextcloudnative.app import java.io.File +import java.nio.file.Files +import java.nio.file.LinkOption import java.nio.file.Path import java.util.prefs.Preferences @@ -15,6 +17,7 @@ internal fun windowsCloudFilesFailureAfterFallbackCleanup( internal const val KEY_WINDOWS_CLOUD_FILES_ROOT = "windows-cloud-files-root" internal const val KEY_WINDOWS_CLOUD_FILES_ROOT_PREFIX = "wcfr." internal const val WINDOWS_CLOUD_FILES_ROOT_SUFFIX = "-v2" +private const val WINDOWS_CLOUD_FILES_REMOVAL_RECOVERY_SUFFIX = "-removal-recovery" internal fun desktopWindowsCloudFilesRoot( accountId: String, @@ -119,18 +122,96 @@ internal fun unregisterWindowsCloudFilesRootsForAccountRemoval( val currentRoot = validatedWindowsCloudFilesRoot(desktopWindowsCloudFilesRoot(accountId, userHome), userHome) val legacyRoot = validatedWindowsCloudFilesRoot(desktopLegacyWindowsCloudFilesRoot(accountId, userHome), userHome) val roots = listOf(currentRoot, legacyRoot) + val recoveryRoot = windowsCloudFilesRemovalRecoveryRoot(accountId, userHome) + val existingRecoveryRoot = persistedWindowsCloudFilesPreservedRoot(preferences, accountId) + check(existingRecoveryRoot == null || existingRecoveryRoot == recoveryRoot) { + "Review and acknowledge the previous preserved Windows Cloud Files folder before removing this account." + } val api = apiFactory() var firstFailure: Throwable? = null try { roots.forEach { root -> runCatching { api.unregisterSyncRoot(root) } - .onSuccess { clearWindowsCloudFilesRootPreferences(preferences, accountId, root) } .onFailure { failure -> if (firstFailure == null) firstFailure = failure } } + firstFailure?.let { throw it } + removeOrPreserveWindowsCloudFilesRoots( + preferences = preferences, + accountId = accountId, + recoveryRoot = recoveryRoot, + roots = roots, + api = api, + ) + roots.forEach { root -> clearWindowsCloudFilesRootPreferences(preferences, accountId, root) } } finally { api.close() } - firstFailure?.let { throw it } +} + +private fun windowsCloudFilesRemovalRecoveryRoot(accountId: String, userHome: File): Path = + File(File(userHome, "Nextcloud Native"), accountId + WINDOWS_CLOUD_FILES_REMOVAL_RECOVERY_SUFFIX) + .toPath() + .toAbsolutePath() + .normalize() + +private fun removeOrPreserveWindowsCloudFilesRoots( + preferences: Preferences, + accountId: String, + recoveryRoot: Path, + roots: List, + api: WindowsCloudFilesApi, +) { + val stagedRoots = roots.mapIndexed { index, root -> root to recoveryRoot.resolve("root-$index") } + val recoveryRegistered = persistedWindowsCloudFilesPreservedRoot(preferences, accountId) == recoveryRoot + if (stagedRoots.none { (root, staged) -> + Files.exists(root, LinkOption.NOFOLLOW_LINKS) || Files.exists(staged, LinkOption.NOFOLLOW_LINKS) + } && !recoveryRegistered + ) return + if (Files.notExists(recoveryRoot, LinkOption.NOFOLLOW_LINKS)) { + Files.createDirectory(recoveryRoot) + try { + persistWindowsCloudFilesPreservedRoot(preferences, accountId, recoveryRoot) + } catch (failure: Throwable) { + runCatching { Files.deleteIfExists(recoveryRoot) }.exceptionOrNull()?.let(failure::addSuppressed) + throw failure + } + } else { + check(recoveryRegistered) { "The Windows Cloud Files removal recovery folder is not owned by this account." } + check(Files.isDirectory(recoveryRoot, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(recoveryRoot)) { + "The Windows Cloud Files removal recovery folder is invalid." + } + persistWindowsCloudFilesPreservedRoot(preferences, accountId, recoveryRoot) + } + stagedRoots.forEach { (root, staged) -> + val sourceExists = Files.exists(root, LinkOption.NOFOLLOW_LINKS) + val stagedExists = Files.exists(staged, LinkOption.NOFOLLOW_LINKS) + check(!sourceExists || !stagedExists) { "Windows Cloud Files removal found duplicate recovery roots." } + if (sourceExists) Files.move(root, staged) + if (Files.exists(staged, LinkOption.NOFOLLOW_LINKS) && windowsCloudFilesTreeIsDisposable(staged, api)) { + deleteWindowsCloudFilesTree(staged) + } + } + Files.list(recoveryRoot).use { entries -> + if (entries.findAny().isPresent) return + } + Files.delete(recoveryRoot) + acknowledgeWindowsCloudFilesPreservedRoot(preferences, accountId) +} + +private fun windowsCloudFilesTreeIsDisposable(root: Path, api: WindowsCloudFilesApi): Boolean = + runCatching { + Files.walk(root).use { entries -> + entries.filter { path -> path != root }.allMatch { path -> + !Files.isSymbolicLink(path) && + api.inspectPlaceholder(path).state == WindowsCloudPlaceholderEntryState.InSync + } + } + }.getOrDefault(false) + +private fun deleteWindowsCloudFilesTree(root: Path) { + Files.walk(root).use { entries -> + entries.sorted(Comparator.reverseOrder()).forEach(Files::delete) + } } internal fun validatedWindowsCloudFilesRoot(root: File, userHome: File): Path { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt index e8b7dcf70..ebbf839b1 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/WindowsUninstallCleanupTest.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class WindowsUninstallCleanupTest { @@ -319,6 +321,94 @@ class WindowsUninstallCleanupTest { } } + @Test + fun accountRemovalDeletesHydratedDataOnlyWhenEveryEntryIsInSync() { + val preferences = Preferences.userRoot().node("windows-account-data-removal-${UUID.randomUUID()}") + val home = Files.createTempDirectory("windows-account-data-removal-home").toFile() + val accountId = "d".repeat(64) + val currentRoot = desktopWindowsCloudFilesRoot(accountId, home).toPath() + val legacyRoot = desktopLegacyWindowsCloudFilesRoot(accountId, home).toPath() + val api = RecordingWindowsCloudFilesApi() + try { + Files.createDirectories(currentRoot.resolve("Documents")) + Files.writeString(currentRoot.resolve("Documents/private.txt"), "hydrated private bytes") + Files.createDirectories(legacyRoot) + Files.writeString(legacyRoot.resolve("legacy.txt"), "legacy hydrated bytes") + + unregisterWindowsCloudFilesRootsForAccountRemoval(preferences, accountId, home) { api } + + assertFalse(Files.exists(currentRoot)) + assertFalse(Files.exists(legacyRoot)) + assertNull(persistedWindowsCloudFilesPreservedRoot(preferences, accountId)) + } finally { + preferences.removeNode() + home.deleteRecursively() + } + } + + @Test + fun accountRemovalMovesUncommittedDataIntoTheExplicitRecoveryFolder() { + val preferences = Preferences.userRoot().node("windows-account-data-recovery-${UUID.randomUUID()}") + val home = Files.createTempDirectory("windows-account-data-recovery-home").toFile() + val accountId = "e".repeat(64) + val currentRoot = desktopWindowsCloudFilesRoot(accountId, home).toPath() + val legacyRoot = desktopLegacyWindowsCloudFilesRoot(accountId, home).toPath() + val api = RecordingWindowsCloudFilesApi().apply { + inspectEntry = { path -> + WindowsCloudPlaceholderInspection( + when (path.fileName.toString()) { + "dirty.txt" -> WindowsCloudPlaceholderEntryState.Dirty + "local.txt" -> WindowsCloudPlaceholderEntryState.Local + else -> WindowsCloudPlaceholderEntryState.InSync + }, + ) + } + } + try { + Files.createDirectories(currentRoot) + Files.writeString(currentRoot.resolve("dirty.txt"), "uncommitted edit") + Files.createDirectories(legacyRoot) + Files.writeString(legacyRoot.resolve("local.txt"), "local-only file") + + unregisterWindowsCloudFilesRootsForAccountRemoval(preferences, accountId, home) { api } + + val recoveryRoot = assertNotNull(persistedWindowsCloudFilesPreservedRoot(preferences, accountId)) + assertFalse(Files.exists(currentRoot)) + assertFalse(Files.exists(legacyRoot)) + assertEquals("uncommitted edit", Files.readString(recoveryRoot.resolve("root-0/dirty.txt"))) + assertEquals("local-only file", Files.readString(recoveryRoot.resolve("root-1/local.txt"))) + } finally { + preferences.removeNode() + home.deleteRecursively() + } + } + + @Test + fun accountRemovalRetryClearsAnEmptyRecoveryFolderAfterInterruptedCleanup() { + val preferences = Preferences.userRoot().node("windows-account-data-retry-${UUID.randomUUID()}") + val home = Files.createTempDirectory("windows-account-data-retry-home").toFile() + val accountId = "f".repeat(64) + val currentRoot = desktopWindowsCloudFilesRoot(accountId, home).toPath() + val api = RecordingWindowsCloudFilesApi().apply { + inspectEntry = { WindowsCloudPlaceholderInspection(WindowsCloudPlaceholderEntryState.Dirty) } + } + try { + Files.createDirectories(currentRoot) + Files.writeString(currentRoot.resolve("draft.txt"), "local draft") + unregisterWindowsCloudFilesRootsForAccountRemoval(preferences, accountId, home) { api } + val recoveryRoot = assertNotNull(persistedWindowsCloudFilesPreservedRoot(preferences, accountId)) + recoveryRoot.toFile().listFiles().orEmpty().forEach(File::deleteRecursively) + + unregisterWindowsCloudFilesRootsForAccountRemoval(preferences, accountId, home) { api } + + assertFalse(Files.exists(recoveryRoot)) + assertNull(persistedWindowsCloudFilesPreservedRoot(preferences, accountId)) + } finally { + preferences.removeNode() + home.deleteRecursively() + } + } + @Test fun partialAccountRootCleanupRemainsJournaledUntilEveryRootIsUnregistered() = runBlocking { val preferences = Preferences.userRoot().node("windows-account-cleanup-retry-${UUID.randomUUID()}") @@ -553,6 +643,9 @@ class WindowsUninstallCleanupTest { var prerequisiteRoot: Path? = null var dependentRoot: Path? = null var failingRoot: Path? = null + var inspectEntry: (Path) -> WindowsCloudPlaceholderInspection = { + WindowsCloudPlaceholderInspection(WindowsCloudPlaceholderEntryState.InSync) + } var closed = false override fun unregisterSyncRoot(root: Path) { @@ -578,7 +671,8 @@ class WindowsUninstallCleanupTest { override fun failPlaceholderFetch(info: WindowsCloudCallbackInfo) = unsupported() override fun acknowledgeDelete(info: WindowsCloudCallbackInfo, accepted: Boolean) = unsupported() override fun acknowledgeRename(info: WindowsCloudCallbackInfo, accepted: Boolean) = unsupported() - override fun placeholderState(path: Path): WindowsCloudPlaceholderState = unsupported() + override fun placeholderState(path: Path): WindowsCloudPlaceholderState = inspectEntry(path).placeholderState + override fun inspectPlaceholder(path: Path): WindowsCloudPlaceholderInspection = inspectEntry(path) override fun allocatedBytes(path: Path): Long = unsupported() override fun lastAccessedAtEpochMillis(path: Path): Long = unsupported() override fun isPinned(path: Path): Boolean = unsupported() From bc5a26ed040a9e79fae20b9a01104e9c30f6ca0d Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 11:04:15 +0200 Subject: [PATCH 069/119] fix(android): revoke handoffs across account transitions --- .../AndroidAccountCredentialController.kt | 9 +-- ...ndroidAccountRemovalCleanupRecoveryWork.kt | 27 ++++++++- .../AndroidExternalFileHandoffCleanup.kt | 59 +++++++++++++++++++ .../AndroidExternalFileHandoffRegistry.kt | 48 ++++++++++++--- .../AndroidExternalFileHandoffStore.kt | 10 +++- ...idAccountRemovalCleanupRecoveryWorkTest.kt | 25 ++++++++ .../AndroidExternalFileHandoffTest.kt | 44 ++++++++++---- .../AndroidPersistedSessionTest.kt | 9 +++ 8 files changed, 205 insertions(+), 26 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffCleanup.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index eb351179e..0897d9a0c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -32,6 +32,7 @@ internal class AndroidAccountCredentialController( private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String) -> Unit, ) { private val appContext = context.applicationContext + private val handoffCleanup = AndroidExternalFileHandoffCleanup(appContext, preferences, ::commitPreferences) private val accountRemovalCleanupJournal = AndroidAccountRemovalCleanupJournal( preferences = preferences, commit = ::commitPreferences, @@ -404,13 +405,13 @@ internal class AndroidAccountCredentialController( encodeNextcloudAccountRegistry(replacement.registry), ).let { editor -> prepareCredentialSlotEdit(editor, replacement) } } - commitPreferences(accountRemovalCleanupJournal.prepareEdit(editor, pendingCleanup)) + commitPreferences(handoffCleanup.prepare(accountRemovalCleanupJournal.prepareEdit(editor, pendingCleanup))) }, cancelAll = scheduler::cancelAll, clearPublishedAccount = { publishAccountIdentity(null) }, ) }, - clearHandoffs = AndroidExternalFileHandoffRegistry::clear, + clearHandoffs = handoffCleanup::complete, recordFailure = ::recordAccountHandoffCleanupFailure, ) } @@ -460,7 +461,7 @@ internal class AndroidAccountCredentialController( encodeNextcloudAccountRegistry(replacement.registry), ) } - commitPreferences(prepareCredentialSlotEdit(editor, replacement)) + commitPreferences(handoffCleanup.prepare(prepareCredentialSlotEdit(editor, replacement))) }, cancelAll = scheduler::cancelAll, publishAccount = publishAccountIdentity, @@ -474,7 +475,7 @@ internal class AndroidAccountCredentialController( }, ) }, - clearHandoffs = AndroidExternalFileHandoffRegistry::clear, + clearHandoffs = handoffCleanup::complete, recordFailure = ::recordAccountHandoffCleanupFailure, ) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt index bea5a9758..5b202ae24 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt @@ -20,7 +20,10 @@ internal fun installAndroidAccountRemovalCleanupRecovery( val appContext = context.applicationContext val preferences = appContext.getSharedPreferences(ANDROID_ACCOUNT_PREFERENCES, Context.MODE_PRIVATE) val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (key == ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY) { + if ( + key == ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY || + key == ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY + ) { AndroidAccountRemovalCleanupRecoveryWork.schedule(appContext, preferences) } } @@ -33,7 +36,10 @@ internal object AndroidAccountRemovalCleanupRecoveryWork { private const val UNIQUE_WORK = "nextcloud-native-account-removal-cleanup" fun schedule(context: Context, preferences: SharedPreferences) { - if (!preferences.contains(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY)) return + if ( + !preferences.contains(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY) && + !hasPendingAndroidExternalHandoffCleanup(preferences) + ) return val request = OneTimeWorkRequestBuilder() .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS) .build() @@ -61,6 +67,21 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( } }, recordMalformed = { Log.w(LOG_TAG, "Malformed account-removal cleanup journal retained") }, ) + val handoffCleanup = AndroidExternalFileHandoffCleanup( + context = applicationContext, + preferences = preferences, + commit = { editor -> ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { + requireCommittedAndroidAccountCredentialEdit(editor) + } }, + ) + val handoffCompleted = retryPendingAndroidExternalHandoffCleanup( + pending = handoffCleanup.pending(), + clearHandoffs = handoffCleanup::clearHandoffs, + clearJournal = handoffCleanup::clearJournal, + recordFailure = { + logAndroidAccountRemovalCleanupRecoveryDeferred { message -> Log.w(LOG_TAG, message) } + }, + ) val registry = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) ?.let(::restoreAndroidCredentialFreeRegistry) ?.registry @@ -86,7 +107,7 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( logAndroidAccountRemovalCleanupRecoveryDeferred { message -> Log.w(LOG_TAG, message) } }, ) - if (completed) Result.success() else Result.retry() + if (completed && handoffCompleted) Result.success() else Result.retry() } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffCleanup.kt new file mode 100644 index 000000000..2006cc860 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffCleanup.kt @@ -0,0 +1,59 @@ +package dev.obiente.nextcloudnative + +import android.content.SharedPreferences + +internal const val ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY = "pending_external_handoff_cleanup_v1" +private const val ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP = "pending" + +internal fun prepareAndroidExternalHandoffCleanup( + editor: SharedPreferences.Editor, +): SharedPreferences.Editor = editor.putString( + ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY, + ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP, +) + +internal class AndroidExternalFileHandoffCleanup( + context: android.content.Context, + private val preferences: SharedPreferences, + private val commit: (SharedPreferences.Editor) -> Unit, +) { + private val appContext = context.applicationContext + + fun prepare(editor: SharedPreferences.Editor): SharedPreferences.Editor = + prepareAndroidExternalHandoffCleanup(editor) + + fun pending(): Boolean = preferences.contains(ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY) + + fun complete() { + clearHandoffs() + clearJournal() + } + + fun clearHandoffs() { + AndroidExternalFileHandoffRegistry.clearPersisted(AndroidExternalFileHandoffStore(appContext)) + } + + fun clearJournal() { + commit(preferences.edit().remove(ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY)) + } +} + +internal fun hasPendingAndroidExternalHandoffCleanup(preferences: SharedPreferences): Boolean = + preferences.contains(ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY) + +internal fun retryPendingAndroidExternalHandoffCleanup( + pending: Boolean, + clearHandoffs: () -> Unit, + clearJournal: () -> Unit, + recordFailure: (Exception) -> Unit, +): Boolean { + if (!pending) return true + return try { + clearHandoffs() + clearJournal() + true + } catch (failure: Exception) { + recordFailure(failure) + false + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffRegistry.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffRegistry.kt index d4fc7b89a..528d896d0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffRegistry.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffRegistry.kt @@ -248,16 +248,43 @@ internal object AndroidExternalFileHandoffRegistry { } fun clear() { + clearWithStore(null) + } + + fun clearPersisted(store: AndroidExternalFileHandoffStore) { + clearWithStore(store) + } + + private fun clearWithStore(store: AndroidExternalFileHandoffStore?) { + var persistenceFailure: Exception? = null + var cleanupStore: AndroidExternalFileHandoffStore? = null val removed = synchronized(lock) { - if (entries.isEmpty()) { - persistLocked() - return@synchronized emptyList() + if (store != null) { + val storeIdentity = store.stateFile.absolutePath + check(boundStoreIdentity == null || boundStoreIdentity == storeIdentity) { + "External handoff cleanup targeted a different persistent store." + } + if (boundStore == null) { + boundStore = store + boundStoreIdentity = storeIdentity + } + } + cleanupStore = boundStore ?: store + entries.values.toList().also { entries.clear() }.also { + try { + cleanupStore?.save(emptyList()) + } catch (failure: Exception) { + persistenceFailure = failure + } } - boundStore?.save(emptyList()) - entries.values.toList().also { entries.clear() } } removed.flatMap(Entry::readers).forEach(AndroidExternalFileHandoffLease::revoke) - removed.forEach { entry -> deleteManagedContentBestEffort(entry.record) } + try { + cleanupStore?.deleteAllManagedContent() + } catch (failure: Exception) { + persistenceFailure?.addSuppressed(failure) ?: run { persistenceFailure = failure } + } + persistenceFailure?.let { throw it } } internal fun resetProcessStateForTests() { @@ -291,7 +318,14 @@ internal object AndroidExternalFileHandoffRegistry { } private fun deleteManagedContentBestEffort(record: AndroidExternalFileHandoffRecord) { - runCatching { boundStore?.deleteManagedContent(record.documentId) } + boundStore?.let { store -> deleteManagedContentBestEffort(store, record) } + } + + private fun deleteManagedContentBestEffort( + store: AndroidExternalFileHandoffStore, + record: AndroidExternalFileHandoffRecord, + ) { + runCatching { store.deleteManagedContent(record.documentId) } .onFailure { failure -> Log.w(LOG_TAG, "Could not clear managed external handoff content", failure) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffStore.kt index e80400f83..57d70e388 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffStore.kt @@ -22,6 +22,7 @@ internal class AndroidExternalFileHandoffStoreException(message: String, cause: internal class AndroidExternalFileHandoffStore( internal val stateFile: File, internal val managedContentRoot: File? = null, + private val deleteStateFile: (File) -> Boolean = File::delete, ) { constructor(context: Context) : this( File(context.applicationContext.noBackupFilesDir, STATE_DIRECTORY).resolve(STATE_FILE_NAME), @@ -64,7 +65,7 @@ internal class AndroidExternalFileHandoffStore( val parent = stateFile.parentFile ?: throw AndroidExternalFileHandoffStoreException("External handoff state has no parent directory.") if (records.isEmpty()) { - if (stateFile.exists() && !stateFile.delete()) { + if (stateFile.exists() && (!deleteStateFile(stateFile) || stateFile.exists())) { throw AndroidExternalFileHandoffStoreException("Could not clear external handoff state.") } return @@ -105,6 +106,13 @@ internal class AndroidExternalFileHandoffStore( } } + fun deleteAllManagedContent() { + val root = managedContentRoot ?: return + if (root.exists() && (!root.deleteRecursively() || root.exists())) { + throw AndroidExternalFileHandoffStoreException("Could not clear managed external handoff content.") + } + } + private fun DataOutputStream.writeRecord(record: AndroidExternalFileHandoffRecord) { writeBoundedString(record.documentId, MAX_DOCUMENT_ID_BYTES) writeBoundedString(record.accountId, MAX_ACCOUNT_ID_BYTES) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt index 4bffeee0a..7a4bb7c9c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt @@ -120,6 +120,31 @@ class AndroidAccountRemovalCleanupRecoveryWorkTest { assertFalse(messages.single().contains("private/path/account-secret")) } + @Test + fun handoffCleanupJournalIsClearedOnlyAfterDurableCleanupSucceeds() { + val events = mutableListOf() + + val firstCompleted = retryPendingAndroidExternalHandoffCleanup( + pending = true, + clearHandoffs = { + events += "clear-handoffs" + error("synthetic persistence failure") + }, + clearJournal = { events += "clear-journal" }, + recordFailure = { events += "failure" }, + ) + val retryCompleted = retryPendingAndroidExternalHandoffCleanup( + pending = true, + clearHandoffs = { events += "retry-handoffs" }, + clearJournal = { events += "clear-journal" }, + recordFailure = { events += "failure" }, + ) + + assertFalse(firstCompleted) + assertTrue(retryCompleted) + assertEquals(listOf("clear-handoffs", "failure", "retry-handoffs", "clear-journal"), events) + } + @Test fun cleanupJournalReadCancellationIsPropagated() { var recorded = false diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffTest.kt index 9a8aed2ff..ebcc8ec40 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidExternalFileHandoffTest.kt @@ -322,10 +322,12 @@ class AndroidExternalFileHandoffTest { } @Test - fun `durable clear failure preserves live handoff authority and reports failure`() { + fun `durable clear failure revokes readers and retry prevents restart restoration`() { val root = Files.createTempDirectory("nextcloud-handoff-clear-test-").toFile() - val store = AndroidExternalFileHandoffStore(root.resolve("records.bin")) + val stateFile = root.resolve("records.bin") + val store = AndroidExternalFileHandoffStore(stateFile, deleteStateFile = { false }) val session = NextcloudSession("https://cloud.example.test", "person", "secret") + var cleanupPending = true AndroidExternalFileHandoffRegistry.resetProcessStateForTests() try { AndroidExternalFileHandoffRegistry.bind(store, nowEpochMillis = 10L) @@ -334,17 +336,37 @@ class AndroidExternalFileHandoffTest { handoffFile(size = 4L), nowEpochMillis = 10L, ) - assertTrue(store.stateFile.delete()) - assertTrue(store.stateFile.mkdir()) - store.stateFile.resolve("blocker").writeText("keep directory non-empty") + val lease = requireNotNull(AndroidExternalFileHandoffRegistry.acquire(record.documentId, session, 11L)) + var revoked = false + lease.onRevoked { revoked = true } - assertFailsWith { - AndroidExternalFileHandoffRegistry.clear() - } - assertEquals( - record, - AndroidExternalFileHandoffRegistry.peek(record.documentId, session, nowEpochMillis = 11L), + assertFalse( + retryPendingAndroidExternalHandoffCleanup( + pending = cleanupPending, + clearHandoffs = AndroidExternalFileHandoffRegistry::clear, + clearJournal = { cleanupPending = false }, + recordFailure = {}, + ), + ) + assertTrue(revoked) + assertFalse(lease.isValid()) + assertEquals(null, AndroidExternalFileHandoffRegistry.peek(record.documentId, session, 11L)) + assertTrue(stateFile.isFile) + + AndroidExternalFileHandoffRegistry.resetProcessStateForTests() + val restartedStore = AndroidExternalFileHandoffStore(stateFile) + assertTrue( + retryPendingAndroidExternalHandoffCleanup( + pending = cleanupPending, + clearHandoffs = { AndroidExternalFileHandoffRegistry.clearPersisted(restartedStore) }, + clearJournal = { cleanupPending = false }, + recordFailure = {}, + ), ) + AndroidExternalFileHandoffRegistry.bind(restartedStore, nowEpochMillis = 11L) + assertFalse(cleanupPending) + assertEquals(null, AndroidExternalFileHandoffRegistry.peek(record.documentId, session, 11L)) + assertFalse(stateFile.exists()) } finally { AndroidExternalFileHandoffRegistry.resetProcessStateForTests() root.deleteRecursively() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index bfc690294..62c43adca 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -859,6 +859,15 @@ class AndroidPersistedSessionTest { assertEquals(listOf("commit-transition"), events) } + @Test + fun accountTransitionPersistsHandoffCleanupBeforeItCanCommit() { + val writes = linkedMapOf() + + prepareAndroidExternalHandoffCleanup(recoveryRecordingEditor(writes, linkedSetOf())) + + assertEquals("pending", writes[ANDROID_PENDING_EXTERNAL_HANDOFF_CLEANUP_KEY]) + } + @Test fun handoffCleanupFailureDoesNotHideACommittedAccountTransition() { val events = mutableListOf() From b2b1f8342687cec0138b0980eabc8cc1e8e84583 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 11:03:17 +0200 Subject: [PATCH 070/119] fix(accounts): retain preview cleanup recovery --- .../AndroidAccountCredentialController.kt | 25 +---- .../AndroidAccountCredentialRecovery.kt | 24 +++- .../AndroidAccountOwnedStateCleanup.kt | 28 ++++- ...ndroidAccountRemovalCleanupRecoveryWork.kt | 9 +- .../AndroidAccountSelectionMaintenance.kt | 12 -- .../AndroidNextcloudServices.kt | 8 +- ...ndroidAccountPreviewCleanupRecoveryTest.kt | 103 ++++++++++++++++++ .../AndroidPersistedSessionTest.kt | 16 --- 8 files changed, 163 insertions(+), 62 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 0897d9a0c..c600f68cf 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -29,7 +29,7 @@ internal class AndroidAccountCredentialController( private val prepareAccountRemoval: suspend (NextcloudSession) -> Unit, private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String) -> Unit, - private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String) -> Unit, + private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String, String?) -> Unit, ) { private val appContext = context.applicationContext private val handoffCleanup = AndroidExternalFileHandoffCleanup(appContext, preferences, ::commitPreferences) @@ -177,9 +177,6 @@ internal class AndroidAccountCredentialController( completeCommittedCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) - if (!active) { - clearRemovedAccountPreview(session) - } } true } @@ -197,7 +194,9 @@ internal class AndroidAccountCredentialController( removeUnavailableAndroidAccountCredentialData( accountIdentity = accountIdentity, prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, - removeAccountOwnedWorkWithoutCredentials = retryQueuedUploadsCleanupWithoutCredentials, + removeAccountOwnedWorkWithoutCredentials = { identity -> + retryQueuedUploadsCleanupWithoutCredentials(identity, pendingCleanup.previewCacheIdentity) + }, persistRemoval = { persistState(recovered.remove(accountId), pendingCleanup) }, rollbackRemoval = { rollbackUnavailableAndroidAccountRemoval( @@ -209,7 +208,6 @@ internal class AndroidAccountCredentialController( recordCommittedCleanupFailure = ::recordAccountRemovalCleanupFailure, ) } - clearRemovedAccountPreview(unavailableSession) notifyDocumentRootsChanged() return true } @@ -307,7 +305,6 @@ internal class AndroidAccountCredentialController( state.registry.accounts.isEmpty() && state.sessions.isEmpty() }?.let(::encryptState) clearPersistedSession(encodedReplacement, replacement, pendingCleanup = pendingCleanup) - clearRemovedAccountPreview(activeSession) notifyDocumentRootsChanged() } @@ -359,7 +356,6 @@ internal class AndroidAccountCredentialController( suspectEncrypted: String, pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, ) { - val activeSession = current.activeSession val replacement = removeActiveAndroidAccountCredentialState(current) val encodedReplacement = replacement.takeUnless { state -> state.registry.accounts.isEmpty() && state.sessions.isEmpty() @@ -370,7 +366,6 @@ internal class AndroidAccountCredentialController( suspectEncrypted, pendingCleanup, ) - activeSession?.let(::clearRemovedAccountPreview) notifyDocumentRootsChanged() } @@ -777,18 +772,6 @@ internal class AndroidAccountCredentialController( operation = "account-selection.cache-cleanup", component = SupportDiagnosticComponent.Cache, ) - private fun clearRemovedAccountPreview(session: NextcloudSession) = - clearAndroidPreviewAfterCommittedRemoval( - NextcloudDocumentIds.cacheAccountId(session), - clearPreviewAccount, - ::recordAccountRemovalCacheCleanupFailure, - ) - private fun recordAccountRemovalCacheCleanupFailure(failure: Exception) = recordCredentialFailure( - code = "ACCOUNT_REMOVAL_CACHE_CLEANUP_FAILED", - operation = "account.remove-cache-cleanup", - component = SupportDiagnosticComponent.Cache, - failure = failure, - ) private fun recordAccountHandoffCleanupFailure(failure: Exception) = recordCredentialFailure( code = "ACCOUNT_HANDOFF_CLEANUP_FAILED", operation = "account.handoff-cleanup", diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index e6420bb3d..7e719226b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -58,10 +58,15 @@ internal fun requireValidAndroidAccountCredentialState( internal data class AndroidPendingAccountRemovalCleanup( val accountStorageKey: String, val workIdentity: String, + val previewCacheIdentity: String? = null, ) { init { require(ACCOUNT_STORAGE_KEY_PATTERN.matches(accountStorageKey)) require(WORK_IDENTITY_PATTERN.matches(workIdentity)) + previewCacheIdentity?.let { identity -> + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(identity)) + require(identity.startsWith(workIdentity)) + } } } @@ -98,18 +103,29 @@ internal fun pendingAndroidAccountRemovalCleanup( ): AndroidPendingAccountRemovalCleanup = AndroidPendingAccountRemovalCleanup( accountStorageKey = session.accountId.storageKey, workIdentity = NextcloudDocumentIds.accountKey(session), + previewCacheIdentity = NextcloudDocumentIds.cacheAccountId(session), ) internal fun encodeAndroidPendingAccountRemovalCleanup( cleanup: AndroidPendingAccountRemovalCleanup, -): String = "${cleanup.accountStorageKey}:${cleanup.workIdentity}" +): String = listOfNotNull( + cleanup.accountStorageKey, + cleanup.workIdentity, + cleanup.previewCacheIdentity, +).joinToString(":") internal fun decodeAndroidPendingAccountRemovalCleanup( encoded: String, ): AndroidPendingAccountRemovalCleanup? { - val accountStorageKey = encoded.substringBefore(':', missingDelimiterValue = "") - val workIdentity = encoded.substringAfter(':', missingDelimiterValue = "") - return runCatching { AndroidPendingAccountRemovalCleanup(accountStorageKey, workIdentity) }.getOrNull() + val fields = encoded.split(':') + if (fields.size !in 2..3) return null + return runCatching { + AndroidPendingAccountRemovalCleanup( + accountStorageKey = fields[0], + workIdentity = fields[1], + previewCacheIdentity = fields.getOrNull(2), + ) + }.getOrNull() } internal data class RestoredAndroidPendingAccountRemovalCleanups( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index 7a01dddaf..56892c795 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -10,6 +10,9 @@ internal class AndroidAccountOwnedStateCleanup( File(context.applicationContext.cacheDir, "files-read-v1"), ), private val virtualFileCache: AndroidVirtualFileCache = AndroidVirtualFileCache(context.applicationContext), + private val clearPreviewAccount: (String) -> Unit = AndroidNativeMediaPreviewCache( + File(context.applicationContext.cacheDir, "native-media-previews-v1"), + )::clearAccount, ) { private val appContext = context.applicationContext private val fileOffline = AndroidFileOfflineAccountCleanup(appContext) @@ -18,7 +21,9 @@ internal class AndroidAccountOwnedStateCleanup( suspend fun remove(session: NextcloudSession) { val accountIdentity = NextcloudDocumentIds.accountKey(session) - runAndroidAccountRemovalCleanups( + runAndroidAccountOwnedStateCleanups( + NextcloudDocumentIds.cacheAccountId(session), + clearPreviewAccount, listOf( { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, @@ -32,7 +37,9 @@ internal class AndroidAccountOwnedStateCleanup( } suspend fun retry(session: NextcloudSession, accountIdentity: String) { - runAndroidAccountRemovalCleanups( + runAndroidAccountOwnedStateCleanups( + NextcloudDocumentIds.cacheAccountId(session), + clearPreviewAccount, listOf( { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, @@ -45,8 +52,10 @@ internal class AndroidAccountOwnedStateCleanup( ) } - suspend fun retryWithoutCredentials(accountIdentity: String) { - runAndroidAccountRemovalCleanups( + suspend fun retryWithoutCredentials(accountIdentity: String, previewCacheIdentity: String? = null) { + runAndroidAccountOwnedStateCleanups( + previewCacheIdentity, + clearPreviewAccount, listOf( { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, @@ -59,3 +68,14 @@ internal class AndroidAccountOwnedStateCleanup( ) } } + +internal suspend fun runAndroidAccountOwnedStateCleanups( + previewCacheIdentity: String?, + clearPreviewAccount: (String) -> Unit, + cleanups: List Unit>, +) { + val previewCleanup: suspend () -> Unit = { + previewCacheIdentity?.let(clearPreviewAccount) + } + runAndroidAccountRemovalCleanups(cleanups + previewCleanup) +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt index 5b202ae24..355a1b205 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt @@ -10,6 +10,7 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import dev.obiente.nextcloudnative.app.NextcloudAccountRecord +import dev.obiente.nextcloudnative.app.NextcloudSession import java.util.concurrent.TimeUnit import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.withLock @@ -99,7 +100,7 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( }, removeAccountOwnedWork = { pending -> ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(pending.workIdentity) { - cleanup.retryWithoutCredentials(pending.workIdentity) + cleanup.retryWithoutCredentials(pending.workIdentity, pending.previewCacheIdentity) } }, clearCleanup = journal::clear, @@ -149,9 +150,15 @@ internal fun androidAccountRemovalCleanupOwnedByRegistry( storageOwner.serverUrl, storageOwner.loginName, ) + val storageOwnerPreviewIdentity = NextcloudDocumentIds.cacheAccountId( + NextcloudSession(storageOwner.serverUrl, storageOwner.loginName, appPassword = ""), + ) check(storageOwnerWorkIdentity == cleanup.workIdentity) { "The account-removal cleanup identities do not match." } + check(cleanup.previewCacheIdentity == null || storageOwnerPreviewIdentity == cleanup.previewCacheIdentity) { + "The account-removal preview identity does not match." + } return true } check(retainedAccounts.none { account -> diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt index e23767e01..1f68158ce 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt @@ -28,15 +28,3 @@ internal fun clearAndroidPreviousPreviewAfterCommittedSelection( runCatching { recordFailure(failure) } } } - -internal fun clearAndroidPreviewAfterCommittedRemoval( - accountCacheId: String, - clearPreviewAccount: (String) -> Unit, - recordFailure: (Exception) -> Unit, -) { - try { - clearPreviewAccount(accountCacheId) - } catch (failure: Exception) { - runCatching { recordFailure(failure) } - } -} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index cd0460e47..fc0874cf4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -451,11 +451,11 @@ internal class AndroidNextcloudServices( private val fileOfflineRepository = AndroidFileOfflineRepository(appContext) private val fileReadCache = AndroidFileReadCache(File(appContext.cacheDir, "files-read-v1")) private val virtualFileCache = AndroidVirtualFileCache(appContext) - private val accountOwnedStateCleanup = AndroidAccountOwnedStateCleanup(appContext, fileReadCache, virtualFileCache) + private val nativeMediaPreviewCache = + AndroidNativeMediaPreviewCache(File(appContext.cacheDir, "native-media-previews-v1")) + private val accountOwnedStateCleanup = + AndroidAccountOwnedStateCleanup(appContext, fileReadCache, virtualFileCache, nativeMediaPreviewCache::clearAccount) private val dynamicApiReadCache = DynamicApiResponseCache(File(appContext.cacheDir, "dynamic-api-v1")) - private val nativeMediaPreviewCache = AndroidNativeMediaPreviewCache( - File(appContext.cacheDir, "native-media-previews-v1"), - ) private val nativeMediaPreviewDecodeMutex = Mutex() private val dynamicApiRequestCoalescer = DynamicApiRequestCoalescer() private val mediaTimelineCarryoverStore = MediaTimelineDavCarryoverStore() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt new file mode 100644 index 000000000..2e6d6c476 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt @@ -0,0 +1,103 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class AndroidAccountPreviewCleanupRecoveryTest { + @Test + fun cleanupTombstoneCarriesThePathConfinedPreviewIdentityAndReadsLegacyEntries() { + val pending = pendingAndroidAccountRemovalCleanup(session()) + + assertEquals(64, requireNotNull(pending.previewCacheIdentity).length) + assertTrue(pending.previewCacheIdentity.startsWith(pending.workIdentity)) + assertEquals(pending, decodeAndroidPendingAccountRemovalCleanup(encodeAndroidPendingAccountRemovalCleanup(pending))) + assertNull( + decodeAndroidPendingAccountRemovalCleanup( + "${pending.accountStorageKey}:${pending.workIdentity}", + )?.previewCacheIdentity, + ) + assertFailsWith { + pending.copy(previewCacheIdentity = "f".repeat(64)) + } + } + + @Test + fun previewDeletionFailureRetainsCommittedCleanupUntilARecoverySucceeds() = runBlocking { + val pending = pendingAndroidAccountRemovalCleanup(session()) + var previewAttempts = 0 + var otherCleanupAttempts = 0 + var cleanupMarkerClears = 0 + var diagnosed = 0 + var failPreviewDeletion = true + suspend fun removeAccountOwnedState() { + runAndroidAccountOwnedStateCleanups( + previewCacheIdentity = pending.previewCacheIdentity, + clearPreviewAccount = { + previewAttempts += 1 + if (failPreviewDeletion) error("synthetic preview deletion failure") + }, + cleanups = listOf({ otherCleanupAttempts += 1 }), + ) + } + + removeAndroidAccountCredentialData( + active = false, + removeQueuedUploads = { removeAccountOwnedState() }, + clearActiveAccount = {}, + rollbackActiveRemoval = {}, + persistInactiveRemoval = {}, + rollbackInactiveRemoval = {}, + completeCommittedCleanup = { cleanupMarkerClears += 1 }, + recordCommittedCleanupFailure = { diagnosed += 1 }, + ) + + assertEquals(0, cleanupMarkerClears) + assertEquals(1, diagnosed) + failPreviewDeletion = false + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = false, + removeAccountOwnedWork = { removeAccountOwnedState() }, + clearCleanup = { cleanupMarkerClears += 1 }, + ) + assertEquals(1, cleanupMarkerClears) + assertEquals(2, previewAttempts) + assertEquals(2, otherCleanupAttempts) + } + + @Test + fun journaledPreviewDeletionIsPathConfinedAndIdempotent() = runBlocking { + val root = Files.createTempDirectory("android-preview-account-cleanup-").toFile() + try { + val pending = pendingAndroidAccountRemovalCleanup(session()) + val previewIdentity = requireNotNull(pending.previewCacheIdentity) + val cache = AndroidNativeMediaPreviewCache(root, maximumBytes = 1_024L) + val key = NativeMediaPreviewCacheKey(previewIdentity, 1L, "etag", 64, "decoder-v1") + assertTrue(cache.store(key, byteArrayOf(1), cache.accountGeneration(previewIdentity))) + + repeat(2) { + runAndroidAccountOwnedStateCleanups( + previewCacheIdentity = previewIdentity, + clearPreviewAccount = cache::clearAccount, + cleanups = emptyList(), + ) + } + + assertNull(cache.load(key)) + assertFailsWith { cache.clearAccount("../outside") } + } finally { + root.deleteRecursively() + } + } + + private fun session() = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "preview-user", + appPassword = "fixture-password", + ) +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 62c43adca..3bcf5ca3d 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -825,22 +825,6 @@ class AndroidPersistedSessionTest { assertEquals(listOf("clear-preview", "diagnose-cleanup"), events) } - @Test - fun previewCleanupFailureDoesNotRollBackACommittedAccountRemoval() { - val events = mutableListOf() - - clearAndroidPreviewAfterCommittedRemoval( - accountCacheId = "account-cache-id", - clearPreviewAccount = { - events += "clear-preview:$it" - error("synthetic preview cleanup failure") - }, - recordFailure = { events += "diagnose-cleanup" }, - ) - - assertEquals(listOf("clear-preview:account-cache-id", "diagnose-cleanup"), events) - } - @Test fun failedAccountTransitionDoesNotClearExternalHandoffs() { val events = mutableListOf() From 90659610a8faf008405aff2aba5075957c1a922a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 11:07:45 +0200 Subject: [PATCH 071/119] test(accounts): fix preview cleanup fixtures --- .../AndroidAccountPreviewCleanupRecoveryTest.kt | 6 +++--- .../AndroidAccountRemovalCleanupRecoveryWorkTest.kt | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt index 2e6d6c476..b082dd473 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt @@ -22,9 +22,8 @@ class AndroidAccountPreviewCleanupRecoveryTest { "${pending.accountStorageKey}:${pending.workIdentity}", )?.previewCacheIdentity, ) - assertFailsWith { - pending.copy(previewCacheIdentity = "f".repeat(64)) - } + val mismatchedIdentity = if (pending.workIdentity.first() == 'f') "e".repeat(64) else "f".repeat(64) + assertFailsWith { pending.copy(previewCacheIdentity = mismatchedIdentity) } } @Test @@ -93,6 +92,7 @@ class AndroidAccountPreviewCleanupRecoveryTest { } finally { root.deleteRecursively() } + Unit } private fun session() = NextcloudSession( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt index 7a4bb7c9c..7a85a9740 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt @@ -171,8 +171,10 @@ class AndroidAccountRemovalCleanupRecoveryWorkTest { loginName = "removed-user", appPassword = "fixture-password", ) + val retainedIdentity = pendingAndroidAccountRemovalCleanup(retained) val crossed = pendingAndroidAccountRemovalCleanup(removed).copy( - workIdentity = NextcloudDocumentIds.accountKey(retained), + workIdentity = retainedIdentity.workIdentity, + previewCacheIdentity = retainedIdentity.previewCacheIdentity, ) val events = mutableListOf() From 1b0a94008e32551f9a04774d18cfedc7f486d54a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 11:00:59 +0200 Subject: [PATCH 072/119] fix(accounts): clear unreadable active ownership --- .../AndroidAccountCredentialController.kt | 10 +++--- .../AndroidAccountCredentialRecovery.kt | 14 ++++++++ .../AndroidAccountCredentialTransitions.kt | 8 +++-- .../AndroidAccountRemovalRecoveryTest.kt | 35 +++++++++++++++++++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index c600f68cf..5c79c5f96 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -185,19 +185,20 @@ internal class AndroidAccountCredentialController( accountId: NextcloudAccountId, recovered: AndroidAccountCredentialState, ): Boolean { - val record = readCredentialFreeRegistry()?.accounts?.firstOrNull { account -> account.id == accountId } - ?: return false - val unavailableSession = NextcloudSession(record.serverUrl, record.loginName, appPassword = "") + val target = resolveAndroidUnavailableAccountRemovalTarget(readCredentialFreeRegistry(), accountId) ?: return false + val unavailableSession = NextcloudSession(target.record.serverUrl, target.record.loginName, appPassword = "") val accountIdentity = NextcloudDocumentIds.accountKey(unavailableSession) val pendingCleanup = pendingAndroidAccountRemovalCleanup(unavailableSession) withAndroidAccountRemovalLease(accountIdentity) { removeUnavailableAndroidAccountCredentialData( accountIdentity = accountIdentity, + active = target.wasActive, prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, removeAccountOwnedWorkWithoutCredentials = { identity -> retryQueuedUploadsCleanupWithoutCredentials(identity, pendingCleanup.previewCacheIdentity) }, persistRemoval = { persistState(recovered.remove(accountId), pendingCleanup) }, + clearActiveAccount = { clearSession(recovered, pendingCleanup, unavailableSession) }, rollbackRemoval = { rollbackUnavailableAndroidAccountRemoval( recovered = recovered, persistRecovered = { state -> persistState(state) }, @@ -298,8 +299,9 @@ internal class AndroidAccountCredentialController( private suspend fun clearSession( current: AndroidAccountCredentialState, pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, + activeFallback: NextcloudSession? = null, ) { - val activeSession = current.activeSession ?: return + val activeSession = current.activeSession ?: activeFallback ?: return val replacement = current.remove(activeSession.accountId) val encodedReplacement = replacement.takeUnless { state -> state.registry.accounts.isEmpty() && state.sessions.isEmpty() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index 7e719226b..853ff301e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -194,6 +194,20 @@ internal fun reconstructAndroidAccountCredentialStateForRemoval( return reconstructAndroidAccountCredentialState(recoverableRegistry, loadSession) } +internal data class AndroidUnavailableAccountRemovalTarget( + val record: NextcloudAccountRecord, + val wasActive: Boolean, +) + +internal fun resolveAndroidUnavailableAccountRemovalTarget( + registry: NextcloudAccountRegistry?, + accountId: NextcloudAccountId, +): AndroidUnavailableAccountRemovalTarget? { + val available = registry ?: return null + val record = available.accounts.firstOrNull { account -> account.id == accountId } ?: return null + return AndroidUnavailableAccountRemovalTarget(record, available.activeAccountId == accountId) +} + internal fun restoreAndroidAccountCredentialStateWithoutAggregate( encodedRegistry: String?, loadSession: (NextcloudAccountId) -> NextcloudSession?, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index 184b49200..993be98e4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -101,20 +101,22 @@ internal suspend fun removeAndroidAccountCredentialData( internal suspend fun removeUnavailableAndroidAccountCredentialData( accountIdentity: String, + active: Boolean = false, prepareAccountRemoval: suspend () -> Unit, removeAccountOwnedWorkWithoutCredentials: suspend (String) -> Unit, persistRemoval: suspend () -> Unit, + clearActiveAccount: suspend () -> Unit = persistRemoval, rollbackRemoval: suspend () -> Unit, completeCommittedCleanup: suspend () -> Unit = {}, recordCommittedCleanupFailure: (Exception) -> Unit = {}, ) { require(accountIdentity.isNotBlank()) removeAndroidAccountCredentialData( - active = false, + active = active, prepareAccountRemoval = prepareAccountRemoval, removeQueuedUploads = { removeAccountOwnedWorkWithoutCredentials(accountIdentity) }, - clearActiveAccount = {}, - rollbackActiveRemoval = {}, + clearActiveAccount = clearActiveAccount, + rollbackActiveRemoval = rollbackRemoval, persistInactiveRemoval = persistRemoval, rollbackInactiveRemoval = rollbackRemoval, completeCommittedCleanup = completeCommittedCleanup, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt index b73cc4627..2b352cdd0 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt @@ -1,6 +1,9 @@ package dev.obiente.nextcloudnative import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord import java.lang.reflect.Proxy import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking @@ -10,6 +13,38 @@ import kotlin.test.assertFailsWith import kotlin.test.assertTrue class AndroidAccountRemovalRecoveryTest { + @Test + fun unavailableActiveCredentialRemovalUsesActiveTeardown() = runBlocking { + val events = mutableListOf() + + removeUnavailableAndroidAccountCredentialData( + accountIdentity = "account-identity", + active = true, + prepareAccountRemoval = { events += "prepare" }, + removeAccountOwnedWorkWithoutCredentials = { events += "remove:$it" }, + persistRemoval = { events += "persist-inactive" }, + clearActiveAccount = { events += "clear-active" }, + rollbackRemoval = { events += "rollback" }, + completeCommittedCleanup = { events += "clear-cleanup" }, + ) + + assertEquals( + listOf("prepare", "clear-active", "remove:account-identity", "clear-cleanup"), + events, + ) + } + + @Test + fun unavailableRemovalTargetPreservesCredentialFreeActiveOwnership() { + val session = NextcloudSession("https://cloud.example.test", "alice", "unused-secret") + val registry = NextcloudAccountRegistry.Empty.upsertAndSelect(session.accountRecord()) + + val target = requireNotNull(resolveAndroidUnavailableAccountRemovalTarget(registry, session.accountId)) + + assertEquals(session.accountRecord(), target.record) + assertTrue(target.wasActive) + } + @Test fun unavailableCredentialRemovalCleansCommittedStateByIdentity() = runBlocking { val events = mutableListOf() From 56d4800b07ec5e59303e56e28a8c8b12f92d7490 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 11:05:14 +0200 Subject: [PATCH 073/119] fix(accounts): preserve unreadable removal rollback --- .../AndroidAccountCredentialController.kt | 10 +++--- .../AndroidAccountCredentialRecovery.kt | 15 +++++++++ .../AndroidAccountCredentialTransitions.kt | 3 +- .../AndroidFileSyncScheduler.kt | 8 ++--- .../AndroidAccountRemovalRecoveryTest.kt | 31 +++++++++++++++++++ .../AndroidFileSyncEngineInvariantTest.kt | 23 ++++++++++++++ 6 files changed, 79 insertions(+), 11 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 5c79c5f96..645a1d443 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -201,7 +201,7 @@ internal class AndroidAccountCredentialController( clearActiveAccount = { clearSession(recovered, pendingCleanup, unavailableSession) }, rollbackRemoval = { rollbackUnavailableAndroidAccountRemoval( - recovered = recovered, persistRecovered = { state -> persistState(state) }, + active = target.wasActive, recovered = recovered, persistRecovered = { state -> persistState(state) }, clearCleanup = { accountRemovalCleanupJournal.clear(accountId.storageKey) }, ) }, @@ -297,12 +297,11 @@ internal class AndroidAccountCredentialController( } private suspend fun clearSession( - current: AndroidAccountCredentialState, - pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, + current: AndroidAccountCredentialState, pendingCleanup: AndroidPendingAccountRemovalCleanup? = null, activeFallback: NextcloudSession? = null, ) { - val activeSession = current.activeSession ?: activeFallback ?: return - val replacement = current.remove(activeSession.accountId) + val removal = resolveAndroidActiveAccountRemovalTransition(current, activeFallback) ?: return + val replacement = removal.replacement val encodedReplacement = replacement.takeUnless { state -> state.registry.accounts.isEmpty() && state.sessions.isEmpty() }?.let(::encryptState) @@ -406,6 +405,7 @@ internal class AndroidAccountCredentialController( }, cancelAll = scheduler::cancelAll, clearPublishedAccount = { publishAccountIdentity(null) }, + onScheduleMaintenanceFailure = ::recordAccountRemovalCleanupFailure, ) }, clearHandoffs = handoffCleanup::complete, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index 853ff301e..35d70d787 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -208,6 +208,21 @@ internal fun resolveAndroidUnavailableAccountRemovalTarget( return AndroidUnavailableAccountRemovalTarget(record, available.activeAccountId == accountId) } +internal data class AndroidActiveAccountRemovalTransition( + val identitySession: NextcloudSession, + val replacement: AndroidAccountCredentialState, +) + +internal fun resolveAndroidActiveAccountRemovalTransition( + current: AndroidAccountCredentialState, + fallback: NextcloudSession? = null, +): AndroidActiveAccountRemovalTransition? { + val session = current.activeSession ?: fallback ?: return null + val record = current.registry.accounts.firstOrNull { account -> account.id == session.accountId } ?: return null + check(session.accountRecord() == record) { "The fallback account identity changed." } + return AndroidActiveAccountRemovalTransition(session, current.remove(session.accountId)) +} + internal fun restoreAndroidAccountCredentialStateWithoutAggregate( encodedRegistry: String?, loadSession: (NextcloudAccountId) -> NextcloudSession?, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index 993be98e4..c37d5e7b0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -9,11 +9,12 @@ internal fun removeActiveAndroidAccountCredentialState( ): AndroidAccountCredentialState = state.registry.activeAccountId?.let(state::remove) ?: state internal suspend fun rollbackUnavailableAndroidAccountRemoval( + active: Boolean = false, recovered: AndroidAccountCredentialState, persistRecovered: suspend (AndroidAccountCredentialState) -> Unit, clearCleanup: suspend () -> Unit, ) { - persistRecovered(recovered) + if (!active) persistRecovered(recovered) clearCleanup() } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt index a1e6cae67..49e582441 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncScheduler.kt @@ -78,16 +78,14 @@ internal class AndroidFileSyncSessionSchedulingGuard { persist: () -> Unit, cancelAll: () -> Unit, clearPublishedAccount: () -> Unit = {}, + onScheduleMaintenanceFailure: (Exception) -> Unit = {}, ) { synchronized(monitor) { persist() generation += 1 accountId = null - try { - clearPublishedAccount() - } finally { - cancelAll() - } + runScheduleMaintenance(onScheduleMaintenanceFailure, clearPublishedAccount) + runScheduleMaintenance(onScheduleMaintenanceFailure, cancelAll) } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt index 2b352cdd0..e8da5074c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertTrue class AndroidAccountRemovalRecoveryTest { @@ -45,6 +46,36 @@ class AndroidAccountRemovalRecoveryTest { assertTrue(target.wasActive) } + @Test + fun activeFallbackIsExcludedFromThePersistedReplacement() { + val fallback = NextcloudSession("https://cloud.example.test", "alice", "must-not-persist") + val recovered = AndroidAccountCredentialState( + registry = NextcloudAccountRegistry.Empty.upsertAndSelect(fallback.accountRecord()) + .copy(activeAccountId = null), + sessions = emptyMap(), + ) + + val removal = requireNotNull(resolveAndroidActiveAccountRemovalTransition(recovered, fallback)) + + assertTrue(removal.replacement.registry.accounts.isEmpty()) + assertTrue(removal.replacement.sessions.isEmpty()) + assertFalse(encodeAndroidAccountCredentialState(removal.replacement).contains("must-not-persist")) + } + + @Test + fun failedActiveRemovalPersistenceDoesNotOverwriteCredentialFreeOwnership() = runBlocking { + val events = mutableListOf() + + rollbackUnavailableAndroidAccountRemoval( + active = true, + recovered = AndroidAccountCredentialState.Empty, + persistRecovered = { events += "persist-reconstructed-state" }, + clearCleanup = { events += "clear-uncommitted-cleanup" }, + ) + + assertEquals(listOf("clear-uncommitted-cleanup"), events) + } + @Test fun unavailableCredentialRemovalCleansCommittedStateByIdentity() = runBlocking { val events = mutableListOf() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index 459a31582..b8812c638 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -1014,6 +1014,29 @@ class AndroidFileSyncEngineInvariantTest { assertEquals(listOf("clear-session", "old-account-still-current"), events) } + @Test + fun committedSessionClearContainsMaintenanceFailures() { + val guard = AndroidFileSyncSessionSchedulingGuard() + guard.restorePersistedSession(load = { "account-old" }, accountIdOf = { it }) + val events = mutableListOf() + + guard.clearSession( + persist = { events += "clear-session" }, + clearPublishedAccount = { + events += "publish-none" + error("synthetic publication failure") + }, + cancelAll = { + events += "cancel-all" + error("synthetic cancellation failure") + }, + onScheduleMaintenanceFailure = { events += "diagnose" }, + ) + + assertEquals(listOf("clear-session", "publish-none", "diagnose", "cancel-all", "diagnose"), events) + assertEquals(null, guard.capture("account-old")) + } + @Test fun newSessionGenerationCanScheduleWhileOldDedupeEntryFinishes() { val guard = AndroidFileSyncSessionSchedulingGuard() From 396609f49172b4e6740a2bb1c8c817b043a18a49 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 11:16:35 +0200 Subject: [PATCH 074/119] fix(accounts): retain original preview retry identity --- .../AndroidAccountCredentialController.kt | 6 ++- .../AndroidAccountCredentialTransitions.kt | 9 +++++ .../AndroidAccountOwnedStateCleanup.kt | 8 +++- ...ndroidAccountPreviewCleanupRecoveryTest.kt | 38 +++++++++++++++++++ .../AndroidPersistedSessionTest.kt | 2 + 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 645a1d443..13e9ea927 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -28,7 +28,7 @@ internal class AndroidAccountCredentialController( private val resumeQueuedUploads: suspend (String) -> Unit, private val prepareAccountRemoval: suspend (NextcloudSession) -> Unit, private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, - private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String) -> Unit, + private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String, String?) -> Unit, private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String, String?) -> Unit, ) { private val appContext = context.applicationContext @@ -686,7 +686,9 @@ internal class AndroidAccountCredentialController( accountOwnedByRegistry = androidAccountRemovalCleanupOwnedByRegistry( pending, readCredentialFreeRegistry()?.accounts, ), - removeAccountOwnedWork = { retryQueuedUploadsCleanup(session, pending.workIdentity) }, + removeAccountOwnedWork = { + retryAndroidAccountOwnedStateCleanup(session, pending, retryQueuedUploadsCleanup) + }, clearCleanup = { accountRemovalCleanupJournal.clear(pending.accountStorageKey) }, ) } catch (cancelled: CancellationException) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index c37d5e7b0..e481edf98 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.NextcloudSession import kotlinx.coroutines.CancellationException import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext @@ -38,6 +39,14 @@ internal fun androidAccountRemovalCleanupRetryFailure(failure: Exception) = Ille failure, ) +internal suspend fun retryAndroidAccountOwnedStateCleanup( + session: NextcloudSession, + pending: AndroidPendingAccountRemovalCleanup, + retry: suspend (NextcloudSession, String, String?) -> Unit, +) { + retry(session, pending.workIdentity, pending.previewCacheIdentity) +} + internal suspend fun resumeAndroidQueuedUploadsAfterSelection( resume: suspend () -> Unit, notifyDocumentRootsChanged: () -> Unit, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index 56892c795..60989b735 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -36,9 +36,13 @@ internal class AndroidAccountOwnedStateCleanup( ) } - suspend fun retry(session: NextcloudSession, accountIdentity: String) { + suspend fun retry( + session: NextcloudSession, + accountIdentity: String, + previewCacheIdentity: String?, + ) { runAndroidAccountOwnedStateCleanups( - NextcloudDocumentIds.cacheAccountId(session), + previewCacheIdentity, clearPreviewAccount, listOf( { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt index b082dd473..9e8496566 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt @@ -4,12 +4,50 @@ import dev.obiente.nextcloudnative.app.NextcloudSession import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.runBlocking class AndroidAccountPreviewCleanupRecoveryTest { + @Test + fun readdedCanonicalAccountRetriesTheRemovedPreviewIdentity() = runBlocking { + val removed = session().copy(serverUrl = "HTTPS://CLOUD.EXAMPLE.TEST:443/") + val readded = session().copy(serverUrl = "https://cloud.example.test") + val pending = pendingAndroidAccountRemovalCleanup(removed) + val retried = mutableListOf>() + + retryAndroidAccountOwnedStateCleanup(readded, pending) { _, workIdentity, previewIdentity -> + retried += workIdentity to previewIdentity + } + + assertEquals(removed.accountId, readded.accountId) + assertFalse(NextcloudDocumentIds.cacheAccountId(removed) == NextcloudDocumentIds.cacheAccountId(readded)) + assertEquals( + NextcloudDocumentIds.accountKey(removed) to NextcloudDocumentIds.cacheAccountId(removed), + retried.single(), + ) + } + + @Test + fun legacyCleanupWithoutPreviewIdentityDoesNotTargetAReaddedAccount() = runBlocking { + val readded = session().copy(serverUrl = "https://cloud.example.test") + val legacy = requireNotNull( + decodeAndroidPendingAccountRemovalCleanup( + "${readded.accountId.storageKey}:${NextcloudDocumentIds.accountKey(readded)}", + ), + ) + val retriedPreviewIdentities = mutableListOf() + + retryAndroidAccountOwnedStateCleanup(readded, legacy) { _, _, previewIdentity -> + retriedPreviewIdentities += previewIdentity + } + + assertEquals(listOf(null), retriedPreviewIdentities) + assertFalse(NextcloudDocumentIds.cacheAccountId(readded) in retriedPreviewIdentities) + } + @Test fun cleanupTombstoneCarriesThePathConfinedPreviewIdentityAndReadsLegacyEntries() { val pending = pendingAndroidAccountRemovalCleanup(session()) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 3bcf5ca3d..30c0b55a3 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -628,6 +628,8 @@ class AndroidPersistedSessionTest { val pending = pendingAndroidAccountRemovalCleanupForSession(replacement, listOf(decoded)) assertEquals(NextcloudDocumentIds.accountKey(original), requireNotNull(pending).workIdentity) + assertEquals(NextcloudDocumentIds.cacheAccountId(original), pending.previewCacheIdentity) + assertFalse(pending.previewCacheIdentity == NextcloudDocumentIds.cacheAccountId(replacement)) assertEquals(original.accountId.storageKey, pending.accountStorageKey) } From d447aa4c402de142eed62b1754ca222ce40dfb40 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 11:52:20 +0200 Subject: [PATCH 075/119] fix(uploads): retain jobs for unreadable account registry --- .../AndroidDurableMultipartUploads.kt | 23 ++++++------ ...AndroidDurableMultipartUploadPolicyTest.kt | 35 ++++++++++++++++--- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index 94f36cb7b..a168b0469 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -18,7 +18,6 @@ import dev.obiente.nextcloudnative.app.DurableUploadStatus import dev.obiente.nextcloudnative.app.LocalUploadFile import dev.obiente.nextcloudnative.app.MAX_DURABLE_UPLOAD_MESSAGE_CHARACTERS import dev.obiente.nextcloudnative.app.MultipartTextField -import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import dev.obiente.nextcloudnative.app.NextcloudApiMethod import dev.obiente.nextcloudnative.app.NextcloudMultipartUploadRequest import dev.obiente.nextcloudnative.app.NextcloudSession @@ -191,8 +190,8 @@ internal class DeckAttachmentUploadWorker( val accountServices = AndroidNextcloudServices(applicationContext) val session = accountServices.loadSession() if (session == null || NextcloudDocumentIds.accountKey(session) != initial.accountId) { - if (durableUploadAccountMismatchOutcome(initial.accountId, accountServices.listAccounts()) == - DurableUploadAccountMismatchOutcome.DeferRetainedAccount + if (durableUploadAccountMismatchOutcome(initial.accountId, accountServices.accountRetentionSnapshot()) == + DurableUploadAccountMismatchOutcome.DeferAccountRecovery ) { recordUploadDiagnostic( severity = SupportDiagnosticSeverity.Warning, @@ -338,19 +337,23 @@ internal class DeckAttachmentUploadWorker( } internal enum class DurableUploadAccountMismatchOutcome { - DeferRetainedAccount, + DeferAccountRecovery, AccountUnavailable, } internal fun durableUploadAccountMismatchOutcome( expectedAccountId: String, - retainedAccounts: List, -): DurableUploadAccountMismatchOutcome = - if (androidAccountIdentityIsRetained(expectedAccountId, retainedAccounts)) { - DurableUploadAccountMismatchOutcome.DeferRetainedAccount - } else { - DurableUploadAccountMismatchOutcome.AccountUnavailable + accountSnapshot: AndroidAccountRetentionSnapshot, +): DurableUploadAccountMismatchOutcome = when (accountSnapshot) { + is AndroidAccountRetentionSnapshot.Available -> { + if (androidAccountIdentityIsRetained(expectedAccountId, accountSnapshot.accounts)) { + DurableUploadAccountMismatchOutcome.DeferAccountRecovery + } else { + DurableUploadAccountMismatchOutcome.AccountUnavailable + } } + AndroidAccountRetentionSnapshot.Unavailable -> DurableUploadAccountMismatchOutcome.DeferAccountRecovery +} internal fun queuedDurableUploadsForAccount( jobs: List, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index 585d9f8de..c3feb755c 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -324,18 +324,45 @@ class AndroidDurableMultipartUploadPolicyTest { val accountId = NextcloudDocumentIds.accountKey(retainedSession) assertEquals( - DurableUploadAccountMismatchOutcome.DeferRetainedAccount, - durableUploadAccountMismatchOutcome(accountId, listOf(retainedSession.accountRecord())), + DurableUploadAccountMismatchOutcome.DeferAccountRecovery, + durableUploadAccountMismatchOutcome( + accountId, + AndroidAccountRetentionSnapshot.Available(listOf(retainedSession.accountRecord())), + ), + ) + } + + @Test + fun `unreadable account registry defers queued upload recovery`() { + assertEquals( + DurableUploadAccountMismatchOutcome.DeferAccountRecovery, + durableUploadAccountMismatchOutcome(ACCOUNT_A, AndroidAccountRetentionSnapshot.Unavailable), + ) + } + + @Test + fun `valid account registry without expected account makes upload unavailable`() { + val retainedSession = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "alice", + appPassword = "fixture-password", ) + val accountId = NextcloudDocumentIds.accountKey(retainedSession) + assertEquals( DurableUploadAccountMismatchOutcome.AccountUnavailable, - durableUploadAccountMismatchOutcome(accountId, emptyList()), + durableUploadAccountMismatchOutcome( + accountId, + AndroidAccountRetentionSnapshot.Available(emptyList()), + ), ) assertEquals( DurableUploadAccountMismatchOutcome.AccountUnavailable, durableUploadAccountMismatchOutcome( accountId, - listOf(retainedSession.copy(loginName = "another-account").accountRecord()), + AndroidAccountRetentionSnapshot.Available( + listOf(retainedSession.copy(loginName = "another-account").accountRecord()), + ), ), ) } From 5221e8d501805f681be08ed25d5d0d80be5c8a64 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 11:49:28 +0200 Subject: [PATCH 076/119] fix(desktop): recover malformed cleanup phases --- .../app/DesktopAccountRemoval.kt | 5 +-- .../app/DesktopAccountOperationGuardTest.kt | 33 ++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 27bb2e04c..48eb6367b 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -25,6 +25,7 @@ internal fun DesktopAccountSyncPairCleanupJournal.requireAccountActivationAllowe internal enum class DesktopAccountSyncPairCleanupPhase { Prepared, Committed, + Unknown, } internal enum class DesktopAccountOwnership { @@ -74,7 +75,7 @@ internal class DesktopAccountSyncPairCleanupJournal( val phase = when (preferences.get(key, null)) { PREPARED -> DesktopAccountSyncPairCleanupPhase.Prepared COMMITTED -> DesktopAccountSyncPairCleanupPhase.Committed - else -> error("The desktop account sync cleanup journal is invalid.") + else -> DesktopAccountSyncPairCleanupPhase.Unknown.also { malformedEntryFound = true } } DesktopAccountSyncPairCleanup(accountId, phase) }.getOrNull() @@ -310,7 +311,7 @@ internal suspend fun retryDesktopAccountSyncPairCleanup( removeSyncPairs: suspend (String) -> Unit, clearCleanup: suspend (String) -> Unit, ) { - if (cleanup.phase == DesktopAccountSyncPairCleanupPhase.Prepared) { + if (cleanup.phase != DesktopAccountSyncPairCleanupPhase.Committed) { when (accountOwnership(cleanup.accountId)) { DesktopAccountOwnership.Present -> { clearCleanup(cleanup.accountId) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 0c8a72744..d5ba68e60 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -684,6 +684,10 @@ class DesktopAccountOperationGuardTest { assertEquals( listOf( + DesktopAccountSyncPairCleanup( + malformedAccountId, + DesktopAccountSyncPairCleanupPhase.Unknown, + ), DesktopAccountSyncPairCleanup( validAccountId, DesktopAccountSyncPairCleanupPhase.Committed, @@ -701,7 +705,7 @@ class DesktopAccountOperationGuardTest { journal.prepare(newAccountId) assertEquals( - setOf(validAccountId, newAccountId), + setOf(malformedAccountId, validAccountId, newAccountId), journal.pending().mapTo(linkedSetOf(), DesktopAccountSyncPairCleanup::accountId), ) assertFalse(journal.blocksAccountActivation(newAccountId)) @@ -800,6 +804,33 @@ class DesktopAccountOperationGuardTest { assertTrue(events.isEmpty()) } + @Test + fun malformedCleanupUsesCredentialFreeOwnershipToRecover() = runBlocking { + val absentEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Unknown, + ), + accountOwnership = { DesktopAccountOwnership.Absent }, + removeSyncPairs = { absentEvents += "remove-pairs" }, + clearCleanup = { absentEvents += "clear-cleanup" }, + ) + assertEquals(listOf("remove-pairs", "clear-cleanup"), absentEvents) + + val presentEvents = mutableListOf() + retryDesktopAccountSyncPairCleanup( + cleanup = DesktopAccountSyncPairCleanup( + CLEANUP_ACCOUNT_ID, + DesktopAccountSyncPairCleanupPhase.Unknown, + ), + accountOwnership = { DesktopAccountOwnership.Present }, + removeSyncPairs = { presentEvents += "remove-pairs" }, + clearCleanup = { presentEvents += "clear-cleanup" }, + ) + assertEquals(listOf("clear-cleanup"), presentEvents) + } + private companion object { const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" } From a37b2c4251574d5c81175aef8e8d323bfda04456 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 11:51:41 +0200 Subject: [PATCH 077/119] fix(android): finish committed account selection --- .../AndroidAccountCredentialController.kt | 107 +++++++++--------- .../AndroidAccountSelectionMaintenance.kt | 28 +++++ .../AndroidAccountSelectionPostCommitTest.kt | 60 ++++++++++ 3 files changed, 144 insertions(+), 51 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionPostCommitTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 13e9ea927..0da0feee4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -435,61 +435,66 @@ internal class AndroidAccountCredentialController( val session = requireNotNull(replacement.activeSession) val encrypted = encryptState(replacement) val scheduler = AndroidFileSyncScheduler(appContext) - withContext(Dispatchers.IO) { - commitAndroidAccountTransitionBeforeHandoffCleanup( - commitTransition = { - ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.replaceSession( - replacementAccountId = NextcloudDocumentIds.accountKey(session), - persist = { - val editor = if (suspectEncrypted == null) { - preferences.edit() - .putString(ANDROID_ACCOUNT_SESSION_KEY, encrypted) - .putString( + completeAndroidAccountSelectionTransition( + commitTransition = { markCommitted -> + commitAndroidAccountTransitionBeforeHandoffCleanup( + commitTransition = { + ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.replaceSession( + replacementAccountId = NextcloudDocumentIds.accountKey(session), + persist = { + val editor = if (suspectEncrypted == null) { + preferences.edit() + .putString(ANDROID_ACCOUNT_SESSION_KEY, encrypted) + .putString( + ANDROID_ACCOUNT_REGISTRY_KEY, + encodeNextcloudAccountRegistry(replacement.registry), + ) + .remove(KEY_TEST_READ_ONLY) + } else { + prepareInvalidAndroidAccountCredentialRecoveryEdit( + editor = preferences.edit(), + replacementEncrypted = encrypted, + ).putString( ANDROID_ACCOUNT_REGISTRY_KEY, encodeNextcloudAccountRegistry(replacement.registry), ) - .remove(KEY_TEST_READ_ONLY) - } else { - prepareInvalidAndroidAccountCredentialRecoveryEdit( - editor = preferences.edit(), - replacementEncrypted = encrypted, - ).putString( - ANDROID_ACCOUNT_REGISTRY_KEY, - encodeNextcloudAccountRegistry(replacement.registry), + } + commitPreferences(handoffCleanup.prepare(prepareCredentialSlotEdit(editor, replacement))) + markCommitted() + }, + cancelAll = scheduler::cancelAll, + publishAccount = publishAccountIdentity, + restoreSchedules = scheduler::restorePersistedPairSchedules, + onScheduleMaintenanceFailure = { + recordCredentialFailure( + code = "FILE_SYNC_SCHEDULE_MAINTENANCE_FAILED", + operation = "account-selection.schedule-maintenance", + component = SupportDiagnosticComponent.Sync, ) - } - commitPreferences(handoffCleanup.prepare(prepareCredentialSlotEdit(editor, replacement))) - }, - cancelAll = scheduler::cancelAll, - publishAccount = publishAccountIdentity, - restoreSchedules = scheduler::restorePersistedPairSchedules, - onScheduleMaintenanceFailure = { - recordCredentialFailure( - code = "FILE_SYNC_SCHEDULE_MAINTENANCE_FAILED", - operation = "account-selection.schedule-maintenance", - component = SupportDiagnosticComponent.Sync, - ) - }, - ) - }, - clearHandoffs = handoffCleanup::complete, - recordFailure = ::recordAccountHandoffCleanupFailure, - ) - } - clearAndroidPreviousPreviewAfterCommittedSelection( - previousSession = previousSession, - selectedSession = session, - clearPreviewAccount = clearPreviewAccount, - recordFailure = { recordAccountSelectionCacheCleanupFailure() }, - ) - resumeAndroidQueuedUploadsAfterSelection( - resume = { resumeQueuedUploads(NextcloudDocumentIds.accountKey(session)) }, - notifyDocumentRootsChanged = notifyDocumentRootsChanged, - recordFailure = { - recordCredentialFailure( - code = "DURABLE_UPLOAD_RESUME_FAILED", - operation = "account-selection.upload-resume", - component = SupportDiagnosticComponent.Storage, + }, + ) + }, + clearHandoffs = handoffCleanup::complete, + recordFailure = ::recordAccountHandoffCleanupFailure, + ) + }, + finishMaintenance = { + clearAndroidPreviousPreviewAfterCommittedSelection( + previousSession = previousSession, + selectedSession = session, + clearPreviewAccount = clearPreviewAccount, + recordFailure = { recordAccountSelectionCacheCleanupFailure() }, + ) + resumeAndroidQueuedUploadsAfterSelection( + resume = { resumeQueuedUploads(NextcloudDocumentIds.accountKey(session)) }, + notifyDocumentRootsChanged = notifyDocumentRootsChanged, + recordFailure = { + recordCredentialFailure( + code = "DURABLE_UPLOAD_RESUME_FAILED", + operation = "account-selection.upload-resume", + component = SupportDiagnosticComponent.Storage, + ) + }, ) }, ) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt index 1f68158ce..2582379c9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionMaintenance.kt @@ -1,6 +1,34 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.NextcloudSession +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext + +internal suspend fun completeAndroidAccountSelectionTransition( + transitionDispatcher: CoroutineDispatcher = Dispatchers.IO, + commitTransition: (() -> Unit) -> Unit, + finishMaintenance: suspend () -> Unit, +) { + val committed = AtomicBoolean() + var cancellation: CancellationException? = null + try { + withContext(transitionDispatcher) { + commitTransition { committed.set(true) } + } + } catch (cancelled: CancellationException) { + if (!committed.get()) throw cancelled + cancellation = cancelled + } + withContext(NonCancellable) { finishMaintenance() } + cancellation?.let { throw it } + currentCoroutineContext().ensureActive() +} internal fun commitAndroidAccountTransitionBeforeHandoffCleanup( commitTransition: () -> Unit, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionPostCommitTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionPostCommitTest.kt new file mode 100644 index 000000000..5ef7bed3b --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionPostCommitTest.kt @@ -0,0 +1,60 @@ +package dev.obiente.nextcloudnative + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class AndroidAccountSelectionPostCommitTest { + @Test + fun cancellationAfterCommitFinishesMaintenanceBeforePropagating() = runBlocking { + val events = mutableListOf() + val cancellation = CancellationException("selection owner stopped after commit") + val selection = async { + val owner = currentCoroutineContext() + completeAndroidAccountSelectionTransition( + transitionDispatcher = Dispatchers.Default, + commitTransition = { markCommitted -> + events += "commit" + markCommitted() + owner.cancel(cancellation) + }, + finishMaintenance = { + yield() + assertTrue(currentCoroutineContext().isActive) + events += "maintain" + }, + ) + } + + assertFailsWith { selection.await() } + assertEquals(listOf("commit", "maintain"), events) + } + + @Test + fun cancellationBeforeCommitDoesNotRunTransitionOrMaintenance() = runBlocking { + val events = mutableListOf() + val selection = async { + currentCoroutineContext().cancel(CancellationException("selection stopped before commit")) + completeAndroidAccountSelectionTransition( + transitionDispatcher = Dispatchers.Default, + commitTransition = { + events += "commit" + it() + }, + finishMaintenance = { events += "maintain" }, + ) + } + + assertFailsWith { selection.await() } + assertTrue(events.isEmpty()) + } +} From ea9acbd69402c83d931d8d38c24ac93ec727f0e3 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 11:53:56 +0200 Subject: [PATCH 078/119] fix(accounts): purge retained mutation recovery --- .../AndroidAccountCredentialController.kt | 10 +- .../AndroidAccountCredentialRecovery.kt | 11 +- .../AndroidAccountCredentialTransitions.kt | 4 +- .../AndroidAccountMutationRecoveryCleanup.kt | 84 +++++++++++ .../AndroidAccountOwnedStateCleanup.kt | 15 +- ...ndroidAccountRemovalCleanupRecoveryWork.kt | 22 ++- .../AndroidNextcloudServices.kt | 15 +- ...droidAccountMutationRecoveryCleanupTest.kt | 133 ++++++++++++++++++ ...ndroidAccountPreviewCleanupRecoveryTest.kt | 10 +- ...idAccountRemovalCleanupRecoveryWorkTest.kt | 31 ++++ .../app/GroupwareCalendarScreen.kt | 2 +- 11 files changed, 314 insertions(+), 23 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 0da0feee4..10b2dfe58 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -28,8 +28,8 @@ internal class AndroidAccountCredentialController( private val resumeQueuedUploads: suspend (String) -> Unit, private val prepareAccountRemoval: suspend (NextcloudSession) -> Unit, private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, - private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String, String?) -> Unit, - private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String, String?) -> Unit, + private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String, String?, String?) -> Unit, + private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String, String?, String?) -> Unit, ) { private val appContext = context.applicationContext private val handoffCleanup = AndroidExternalFileHandoffCleanup(appContext, preferences, ::commitPreferences) @@ -195,7 +195,11 @@ internal class AndroidAccountCredentialController( active = target.wasActive, prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, removeAccountOwnedWorkWithoutCredentials = { identity -> - retryQueuedUploadsCleanupWithoutCredentials(identity, pendingCleanup.previewCacheIdentity) + retryQueuedUploadsCleanupWithoutCredentials( + identity, + pendingCleanup.previewCacheIdentity, + pendingCleanup.durableMutationIdentity, + ) }, persistRemoval = { persistState(recovered.remove(accountId), pendingCleanup) }, clearActiveAccount = { clearSession(recovered, pendingCleanup, unavailableSession) }, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index 35d70d787..d83a5d1d6 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -6,6 +6,7 @@ import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import dev.obiente.nextcloudnative.app.NextcloudAccountRegistry import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.accountRecord +import dev.obiente.nextcloudnative.app.durableMutationAccountScope import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry import kotlinx.coroutines.sync.Mutex @@ -59,6 +60,7 @@ internal data class AndroidPendingAccountRemovalCleanup( val accountStorageKey: String, val workIdentity: String, val previewCacheIdentity: String? = null, + val durableMutationIdentity: String? = null, ) { init { require(ACCOUNT_STORAGE_KEY_PATTERN.matches(accountStorageKey)) @@ -67,6 +69,10 @@ internal data class AndroidPendingAccountRemovalCleanup( require(ACCOUNT_STORAGE_KEY_PATTERN.matches(identity)) require(identity.startsWith(workIdentity)) } + durableMutationIdentity?.let { identity -> + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(identity)) + require(previewCacheIdentity != null) + } } } @@ -104,6 +110,7 @@ internal fun pendingAndroidAccountRemovalCleanup( accountStorageKey = session.accountId.storageKey, workIdentity = NextcloudDocumentIds.accountKey(session), previewCacheIdentity = NextcloudDocumentIds.cacheAccountId(session), + durableMutationIdentity = durableMutationAccountScope(session), ) internal fun encodeAndroidPendingAccountRemovalCleanup( @@ -112,18 +119,20 @@ internal fun encodeAndroidPendingAccountRemovalCleanup( cleanup.accountStorageKey, cleanup.workIdentity, cleanup.previewCacheIdentity, + cleanup.durableMutationIdentity, ).joinToString(":") internal fun decodeAndroidPendingAccountRemovalCleanup( encoded: String, ): AndroidPendingAccountRemovalCleanup? { val fields = encoded.split(':') - if (fields.size !in 2..3) return null + if (fields.size !in 2..4) return null return runCatching { AndroidPendingAccountRemovalCleanup( accountStorageKey = fields[0], workIdentity = fields[1], previewCacheIdentity = fields.getOrNull(2), + durableMutationIdentity = fields.getOrNull(3), ) }.getOrNull() } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index e481edf98..c4da0f73d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -42,9 +42,9 @@ internal fun androidAccountRemovalCleanupRetryFailure(failure: Exception) = Ille internal suspend fun retryAndroidAccountOwnedStateCleanup( session: NextcloudSession, pending: AndroidPendingAccountRemovalCleanup, - retry: suspend (NextcloudSession, String, String?) -> Unit, + retry: suspend (NextcloudSession, String, String?, String?) -> Unit, ) { - retry(session, pending.workIdentity, pending.previewCacheIdentity) + retry(session, pending.workIdentity, pending.previewCacheIdentity, pending.durableMutationIdentity) } internal suspend fun resumeAndroidQueuedUploadsAfterSelection( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt new file mode 100644 index 000000000..b05749f34 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt @@ -0,0 +1,84 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.DurableMutationRecoveryKind +import dev.obiente.nextcloudnative.app.isSafePendingMutationId +import java.io.File + +internal class AndroidAccountMutationRecoveryCleanup( + private val preferences: SharedPreferences, + pendingDynamicMutationDirectory: File, +) { + private val pendingDynamicMutationDirectory = pendingDynamicMutationDirectory.canonicalFile + + constructor(context: Context) : this( + preferences = context.applicationContext.getSharedPreferences("nextcloud_native", Context.MODE_PRIVATE), + pendingDynamicMutationDirectory = File(context.applicationContext.filesDir, "mutations/dynamic-v1"), + ) + + fun clearDurableRecoveries(accountScope: String) { + require(accountScope.isCanonicalAndroidMutationAccountScope()) { + "The durable mutation account identity is invalid." + } + synchronized(androidDurableMutationRecoveryLock) { + val keys = DurableMutationRecoveryKind.entries.map { kind -> + androidDurableMutationRecoveryKey(accountScope, kind) + } + if (keys.none(preferences::contains)) return@synchronized + val editor = preferences.edit() + keys.forEach(editor::remove) + check(editor.commit() && keys.none(preferences::contains)) { + "Could not clear this account's durable mutation recovery." + } + } + } + + fun clearPendingDynamicMutations(accountIdentity: String) { + require(accountIdentity.isCanonicalAndroidMutationAccountScope()) { + "The pending mutation account identity is invalid." + } + if (!pendingDynamicMutationDirectory.exists()) return + check(pendingDynamicMutationDirectory.isDirectory) { + "The pending mutation store is not a directory." + } + val candidates = pendingDynamicMutationDirectory.listFiles() + ?: error("The pending mutation store could not be read.") + candidates + .filter { candidate -> candidate.isOwnedPendingDynamicMutation(accountIdentity) } + .forEach { candidate -> + check(candidate.canonicalFile.parentFile == pendingDynamicMutationDirectory) { + "Unsafe pending mutation cleanup path." + } + check(!candidate.exists() || candidate.delete() && !candidate.exists()) { + "Could not clear this account's pending mutation." + } + } + } +} + +internal fun androidDurableMutationRecoveryKey( + accountScope: String, + kind: DurableMutationRecoveryKind, +): String = "durable-mutation-${kind.storageKey}-$accountScope" + +private fun File.isOwnedPendingDynamicMutation(accountIdentity: String): Boolean { + val suffix = when { + name.endsWith(".json.part") -> name.removeSuffix(".json.part") + name.endsWith(".json") -> name.removeSuffix(".json") + else -> return false + } + val ownedPrefix = "$accountIdentity-" + if (!suffix.startsWith(ownedPrefix)) return false + val identity = suffix.removePrefix(ownedPrefix) + val digestSeparator = identity.lastIndexOf('-') + if (digestSeparator <= 0) return false + val appId = identity.substring(0, digestSeparator) + val digest = identity.substring(digestSeparator + 1) + return appId.isSafePendingMutationId() && digest.isCanonicalAndroidMutationAccountScope() +} + +internal fun String.isCanonicalAndroidMutationAccountScope(): Boolean = + length == 64 && all { character -> character in '0'..'9' || character in 'a'..'f' } + +internal val androidDurableMutationRecoveryLock = Any() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index 60989b735..e9ec302b3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -2,6 +2,7 @@ package dev.obiente.nextcloudnative import android.content.Context import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.durableMutationAccountScope import java.io.File internal class AndroidAccountOwnedStateCleanup( @@ -18,6 +19,7 @@ internal class AndroidAccountOwnedStateCleanup( private val fileOffline = AndroidFileOfflineAccountCleanup(appContext) private val incomingShares = AndroidIncomingShareAccountCleanup(appContext) private val durableUploads = AndroidDurableUploadAccountCleanup(appContext) + private val mutationRecovery = AndroidAccountMutationRecoveryCleanup(appContext) suspend fun remove(session: NextcloudSession) { val accountIdentity = NextcloudDocumentIds.accountKey(session) @@ -32,6 +34,8 @@ internal class AndroidAccountOwnedStateCleanup( { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, + { mutationRecovery.clearDurableRecoveries(durableMutationAccountScope(session)) }, + { mutationRecovery.clearPendingDynamicMutations(NextcloudDocumentIds.cacheAccountId(session)) }, ), ) } @@ -40,6 +44,7 @@ internal class AndroidAccountOwnedStateCleanup( session: NextcloudSession, accountIdentity: String, previewCacheIdentity: String?, + durableMutationIdentity: String?, ) { runAndroidAccountOwnedStateCleanups( previewCacheIdentity, @@ -52,11 +57,17 @@ internal class AndroidAccountOwnedStateCleanup( { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, + { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, + { previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) }, ), ) } - suspend fun retryWithoutCredentials(accountIdentity: String, previewCacheIdentity: String? = null) { + suspend fun retryWithoutCredentials( + accountIdentity: String, + previewCacheIdentity: String? = null, + durableMutationIdentity: String? = null, + ) { runAndroidAccountOwnedStateCleanups( previewCacheIdentity, clearPreviewAccount, @@ -68,6 +79,8 @@ internal class AndroidAccountOwnedStateCleanup( { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, + { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, + { previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) }, ), ) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt index 355a1b205..9435d8083 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt @@ -11,6 +11,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import dev.obiente.nextcloudnative.app.NextcloudAccountRecord import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.durableMutationAccountScope import java.util.concurrent.TimeUnit import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.withLock @@ -100,7 +101,11 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( }, removeAccountOwnedWork = { pending -> ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(pending.workIdentity) { - cleanup.retryWithoutCredentials(pending.workIdentity, pending.previewCacheIdentity) + cleanup.retryWithoutCredentials( + pending.workIdentity, + pending.previewCacheIdentity, + pending.durableMutationIdentity, + ) } }, clearCleanup = journal::clear, @@ -159,6 +164,14 @@ internal fun androidAccountRemovalCleanupOwnedByRegistry( check(cleanup.previewCacheIdentity == null || storageOwnerPreviewIdentity == cleanup.previewCacheIdentity) { "The account-removal preview identity does not match." } + check( + cleanup.durableMutationIdentity == null || + durableMutationAccountScope( + NextcloudSession(storageOwner.serverUrl, storageOwner.loginName, appPassword = ""), + ) == cleanup.durableMutationIdentity, + ) { + "The account-removal mutation identity does not match." + } return true } check(retainedAccounts.none { account -> @@ -166,6 +179,13 @@ internal fun androidAccountRemovalCleanupOwnedByRegistry( }) { "The account-removal cleanup identity belongs to a retained account." } + check(cleanup.durableMutationIdentity == null || retainedAccounts.none { account -> + durableMutationAccountScope( + NextcloudSession(account.serverUrl, account.loginName, appPassword = ""), + ) == cleanup.durableMutationIdentity + }) { + "The account-removal mutation identity belongs to a retained account." + } return false } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index fc0874cf4..946dadfd7 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -778,7 +778,7 @@ internal class AndroidNextcloudServices( kind: DurableMutationRecoveryKind, ): String? = withContext(Dispatchers.IO) { if (!accountScope.isCanonicalAndroidMutationAccountScope()) return@withContext null - preferences.getString(durableMutationRecoveryKey(accountScope, kind), null) + preferences.getString(androidDurableMutationRecoveryKey(accountScope, kind), null) ?.takeIf { encoded -> encoded.isNotEmpty() && encoded.encodeToByteArray().size <= MAX_ANDROID_MUTATION_RECOVERY_BYTES } @@ -794,7 +794,7 @@ internal class AndroidNextcloudServices( return@withContext false } synchronized(androidDurableMutationRecoveryLock) { - val key = durableMutationRecoveryKey(accountScope, kind) + val key = androidDurableMutationRecoveryKey(accountScope, kind) if (preferences.contains(key)) return@synchronized false preferences.edit().putString(key, encoded).commit() && preferences.getString(key, null) == encoded } @@ -809,7 +809,7 @@ internal class AndroidNextcloudServices( if (expectedEncoded.isEmpty() || expectedEncoded.encodeToByteArray().size > MAX_ANDROID_MUTATION_RECOVERY_BYTES ) return@withContext false - val key = durableMutationRecoveryKey(accountScope, kind) + val key = androidDurableMutationRecoveryKey(accountScope, kind) synchronized(androidDurableMutationRecoveryLock) { val actual = preferences.getString(key, null) ?: return@synchronized true if (actual != expectedEncoded) return@synchronized false @@ -817,11 +817,6 @@ internal class AndroidNextcloudServices( } } - private fun durableMutationRecoveryKey( - accountScope: String, - kind: DurableMutationRecoveryKind, - ): String = "durable-mutation-${kind.storageKey}-$accountScope" - override suspend fun loadCachedDynamicAppDiscovery( session: NextcloudSession, appId: String, @@ -3967,9 +3962,6 @@ private fun NextcloudFile.isNativeTiffPreviewFormat(): Boolean { private const val MAX_ANDROID_MUTATION_RECOVERY_BYTES = 1024 * 1024 -private fun String.isCanonicalAndroidMutationAccountScope(): Boolean = - length == 64 && all { character -> character in '0'..'9' || character in 'a'..'f' } - internal sealed interface NativeTiffRangeReadPlan { val fileId: Long val sourceSize: Long @@ -4253,7 +4245,6 @@ private fun org.w3c.dom.Node.systemTagFirstText(namespace: String, localName: St private const val SYSTEM_TAG_DAV_NAMESPACE = "DAV:" private const val SYSTEM_TAG_OC_NAMESPACE = "http://owncloud.org/ns" private const val SYSTEM_TAG_NC_NAMESPACE = "http://nextcloud.org/ns" -private val androidDurableMutationRecoveryLock = Any() private const val MINIMUM_NATIVE_MEDIA_PREVIEW_DIMENSION = 64 private const val MAXIMUM_NATIVE_MEDIA_PREVIEW_DIMENSION = 4_096 private const val NATIVE_TIFF_DECODER_VERSION = "tiff-stream-v4" diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt new file mode 100644 index 000000000..11bd6aba5 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt @@ -0,0 +1,133 @@ +package dev.obiente.nextcloudnative + +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.DurableMutationRecoveryKind +import java.io.File +import java.lang.reflect.Proxy +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidAccountMutationRecoveryCleanupTest { + @Test + fun accountCleanupPurgesEveryDurableKindAndOwnedPendingFile() { + val root = Files.createTempDirectory("android-account-mutation-cleanup-").toFile() + val outside = Files.createTempFile( + requireNotNull(root.parentFile).toPath(), + "retained-mutation-", + ".json", + ).toFile() + try { + val removed = "a".repeat(64) + val retained = "b".repeat(64) + val values = linkedMapOf() + DurableMutationRecoveryKind.entries.forEach { kind -> + values[androidDurableMutationRecoveryKey(removed, kind)] = "removed-${kind.storageKey}" + values[androidDurableMutationRecoveryKey(retained, kind)] = "retained-${kind.storageKey}" + } + values["unrelated-preference"] = "retained" + val digest = "1".repeat(64) + val removedPublished = File(root, "$removed-notes-$digest.json").apply { writeText("removed") } + val removedStaging = File(root, "$removed-calendar-$digest.json.part").apply { writeText("removed") } + val retainedPublished = File(root, "$retained-notes-$digest.json").apply { writeText("retained") } + val malformedLookalike = File(root, "$removed-bad app-$digest.json").apply { writeText("retained") } + val cleanup = AndroidAccountMutationRecoveryCleanup(recordingPreferences(values), root) + + repeat(2) { + cleanup.clearDurableRecoveries(removed) + cleanup.clearPendingDynamicMutations(removed) + } + + DurableMutationRecoveryKind.entries.forEach { kind -> + assertFalse(androidDurableMutationRecoveryKey(removed, kind) in values) + assertEquals("retained-${kind.storageKey}", values[androidDurableMutationRecoveryKey(retained, kind)]) + } + assertEquals("retained", values["unrelated-preference"]) + assertFalse(removedPublished.exists()) + assertFalse(removedStaging.exists()) + assertTrue(retainedPublished.isFile) + assertTrue(malformedLookalike.isFile) + assertTrue(outside.isFile) + } finally { + root.deleteRecursively() + outside.delete() + } + } + + @Test + fun invalidPendingIdentityCannotEscapeTheMutationDirectory() { + val root = Files.createTempDirectory("android-account-mutation-confinement-").toFile() + val outside = Files.createTempFile( + requireNotNull(root.parentFile).toPath(), + "outside-mutation-", + ".json", + ).toFile() + try { + val cleanup = AndroidAccountMutationRecoveryCleanup(recordingPreferences(linkedMapOf()), root) + + assertFailsWith { + cleanup.clearPendingDynamicMutations("../${outside.name}") + } + + assertTrue(outside.isFile) + } finally { + root.deleteRecursively() + outside.delete() + } + } + + @Test + fun failedDurablePreferenceCommitLeavesEveryRecoveryForRetry() { + val removed = "c".repeat(64) + val values = DurableMutationRecoveryKind.entries.associateTo(linkedMapOf()) { kind -> + androidDurableMutationRecoveryKey(removed, kind) to "pending-${kind.storageKey}" + } + val root = Files.createTempDirectory("android-account-mutation-retry-").toFile() + try { + val cleanup = AndroidAccountMutationRecoveryCleanup( + recordingPreferences(values, commitResult = false), + root, + ) + + assertFailsWith { cleanup.clearDurableRecoveries(removed) } + + assertEquals(DurableMutationRecoveryKind.entries.size, values.size) + } finally { + root.deleteRecursively() + } + } + + private fun recordingPreferences( + values: MutableMap, + commitResult: Boolean = true, + ): SharedPreferences = Proxy.newProxyInstance( + SharedPreferences::class.java.classLoader, + arrayOf(SharedPreferences::class.java), + ) { _, method, arguments -> + when (method.name) { + "contains" -> values.containsKey(requireNotNull(arguments)[0] as String) + "edit" -> recordingEditor(values, commitResult) + else -> error("Unexpected SharedPreferences call: ${method.name}") + } + } as SharedPreferences + + private fun recordingEditor( + values: MutableMap, + commitResult: Boolean, + ): SharedPreferences.Editor { + val removals = linkedSetOf() + return Proxy.newProxyInstance( + SharedPreferences.Editor::class.java.classLoader, + arrayOf(SharedPreferences.Editor::class.java), + ) { proxy, method, arguments -> + when (method.name) { + "remove" -> proxy.also { removals += requireNotNull(arguments)[0] as String } + "commit" -> commitResult.also { committed -> if (committed) removals.forEach(values::remove) } + else -> error("Unexpected SharedPreferences.Editor call: ${method.name}") + } + } as SharedPreferences.Editor + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt index 9e8496566..a65ea9a89 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt @@ -18,7 +18,7 @@ class AndroidAccountPreviewCleanupRecoveryTest { val pending = pendingAndroidAccountRemovalCleanup(removed) val retried = mutableListOf>() - retryAndroidAccountOwnedStateCleanup(readded, pending) { _, workIdentity, previewIdentity -> + retryAndroidAccountOwnedStateCleanup(readded, pending) { _, workIdentity, previewIdentity, _ -> retried += workIdentity to previewIdentity } @@ -40,7 +40,7 @@ class AndroidAccountPreviewCleanupRecoveryTest { ) val retriedPreviewIdentities = mutableListOf() - retryAndroidAccountOwnedStateCleanup(readded, legacy) { _, _, previewIdentity -> + retryAndroidAccountOwnedStateCleanup(readded, legacy) { _, _, previewIdentity, _ -> retriedPreviewIdentities += previewIdentity } @@ -53,6 +53,7 @@ class AndroidAccountPreviewCleanupRecoveryTest { val pending = pendingAndroidAccountRemovalCleanup(session()) assertEquals(64, requireNotNull(pending.previewCacheIdentity).length) + assertEquals(64, requireNotNull(pending.durableMutationIdentity).length) assertTrue(pending.previewCacheIdentity.startsWith(pending.workIdentity)) assertEquals(pending, decodeAndroidPendingAccountRemovalCleanup(encodeAndroidPendingAccountRemovalCleanup(pending))) assertNull( @@ -60,6 +61,11 @@ class AndroidAccountPreviewCleanupRecoveryTest { "${pending.accountStorageKey}:${pending.workIdentity}", )?.previewCacheIdentity, ) + assertNull( + decodeAndroidPendingAccountRemovalCleanup( + "${pending.accountStorageKey}:${pending.workIdentity}", + )?.durableMutationIdentity, + ) val mismatchedIdentity = if (pending.workIdentity.first() == 'f') "e".repeat(64) else "f".repeat(64) assertFailsWith { pending.copy(previewCacheIdentity = mismatchedIdentity) } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt index 7a85a9740..39efbfa30 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt @@ -209,6 +209,37 @@ class AndroidAccountRemovalCleanupRecoveryWorkTest { ) } + @Test + fun crossedDurableMutationIdentityCannotDeleteARetainedAccountsRecovery() = runBlocking { + val retained = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "retained-user", + appPassword = "fixture-password", + ) + val removed = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "removed-user", + appPassword = "fixture-password", + ) + val crossed = pendingAndroidAccountRemovalCleanup(removed).copy( + durableMutationIdentity = pendingAndroidAccountRemovalCleanup(retained).durableMutationIdentity, + ) + val events = mutableListOf() + + val completed = recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(crossed), + accountOwnedByRegistry = { cleanup -> + androidAccountRemovalCleanupOwnedByRegistry(cleanup, listOf(retained.accountRecord())) + }, + removeAccountOwnedWork = { events += "remove" }, + clearCleanup = { events += "clear" }, + recordFailure = { events += "failure" }, + ) + + assertFalse(completed) + assertEquals(listOf("failure"), events) + } + private fun cleanup(accountCharacter: String, workCharacter: String) = AndroidPendingAccountRemovalCleanup( accountStorageKey = accountCharacter.repeat(64), diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt index 235389d6d..e2f4e6727 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt @@ -979,7 +979,7 @@ private val calendarMutationRecoveryJson = Json { ignoreUnknownKeys = true } -internal fun durableMutationAccountScope(session: NextcloudSession): String = +fun durableMutationAccountScope(session: NextcloudSession): String = publicContentSha256( listOf(session.serverUrl.trimEnd('/'), session.loginName) .joinToString("|") { value -> "${value.length}:$value" } From 330ed9f039e24610599297e9c1aa77670b68363e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 12:12:09 +0200 Subject: [PATCH 079/119] fix(accounts): persist mutation cleanup retries --- .../AndroidAccountMutationRecoveryCleanup.kt | 7 +- ...droidAccountMutationRecoveryCleanupTest.kt | 86 ++++++++++++++++--- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt index b05749f34..33873bb92 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt @@ -25,9 +25,12 @@ internal class AndroidAccountMutationRecoveryCleanup( val keys = DurableMutationRecoveryKind.entries.map { kind -> androidDurableMutationRecoveryKey(accountScope, kind) } - if (keys.none(preferences::contains)) return@synchronized val editor = preferences.edit() keys.forEach(editor::remove) + editor.putBoolean( + ANDROID_DURABLE_MUTATION_CLEANUP_TOGGLE_KEY, + !preferences.getBoolean(ANDROID_DURABLE_MUTATION_CLEANUP_TOGGLE_KEY, false), + ) check(editor.commit() && keys.none(preferences::contains)) { "Could not clear this account's durable mutation recovery." } @@ -82,3 +85,5 @@ internal fun String.isCanonicalAndroidMutationAccountScope(): Boolean = length == 64 && all { character -> character in '0'..'9' || character in 'a'..'f' } internal val androidDurableMutationRecoveryLock = Any() + +private const val ANDROID_DURABLE_MUTATION_CLEANUP_TOGGLE_KEY = "durable-mutation-cleanup-toggle-v1" diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt index 11bd6aba5..476c89566 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt @@ -80,21 +80,30 @@ class AndroidAccountMutationRecoveryCleanupTest { } @Test - fun failedDurablePreferenceCommitLeavesEveryRecoveryForRetry() { + fun retryPersistsCleanupAfterFailedCommitRemovedOnlyTheMemoryValues() { val removed = "c".repeat(64) - val values = DurableMutationRecoveryKind.entries.associateTo(linkedMapOf()) { kind -> + val diskValues = DurableMutationRecoveryKind.entries.associateTo(linkedMapOf()) { kind -> androidDurableMutationRecoveryKey(removed, kind) to "pending-${kind.storageKey}" } + val preferences = failedThenSuccessfulPreferences(diskValues) val root = Files.createTempDirectory("android-account-mutation-retry-").toFile() try { - val cleanup = AndroidAccountMutationRecoveryCleanup( - recordingPreferences(values, commitResult = false), - root, - ) + val cleanup = AndroidAccountMutationRecoveryCleanup(preferences.preferences, root) assertFailsWith { cleanup.clearDurableRecoveries(removed) } + assertTrue(DurableMutationRecoveryKind.entries.all { kind -> + androidDurableMutationRecoveryKey(removed, kind) in preferences.diskValues + }) + assertTrue(DurableMutationRecoveryKind.entries.all { kind -> + !preferences.preferences.contains(androidDurableMutationRecoveryKey(removed, kind)) + }) - assertEquals(DurableMutationRecoveryKind.entries.size, values.size) + cleanup.clearDurableRecoveries(removed) + + assertEquals(2, preferences.commitCalls()) + assertTrue(DurableMutationRecoveryKind.entries.all { kind -> + androidDurableMutationRecoveryKey(removed, kind) !in preferences.diskValues + }) } finally { root.deleteRecursively() } @@ -102,21 +111,20 @@ class AndroidAccountMutationRecoveryCleanupTest { private fun recordingPreferences( values: MutableMap, - commitResult: Boolean = true, ): SharedPreferences = Proxy.newProxyInstance( SharedPreferences::class.java.classLoader, arrayOf(SharedPreferences::class.java), ) { _, method, arguments -> when (method.name) { "contains" -> values.containsKey(requireNotNull(arguments)[0] as String) - "edit" -> recordingEditor(values, commitResult) + "getBoolean" -> arguments?.get(1) as Boolean + "edit" -> recordingEditor(values) else -> error("Unexpected SharedPreferences call: ${method.name}") } } as SharedPreferences private fun recordingEditor( values: MutableMap, - commitResult: Boolean, ): SharedPreferences.Editor { val removals = linkedSetOf() return Proxy.newProxyInstance( @@ -125,9 +133,65 @@ class AndroidAccountMutationRecoveryCleanupTest { ) { proxy, method, arguments -> when (method.name) { "remove" -> proxy.also { removals += requireNotNull(arguments)[0] as String } - "commit" -> commitResult.also { committed -> if (committed) removals.forEach(values::remove) } + "putBoolean" -> proxy + "commit" -> true.also { removals.forEach(values::remove) } else -> error("Unexpected SharedPreferences.Editor call: ${method.name}") } } as SharedPreferences.Editor } + + private fun failedThenSuccessfulPreferences( + initialDiskValues: MutableMap, + ): RestartFaithfulPreferences { + val diskValues = initialDiskValues.toMutableMap() + val memoryValues = initialDiskValues.toMutableMap() + var commitCalls = 0 + lateinit var preferences: SharedPreferences + preferences = Proxy.newProxyInstance( + SharedPreferences::class.java.classLoader, + arrayOf(SharedPreferences::class.java), + ) { _, method, arguments -> + when (method.name) { + "contains" -> requireNotNull(arguments)[0] in memoryValues + "getBoolean" -> memoryValues[requireNotNull(arguments)[0] as String] as? Boolean ?: arguments[1] + "edit" -> { + val removals = linkedSetOf() + val booleans = linkedMapOf() + Proxy.newProxyInstance( + SharedPreferences.Editor::class.java.classLoader, + arrayOf(SharedPreferences.Editor::class.java), + ) { proxy, editorMethod, editorArguments -> + when (editorMethod.name) { + "remove" -> proxy.also { + removals += requireNotNull(editorArguments)[0] as String + } + "putBoolean" -> proxy.also { + booleans[requireNotNull(editorArguments)[0] as String] = editorArguments[1] as Boolean + } + "commit" -> { + commitCalls += 1 + removals.forEach(memoryValues::remove) + memoryValues.putAll(booleans) + (commitCalls > 1).also { persisted -> + if (persisted) { + removals.forEach(diskValues::remove) + diskValues.putAll(booleans) + } + } + } + else -> error("Unexpected SharedPreferences.Editor call: ${editorMethod.name}") + } + } as SharedPreferences.Editor + } + else -> error("Unexpected SharedPreferences call: ${method.name}") + } + } as SharedPreferences + return RestartFaithfulPreferences(preferences, diskValues) { commitCalls } + } + + private data class RestartFaithfulPreferences( + val preferences: SharedPreferences, + val diskValues: Map, + val commitCalls: () -> Int, + ) } From ba32b37db2fa7a9e20023f9ddb35e243c6580b3e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 14:42:27 +0200 Subject: [PATCH 080/119] fix(account): purge private state after removal --- .../AndroidAccountOwnedStateCleanup.kt | 25 +++- .../AndroidNextcloudServices.kt | 9 +- .../AndroidDynamicApiCachePolicyTest.kt | 41 +++++ .../contracts/DynamicApiResponseCache.kt | 23 ++- .../contracts/DynamicApiResponseCacheTest.kt | 16 ++ .../app/DynamicApiRequestCoalescer.kt | 102 ++++++++++--- .../app/DynamicApiRequestCoalescerTest.kt | 65 ++++++++ .../DesktopAccountCredentialPersistence.kt | 3 + .../app/DesktopAccountRemoval.kt | 92 +++++++++--- .../DesktopDurableMutationRecoveryStore.kt | 28 ++++ .../app/DesktopNextcloudServices.kt | 112 ++------------ .../DesktopPendingDynamicMutationCleanup.kt | 141 ++++++++++++++++++ .../app/DesktopAccountOperationGuardTest.kt | 14 +- ...DesktopDurableMutationRecoveryStoreTest.kt | 41 +++++ ...ktopPendingDynamicMutationDirectoryTest.kt | 86 +++++++++++ 15 files changed, 648 insertions(+), 150 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index e9ec302b3..dffb69da7 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -1,8 +1,11 @@ package dev.obiente.nextcloudnative import android.content.Context +import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer +import dev.obiente.nextcloudnative.app.NextcloudApiResponse import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.durableMutationAccountScope +import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache import java.io.File internal class AndroidAccountOwnedStateCleanup( @@ -14,6 +17,11 @@ internal class AndroidAccountOwnedStateCleanup( private val clearPreviewAccount: (String) -> Unit = AndroidNativeMediaPreviewCache( File(context.applicationContext.cacheDir, "native-media-previews-v1"), )::clearAccount, + private val dynamicApiReadCache: DynamicApiResponseCache = DynamicApiResponseCache( + File(context.applicationContext.cacheDir, "dynamic-api-v1"), + ), + private val dynamicApiRequestCoalescer: DynamicApiRequestCoalescer = + DynamicApiRequestCoalescer(), ) { private val appContext = context.applicationContext private val fileOffline = AndroidFileOfflineAccountCleanup(appContext) @@ -23,8 +31,9 @@ internal class AndroidAccountOwnedStateCleanup( suspend fun remove(session: NextcloudSession) { val accountIdentity = NextcloudDocumentIds.accountKey(session) + val cacheIdentity = NextcloudDocumentIds.cacheAccountId(session) runAndroidAccountOwnedStateCleanups( - NextcloudDocumentIds.cacheAccountId(session), + cacheIdentity, clearPreviewAccount, listOf( { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, @@ -35,7 +44,8 @@ internal class AndroidAccountOwnedStateCleanup( { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, { mutationRecovery.clearDurableRecoveries(durableMutationAccountScope(session)) }, - { mutationRecovery.clearPendingDynamicMutations(NextcloudDocumentIds.cacheAccountId(session)) }, + { clearDynamicApiState(cacheIdentity) }, + { mutationRecovery.clearPendingDynamicMutations(cacheIdentity) }, ), ) } @@ -58,6 +68,7 @@ internal class AndroidAccountOwnedStateCleanup( { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, + { previewCacheIdentity?.let { clearDynamicApiState(it) } }, { previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) }, ), ) @@ -80,12 +91,22 @@ internal class AndroidAccountOwnedStateCleanup( { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, + { previewCacheIdentity?.let { clearDynamicApiState(it) } }, { previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) }, ), ) } + + private suspend fun clearDynamicApiState(accountIdentity: String) = + clearAndroidDynamicApiState(accountIdentity, dynamicApiRequestCoalescer, dynamicApiReadCache) } +internal suspend fun clearAndroidDynamicApiState( + accountIdentity: String, + coalescer: DynamicApiRequestCoalescer, + cache: DynamicApiResponseCache, +) = coalescer.fenceAccount(accountIdentity) { cache.invalidateAccount(accountIdentity) } + internal suspend fun runAndroidAccountOwnedStateCleanups( previewCacheIdentity: String?, clearPreviewAccount: (String) -> Unit, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 946dadfd7..b32828644 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -453,11 +453,14 @@ internal class AndroidNextcloudServices( private val virtualFileCache = AndroidVirtualFileCache(appContext) private val nativeMediaPreviewCache = AndroidNativeMediaPreviewCache(File(appContext.cacheDir, "native-media-previews-v1")) - private val accountOwnedStateCleanup = - AndroidAccountOwnedStateCleanup(appContext, fileReadCache, virtualFileCache, nativeMediaPreviewCache::clearAccount) private val dynamicApiReadCache = DynamicApiResponseCache(File(appContext.cacheDir, "dynamic-api-v1")) - private val nativeMediaPreviewDecodeMutex = Mutex() private val dynamicApiRequestCoalescer = DynamicApiRequestCoalescer() + private val accountOwnedStateCleanup = + AndroidAccountOwnedStateCleanup( + appContext, fileReadCache, virtualFileCache, nativeMediaPreviewCache::clearAccount, + dynamicApiReadCache, dynamicApiRequestCoalescer, + ) + private val nativeMediaPreviewDecodeMutex = Mutex() private val mediaTimelineCarryoverStore = MediaTimelineDavCarryoverStore() private val memoriesTimeline = MemoriesPreferredTimelineReadService { session, request -> executeNextcloudApi(session, request) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt index fa1059940..6cc56af48 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt @@ -3,9 +3,17 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer import dev.obiente.nextcloudnative.app.NextcloudApiCachePolicy import dev.obiente.nextcloudnative.app.NextcloudApiResponse +import dev.obiente.nextcloudnative.contracts.CachedDynamicApiResponse +import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache +import java.nio.file.Files +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertNull class AndroidDynamicApiCachePolicyTest { @Test @@ -66,4 +74,37 @@ class AndroidDynamicApiCachePolicyTest { assertEquals(0, invalidations) assertEquals(1, networkLoads) } + + @Test + fun `account cleanup fences a late Android GET before deleting its cache`() = runBlocking { + supervisorScope { + val root = Files.createTempDirectory("android-dynamic-cache-cleanup-").toFile() + try { + val accountId = "a".repeat(64) + val requestIdentity = "GET /dashboard/widgets" + val cache = DynamicApiResponseCache(root) + val coalescer = DynamicApiRequestCoalescer() + val started = CompletableDeferred() + val release = CompletableDeferred() + val response = CachedDynamicApiResponse(200, "private".encodeToByteArray(), null, null) + cache.store(accountId, requestIdentity, response) + val read = async { + coalescer.execute(accountId, requestIdentity, load = { + started.complete(Unit) + release.await() + response + }, commit = { cache.store(accountId, requestIdentity, it) }) + } + started.await() + + clearAndroidDynamicApiState(accountId, coalescer, cache) + release.complete(Unit) + + assertFails { read.await() } + assertNull(cache.load(accountId, requestIdentity, 1_024)) + } finally { + root.deleteRecursively() + } + } + } } diff --git a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCache.kt b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCache.kt index b7cdd563b..8639db591 100644 --- a/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCache.kt +++ b/contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCache.kt @@ -8,6 +8,8 @@ import java.io.EOFException import java.io.File import java.io.FileInputStream import java.io.FileOutputStream +import java.nio.file.Files +import java.nio.file.LinkOption import java.security.MessageDigest data class CachedDynamicApiResponse( @@ -109,7 +111,26 @@ class DynamicApiResponseCache( @Synchronized fun invalidateAccount(accountId: String) { requireAccountId(accountId) - accountDirectory(accountId).deleteRecursively() + val directory = accountDirectory(accountId) + val path = directory.toPath() + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return + check(Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(path)) { + "The dynamic API response cache account path is unsafe." + } + val entries = checkNotNull(directory.listFiles()) { + "Could not read the dynamic API response cache account directory." + } + entries.forEach { entry -> + check(Files.isRegularFile(entry.toPath(), LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(entry.toPath())) { + "The dynamic API response cache contains an unsafe entry." + } + check(entry.delete() && !Files.exists(entry.toPath(), LinkOption.NOFOLLOW_LINKS)) { + "Could not delete a dynamic API response cache entry." + } + } + check(directory.delete() && !Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + "Could not delete the dynamic API response cache account directory." + } } @Synchronized diff --git a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCacheTest.kt b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCacheTest.kt index 70f4ffc7f..faf16b8a2 100644 --- a/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCacheTest.kt +++ b/contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/DynamicApiResponseCacheTest.kt @@ -4,6 +4,7 @@ import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNull class DynamicApiResponseCacheTest { @@ -53,6 +54,21 @@ class DynamicApiResponseCacheTest { } } + @Test + fun accountInvalidationFailsClosedForAnUnsafeAccountPath() { + val root = Files.createTempDirectory("ncn-dynamic-api-cache-").toFile() + try { + root.resolve(account).writeText("not a cache directory") + + assertFailsWith { + DynamicApiResponseCache(root).invalidateAccount(account) + } + assertEquals("not a cache directory", root.resolve(account).readText()) + } finally { + root.deleteRecursively() + } + } + @Test fun requestInvalidationPreservesOtherCachedResponses() { val root = Files.createTempDirectory("ncn-dynamic-api-cache-").toFile() diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt index 086154e83..35b35edb5 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt @@ -1,8 +1,11 @@ package dev.obiente.nextcloudnative.app import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext /** * Coalesces identical authenticated reads without allowing account or request-generation reuse. @@ -23,6 +26,7 @@ class DynamicApiRequestCoalescer { private val mutex = Mutex() private val accountGenerations = mutableMapOf() + private val fencedAccountGenerations = mutableMapOf() private val requestGenerations = mutableMapOf() private val inFlight = mutableMapOf>() @@ -56,37 +60,47 @@ class DynamicApiRequestCoalescer { val loaded = try { load() } catch (failure: Throwable) { - val invalidated = mutex.withLock { - val entryWasInvalidated = - (accountGenerations[accountId] ?: 0L) != entry.accountGeneration || - (requestGenerations[key] ?: 0L) != entry.requestGeneration + if (failure is CancellationException) { + withContext(NonCancellable) { + mutex.withLock { + inFlight.remove(key, entry) + entry.result.completeExceptionally(failure) + retireRequestGenerationIfIdle(key, entry.requestGeneration) + retireAccountFenceIfIdle(accountId) + } + } + throw failure + } + val invalidation = mutex.withLock { + val cause = invalidationCause(accountId, key, entry) inFlight.remove(key, entry) - if (entryWasInvalidated) { - entry.result.completeExceptionally(DynamicReadInvalidatedException()) + if (cause != InvalidationCause.None) { + entry.result.completeExceptionally(cause.exception()) } else { entry.result.completeExceptionally(failure) retireRequestGenerationIfIdle(key, entry.requestGeneration) } - entryWasInvalidated + retireAccountFenceIfIdle(accountId) + cause } - if (invalidated) continue + if (invalidation == InvalidationCause.Invalidated) continue + if (invalidation == InvalidationCause.Fenced) throw DynamicReadAccountFencedException() throw failure } - val accepted = mutex.withLock { - if ( - (accountGenerations[accountId] ?: 0L) != entry.accountGeneration || - (requestGenerations[key] ?: 0L) != entry.requestGeneration - ) { + val invalidation = mutex.withLock { + val cause = invalidationCause(accountId, key, entry) + if (cause != InvalidationCause.None) { inFlight.remove(key, entry) - entry.result.completeExceptionally(DynamicReadInvalidatedException()) - false + entry.result.completeExceptionally(cause.exception()) + retireAccountFenceIfIdle(accountId) + cause } else { try { commit(loaded) inFlight.remove(key, entry) entry.result.complete(loaded) retireRequestGenerationIfIdle(key, entry.requestGeneration) - true + InvalidationCause.None } catch (failure: Throwable) { inFlight.remove(key, entry) entry.result.completeExceptionally(failure) @@ -95,7 +109,8 @@ class DynamicApiRequestCoalescer { } } } - if (accepted) return loaded + if (invalidation == InvalidationCause.None) return loaded + if (invalidation == InvalidationCause.Fenced) throw DynamicReadAccountFencedException() } } @@ -107,6 +122,22 @@ class DynamicApiRequestCoalescer { } } + /** + * Invalidates an account and terminates reads that entered before the fence. + * Reads invoked after the fence use the new generation normally. + */ + suspend fun fenceAccount(accountId: String, invalidate: () -> Unit) { + mutex.withLock { + val generation = (accountGenerations[accountId] ?: 0L) + 1L + accountGenerations[accountId] = generation + if (inFlight.any { (key, entry) -> key.accountId == accountId && entry.accountGeneration < generation }) { + fencedAccountGenerations[accountId] = generation + } + requestGenerations.keys.removeAll { it.accountId == accountId } + invalidate() + } + } + suspend fun invalidateRequest( accountId: String, requestIdentity: String, @@ -131,6 +162,43 @@ class DynamicApiRequestCoalescer { requestGenerations.remove(key) } } + + private fun invalidationCause(accountId: String, key: Key, entry: InFlight): InvalidationCause { + val accountGeneration = accountGenerations[accountId] ?: 0L + if (accountGeneration != entry.accountGeneration) { + return if ((fencedAccountGenerations[accountId] ?: Long.MIN_VALUE) > entry.accountGeneration) { + InvalidationCause.Fenced + } else { + InvalidationCause.Invalidated + } + } + return if ((requestGenerations[key] ?: 0L) != entry.requestGeneration) { + InvalidationCause.Invalidated + } else { + InvalidationCause.None + } + } + + private fun retireAccountFenceIfIdle(accountId: String) { + val fence = fencedAccountGenerations[accountId] ?: return + if (inFlight.none { (key, entry) -> key.accountId == accountId && entry.accountGeneration < fence }) { + fencedAccountGenerations.remove(accountId) + } + } + + private enum class InvalidationCause { + None, + Invalidated, + Fenced; + + fun exception(): Exception = when (this) { + None -> error("A current dynamic read has no invalidation failure.") + Invalidated -> DynamicReadInvalidatedException() + Fenced -> DynamicReadAccountFencedException() + } + } } private class DynamicReadInvalidatedException : Exception() + +internal class DynamicReadAccountFencedException : Exception("The account was removed while this read was running.") diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt index 19e7c16da..49363602a 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt @@ -3,10 +3,15 @@ package dev.obiente.nextcloudnative.app import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.fail class DynamicApiRequestCoalescerTest { @@ -72,6 +77,66 @@ class DynamicApiRequestCoalescerTest { assertEquals(listOf("new"), committed) } + @Test + fun `account removal fence terminates already entered reads without committing`() = runBlocking { + supervisorScope { + val coalescer = DynamicApiRequestCoalescer() + val readStarted = CompletableDeferred() + val finishRead = CompletableDeferred() + val committed = mutableListOf() + var loads = 0 + + val owner = async { + coalescer.execute("account-a", "GET items", load = { + loads += 1 + readStarted.complete(Unit) + finishRead.await() + "removed-account-data" + }, commit = committed::add) + } + readStarted.await() + val waiter = async(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { fail("must coalesce") }) + } + coalescer.fenceAccount("account-a") { committed.clear() } + finishRead.complete(Unit) + + assertFailsWith { owner.await() } + assertFailsWith { waiter.await() } + assertEquals(1, loads) + assertEquals(emptyList(), committed) + } + } + + @Test + fun `read invoked after an account removal fence uses the new generation`() = runBlocking { + val coalescer = DynamicApiRequestCoalescer() + + coalescer.fenceAccount("account-a") {} + + assertEquals( + "re-added-account-data", + coalescer.execute("account-a", "GET items", load = { "re-added-account-data" }), + ) + } + + @Test + fun `cancelled owner remains cancelled and releases its in flight entry`() = runBlocking { + val coalescer = DynamicApiRequestCoalescer() + var loads = 0 + val cancelled = launch(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { + loads += 1 + awaitCancellation() + }) + } + + cancelled.cancelAndJoin() + + assertEquals("fresh", coalescer.execute("account-a", "GET items", load = { "fresh" })) + assertEquals(1, loads) + } + @Test fun `request invalidation retries only the matching in flight read`() = runBlocking { val coalescer = DynamicApiRequestCoalescer() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 128fa5872..ce2462389 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -691,6 +691,9 @@ private fun NextcloudAccountRecord.toSession(appPassword: String) = NextcloudSes internal fun desktopFileCacheAccountId(account: NextcloudAccountRecord): String = desktopFileCacheAccountId(account.toSession(appPassword = "")) +internal fun desktopDurableMutationAccountScope(account: NextcloudAccountRecord): String = + durableMutationAccountScope(account.toSession(appPassword = "")) + internal class DesktopAccountSessionPublication( private val registerPrivateValue: (String) -> Unit, private val publishAccountIdentity: (String) -> Unit, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 48eb6367b..5b99862c6 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -37,6 +37,7 @@ internal enum class DesktopAccountOwnership { internal data class DesktopAccountSyncPairCleanup( val accountId: String, val phase: DesktopAccountSyncPairCleanupPhase, + val durableMutationAccountScope: String? = null, ) internal class DesktopAccountSyncPairCleanupJournal( @@ -45,9 +46,16 @@ internal class DesktopAccountSyncPairCleanupJournal( ) { private val malformedReported = AtomicBoolean() - fun prepare(accountId: String) = persist(accountId, DesktopAccountSyncPairCleanupPhase.Prepared) + fun prepare(accountId: String, durableMutationAccountScope: String? = null) = + persist(accountId, DesktopAccountSyncPairCleanupPhase.Prepared, durableMutationAccountScope) - fun commit(accountId: String) = persist(accountId, DesktopAccountSyncPairCleanupPhase.Committed) + fun commit(accountId: String) { + val current = decode(accountId, preferences.get(cleanupKey(accountId), null)) + check(current.phase != DesktopAccountSyncPairCleanupPhase.Unknown) { + "The desktop account sync cleanup journal phase is unsupported." + } + persist(accountId, DesktopAccountSyncPairCleanupPhase.Committed, current.durableMutationAccountScope) + } fun clear(accountId: String) { validateDesktopSyncPairCleanupAccountId(accountId) @@ -57,8 +65,8 @@ internal class DesktopAccountSyncPairCleanupJournal( fun blocksAccountActivation(accountId: String): Boolean { validateDesktopSyncPairCleanupAccountId(accountId) - val phase = preferences.get(cleanupKey(accountId), null) - val blocked = phase != null && phase != PREPARED && phase != COMMITTED + val encoded = preferences.get(cleanupKey(accountId), null) + val blocked = encoded != null && decode(accountId, encoded).phase == DesktopAccountSyncPairCleanupPhase.Unknown if (blocked) recordMalformedOnce() return blocked } @@ -72,12 +80,9 @@ internal class DesktopAccountSyncPairCleanupJournal( val accountId = key.removePrefix(KEY_PREFIX) val cleanup = runCatching { validateDesktopSyncPairCleanupAccountId(accountId) - val phase = when (preferences.get(key, null)) { - PREPARED -> DesktopAccountSyncPairCleanupPhase.Prepared - COMMITTED -> DesktopAccountSyncPairCleanupPhase.Committed - else -> DesktopAccountSyncPairCleanupPhase.Unknown.also { malformedEntryFound = true } + decode(accountId, preferences.get(key, null)).also { cleanup -> + if (cleanup.phase == DesktopAccountSyncPairCleanupPhase.Unknown) malformedEntryFound = true } - DesktopAccountSyncPairCleanup(accountId, phase) }.getOrNull() if (cleanup == null) malformedEntryFound = true cleanup @@ -90,10 +95,18 @@ internal class DesktopAccountSyncPairCleanupJournal( return cleanups } - private fun persist(accountId: String, phase: DesktopAccountSyncPairCleanupPhase) { + private fun persist( + accountId: String, + phase: DesktopAccountSyncPairCleanupPhase, + durableMutationAccountScope: String?, + ) { validateDesktopSyncPairCleanupAccountId(accountId) + require( + durableMutationAccountScope == null || durableMutationAccountScope.isCanonicalGroupwareMutationAccountScope(), + ) { "The desktop durable mutation cleanup identity is invalid." } val key = cleanupKey(accountId) - check(preferences.get(key, null) in setOf(null, PREPARED, COMMITTED)) { + val current = preferences.get(key, null)?.let { decode(accountId, it) } + check(current == null || current.phase != DesktopAccountSyncPairCleanupPhase.Unknown) { "The desktop account sync cleanup journal phase is unsupported." } val pending = pending() @@ -102,11 +115,37 @@ internal class DesktopAccountSyncPairCleanupJournal( } preferences.put( key, - if (phase == DesktopAccountSyncPairCleanupPhase.Prepared) PREPARED else COMMITTED, + encode(phase, durableMutationAccountScope), ) preferences.flush() } + private fun decode(accountId: String, encoded: String?): DesktopAccountSyncPairCleanup { + val legacyPhase = when (encoded) { + PREPARED -> DesktopAccountSyncPairCleanupPhase.Prepared + COMMITTED -> DesktopAccountSyncPairCleanupPhase.Committed + else -> null + } + if (legacyPhase != null) return DesktopAccountSyncPairCleanup(accountId, legacyPhase) + val fields = encoded?.split(VALUE_SEPARATOR).orEmpty() + val phase = when (fields.getOrNull(1)) { + PREPARED -> DesktopAccountSyncPairCleanupPhase.Prepared + COMMITTED -> DesktopAccountSyncPairCleanupPhase.Committed + else -> DesktopAccountSyncPairCleanupPhase.Unknown + } + val scope = fields.getOrNull(2)?.takeIf(String::isCanonicalGroupwareMutationAccountScope) + return if (fields.size == 3 && fields[0] == VALUE_VERSION && scope != null) { + DesktopAccountSyncPairCleanup(accountId, phase, scope) + } else { + DesktopAccountSyncPairCleanup(accountId, DesktopAccountSyncPairCleanupPhase.Unknown) + } + } + + private fun encode(phase: DesktopAccountSyncPairCleanupPhase, scope: String?): String { + val encodedPhase = if (phase == DesktopAccountSyncPairCleanupPhase.Prepared) PREPARED else COMMITTED + return scope?.let { "$VALUE_VERSION$VALUE_SEPARATOR$encodedPhase$VALUE_SEPARATOR$it" } ?: encodedPhase + } + private fun recordMalformedOnce() { if (malformedReported.compareAndSet(false, true)) runCatching(recordMalformed) } @@ -119,6 +158,8 @@ internal class DesktopAccountSyncPairCleanupJournal( const val KEY_PREFIX = "fsac." const val PREPARED = "prepared" const val COMMITTED = "committed" + const val VALUE_VERSION = "v2" + const val VALUE_SEPARATOR = '|' } } @@ -190,15 +231,16 @@ internal fun setDesktopVirtualFileProviderPreference( internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( accountId: String, - prepareCleanup: suspend (String) -> Unit, + durableMutationAccountScope: String? = null, + prepareCleanup: suspend (String, String?) -> Unit, commitCleanup: suspend (String) -> Unit, clearCleanup: suspend (String) -> Unit, accountOwnership: (String) -> DesktopAccountOwnership, removeCredential: suspend () -> Boolean, - removeSyncPairs: suspend () -> Unit, + removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, recordCleanupFailure: suspend (Exception) -> Unit, ): Boolean { - prepareCleanup(accountId) + prepareCleanup(accountId, durableMutationAccountScope) val removed = try { removeCredential() } catch (failure: Throwable) { @@ -217,7 +259,13 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( } try { commitCleanup(accountId) - removeSyncPairs() + removeSyncPairs( + DesktopAccountSyncPairCleanup( + accountId, + DesktopAccountSyncPairCleanupPhase.Committed, + durableMutationAccountScope, + ), + ) clearCleanup(accountId) } catch (cancelled: CancellationException) { throw cancelled @@ -229,10 +277,11 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( accountId: String?, + durableMutationAccountScope: String? = null, cleanupJournal: DesktopAccountSyncPairCleanupJournal, accountOwnership: (String) -> DesktopAccountOwnership, commitRemoval: suspend () -> Unit, - removeSyncPairs: suspend (String) -> Unit, + removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, ) { if (accountId == null) { @@ -241,6 +290,7 @@ internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( } removeDesktopAccountBeforeSyncPairCleanup( accountId = accountId, + durableMutationAccountScope = durableMutationAccountScope, prepareCleanup = cleanupJournal::prepare, commitCleanup = cleanupJournal::commit, clearCleanup = cleanupJournal::clear, @@ -249,7 +299,7 @@ internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( commitRemoval() true }, - removeSyncPairs = { removeSyncPairs(accountId) }, + removeSyncPairs = removeSyncPairs, recordCleanupFailure = { failure -> recordDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(accountId, failure)) }, @@ -308,7 +358,7 @@ internal fun finishCommittedDesktopAccountRemoval( internal suspend fun retryDesktopAccountSyncPairCleanup( cleanup: DesktopAccountSyncPairCleanup, accountOwnership: (String) -> DesktopAccountOwnership, - removeSyncPairs: suspend (String) -> Unit, + removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, clearCleanup: suspend (String) -> Unit, ) { if (cleanup.phase != DesktopAccountSyncPairCleanupPhase.Committed) { @@ -321,14 +371,14 @@ internal suspend fun retryDesktopAccountSyncPairCleanup( DesktopAccountOwnership.Absent -> Unit } } - removeSyncPairs(cleanup.accountId) + removeSyncPairs(cleanup) clearCleanup(cleanup.accountId) } internal suspend fun retryPendingDesktopAccountSyncPairCleanups( cleanupJournal: DesktopAccountSyncPairCleanupJournal, accountOwnership: (String) -> DesktopAccountOwnership, - removeSyncPairs: suspend (String) -> Unit, + removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, recordCleanupFailure: (String, Exception) -> Unit, ) { cleanupJournal.pending().forEach { cleanup -> diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStore.kt index eb691eb8f..b4c1761b2 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStore.kt @@ -98,6 +98,34 @@ internal class DesktopDurableMutationRecoveryStore( }.getOrDefault(false) } + fun removeAccount(accountScope: String) { + require(accountScope.isCanonicalGroupwareMutationAccountScope()) { "The mutation account scope is invalid." } + if (!Files.exists(root.toPath(), LinkOption.NOFOLLOW_LINKS)) return + val privacy = requirePrivateDirectory(root) + withExclusiveStoreLock(root, privacy) { + val targets = DurableMutationRecoveryKind.entries.flatMap { kind -> + val target = target(accountScope, kind) + listOf(target, File(root, ".${target.name}.part")) + } + var deleted = false + targets.forEach { candidate -> + val path = candidate.toPath() + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + check(Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(path)) { + "Mutation recovery state is not a regular file." + } + requirePrivatePath(candidate, privacy, directory = false) + check(Files.deleteIfExists(path)) { "Could not delete mutation recovery state." } + deleted = true + } + } + if (deleted) syncDirectory(root, privacy) + check(targets.none { Files.exists(it.toPath(), LinkOption.NOFOLLOW_LINKS) }) { + "Could not remove all mutation recovery state for the account." + } + } + } + private fun target(accountScope: String, kind: DurableMutationRecoveryKind): File { require(accountScope.isCanonicalGroupwareMutationAccountScope()) { "The mutation account scope is invalid." } return File(root, "${kind.storageKey}-$accountScope.json") diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index e36614220..db3c9af58 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -13,7 +13,6 @@ import java.awt.datatransfer.StringSelection import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File -import java.io.FileOutputStream import java.io.IOException import java.net.URI import java.net.URLDecoder @@ -24,8 +23,6 @@ import java.nio.file.Files import java.nio.file.LinkOption import java.nio.file.Path import java.nio.file.StandardCopyOption -import java.nio.file.attribute.PosixFilePermission -import java.nio.file.attribute.PosixFilePermissions import java.security.MessageDigest import java.util.Base64 import java.util.UUID @@ -684,27 +681,6 @@ private fun desktopContractCacheDirectory(name: String): File { return File(cacheRoot, "nextcloud-native/contracts/$name") } -internal fun desktopPendingDynamicMutationDirectory( - osName: String = System.getProperty("os.name").orEmpty(), - environment: Map = System.getenv(), - userHome: File = File(System.getProperty("user.home")), -): File = when { - osName.startsWith("Windows", ignoreCase = true) -> { - val localAppData = environment["LOCALAPPDATA"]?.takeIf(String::isNotBlank) - ?.let(::File) - ?: File(userHome, "AppData/Local") - File(localAppData, "Nextcloud Native/State/Pending Mutations") - } - osName.startsWith("Mac", ignoreCase = true) -> - File(userHome, "Library/Application Support/Nextcloud Native/Pending Mutations") - else -> { - val stateRoot = environment["XDG_STATE_HOME"]?.takeIf(String::isNotBlank) - ?.let(::File) - ?: File(userHome, ".local/state") - File(stateRoot, "nextcloud-native/pending-mutations-v1") - } -}.absoluteFile - internal const val DESKTOP_PROJECT_CONTENT_CONNECT_TIMEOUT_SECONDS = 10L internal const val DESKTOP_PROJECT_CONTENT_READ_TIMEOUT_SECONDS = 30L internal const val DESKTOP_PROJECT_CONTENT_WRITE_TIMEOUT_SECONDS = 30L @@ -739,80 +715,6 @@ internal fun publishDesktopProjectContentCache(temporary: File, destination: Fil } } -private val PENDING_MUTATION_DIRECTORY_PERMISSIONS = setOf( - PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE, - PosixFilePermission.OWNER_EXECUTE, -) -private val PENDING_MUTATION_FILE_PERMISSIONS = setOf( - PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE, -) - -internal fun ensurePrivatePendingMutationDirectory(directory: File) { - Files.createDirectories(directory.toPath()) - setPendingMutationPosixPermissions(directory.toPath(), PENDING_MUTATION_DIRECTORY_PERMISSIONS) -} - -internal fun setPrivatePendingMutationFilePermissions(file: File) { - setPendingMutationPosixPermissions(file.toPath(), PENDING_MUTATION_FILE_PERMISSIONS) -} - -private fun setPendingMutationPosixPermissions(path: Path, permissions: Set) { - if (Files.getFileStore(path).supportsFileAttributeView("posix")) { - Files.setPosixFilePermissions(path, permissions) - } -} - -private fun createPrivatePendingMutationTemporary(directory: File, targetName: String): Path { - val directoryPath = directory.toPath() - return if (Files.getFileStore(directoryPath).supportsFileAttributeView("posix")) { - Files.createTempFile( - directoryPath, - "$targetName-", - ".part", - PosixFilePermissions.asFileAttribute(PENDING_MUTATION_FILE_PERMISSIONS), - ) - } else { - Files.createTempFile(directoryPath, "$targetName-", ".part") - } -} - -internal fun writePrivatePendingMutationFile( - directory: File, - target: File, - bytes: ByteArray, -) { - require(target.parentFile?.absoluteFile == directory.absoluteFile) { - "The pending mutation target must be inside its private directory." - } - ensurePrivatePendingMutationDirectory(directory) - val temporary = createPrivatePendingMutationTemporary(directory, target.name) - try { - FileOutputStream(temporary.toFile()).use { output -> - output.write(bytes) - output.fd.sync() - } - try { - Files.move( - temporary, - target.toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING, - ) - } catch (_: AtomicMoveNotSupportedException) { - Files.move( - temporary, - target.toPath(), - StandardCopyOption.REPLACE_EXISTING, - ) - } - setPrivatePendingMutationFilePermissions(target) - } finally { - Files.deleteIfExists(temporary) - } -} - class DesktopNextcloudServices( private val onThemePreferenceChanged: (ThemePreference) -> Unit = {}, private val onKeepRunningInBackgroundChanged: (Boolean) -> Unit = {}, @@ -3664,11 +3566,13 @@ class DesktopNextcloudServices( val account = listAccounts().firstOrNull { record -> record.id == accountId } ?: return@serialize false val providerAccountId = desktopFileCacheAccountId(account) + val durableMutationScope = desktopDurableMutationAccountScope(account) requireDesktopAccountRemovalReady(providerAccountId, isLinuxDesktop()) accountOperationGuard.withSyncRunLock { fileSyncEngine.requireAccountRemovalReady(providerAccountId) val removed = removeDesktopAccountBeforeSyncPairCleanup( accountId = providerAccountId, + durableMutationAccountScope = durableMutationScope, prepareCleanup = accountSyncPairCleanupJournal::prepare, commitCleanup = accountSyncPairCleanupJournal::commit, clearCleanup = accountSyncPairCleanupJournal::clear, @@ -3680,7 +3584,7 @@ class DesktopNextcloudServices( accountCredentials.removeAccount(accountId) } } }, - removeSyncPairs = { removeDesktopAccountOwnedState(providerAccountId) }, + removeSyncPairs = ::removeDesktopAccountOwnedState, ) { recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(providerAccountId, it)) } @@ -3718,6 +3622,8 @@ class DesktopNextcloudServices( val activeSession = loadDesktopRemoteRevocationSession(activeAccountId, expectedSession, ::loadSession) val accountId = activeSession?.let(::desktopFileCacheAccountId) ?: activeRecord?.let(::desktopFileCacheAccountId) + val durableMutationScope = activeSession?.let(::durableMutationAccountScope) + ?: activeRecord?.let(::desktopDurableMutationAccountScope) val syncJob = synchronized(this) { val active = backgroundFileSyncJob backgroundFileSyncJob = null @@ -3838,7 +3744,7 @@ class DesktopNextcloudServices( } try { clearDesktopActiveAccountBeforeSyncPairCleanup( - accountId, accountSyncPairCleanupJournal, ::desktopAccountOwnership, + accountId, durableMutationScope, accountSyncPairCleanupJournal, ::desktopAccountOwnership, { commitDesktopAccountRemovalBeforeVirtualFileTeardown( commitRemoval = { @@ -3925,7 +3831,11 @@ class DesktopNextcloudServices( } } - private suspend fun removeDesktopAccountOwnedState(accountId: String) { + private suspend fun removeDesktopAccountOwnedState(cleanup: DesktopAccountSyncPairCleanup) { + val accountId = cleanup.accountId + clearDesktopDynamicApiState(accountId, dynamicApiRequestCoalescer, dynamicApiReadCache) + removeDesktopPendingDynamicMutations(pendingDynamicMutationDirectory, accountId) + cleanup.durableMutationAccountScope?.let(durableMutationRecovery::removeAccount) removeDesktopAccountPrivateStorage(accountId, fileSyncEngine, fileReadCache, virtualRangeCache(accountId)) if (!isWindowsDesktop()) return try { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt new file mode 100644 index 000000000..81c06452b --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationCleanup.kt @@ -0,0 +1,141 @@ +package dev.obiente.nextcloudnative.app + +import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache +import java.io.File +import java.io.FileOutputStream +import java.nio.channels.FileChannel +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption +import java.nio.file.attribute.PosixFilePermission +import java.nio.file.attribute.PosixFilePermissions + +internal suspend fun clearDesktopDynamicApiState( + accountId: String, + coalescer: DynamicApiRequestCoalescer, + cache: DynamicApiResponseCache, +) = coalescer.fenceAccount(accountId) { cache.invalidateAccount(accountId) } + +internal fun desktopPendingDynamicMutationDirectory( + osName: String = System.getProperty("os.name").orEmpty(), + environment: Map = System.getenv(), + userHome: File = File(System.getProperty("user.home")), +): File = when { + osName.startsWith("Windows", ignoreCase = true) -> { + val localAppData = environment["LOCALAPPDATA"]?.takeIf(String::isNotBlank) + ?.let(::File) + ?: File(userHome, "AppData/Local") + File(localAppData, "Nextcloud Native/State/Pending Mutations") + } + osName.startsWith("Mac", ignoreCase = true) -> + File(userHome, "Library/Application Support/Nextcloud Native/Pending Mutations") + else -> { + val stateRoot = environment["XDG_STATE_HOME"]?.takeIf(String::isNotBlank) + ?.let(::File) + ?: File(userHome, ".local/state") + File(stateRoot, "nextcloud-native/pending-mutations-v1") + } +}.absoluteFile + +private val PENDING_MUTATION_DIRECTORY_PERMISSIONS = setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, +) +private val PENDING_MUTATION_FILE_PERMISSIONS = setOf( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, +) + +internal fun ensurePrivatePendingMutationDirectory(directory: File) { + Files.createDirectories(directory.toPath()) + setPendingMutationPosixPermissions(directory.toPath(), PENDING_MUTATION_DIRECTORY_PERMISSIONS) +} + +internal fun setPrivatePendingMutationFilePermissions(file: File) { + setPendingMutationPosixPermissions(file.toPath(), PENDING_MUTATION_FILE_PERMISSIONS) +} + +private fun setPendingMutationPosixPermissions(path: Path, permissions: Set) { + if (Files.getFileStore(path).supportsFileAttributeView("posix")) { + Files.setPosixFilePermissions(path, permissions) + } +} + +private fun createPrivatePendingMutationTemporary(directory: File, targetName: String): Path { + val directoryPath = directory.toPath() + return if (Files.getFileStore(directoryPath).supportsFileAttributeView("posix")) { + Files.createTempFile( + directoryPath, + "$targetName-", + ".part", + PosixFilePermissions.asFileAttribute(PENDING_MUTATION_FILE_PERMISSIONS), + ) + } else { + Files.createTempFile(directoryPath, "$targetName-", ".part") + } +} + +internal fun writePrivatePendingMutationFile(directory: File, target: File, bytes: ByteArray) { + require(target.parentFile?.absoluteFile == directory.absoluteFile) { + "The pending mutation target must be inside its private directory." + } + ensurePrivatePendingMutationDirectory(directory) + val temporary = createPrivatePendingMutationTemporary(directory, target.name) + try { + FileOutputStream(temporary.toFile()).use { output -> + output.write(bytes) + output.fd.sync() + } + try { + Files.move(temporary, target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, target.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + setPrivatePendingMutationFilePermissions(target) + } finally { + Files.deleteIfExists(temporary) + } +} + +internal fun removeDesktopPendingDynamicMutations(directory: File, accountId: String) { + require(accountId.isCanonicalGroupwareMutationAccountScope()) { + "The pending mutation cleanup account identity is invalid." + } + val directoryPath = directory.toPath().toAbsolutePath().normalize() + if (!Files.exists(directoryPath, LinkOption.NOFOLLOW_LINKS)) return + check(Files.isDirectory(directoryPath, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(directoryPath)) { + "The pending mutation store is not a safe directory." + } + val ownedPrefix = "$accountId-" + val ownedName = Regex( + "^$accountId-[A-Za-z0-9._:-]{1,256}-[0-9a-f]{64}\\.json(?:-[^/]{1,128}\\.part)?$", + ) + var deleted = false + Files.newDirectoryStream(directoryPath).use { entries -> + entries.forEach { entry -> + val name = entry.fileName.toString() + if (!name.startsWith(ownedPrefix)) return@forEach + check(ownedName.matches(name)) { "The pending mutation store contains an unsafe account entry." } + check(entry.toAbsolutePath().normalize().parent == directoryPath) { + "The pending mutation entry escapes its private directory." + } + check(Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(entry)) { + "The pending mutation account entry is not a regular file." + } + check(Files.deleteIfExists(entry)) { "Could not delete a pending mutation account entry." } + deleted = true + } + } + if (deleted && Files.getFileStore(directoryPath).supportsFileAttributeView("posix")) { + FileChannel.open(directoryPath, StandardOpenOption.READ).use { channel -> channel.force(true) } + } + Files.newDirectoryStream(directoryPath).use { entries -> + check(entries.none { it.fileName.toString().startsWith(ownedPrefix) }) { + "Could not remove all pending mutation state for the account." + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index d5ba68e60..bcc811196 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -544,7 +544,7 @@ class DesktopAccountOperationGuardTest { val removed = removeDesktopAccountBeforeSyncPairCleanup( accountId = CLEANUP_ACCOUNT_ID, - prepareCleanup = { events += "prepare-cleanup" }, + prepareCleanup = { _, _ -> events += "prepare-cleanup" }, commitCleanup = { events += "commit-cleanup" }, clearCleanup = { events += "clear-cleanup" }, accountOwnership = { DesktopAccountOwnership.Absent }, @@ -579,7 +579,7 @@ class DesktopAccountOperationGuardTest { assertFailsWith { removeDesktopAccountBeforeSyncPairCleanup( accountId = CLEANUP_ACCOUNT_ID, - prepareCleanup = { events += "prepare-cleanup" }, + prepareCleanup = { _, _ -> events += "prepare-cleanup" }, commitCleanup = { events += "commit-cleanup" }, clearCleanup = { events += "clear-cleanup" }, accountOwnership = { DesktopAccountOwnership.Absent }, @@ -608,7 +608,7 @@ class DesktopAccountOperationGuardTest { assertFailsWith { removeDesktopAccountBeforeSyncPairCleanup( accountId = CLEANUP_ACCOUNT_ID, - prepareCleanup = { events += "prepare-cleanup" }, + prepareCleanup = { _, _ -> events += "prepare-cleanup" }, commitCleanup = { events += "commit-cleanup" }, clearCleanup = { events += "clear-cleanup" }, accountOwnership = { DesktopAccountOwnership.Absent }, @@ -658,7 +658,7 @@ class DesktopAccountOperationGuardTest { events += "remove-credential" error("synthetic credential commit failure") }, - removeSyncPairs = { events += "remove-pairs-$it" }, + removeSyncPairs = { events += "remove-pairs-${it.accountId}" }, recordDiagnostic = { events += "diagnose-cleanup" }, ) } @@ -727,6 +727,7 @@ class DesktopAccountOperationGuardTest { assertTrue( removeDesktopAccountBeforeSyncPairCleanup( accountId = CLEANUP_ACCOUNT_ID, + durableMutationAccountScope = MUTATION_SCOPE, prepareCleanup = firstJournal::prepare, commitCleanup = firstJournal::commit, clearCleanup = firstJournal::clear, @@ -744,16 +745,18 @@ class DesktopAccountOperationGuardTest { DesktopAccountSyncPairCleanup( CLEANUP_ACCOUNT_ID, DesktopAccountSyncPairCleanupPhase.Committed, + MUTATION_SCOPE, ), ), restored.pending(), ) + assertEquals("v2|committed|$MUTATION_SCOPE", preferences.get("fsac.$CLEANUP_ACCOUNT_ID", null)) val retryEvents = mutableListOf() retryDesktopAccountSyncPairCleanup( cleanup = restored.pending().single(), accountOwnership = { DesktopAccountOwnership.Present }, - removeSyncPairs = { retryEvents += "remove-pairs-$it" }, + removeSyncPairs = { retryEvents += "remove-pairs-${it.accountId}" }, clearCleanup = { retryEvents += "clear-cleanup-$it" restored.clear(it) @@ -833,5 +836,6 @@ class DesktopAccountOperationGuardTest { private companion object { const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + const val MUTATION_SCOPE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStoreTest.kt index 2d2eeb6f9..86f304654 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDurableMutationRecoveryStoreTest.kt @@ -53,6 +53,47 @@ class DesktopDurableMutationRecoveryStoreTest { } } + @Test + fun `account removal purges every recovery kind without touching another account`() { + val root = Files.createTempDirectory("mutation-recovery-account-removal-test").toFile() + try { + val store = DesktopDurableMutationRecoveryStore(root) + val removedScope = "d".repeat(64) + val retainedScope = "e".repeat(64) + DurableMutationRecoveryKind.entries.forEach { kind -> + assertTrue(store.save(removedScope, kind, "removed-${kind.storageKey}")) + assertTrue(store.save(retainedScope, kind, "retained-${kind.storageKey}")) + } + + store.removeAccount(removedScope) + + DurableMutationRecoveryKind.entries.forEach { kind -> + assertNull(store.load(removedScope, kind)) + assertEquals("retained-${kind.storageKey}", store.load(retainedScope, kind)) + } + } finally { + root.deleteRecursively() + } + } + + @Test + fun `account removal fails closed on an unsafe recovery path`() { + val root = Files.createTempDirectory("mutation-recovery-account-removal-unsafe-test").toFile() + try { + val store = DesktopDurableMutationRecoveryStore(root) + val scope = "f".repeat(64) + assertTrue(store.save(scope, DurableMutationRecoveryKind.Calendar, "safe")) + val target = root.resolve("${DurableMutationRecoveryKind.Calendar.storageKey}-$scope.json") + assertTrue(target.delete()) + assertTrue(target.mkdir()) + + assertFailsWith { store.removeAccount(scope) } + assertTrue(target.isDirectory) + } finally { + root.deleteRecursively() + } + } + @Test fun `recovery state fails closed when owner-only permissions drift`() { val root = Files.createTempDirectory("mutation-recovery-permissions-test").toFile() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt index 708034082..573bfab13 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopPendingDynamicMutationDirectoryTest.kt @@ -1,12 +1,21 @@ package dev.obiente.nextcloudnative.app +import dev.obiente.nextcloudnative.contracts.CachedDynamicApiResponse +import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache import java.io.File import java.nio.file.Files import java.nio.file.attribute.PosixFilePermission import kotlin.io.path.createTempDirectory +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.supervisorScope import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue class DesktopPendingDynamicMutationDirectoryTest { @Test @@ -68,4 +77,81 @@ class DesktopPendingDynamicMutationDirectoryTest { root.deleteRecursively() } } + + @Test + fun `account cleanup removes only path confined pending mutation entries`() { + val directory = createTempDirectory("pending-mutation-cleanup-").toFile() + val accountId = "a".repeat(64) + val otherAccountId = "b".repeat(64) + try { + ensurePrivatePendingMutationDirectory(directory) + val owned = directory.resolve("$accountId-deck-${"1".repeat(64)}.json") + val ownedTemporary = directory.resolve("${owned.name}-retry.part") + val retained = directory.resolve("$otherAccountId-deck-${"2".repeat(64)}.json") + listOf(owned, ownedTemporary, retained).forEach { file -> + file.writeText("private") + setPrivatePendingMutationFilePermissions(file) + } + + removeDesktopPendingDynamicMutations(directory, accountId) + + assertFalse(owned.exists()) + assertFalse(ownedTemporary.exists()) + assertTrue(retained.isFile) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `account cleanup fails closed on an unrecognized owned entry`() { + val directory = createTempDirectory("pending-mutation-cleanup-unsafe-").toFile() + val accountId = "c".repeat(64) + try { + ensurePrivatePendingMutationDirectory(directory) + val unsafe = directory.resolve("$accountId-unknown") + unsafe.writeText("private") + setPrivatePendingMutationFilePermissions(unsafe) + + assertFailsWith { + removeDesktopPendingDynamicMutations(directory, accountId) + } + assertTrue(unsafe.isFile) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun `desktop account cleanup fences a late GET before deleting its cache`() = runBlocking { + supervisorScope { + val root = createTempDirectory("desktop-dynamic-cache-cleanup-").toFile() + try { + val accountId = "d".repeat(64) + val requestIdentity = "GET /dashboard/widgets" + val cache = DynamicApiResponseCache(root) + val coalescer = DynamicApiRequestCoalescer() + val started = CompletableDeferred() + val release = CompletableDeferred() + val response = CachedDynamicApiResponse(200, "private".encodeToByteArray(), null, null) + cache.store(accountId, requestIdentity, response) + val read = async { + coalescer.execute(accountId, requestIdentity, load = { + started.complete(Unit) + release.await() + response + }, commit = { cache.store(accountId, requestIdentity, it) }) + } + started.await() + + clearDesktopDynamicApiState(accountId, coalescer, cache) + release.complete(Unit) + + assertFailsWith { read.await() } + kotlin.test.assertNull(cache.load(accountId, requestIdentity, 1_024)) + } finally { + root.deleteRecursively() + } + } + } } From 139c220d7cbb2a9632022fe0b987de5ea2ca4810 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:59:49 +0000 Subject: [PATCH 081/119] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 0df95708e..4889c4443 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -477,7 +477,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DocumentPreview.kt": "a9a8743dd7a381504282cc6ddc68034425024ccc1ae51667da9734bbfc7b1a79", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DurableMutationRecoveryDialog.kt": "e720eadb477a347762cd1894285788ac9f6820972431fe0a1953d01955667bfe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt": "2b7ef2d18b4a23615686ced0b7c9c621c58dc5edd0202104d0ca55b1ebf61d81", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "2744c7ce9f156ad7a6f9064e8d04e8790b7b66b599e33925f0288fbef73ecc1d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "a0a82881adf8c7b100c7f1b2f21c63d609e5005fa7b6a2bc0424aeb902bbafc8", "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", From 8858e0890e414bc5a337f3919fb5c1877f3005e9 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 15:27:30 +0200 Subject: [PATCH 082/119] fix(account): serialize private-state publication --- .../AndroidAccountOperationGuard.kt | 12 ++ .../AndroidAccountOwnedStateCleanup.kt | 7 +- .../AndroidDynamicApiProcessState.kt | 28 +++++ .../AndroidNextcloudServices.kt | 105 +++++++++--------- ...ndroidPendingDynamicMutationPublication.kt | 36 ++++++ .../AndroidAccountOperationGuardTest.kt | 105 ++++++++++++++++++ .../AndroidDynamicApiCachePolicyTest.kt | 54 +++++++++ .../app/GroupwareCalendarScreen.kt | 2 +- .../app/GroupwareContactsScreen.kt | 2 +- .../app/GroupwareTasksScreen.kt | 2 +- .../nextcloudnative/app/NextcloudNotes.kt | 1 + .../nextcloudnative/app/NextcloudPlatform.kt | 1 + .../app/DesktopAccountOperationGuard.kt | 10 ++ .../app/DesktopNextcloudServices.kt | 50 ++++++--- .../app/DesktopAccountOperationGuardTest.kt | 89 +++++++++++++++ 15 files changed, 428 insertions(+), 76 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiProcessState.kt create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPendingDynamicMutationPublication.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index f699b46c9..8845af225 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock internal class AndroidAccountOperationGuard { private val monitor = Any() @@ -146,3 +147,14 @@ internal suspend fun AndroidAccountOperationGuard.withAuthenticatedMuta unavailable = { error("The account changed before the authenticated change could be sent.") }, action = action, ) + +internal suspend fun withAndroidAccountPrivateStatePublication( + expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, + credentialMutationMutex: Mutex, + guard: AndroidAccountOperationGuard, + resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, + unavailable: suspend () -> Result, + publish: suspend () -> Result, +): Result = credentialMutationMutex.withLock { + guard.withExactAccountSession(expectedSession, resolveSession, unavailable) { publish() } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index dffb69da7..774d9b297 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -2,7 +2,6 @@ package dev.obiente.nextcloudnative import android.content.Context import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer -import dev.obiente.nextcloudnative.app.NextcloudApiResponse import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.durableMutationAccountScope import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache @@ -17,11 +16,9 @@ internal class AndroidAccountOwnedStateCleanup( private val clearPreviewAccount: (String) -> Unit = AndroidNativeMediaPreviewCache( File(context.applicationContext.cacheDir, "native-media-previews-v1"), )::clearAccount, - private val dynamicApiReadCache: DynamicApiResponseCache = DynamicApiResponseCache( + private val dynamicApiState: AndroidDynamicApiProcessState = androidDynamicApiProcessState( File(context.applicationContext.cacheDir, "dynamic-api-v1"), ), - private val dynamicApiRequestCoalescer: DynamicApiRequestCoalescer = - DynamicApiRequestCoalescer(), ) { private val appContext = context.applicationContext private val fileOffline = AndroidFileOfflineAccountCleanup(appContext) @@ -98,7 +95,7 @@ internal class AndroidAccountOwnedStateCleanup( } private suspend fun clearDynamicApiState(accountIdentity: String) = - clearAndroidDynamicApiState(accountIdentity, dynamicApiRequestCoalescer, dynamicApiReadCache) + clearAndroidDynamicApiState(accountIdentity, dynamicApiState.coalescer, dynamicApiState.cache) } internal suspend fun clearAndroidDynamicApiState( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiProcessState.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiProcessState.kt new file mode 100644 index 000000000..a0765fe98 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiProcessState.kt @@ -0,0 +1,28 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer +import dev.obiente.nextcloudnative.app.NextcloudApiResponse +import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +/** + * Coordinates every Android owner of one dynamic response cache directory. + * + * AndroidManifest.xml does not assign an android:process to an app component, so activities, + * providers, and workers share this process registry. Disk deletion still fails closed in the + * cache implementation instead of relying on this process-only coordination for filesystem safety. + */ +internal class AndroidDynamicApiProcessState internal constructor(root: File) { + val cache = DynamicApiResponseCache(root) + val coalescer = DynamicApiRequestCoalescer() +} + +internal fun androidDynamicApiProcessState(root: File): AndroidDynamicApiProcessState { + val canonicalRoot = root.canonicalFile + return ANDROID_DYNAMIC_API_PROCESS_STATES.computeIfAbsent(canonicalRoot.path) { + AndroidDynamicApiProcessState(canonicalRoot) + } +} + +private val ANDROID_DYNAMIC_API_PROCESS_STATES = ConcurrentHashMap() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index b32828644..fce985287 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -23,6 +23,7 @@ import dev.obiente.nextcloudnative.app.DurableUploadEnqueueResult import dev.obiente.nextcloudnative.app.DurableUploadScope import dev.obiente.nextcloudnative.app.DurableUploadStatus import dev.obiente.nextcloudnative.app.DurableMutationRecoveryKind +import dev.obiente.nextcloudnative.app.durableMutationAccountScope import dev.obiente.nextcloudnative.app.LoginChallenge import dev.obiente.nextcloudnative.app.LoginPollResult import dev.obiente.nextcloudnative.app.LoginTransportSecurity @@ -251,9 +252,6 @@ import java.io.IOException import java.io.OutputStream import java.net.URLEncoder import java.nio.charset.StandardCharsets -import java.nio.file.AtomicMoveNotSupportedException -import java.nio.file.Files -import java.nio.file.StandardCopyOption import java.util.Locale import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -373,34 +371,6 @@ internal suspend fun executeAndroidDynamicApiGet( ) } -/** Publishes a pre-synced mutation marker before its non-idempotent request may start. */ -internal fun publishAndroidPendingMutation(temporary: File, target: File) { - require(temporary.isFile) - require(temporary.parentFile == target.parentFile) - try { - Files.move( - temporary.toPath(), - target.toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING, - ) - } catch (_: AtomicMoveNotSupportedException) { - copyAndSyncAndroidPendingMutation(temporary, target) - } -} - -internal fun copyAndSyncAndroidPendingMutation(temporary: File, target: File) { - require(temporary.isFile) - require(temporary.parentFile == target.parentFile) - FileInputStream(temporary).use { input -> - FileOutputStream(target).use { output -> - input.copyTo(output) - output.fd.sync() - } - } - check(temporary.delete()) { "Could not clear the published pending mutation staging file." } -} - internal class AndroidNextcloudServices( context: Context, private val fileSyncRootPicker: AndroidFileSyncRootPicker? = null, @@ -453,12 +423,13 @@ internal class AndroidNextcloudServices( private val virtualFileCache = AndroidVirtualFileCache(appContext) private val nativeMediaPreviewCache = AndroidNativeMediaPreviewCache(File(appContext.cacheDir, "native-media-previews-v1")) - private val dynamicApiReadCache = DynamicApiResponseCache(File(appContext.cacheDir, "dynamic-api-v1")) - private val dynamicApiRequestCoalescer = DynamicApiRequestCoalescer() + private val dynamicApiState = androidDynamicApiProcessState(File(appContext.cacheDir, "dynamic-api-v1")) + private val dynamicApiReadCache = dynamicApiState.cache + private val dynamicApiRequestCoalescer = dynamicApiState.coalescer private val accountOwnedStateCleanup = AndroidAccountOwnedStateCleanup( appContext, fileReadCache, virtualFileCache, nativeMediaPreviewCache::clearAccount, - dynamicApiReadCache, dynamicApiRequestCoalescer, + dynamicApiState, ) private val nativeMediaPreviewDecodeMutex = Mutex() private val mediaTimelineCarryoverStore = MediaTimelineDavCarryoverStore() @@ -788,18 +759,28 @@ internal class AndroidNextcloudServices( } override suspend fun saveDurableMutationRecovery( + session: NextcloudSession, accountScope: String, kind: DurableMutationRecoveryKind, encoded: String, ): Boolean = withContext(Dispatchers.IO) { if (!accountScope.isCanonicalAndroidMutationAccountScope()) return@withContext false + if (durableMutationAccountScope(session) != accountScope) return@withContext false if (encoded.isEmpty() || encoded.encodeToByteArray().size > MAX_ANDROID_MUTATION_RECOVERY_BYTES) { return@withContext false } - synchronized(androidDurableMutationRecoveryLock) { - val key = androidDurableMutationRecoveryKey(accountScope, kind) - if (preferences.contains(key)) return@synchronized false - preferences.edit().putString(key, encoded).commit() && preferences.getString(key, null) == encoded + withAndroidAccountPrivateStatePublication( + expectedSession = session, + credentialMutationMutex = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX, + guard = ANDROID_ACCOUNT_OPERATION_GUARD, + resolveSession = { accountCredentials.loadSession(session.accountId) }, + unavailable = { false }, + ) { + synchronized(androidDurableMutationRecoveryLock) { + val key = androidDurableMutationRecoveryKey(accountScope, kind) + if (preferences.contains(key)) return@synchronized false + preferences.edit().putString(key, encoded).commit() && preferences.getString(key, null) == encoded + } } } @@ -890,21 +871,29 @@ internal class AndroidNextcloudServices( targetRecordId: String, values: Map, ) = withContext(Dispatchers.IO) { - val encoded = requireNotNull( - encodePersistedDynamicMutation(appId, actionId, targetRecordId, values), - ) { "The pending dynamic mutation is invalid." } - val target = requireNotNull(pendingDynamicMutationFile(session, appId, actionId, targetRecordId)) { - "The pending dynamic mutation identity is invalid." - } - check(pendingDynamicMutationDirectory.mkdirs() || pendingDynamicMutationDirectory.isDirectory) { - "Could not create the pending mutation store." - } - val temporary = File(pendingDynamicMutationDirectory, "${target.name}.part") - FileOutputStream(temporary).use { output -> - output.write(encoded.encodeToByteArray()) - output.fd.sync() + withAndroidAccountPrivateStatePublication( + expectedSession = session, + credentialMutationMutex = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX, + guard = ANDROID_ACCOUNT_OPERATION_GUARD, + resolveSession = { accountCredentials.loadSession(session.accountId) }, + unavailable = { error("The account changed before the pending mutation could be recorded.") }, + ) { + val encoded = requireNotNull( + encodePersistedDynamicMutation(appId, actionId, targetRecordId, values), + ) { "The pending dynamic mutation is invalid." } + val target = requireNotNull(pendingDynamicMutationFile(session, appId, actionId, targetRecordId)) { + "The pending dynamic mutation identity is invalid." + } + check(pendingDynamicMutationDirectory.mkdirs() || pendingDynamicMutationDirectory.isDirectory) { + "Could not create the pending mutation store." + } + val temporary = File(pendingDynamicMutationDirectory, "${target.name}.part") + FileOutputStream(temporary).use { output -> + output.write(encoded.encodeToByteArray()) + output.fd.sync() + } + publishAndroidPendingMutation(temporary, target) } - publishAndroidPendingMutation(temporary, target) } override suspend fun clearPendingDynamicMutation( @@ -913,8 +902,16 @@ internal class AndroidNextcloudServices( actionId: String, targetRecordId: String, ) = withContext(Dispatchers.IO) { - pendingDynamicMutationFile(session, appId, actionId, targetRecordId)?.let { target -> - check(!target.exists() || target.delete()) { "Could not clear the pending mutation." } + withAndroidAccountPrivateStatePublication( + expectedSession = session, + credentialMutationMutex = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX, + guard = ANDROID_ACCOUNT_OPERATION_GUARD, + resolveSession = { accountCredentials.loadSession(session.accountId) }, + unavailable = { error("The account changed before the pending mutation could be cleared.") }, + ) { + pendingDynamicMutationFile(session, appId, actionId, targetRecordId)?.let { target -> + check(!target.exists() || target.delete()) { "Could not clear the pending mutation." } + } } Unit } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPendingDynamicMutationPublication.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPendingDynamicMutationPublication.kt new file mode 100644 index 000000000..62e38f4c0 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPendingDynamicMutationPublication.kt @@ -0,0 +1,36 @@ +package dev.obiente.nextcloudnative + +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** Publishes a pre-synced mutation marker before its non-idempotent request may start. */ +internal fun publishAndroidPendingMutation(temporary: File, target: File) { + require(temporary.isFile) + require(temporary.parentFile == target.parentFile) + try { + Files.move( + temporary.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + copyAndSyncAndroidPendingMutation(temporary, target) + } +} + +internal fun copyAndSyncAndroidPendingMutation(temporary: File, target: File) { + require(temporary.isFile) + require(temporary.parentFile == target.parentFile) + FileInputStream(temporary).use { input -> + FileOutputStream(target).use { output -> + input.copyTo(output) + output.fd.sync() + } + } + check(temporary.delete()) { "Could not clear the published pending mutation staging file." } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index cd7743807..4b3fb0105 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -10,10 +10,115 @@ import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield class AndroidAccountOperationGuardTest { + @Test + fun lateDurableWriterCannotPublishAfterRemovalAndCredentialReplacement() = runBlocking { + val guard = AndroidAccountOperationGuard() + val credentialMutations = Mutex() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val replacement = original.copy(appPassword = "replacement-password") + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var current: NextcloudSession? = original + var durablePublished = false + val removal = async { + credentialMutations.withLock { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { + current = replacement + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + } + removalEntered.await() + + val staleWriter = async { + withAndroidAccountPrivateStatePublication( + expectedSession = original, + credentialMutationMutex = credentialMutations, + guard = guard, + resolveSession = { current }, + unavailable = { false }, + ) { + durablePublished = true + true + } + } + yield() + assertFalse(staleWriter.isCompleted) + releaseRemoval.complete(Unit) + removal.await() + + assertFalse(staleWriter.await()) + assertFalse(durablePublished) + assertTrue( + withAndroidAccountPrivateStatePublication( + expectedSession = replacement, + credentialMutationMutex = credentialMutations, + guard = guard, + resolveSession = { current }, + unavailable = { false }, + publish = { true }, + ), + ) + } + + @Test + fun latePendingWriterCannotPublishAfterRemovalAndReadd() = runBlocking { + val guard = AndroidAccountOperationGuard() + val credentialMutations = Mutex() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val readded = original.copy(appPassword = "new-password") + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var current: NextcloudSession? = original + var pendingPublished = false + val removal = async { + credentialMutations.withLock { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { + current = readded + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + } + removalEntered.await() + + val staleWriter = async { + withAndroidAccountPrivateStatePublication( + expectedSession = original, + credentialMutationMutex = credentialMutations, + guard = guard, + resolveSession = { current }, + unavailable = { false }, + ) { + pendingPublished = true + true + } + } + yield() + releaseRemoval.complete(Unit) + removal.await() + + assertFalse(staleWriter.await()) + assertFalse(pendingPublished) + assertTrue( + withAndroidAccountPrivateStatePublication( + expectedSession = readded, + credentialMutationMutex = credentialMutations, + guard = guard, + resolveSession = { current }, + unavailable = { false }, + publish = { true }, + ), + ) + } + @Test fun staleSyncSessionIsRejectedAfterAnAccountTransition() { val previous = dev.obiente.nextcloudnative.app.NextcloudSession( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt index 6cc56af48..878a550bf 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt @@ -14,8 +14,24 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails import kotlin.test.assertNull +import kotlin.test.assertSame class AndroidDynamicApiCachePolicyTest { + @Test + fun `Android services sharing a canonical cache root share removal fences`() { + val parent = Files.createTempDirectory("android-dynamic-process-state-").toFile() + try { + val first = androidDynamicApiProcessState(parent.resolve("cache")) + val second = androidDynamicApiProcessState(parent.resolve("nested/../cache")) + + assertSame(first, second) + assertSame(first.cache, second.cache) + assertSame(first.coalescer, second.coalescer) + } finally { + parent.deleteRecursively() + } + } + @Test fun `force network bypasses both Android dynamic cache reads`() = runBlocking { val coalescer = DynamicApiRequestCoalescer() @@ -107,4 +123,42 @@ class AndroidDynamicApiCachePolicyTest { } } } + + @Test + fun `second Android service cannot commit a GET that crossed account removal`() = runBlocking { + supervisorScope { + val root = Files.createTempDirectory("android-dynamic-cross-service-").toFile() + try { + val accountId = "b".repeat(64) + val requestIdentity = "GET /dashboard/widgets" + val firstService = androidDynamicApiProcessState(root) + val secondService = androidDynamicApiProcessState(root.resolve(".")) + val started = CompletableDeferred() + val release = CompletableDeferred() + val response = CachedDynamicApiResponse(200, "private".encodeToByteArray(), null, null) + val read = async { + secondService.coalescer.execute(accountId, requestIdentity, load = { + started.complete(Unit) + release.await() + NextcloudApiResponse(200, response.body, response.contentType, response.etag) + }, commit = { loaded -> + secondService.cache.store( + accountId, + requestIdentity, + CachedDynamicApiResponse(loaded.status, loaded.body, loaded.contentType, loaded.etag), + ) + }) + } + started.await() + + clearAndroidDynamicApiState(accountId, firstService.coalescer, firstService.cache) + release.complete(Unit) + + assertFails { read.await() } + assertNull(firstService.cache.load(accountId, requestIdentity, 1_024)) + } finally { + root.deleteRecursively() + } + } + } } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt index e2f4e6727..9c7682024 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt @@ -195,7 +195,7 @@ fun NativeGroupwareCalendarScreen( mutationOperationInProgress = true onMutationInProgressChanged(true) val saved = try { - services.saveDurableMutationRecovery(accountScope, DurableMutationRecoveryKind.Calendar, encoded) + services.saveDurableMutationRecovery(session, accountScope, DurableMutationRecoveryKind.Calendar, encoded) } catch (failure: CancellationException) { throw failure } catch (_: Exception) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt index 94a5afa85..85c3d10e9 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt @@ -121,7 +121,7 @@ fun NativeGroupwareContactsScreen( mutationOperationInProgress = true onMutationInProgressChanged(true) val saved = try { - services.saveDurableMutationRecovery(accountScope, DurableMutationRecoveryKind.Contacts, encoded) + services.saveDurableMutationRecovery(session, accountScope, DurableMutationRecoveryKind.Contacts, encoded) } catch (failure: CancellationException) { throw failure } catch (_: Exception) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt index ba21aa339..55e57e3c5 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt @@ -110,7 +110,7 @@ fun NativeGroupwareTasksScreen( recoveryVerification = TaskRecoveryVerification.Unknown onMutationInProgressChanged(true) val saved = try { - services.saveDurableMutationRecovery(accountScope, DurableMutationRecoveryKind.Tasks, encoded) + services.saveDurableMutationRecovery(session, accountScope, DurableMutationRecoveryKind.Tasks, encoded) } catch (failure: CancellationException) { throw failure } catch (_: Exception) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt index 48250ac91..e6e4ef4c5 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt @@ -1378,6 +1378,7 @@ internal fun NextcloudNoteEditor( .encodeForDurableStorage() val saved = try { services.saveDurableMutationRecovery( + session, accountScope, DurableMutationRecoveryKind.NoteDeletion, encoded, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index c034cb47a..744f4ccdc 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -624,6 +624,7 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa ): String? = null suspend fun saveDurableMutationRecovery( + session: NextcloudSession, accountScope: String, kind: DurableMutationRecoveryKind, encoded: String, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index fabcd59c9..777de5439 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -92,6 +92,16 @@ internal suspend fun DesktopAccountOperationGuard.withAuthenticatedMuta action(requireNotNull(current)) } +internal suspend fun DesktopAccountOperationGuard.withAccountPrivateStatePublication( + expectedSession: NextcloudSession, + resolveSession: suspend () -> NextcloudSession?, + unavailable: suspend () -> Result, + publish: suspend () -> Result, +): Result = serialize { + val current = resolveSession() + if (current == expectedSession) publish() else unavailable() +} + internal fun requireDesktopAccountRemovalWritebacksResolved(pendingWritebackCount: Int) { check(pendingWritebackCount == 0) { "Finish or discard pending virtual file changes before removing this 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 db3c9af58..d17998a53 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3332,10 +3332,20 @@ class DesktopNextcloudServices( ): String? = withContext(Dispatchers.IO) { durableMutationRecovery.load(accountScope, kind) } override suspend fun saveDurableMutationRecovery( + session: NextcloudSession, accountScope: String, kind: DurableMutationRecoveryKind, encoded: String, - ): Boolean = withContext(Dispatchers.IO) { durableMutationRecovery.save(accountScope, kind, encoded) } + ): Boolean = withContext(Dispatchers.IO) { + if (durableMutationAccountScope(session) != accountScope) return@withContext false + accountOperationGuard.withAccountPrivateStatePublication( + expectedSession = session, + resolveSession = { loadSession(session.accountId) }, + unavailable = { false }, + ) { + durableMutationRecovery.save(accountScope, kind, encoded) + } + } override suspend fun clearDurableMutationRecovery( accountScope: String, @@ -3428,17 +3438,23 @@ class DesktopNextcloudServices( targetRecordId: String, values: Map, ) = withContext(Dispatchers.IO) { - val encoded = requireNotNull( - encodePersistedDynamicMutation(appId, actionId, targetRecordId, values), - ) { "The pending dynamic mutation is invalid." } - val target = requireNotNull(pendingDynamicMutationFile(session, appId, actionId, targetRecordId)) { - "The pending dynamic mutation identity is invalid." - } - writePrivatePendingMutationFile( - directory = pendingDynamicMutationDirectory, - target = target, - bytes = encoded.encodeToByteArray(), - ) + accountOperationGuard.withAccountPrivateStatePublication( + expectedSession = session, + resolveSession = { loadSession(session.accountId) }, + unavailable = { error("The account changed before the pending mutation could be recorded.") }, + ) { + val encoded = requireNotNull( + encodePersistedDynamicMutation(appId, actionId, targetRecordId, values), + ) { "The pending dynamic mutation is invalid." } + val target = requireNotNull(pendingDynamicMutationFile(session, appId, actionId, targetRecordId)) { + "The pending dynamic mutation identity is invalid." + } + writePrivatePendingMutationFile( + directory = pendingDynamicMutationDirectory, + target = target, + bytes = encoded.encodeToByteArray(), + ) + } Unit } @@ -3448,8 +3464,14 @@ class DesktopNextcloudServices( actionId: String, targetRecordId: String, ) = withContext(Dispatchers.IO) { - pendingDynamicMutationFile(session, appId, actionId, targetRecordId)?.let { target -> - check(!target.exists() || target.delete()) { "Could not clear the pending mutation." } + accountOperationGuard.withAccountPrivateStatePublication( + expectedSession = session, + resolveSession = { loadSession(session.accountId) }, + unavailable = { error("The account changed before the pending mutation could be cleared.") }, + ) { + pendingDynamicMutationFile(session, appId, actionId, targetRecordId)?.let { target -> + check(!target.exists() || target.delete()) { "Could not clear the pending mutation." } + } } Unit } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index bcc811196..fe9f74f0b 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -19,6 +19,95 @@ import java.util.prefs.Preferences import kotlin.concurrent.thread class DesktopAccountOperationGuardTest { + @Test + fun lateDurableWriterCannotPublishAfterRemovalAndCredentialReplacement() = runBlocking { + val guard = DesktopAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val replacement = original.copy(appPassword = "replacement-password") + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var current: NextcloudSession? = original + var durablePublished = false + val removal = async { + guard.serialize { + current = replacement + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + removalEntered.await() + + val staleWriter = async { + guard.withAccountPrivateStatePublication( + expectedSession = original, + resolveSession = { current }, + unavailable = { false }, + ) { + durablePublished = true + true + } + } + yield() + assertFalse(staleWriter.isCompleted) + releaseRemoval.complete(Unit) + removal.await() + + assertFalse(staleWriter.await()) + assertFalse(durablePublished) + assertTrue( + guard.withAccountPrivateStatePublication( + expectedSession = replacement, + resolveSession = { current }, + unavailable = { false }, + publish = { true }, + ), + ) + } + + @Test + fun latePendingWriterCannotPublishAfterRemovalAndReadd() = runBlocking { + val guard = DesktopAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val readded = original.copy(appPassword = "new-password") + val removalEntered = CompletableDeferred() + val releaseRemoval = CompletableDeferred() + var current: NextcloudSession? = original + var pendingPublished = false + val removal = async { + guard.serialize { + current = readded + removalEntered.complete(Unit) + releaseRemoval.await() + } + } + removalEntered.await() + + val staleWriter = async { + guard.withAccountPrivateStatePublication( + expectedSession = original, + resolveSession = { current }, + unavailable = { false }, + ) { + pendingPublished = true + true + } + } + yield() + releaseRemoval.complete(Unit) + removal.await() + + assertFalse(staleWriter.await()) + assertFalse(pendingPublished) + assertTrue( + guard.withAccountPrivateStatePublication( + expectedSession = readded, + resolveSession = { current }, + unavailable = { false }, + publish = { true }, + ), + ) + } + @Test fun abortedAccountSelectionAlwaysRestartsDesktopSync() = runBlocking { var restartCount = 0 From e04fd663529c97508aa9b5cfce2b2b6378b15047 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 16:15:39 +0200 Subject: [PATCH 083/119] fix(account): purge removal residual state --- .../AndroidAccountMutationRecoveryCleanup.kt | 10 +- .../AndroidAccountOwnedStateCleanup.kt | 4 + .../AndroidMediaBackupAccountCleanup.kt | 26 ++++ .../AndroidNextcloudServices.kt | 7 +- ...droidAccountMutationRecoveryCleanupTest.kt | 22 ++- .../AndroidMediaBackupAccountCleanupTest.kt | 135 ++++++++++++++++++ .../app/DynamicApiRequestCoalescer.kt | 36 +++-- .../app/DynamicApiRequestCoalescerTest.kt | 35 ++++- .../app/DesktopExternalFileHandoff.kt | 96 ++++++++++++- .../app/DesktopNextcloudServices.kt | 98 +++++++------ .../app/DesktopExternalFileHandoffTest.kt | 112 ++++++++++++++- 11 files changed, 514 insertions(+), 67 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanupTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt index 33873bb92..5bab6f672 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanup.kt @@ -5,6 +5,7 @@ import android.content.SharedPreferences import dev.obiente.nextcloudnative.app.DurableMutationRecoveryKind import dev.obiente.nextcloudnative.app.isSafePendingMutationId import java.io.File +import java.nio.file.Files internal class AndroidAccountMutationRecoveryCleanup( private val preferences: SharedPreferences, @@ -47,12 +48,19 @@ internal class AndroidAccountMutationRecoveryCleanup( } val candidates = pendingDynamicMutationDirectory.listFiles() ?: error("The pending mutation store could not be read.") + val ownedPrefix = "$accountIdentity-" candidates - .filter { candidate -> candidate.isOwnedPendingDynamicMutation(accountIdentity) } + .filter { candidate -> candidate.name.startsWith(ownedPrefix) } .forEach { candidate -> + check(candidate.isOwnedPendingDynamicMutation(accountIdentity)) { + "The pending mutation store contains an unsupported account entry." + } check(candidate.canonicalFile.parentFile == pendingDynamicMutationDirectory) { "Unsafe pending mutation cleanup path." } + check(candidate.isFile && !Files.isSymbolicLink(candidate.toPath())) { + "The pending mutation account entry is not a regular file." + } check(!candidate.exists() || candidate.delete() && !candidate.exists()) { "Could not clear this account's pending mutation." } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index 774d9b297..19538ec84 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -24,6 +24,7 @@ internal class AndroidAccountOwnedStateCleanup( private val fileOffline = AndroidFileOfflineAccountCleanup(appContext) private val incomingShares = AndroidIncomingShareAccountCleanup(appContext) private val durableUploads = AndroidDurableUploadAccountCleanup(appContext) + private val mediaBackupLedger = AndroidMediaBackupAccountCleanup(appContext) private val mutationRecovery = AndroidAccountMutationRecoveryCleanup(appContext) suspend fun remove(session: NextcloudSession) { @@ -38,6 +39,7 @@ internal class AndroidAccountOwnedStateCleanup( { incomingShares.removeForAccount(session) }, { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { mediaBackupLedger.removeForAccount(accountIdentity) }, { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, { mutationRecovery.clearDurableRecoveries(durableMutationAccountScope(session)) }, @@ -62,6 +64,7 @@ internal class AndroidAccountOwnedStateCleanup( { incomingShares.removeForAccount(accountIdentity, session) }, { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { mediaBackupLedger.removeForAccount(accountIdentity) }, { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, @@ -85,6 +88,7 @@ internal class AndroidAccountOwnedStateCleanup( { incomingShares.removeForAccount(accountIdentity) }, { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { mediaBackupLedger.removeForAccount(accountIdentity) }, { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt new file mode 100644 index 000000000..bac1319c3 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt @@ -0,0 +1,26 @@ +package dev.obiente.nextcloudnative + +import android.content.Context +import dev.obiente.nextcloudnative.app.MediaBackupLedgerStore + +internal class AndroidMediaBackupAccountCleanup( + private val openStore: () -> MediaBackupLedgerStore, +) { + constructor(context: Context) : this( + openStore = { + createAndroidMediaBackupLedgerStore( + context = context.applicationContext, + recoverInterruptedTransfers = false, + ) + }, + ) + + suspend fun removeForAccount(accountId: String) { + val store = openStore() + try { + store.deleteAccount(accountId) + } finally { + store.close() + } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index fce985287..72d83c62e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -939,8 +939,11 @@ internal class AndroidNextcloudServices( override suspend fun prepareDeckCardDraftRecovery(session: NextcloudSession) = withContext(Dispatchers.IO) { deckCardDrafts.migrateLegacyEntries(session) } - override suspend fun saveSession(session: NextcloudSession): NextcloudSession = - accountCredentials.saveSession(session) + override suspend fun saveSession(session: NextcloudSession): NextcloudSession { + val persisted = accountCredentials.saveSession(session) + dynamicApiRequestCoalescer.activateAccount(NextcloudDocumentIds.cacheAccountId(persisted)) + return persisted + } internal fun accountRetentionSnapshot() = accountCredentials.accountRetentionSnapshot() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt index 476c89566..39052c7a5 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountMutationRecoveryCleanupTest.kt @@ -33,7 +33,6 @@ class AndroidAccountMutationRecoveryCleanupTest { val removedPublished = File(root, "$removed-notes-$digest.json").apply { writeText("removed") } val removedStaging = File(root, "$removed-calendar-$digest.json.part").apply { writeText("removed") } val retainedPublished = File(root, "$retained-notes-$digest.json").apply { writeText("retained") } - val malformedLookalike = File(root, "$removed-bad app-$digest.json").apply { writeText("retained") } val cleanup = AndroidAccountMutationRecoveryCleanup(recordingPreferences(values), root) repeat(2) { @@ -49,7 +48,6 @@ class AndroidAccountMutationRecoveryCleanupTest { assertFalse(removedPublished.exists()) assertFalse(removedStaging.exists()) assertTrue(retainedPublished.isFile) - assertTrue(malformedLookalike.isFile) assertTrue(outside.isFile) } finally { root.deleteRecursively() @@ -57,6 +55,26 @@ class AndroidAccountMutationRecoveryCleanupTest { } } + @Test + fun malformedOrNewerAccountEntryDefersCleanupInsteadOfBeingIgnored() { + val root = Files.createTempDirectory("android-account-mutation-unknown-").toFile() + try { + val removed = "d".repeat(64) + val unknown = File(root, "$removed-notes-${"2".repeat(64)}.json.v2").apply { + writeText("future-format") + } + val cleanup = AndroidAccountMutationRecoveryCleanup(recordingPreferences(linkedMapOf()), root) + + assertFailsWith { + cleanup.clearPendingDynamicMutations(removed) + } + + assertTrue(unknown.isFile) + } finally { + root.deleteRecursively() + } + } + @Test fun invalidPendingIdentityCannotEscapeTheMutationDirectory() { val root = Files.createTempDirectory("android-account-mutation-confinement-").toFile() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanupTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanupTest.kt new file mode 100644 index 000000000..7b00b4230 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanupTest.kt @@ -0,0 +1,135 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.LocalMediaObject +import dev.obiente.nextcloudnative.app.MediaBackupLedgerRecord +import dev.obiente.nextcloudnative.app.MediaBackupLedgerStore +import dev.obiente.nextcloudnative.app.MediaBackupTransferState +import java.nio.file.Files +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class AndroidMediaBackupAccountCleanupTest { + @Test + fun cleanupDeletesOnlyTheRemovedAccountsLedgerRowsAndIsIdempotent() = runBlocking { + val database = Files.createTempDirectory("android-media-cleanup-").resolve("ledger.db").toFile() + val removed = "a".repeat(64) + val retained = "b".repeat(64) + try { + MediaBackupLedgerStore(database.absolutePath).also { store -> + store.upsert(record(removed, "removed")) + store.upsert(record(retained, "retained")) + store.close() + } + val cleanup = AndroidMediaBackupAccountCleanup { + MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false) + } + + repeat(2) { cleanup.removeForAccount(removed) } + + MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false).also { store -> + assertNull(store.load(removed, "removed")) + assertNotNull(store.load(retained, "retained")) + store.close() + } + } finally { + database.parentFile.deleteRecursively() + } + } + + @Test + fun failedOpenLeavesRowsForJournaledRetry() = runBlocking { + val database = Files.createTempDirectory("android-media-cleanup-retry-").resolve("ledger.db").toFile() + val removed = "c".repeat(64) + try { + MediaBackupLedgerStore(database.absolutePath).also { store -> + store.upsert(record(removed, "pending")) + store.close() + } + var failOpen = true + val cleanup = AndroidMediaBackupAccountCleanup { + if (failOpen) error("synthetic ledger open failure") + MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false) + } + + assertFailsWith { cleanup.removeForAccount(removed) } + failOpen = false + MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false).also { store -> + assertNotNull(store.load(removed, "pending")) + store.close() + } + + cleanup.removeForAccount(removed) + + MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false).also { store -> + assertNull(store.load(removed, "pending")) + store.close() + } + } finally { + database.parentFile.deleteRecursively() + } + } + + @Test + fun accountRemovalWaitsForAStartedLedgerWriterAndDeletesItsResult() = runBlocking { + val database = Files.createTempDirectory("android-media-cleanup-race-").resolve("ledger.db").toFile() + val accountId = "d".repeat(64) + val guard = AndroidAccountOperationGuard() + val writerStarted = CompletableDeferred() + val finishWriter = CompletableDeferred() + var removalFinished = false + try { + val cleanup = AndroidMediaBackupAccountCleanup { + MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false) + } + val writer = async { + guard.withAccount(accountId) { + writerStarted.complete(Unit) + finishWriter.await() + MediaBackupLedgerStore(database.absolutePath).also { store -> + store.upsert(record(accountId, "late")) + store.close() + } + } + } + writerStarted.await() + val removal = async(start = CoroutineStart.UNDISPATCHED) { + guard.withAccount(accountId) { + cleanup.removeForAccount(accountId) + removalFinished = true + } + } + + assertFalse(removalFinished) + finishWriter.complete(Unit) + writer.await() + removal.await() + MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false).also { store -> + assertNull(store.load(accountId, "late")) + store.close() + } + } finally { + database.parentFile.deleteRecursively() + } + } + + private fun record(accountId: String, key: String) = MediaBackupLedgerRecord( + accountId = accountId, + local = LocalMediaObject( + key = key, + displayName = "$key.jpg", + size = 4, + revision = "generation-1", + ), + receipt = null, + transferState = MediaBackupTransferState.Pending, + attemptCount = 0, + updatedAtEpochMillis = 1, + ) +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt index 35b35edb5..627b8ab19 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt @@ -26,7 +26,9 @@ class DynamicApiRequestCoalescer { private val mutex = Mutex() private val accountGenerations = mutableMapOf() + private val closedAccounts = mutableSetOf() private val fencedAccountGenerations = mutableMapOf() + private val fencedInFlight = mutableMapOf>>() private val requestGenerations = mutableMapOf() private val inFlight = mutableMapOf>() @@ -40,8 +42,10 @@ class DynamicApiRequestCoalescer { val key = Key(accountId, requestIdentity) var owner = false val entry = mutex.withLock { - inFlight[key] ?: InFlight( - accountGeneration = accountGenerations[accountId] ?: 0L, + if (accountId in closedAccounts) throw DynamicReadAccountFencedException() + val accountGeneration = accountGenerations[accountId] ?: 0L + inFlight[key]?.takeIf { current -> current.accountGeneration == accountGeneration } ?: InFlight( + accountGeneration = accountGeneration, requestGeneration = requestGenerations[key] ?: 0L, result = CompletableDeferred(), ).also { @@ -66,7 +70,7 @@ class DynamicApiRequestCoalescer { inFlight.remove(key, entry) entry.result.completeExceptionally(failure) retireRequestGenerationIfIdle(key, entry.requestGeneration) - retireAccountFenceIfIdle(accountId) + retireAccountFenceEntry(accountId, entry) } } throw failure @@ -80,7 +84,7 @@ class DynamicApiRequestCoalescer { entry.result.completeExceptionally(failure) retireRequestGenerationIfIdle(key, entry.requestGeneration) } - retireAccountFenceIfIdle(accountId) + retireAccountFenceEntry(accountId, entry) cause } if (invalidation == InvalidationCause.Invalidated) continue @@ -92,7 +96,7 @@ class DynamicApiRequestCoalescer { if (cause != InvalidationCause.None) { inFlight.remove(key, entry) entry.result.completeExceptionally(cause.exception()) - retireAccountFenceIfIdle(accountId) + retireAccountFenceEntry(accountId, entry) cause } else { try { @@ -124,20 +128,30 @@ class DynamicApiRequestCoalescer { /** * Invalidates an account and terminates reads that entered before the fence. - * Reads invoked after the fence use the new generation normally. + * The account remains closed until credential activation explicitly reopens it. */ suspend fun fenceAccount(accountId: String, invalidate: () -> Unit) { mutex.withLock { val generation = (accountGenerations[accountId] ?: 0L) + 1L accountGenerations[accountId] = generation - if (inFlight.any { (key, entry) -> key.accountId == accountId && entry.accountGeneration < generation }) { + closedAccounts += accountId + val fenced = inFlight.filter { (key, entry) -> + key.accountId == accountId && entry.accountGeneration < generation + }.values + if (fenced.isNotEmpty()) { fencedAccountGenerations[accountId] = generation + fencedInFlight.getOrPut(accountId, ::mutableSetOf).addAll(fenced) } requestGenerations.keys.removeAll { it.accountId == accountId } invalidate() } } + /** Reopens reads only after the caller has persisted the exact account credentials. */ + suspend fun activateAccount(accountId: String) { + mutex.withLock { closedAccounts.remove(accountId) } + } + suspend fun invalidateRequest( accountId: String, requestIdentity: String, @@ -179,9 +193,11 @@ class DynamicApiRequestCoalescer { } } - private fun retireAccountFenceIfIdle(accountId: String) { - val fence = fencedAccountGenerations[accountId] ?: return - if (inFlight.none { (key, entry) -> key.accountId == accountId && entry.accountGeneration < fence }) { + private fun retireAccountFenceEntry(accountId: String, entry: InFlight) { + val entries = fencedInFlight[accountId] ?: return + entries.remove(entry) + if (entries.isEmpty()) { + fencedInFlight.remove(accountId) fencedAccountGenerations.remove(accountId) } } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt index 49363602a..6f19b0083 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt @@ -109,17 +109,50 @@ class DynamicApiRequestCoalescerTest { } @Test - fun `read invoked after an account removal fence uses the new generation`() = runBlocking { + fun `read invoked after an account removal fence stays closed until activation`() = runBlocking { val coalescer = DynamicApiRequestCoalescer() coalescer.fenceAccount("account-a") {} + assertFailsWith { + coalescer.execute("account-a", "GET items", load = { fail("must remain closed") }) + } + coalescer.activateAccount("account-a") assertEquals( "re-added-account-data", coalescer.execute("account-a", "GET items", load = { "re-added-account-data" }), ) } + @Test + fun `readding an account does not let its stale read commit`() = runBlocking { + supervisorScope { + val coalescer = DynamicApiRequestCoalescer() + val readStarted = CompletableDeferred() + val finishRead = CompletableDeferred() + val committed = mutableListOf() + val stale = async { + coalescer.execute("account-a", "GET items", load = { + readStarted.complete(Unit) + finishRead.await() + "removed-account-data" + }, commit = committed::add) + } + + readStarted.await() + coalescer.fenceAccount("account-a") { committed.clear() } + coalescer.activateAccount("account-a") + val replacement = async { + coalescer.execute("account-a", "GET items", load = { "replacement-account-data" }, commit = committed::add) + } + finishRead.complete(Unit) + + assertFailsWith { stale.await() } + assertEquals("replacement-account-data", replacement.await()) + assertEquals(listOf("replacement-account-data"), committed) + } + } + @Test fun `cancelled owner remains cancelled and releases its in flight entry`() = runBlocking { val coalescer = DynamicApiRequestCoalescer() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt index a68c66b7f..123613c5c 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt @@ -10,6 +10,7 @@ import java.io.FileOutputStream import java.io.RandomAccessFile import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files +import java.nio.file.LinkOption import java.nio.file.StandardCopyOption import java.util.UUID import java.util.concurrent.atomic.AtomicReference @@ -28,7 +29,12 @@ internal class DesktopExternalFileHandoff( private val exportFile: (File) -> DesktopStagedFileExport = ::exportDesktopStagedFile, private val reservations: DesktopStagingSpaceReservations = sharedDesktopStagingSpaceReservations, ) { + init { + pruneLegacyDesktopExternalFileCache(root) + } + suspend fun launch( + accountId: String, file: NextcloudFile, action: ExternalFileHandoffAction, capability: ExternalFileHandoffCapability, @@ -40,7 +46,7 @@ internal class DesktopExternalFileHandoff( validateDownloadedExternalFile(file, content, capability.maximumInMemoryFileBytes)?.let { rejection -> return@withContext DesktopStagedExternalFile.Rejected(rejection) } - DesktopStagedExternalFile.Ready(stageDetachedCopy(file.name, content.bytes)) + DesktopStagedExternalFile.Ready(stageDetachedCopy(accountId, file.name, content.bytes)) } if (staged is DesktopStagedExternalFile.Rejected) return staged.result staged as DesktopStagedExternalFile.Ready @@ -48,6 +54,7 @@ internal class DesktopExternalFileHandoff( } suspend fun launchStreamed( + accountId: String, file: NextcloudFile, action: ExternalFileHandoffAction, capability: ExternalFileHandoffCapability, @@ -56,6 +63,7 @@ internal class DesktopExternalFileHandoff( validateExternalFileHandoff(file, action, capability)?.let { return it } val staged = withContext(Dispatchers.IO) { stageStreamedCopy( + accountId = accountId, sourceName = file.name, declaredByteCount = file.size, expectedEtag = requireSafeFileRangeEtag(requireNotNull(file.etag)), @@ -66,6 +74,7 @@ internal class DesktopExternalFileHandoff( } suspend fun launchDetached( + accountId: String, attachment: DeckAttachment, action: ExternalFileHandoffAction, capability: ExternalFileHandoffCapability, @@ -77,6 +86,7 @@ internal class DesktopExternalFileHandoff( validateDeckAttachmentHandoff(attachment, action, capability)?.let { return it } val staged = withContext(Dispatchers.IO) { stageStreamedCopy( + accountId = accountId, sourceName = attachment.name, declaredByteCount = attachment.byteCount, download = download, @@ -113,13 +123,13 @@ internal class DesktopExternalFileHandoff( } private suspend fun stageStreamedCopy( + accountId: String, sourceName: String, declaredByteCount: Long?, expectedEtag: String? = null, download: suspend (FileOutputStream, Long) -> DesktopDetachedDownload, ): File { - check(root.isDirectory || root.mkdirs()) { "Could not create the desktop external-file cache." } - val canonicalRoot = root.canonicalFile + val canonicalRoot = prepareAccountRoot(accountId) pruneDesktopExternalFileCache(canonicalRoot, declaredByteCount ?: 0L) val reservation = reservations.reserve( root = canonicalRoot, @@ -170,10 +180,9 @@ internal class DesktopExternalFileHandoff( } } - private fun stageDetachedCopy(sourceName: String, bytes: ByteArray): File { + private fun stageDetachedCopy(accountId: String, sourceName: String, bytes: ByteArray): File { require(bytes.size.toLong() <= MAX_IN_MEMORY_EXTERNAL_FILE_HANDOFF_BYTES) - check(root.isDirectory || root.mkdirs()) { "Could not create the desktop external-file cache." } - val canonicalRoot = root.canonicalFile + val canonicalRoot = prepareAccountRoot(accountId) pruneDesktopExternalFileCache(canonicalRoot, bytes.size.toLong()) reservations.reserve( root = canonicalRoot, @@ -212,12 +221,65 @@ internal class DesktopExternalFileHandoff( } } + fun removeAccount(accountId: String) { + requireDesktopExternalFileHandoffAccountId(accountId) + pruneLegacyDesktopExternalFileCache(root) + val rootPath = root.toPath().toAbsolutePath().normalize() + if (!Files.exists(rootPath, LinkOption.NOFOLLOW_LINKS)) return + check(Files.isDirectory(rootPath, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(rootPath)) { + "The desktop external-file cache root is not a safe directory." + } + val accountPath = rootPath.resolve(accountId) + if (!Files.exists(accountPath, LinkOption.NOFOLLOW_LINKS)) return + check(accountPath.parent == rootPath && !Files.isSymbolicLink(accountPath)) { + "Unsafe desktop external-file account directory." + } + check(Files.isDirectory(accountPath, LinkOption.NOFOLLOW_LINKS)) { + "The desktop external-file account entry is not a directory." + } + check(accountPath.toFile().deleteRecursively() && !Files.exists(accountPath, LinkOption.NOFOLLOW_LINKS)) { + "Could not clear this account's desktop external-file copies." + } + } + + private fun prepareAccountRoot(accountId: String): File { + requireDesktopExternalFileHandoffAccountId(accountId) + pruneLegacyDesktopExternalFileCache(root) + check(root.isDirectory || root.mkdirs()) { "Could not create the desktop external-file cache." } + val canonicalRoot = root.canonicalFile + val accountRoot = File(canonicalRoot, accountId) + check(accountRoot.isDirectory || accountRoot.mkdir()) { + "Could not create the desktop external-file account cache." + } + check(accountRoot.canonicalFile.parentFile == canonicalRoot) { + "Unsafe desktop external-file account directory." + } + return accountRoot.canonicalFile + } + private sealed interface DesktopStagedExternalFile { data class Ready(val file: File) : DesktopStagedExternalFile data class Rejected(val result: ExternalFileHandoffResult.Rejected) : DesktopStagedExternalFile } } +internal suspend fun DesktopAccountOperationGuard.withExternalFileHandoffSession( + expectedSession: NextcloudSession, + resolveSession: suspend () -> NextcloudSession?, + handoff: suspend () -> Result, +): Result = withAccountPrivateStatePublication( + expectedSession = expectedSession, + resolveSession = resolveSession, + unavailable = { error("The account changed before the external file copy could be published.") }, + publish = handoff, +) + +private fun requireDesktopExternalFileHandoffAccountId(accountId: String) { + require(accountId.length == 64 && accountId.all { character -> + character in '0'..'9' || character in 'a'..'f' + }) { "The desktop external-file account identity is invalid." } +} + internal enum class DesktopStagedFileExport { Exported, Cancelled, @@ -235,6 +297,26 @@ internal fun desktopExternalFileHandoffDirectory(): File { return File(cacheRoot, "nextcloud-native/external-open") } +internal fun pruneLegacyDesktopExternalFileCache( + root: File, + nowMillis: Long = System.currentTimeMillis(), +) { + val rootPath = root.toPath().toAbsolutePath().normalize() + if (!Files.exists(rootPath, LinkOption.NOFOLLOW_LINKS)) return + if (!Files.isDirectory(rootPath, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(rootPath)) return + Files.newDirectoryStream(rootPath).use { entries -> + entries.forEach { entry -> + val name = entry.fileName.toString() + if (!name.matches(LEGACY_EXTERNAL_FILE_OPERATION_DIRECTORY)) return@forEach + if (!Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry)) return@forEach + val modified = Files.getLastModifiedTime(entry, LinkOption.NOFOLLOW_LINKS).toMillis() + if (nowMillis >= modified && nowMillis - modified > DESKTOP_EXTERNAL_FILE_MAX_AGE_MILLIS) { + entry.toFile().deleteRecursively() + } + } + } +} + internal fun pruneDesktopExternalFileCache( root: File, requiredBytes: Long, @@ -367,6 +449,8 @@ private fun desktopRecursiveFileBytes(file: File): Long = when { } private const val DESKTOP_EXTERNAL_FILE_MINIMUM_RETENTION_MILLIS = 60L * 60L * 1000L +private val LEGACY_EXTERNAL_FILE_OPERATION_DIRECTORY = + Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") private fun saturatingDesktopFileBytes(left: Long, right: Long): Long = if (right > Long.MAX_VALUE - left) Long.MAX_VALUE else left + right diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index d17998a53..f78a3b131 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3518,6 +3518,7 @@ class DesktopNextcloudServices( accountCredentials.saveSession(session).also(accountSessionPublication::publish) } } + dynamicApiRequestCoalescer.activateAccount(desktopFileCacheAccountId(persistedSession)) synchronized(fileRangeSessionLock) { sessionClearing = false } startDesktopSyncLifecycle() persistedSession @@ -3858,6 +3859,7 @@ class DesktopNextcloudServices( clearDesktopDynamicApiState(accountId, dynamicApiRequestCoalescer, dynamicApiReadCache) removeDesktopPendingDynamicMutations(pendingDynamicMutationDirectory, accountId) cleanup.durableMutationAccountScope?.let(durableMutationRecovery::removeAccount) + externalFileHandoff.removeAccount(accountId) removeDesktopAccountPrivateStorage(accountId, fileSyncEngine, fileReadCache, virtualRangeCache(accountId)) if (!isWindowsDesktop()) return try { @@ -3932,19 +3934,23 @@ class DesktopNextcloudServices( action: ExternalFileHandoffAction, ): ExternalFileHandoffResult { val capability = (externalFileHandoffSupport as ExternalFileHandoffSupport.Available).capability - return externalFileHandoff.launchStreamed(file, action, capability) { output, maximumBytes -> - val expectedEtag = requireSafeFileRangeEtag(requireNotNull(file.etag)) - downloadDesktopDetachedFile( - noRedirectHttpClient, session, buildNextcloudFileUrl(session.serverUrl, userId, file.path), - output, maximumBytes, USER_AGENT, - failureMessage = { status -> "Opening the file in another app failed (HTTP $status)." }, - limitMessage = "The file exceeds the platform byte representation.", - requestHeaders = mapOf("If-Match" to expectedEtag), - handoffEtag = expectedEtag, - onNetworkFailure = { started, attempt, failure -> - recordDesktopStreamingFailure(session, "external_file", started, attempt, failure) - }, - ) + return accountOperationGuard.withExternalFileHandoffSession(session, { loadSession(session.accountId) }) { + externalFileHandoff.launchStreamed( + desktopFileCacheAccountId(session), file, action, capability, + ) { output, maximumBytes -> + val expectedEtag = requireSafeFileRangeEtag(requireNotNull(file.etag)) + downloadDesktopDetachedFile( + noRedirectHttpClient, session, buildNextcloudFileUrl(session.serverUrl, userId, file.path), + output, maximumBytes, USER_AGENT, + failureMessage = { status -> "Opening the file in another app failed (HTTP $status)." }, + limitMessage = "The file exceeds the platform byte representation.", + requestHeaders = mapOf("If-Match" to expectedEtag), + handoffEtag = expectedEtag, + onNetworkFailure = { started, attempt, failure -> + recordDesktopStreamingFailure(session, "external_file", started, attempt, failure) + }, + ) + } } } @@ -3963,18 +3969,22 @@ class DesktopNextcloudServices( ocsApiRequest = true, ).requireSafe() val capability = (externalFileHandoffSupport as ExternalFileHandoffSupport.Available).capability - return externalFileHandoff.launchDetached(attachment, action, capability) { output, maximumBytes -> - downloadDesktopDetachedFile( - noRedirectHttpClient, session, buildNextcloudApiUrl(session.serverUrl, requestSpec), - output, maximumBytes, USER_AGENT, - failureMessage = { status -> "Opening the Deck attachment failed (HTTP $status)." }, - limitMessage = "The Deck attachment exceeds the platform byte representation.", - accept = "*/*", - requestHeaders = mapOf("OCS-APIRequest" to "true"), - onNetworkFailure = { started, attempt, failure -> - recordDesktopStreamingFailure(session, "deck_attachment", started, attempt, failure) - }, - ) + return accountOperationGuard.withExternalFileHandoffSession(session, { loadSession(session.accountId) }) { + externalFileHandoff.launchDetached( + desktopFileCacheAccountId(session), attachment, action, capability, + ) { output, maximumBytes -> + downloadDesktopDetachedFile( + noRedirectHttpClient, session, buildNextcloudApiUrl(session.serverUrl, requestSpec), + output, maximumBytes, USER_AGENT, + failureMessage = { status -> "Opening the Deck attachment failed (HTTP $status)." }, + limitMessage = "The Deck attachment exceeds the platform byte representation.", + accept = "*/*", + requestHeaders = mapOf("OCS-APIRequest" to "true"), + onNetworkFailure = { started, attempt, failure -> + recordDesktopStreamingFailure(session, "deck_attachment", started, attempt, failure) + }, + ) + } } } @@ -4698,24 +4708,28 @@ class DesktopNextcloudServices( ) val expectedHandoffEtag = requireSafeFileRangeEtag(requireNotNull(historicalCopy.etag)) val specification = fileVersionContentRequest(userId, fileId, version.id) - return externalFileHandoff.launchStreamed(historicalCopy, action, capability) { output, maximumBytes -> - downloadDesktopDetachedFile( - noRedirectHttpClient, session, session.serverUrl + specification.relativePath, - output, maximumBytes, USER_AGENT, - failureMessage = { status -> "Downloading the historical version failed (HTTP $status)." }, - limitMessage = "The historical version exceeds the platform byte representation.", - handoffEtag = expectedHandoffEtag, - validateResponseEtag = { returnedEtag -> - if (version.etag != null && returnedEtag != null) { - check(requireSafeFileRangeEtag(returnedEtag) == requireSafeFileRangeEtag(version.etag)) { - "The historical version changed while it was being exported." + return accountOperationGuard.withExternalFileHandoffSession(session, { loadSession(session.accountId) }) { + externalFileHandoff.launchStreamed( + desktopFileCacheAccountId(session), historicalCopy, action, capability, + ) { output, maximumBytes -> + downloadDesktopDetachedFile( + noRedirectHttpClient, session, session.serverUrl + specification.relativePath, + output, maximumBytes, USER_AGENT, + failureMessage = { status -> "Downloading the historical version failed (HTTP $status)." }, + limitMessage = "The historical version exceeds the platform byte representation.", + handoffEtag = expectedHandoffEtag, + validateResponseEtag = { returnedEtag -> + if (version.etag != null && returnedEtag != null) { + check(requireSafeFileRangeEtag(returnedEtag) == requireSafeFileRangeEtag(version.etag)) { + "The historical version changed while it was being exported." + } } - } - }, - onNetworkFailure = { started, attempt, failure -> - recordDesktopStreamingFailure(session, "file_version", started, attempt, failure) - }, - ) + }, + onNetworkFailure = { started, attempt, failure -> + recordDesktopStreamingFailure(session, "file_version", started, attempt, failure) + }, + ) + } } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt index 3047fc4ad..ea98ca0a5 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt @@ -2,6 +2,9 @@ package dev.obiente.nextcloudnative.app import java.io.File import java.nio.file.Files +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -22,6 +25,7 @@ class DesktopExternalFileHandoffTest { }) val result = handoff.launch( + accountId = accountId(), file = file(), action = ExternalFileHandoffAction.OpenWith, capability = capability(), @@ -38,7 +42,8 @@ class DesktopExternalFileHandoffTest { val staged = requireNotNull(launched) assertEquals("report.pdf", staged.name) assertEquals("detached copy", staged.readText()) - assertEquals(root.canonicalFile, staged.parentFile?.parentFile?.canonicalFile) + assertEquals(accountId(), staged.parentFile?.parentFile?.name) + assertEquals(root.canonicalFile, staged.parentFile?.parentFile?.parentFile?.canonicalFile) assertFalse(staged.canWrite()) } finally { root.deleteRecursively() @@ -62,6 +67,7 @@ class DesktopExternalFileHandoffTest { DesktopStagedFileExport.Exported }, ).launch( + accountId = accountId(), file = file(), action = ExternalFileHandoffAction.Share, capability = capability(ExternalFileHandoffAction.Share), @@ -77,7 +83,7 @@ class DesktopExternalFileHandoffTest { assertIs(result) assertEquals("detached copy", exported) assertEquals(0, openCalls) - assertTrue(root.listFiles().orEmpty().isEmpty()) + assertTrue(root.resolve(accountId()).listFiles().orEmpty().isEmpty()) } finally { root.deleteRecursively() } @@ -92,6 +98,7 @@ class DesktopExternalFileHandoffTest { launchCalls += 1 true }).launch( + accountId = accountId(), file = file(), action = ExternalFileHandoffAction.OpenWith, capability = capability(), @@ -124,6 +131,7 @@ class DesktopExternalFileHandoffTest { launched = file true }).launchDetached( + accountId = accountId(), attachment = attachment(byteCount = 13L), action = ExternalFileHandoffAction.OpenWith, capability = capability(), @@ -153,6 +161,7 @@ class DesktopExternalFileHandoffTest { launchCalls += 1 true }).launchDetached( + accountId = accountId(), attachment = attachment(byteCount = null), action = ExternalFileHandoffAction.OpenWith, capability = ExternalFileHandoffCapability( @@ -182,6 +191,7 @@ class DesktopExternalFileHandoffTest { launchCalls += 1 true }).launchDetached( + accountId = accountId(), attachment = attachment(byteCount = 5L), action = ExternalFileHandoffAction.OpenWith, capability = capability(), @@ -193,7 +203,7 @@ class DesktopExternalFileHandoffTest { } assertEquals(0, launchCalls) - assertTrue(root.listFiles().orEmpty().isEmpty()) + assertTrue(root.resolve(accountId()).listFiles().orEmpty().isEmpty()) } finally { root.deleteRecursively() } @@ -257,11 +267,105 @@ class DesktopExternalFileHandoffTest { } } + @Test + fun `account cleanup removes only that accounts detached copies`() = runBlocking { + val root = Files.createTempDirectory("nextcloud-desktop-account-handoff-").toFile() + val removed = "a".repeat(64) + val retained = "b".repeat(64) + try { + val handoff = DesktopExternalFileHandoff(root, launchFile = { true }) + handoff.launch(removed, file(), ExternalFileHandoffAction.OpenWith, capability()) { + NextcloudFileContent("removed".encodeToByteArray(), "application/pdf", "\"v1\"") + } + handoff.launch(retained, file(), ExternalFileHandoffAction.OpenWith, capability()) { + NextcloudFileContent("retained".encodeToByteArray(), "application/pdf", "\"v1\"") + } + + repeat(2) { handoff.removeAccount(removed) } + + assertFalse(root.resolve(removed).exists()) + assertEquals("retained", root.resolve(retained).walkTopDown().first(File::isFile).readText()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `legacy cleanup expires old unscoped copies without deleting account directories`() { + val root = Files.createTempDirectory("nextcloud-desktop-legacy-handoff-").toFile() + val expired = root.resolve("123e4567-e89b-12d3-a456-426614174000").apply { mkdir() } + val recent = root.resolve("123e4567-e89b-12d3-a456-426614174001").apply { mkdir() } + val scoped = root.resolve(accountId()).apply { mkdir() } + val now = 2L * DESKTOP_EXTERNAL_FILE_TEST_DAY_MILLIS + try { + expired.resolve("payload.bin").writeText("expired") + recent.resolve("payload.bin").writeText("recent") + scoped.resolve("payload.bin").writeText("scoped") + expired.setLastModified(1L) + recent.setLastModified(now) + scoped.setLastModified(1L) + + pruneLegacyDesktopExternalFileCache(root, now) + + assertFalse(expired.exists()) + assertTrue(recent.isDirectory) + assertTrue(scoped.isDirectory) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `account removal waits for in flight handoff then deletes its copy`() = runBlocking { + val root = Files.createTempDirectory("nextcloud-desktop-handoff-removal-").toFile() + val guard = DesktopAccountOperationGuard() + val session = session() + val scopedAccountId = desktopFileCacheAccountId(session) + val downloadStarted = CompletableDeferred() + val finishDownload = CompletableDeferred() + var removalFinished = false + try { + val handoff = DesktopExternalFileHandoff(root, launchFile = { true }) + val launch = async { + guard.withExternalFileHandoffSession(session, { session }) { + handoff.launch(scopedAccountId, file(), ExternalFileHandoffAction.OpenWith, capability()) { + downloadStarted.complete(Unit) + finishDownload.await() + NextcloudFileContent("detached".encodeToByteArray(), "application/pdf", "\"v1\"") + } + } + } + downloadStarted.await() + val removal = async(start = CoroutineStart.UNDISPATCHED) { + guard.serialize { + handoff.removeAccount(scopedAccountId) + removalFinished = true + } + } + + assertFalse(removalFinished) + finishDownload.complete(Unit) + assertIs(launch.await()) + removal.await() + assertFalse(root.resolve(scopedAccountId).exists()) + } finally { + root.deleteRecursively() + } + } + private fun capability(vararg actions: ExternalFileHandoffAction) = ExternalFileHandoffCapability( supportedActions = actions.toSet().ifEmpty { setOf(ExternalFileHandoffAction.OpenWith) }, maximumInMemoryFileBytes = MAX_IN_MEMORY_EXTERNAL_FILE_HANDOFF_BYTES, ) + private fun accountId() = "0123456789abcdef".repeat(4) + + private fun session() = NextcloudSession( + serverUrl = "https://cloud.invalid", + loginName = "ada", + appPassword = "synthetic-secret", + ) + private fun file() = NextcloudFile( path = "Documents/report.pdf", name = "report.pdf", @@ -287,3 +391,5 @@ class DesktopExternalFileHandoffTest { lastModified = null, ) } + +private const val DESKTOP_EXTERNAL_FILE_TEST_DAY_MILLIS = 24L * 60L * 60L * 1000L From a2d4b097b6b119ce884c534a1cef122e11a960b7 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 16:17:13 +0200 Subject: [PATCH 084/119] fix(desktop): clear legacy handoffs on removal --- .../nextcloudnative/app/DesktopExternalFileHandoff.kt | 5 +++-- .../nextcloudnative/app/DesktopExternalFileHandoffTest.kt | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt index 123613c5c..2ab636025 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt @@ -223,7 +223,7 @@ internal class DesktopExternalFileHandoff( fun removeAccount(accountId: String) { requireDesktopExternalFileHandoffAccountId(accountId) - pruneLegacyDesktopExternalFileCache(root) + pruneLegacyDesktopExternalFileCache(root, removeAll = true) val rootPath = root.toPath().toAbsolutePath().normalize() if (!Files.exists(rootPath, LinkOption.NOFOLLOW_LINKS)) return check(Files.isDirectory(rootPath, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(rootPath)) { @@ -300,6 +300,7 @@ internal fun desktopExternalFileHandoffDirectory(): File { internal fun pruneLegacyDesktopExternalFileCache( root: File, nowMillis: Long = System.currentTimeMillis(), + removeAll: Boolean = false, ) { val rootPath = root.toPath().toAbsolutePath().normalize() if (!Files.exists(rootPath, LinkOption.NOFOLLOW_LINKS)) return @@ -310,7 +311,7 @@ internal fun pruneLegacyDesktopExternalFileCache( if (!name.matches(LEGACY_EXTERNAL_FILE_OPERATION_DIRECTORY)) return@forEach if (!Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry)) return@forEach val modified = Files.getLastModifiedTime(entry, LinkOption.NOFOLLOW_LINKS).toMillis() - if (nowMillis >= modified && nowMillis - modified > DESKTOP_EXTERNAL_FILE_MAX_AGE_MILLIS) { + if (removeAll || nowMillis >= modified && nowMillis - modified > DESKTOP_EXTERNAL_FILE_MAX_AGE_MILLIS) { entry.toFile().deleteRecursively() } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt index ea98ca0a5..2bbe537eb 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt @@ -272,6 +272,7 @@ class DesktopExternalFileHandoffTest { val root = Files.createTempDirectory("nextcloud-desktop-account-handoff-").toFile() val removed = "a".repeat(64) val retained = "b".repeat(64) + val freshLegacy = root.resolve("123e4567-e89b-12d3-a456-426614174000") try { val handoff = DesktopExternalFileHandoff(root, launchFile = { true }) handoff.launch(removed, file(), ExternalFileHandoffAction.OpenWith, capability()) { @@ -280,10 +281,13 @@ class DesktopExternalFileHandoffTest { handoff.launch(retained, file(), ExternalFileHandoffAction.OpenWith, capability()) { NextcloudFileContent("retained".encodeToByteArray(), "application/pdf", "\"v1\"") } + freshLegacy.mkdir() + freshLegacy.resolve("payload.bin").writeText("unknown-account-legacy-copy") repeat(2) { handoff.removeAccount(removed) } assertFalse(root.resolve(removed).exists()) + assertFalse(freshLegacy.exists()) assertEquals("retained", root.resolve(retained).walkTopDown().first(File::isFile).readText()) } finally { root.deleteRecursively() From 6c240a474913e74ea939292d7bfab685dd7f08e5 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 16:21:41 +0200 Subject: [PATCH 085/119] test(android): make media cleanup deterministic --- .../AndroidMediaBackupAccountCleanup.kt | 18 +-- .../AndroidMediaBackupAccountCleanupTest.kt | 138 +++++------------- .../app/MediaBackupLedgerTest.kt | 14 ++ 3 files changed, 62 insertions(+), 108 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt index bac1319c3..932d00bb5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt @@ -4,23 +4,23 @@ import android.content.Context import dev.obiente.nextcloudnative.app.MediaBackupLedgerStore internal class AndroidMediaBackupAccountCleanup( - private val openStore: () -> MediaBackupLedgerStore, + private val removeFromLedger: suspend (String) -> Unit, ) { constructor(context: Context) : this( - openStore = { - createAndroidMediaBackupLedgerStore( + removeFromLedger = { accountId -> + val store = createAndroidMediaBackupLedgerStore( context = context.applicationContext, recoverInterruptedTransfers = false, ) + try { + store.deleteAccount(accountId) + } finally { + store.close() + } }, ) suspend fun removeForAccount(accountId: String) { - val store = openStore() - try { - store.deleteAccount(accountId) - } finally { - store.close() - } + removeFromLedger(accountId) } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanupTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanupTest.kt index 7b00b4230..70bbdc230 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanupTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanupTest.kt @@ -1,135 +1,75 @@ package dev.obiente.nextcloudnative -import dev.obiente.nextcloudnative.app.LocalMediaObject -import dev.obiente.nextcloudnative.app.MediaBackupLedgerRecord -import dev.obiente.nextcloudnative.app.MediaBackupLedgerStore -import dev.obiente.nextcloudnative.app.MediaBackupTransferState -import java.nio.file.Files import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull +import kotlin.test.assertTrue class AndroidMediaBackupAccountCleanupTest { @Test - fun cleanupDeletesOnlyTheRemovedAccountsLedgerRowsAndIsIdempotent() = runBlocking { - val database = Files.createTempDirectory("android-media-cleanup-").resolve("ledger.db").toFile() + fun cleanupDeletesOnlyTheRemovedAccountsLedgerRowsAndIsIdempotent(): Unit = runBlocking { val removed = "a".repeat(64) val retained = "b".repeat(64) - try { - MediaBackupLedgerStore(database.absolutePath).also { store -> - store.upsert(record(removed, "removed")) - store.upsert(record(retained, "retained")) - store.close() - } - val cleanup = AndroidMediaBackupAccountCleanup { - MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false) - } + val rows = mutableMapOf(removed to mutableSetOf("removed"), retained to mutableSetOf("retained")) + val cleanup = AndroidMediaBackupAccountCleanup { accountId -> rows.remove(accountId) } - repeat(2) { cleanup.removeForAccount(removed) } + repeat(2) { cleanup.removeForAccount(removed) } - MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false).also { store -> - assertNull(store.load(removed, "removed")) - assertNotNull(store.load(retained, "retained")) - store.close() - } - } finally { - database.parentFile.deleteRecursively() - } + assertEquals(mapOf(retained to setOf("retained")), rows) } @Test - fun failedOpenLeavesRowsForJournaledRetry() = runBlocking { - val database = Files.createTempDirectory("android-media-cleanup-retry-").resolve("ledger.db").toFile() + fun failedOpenLeavesRowsForJournaledRetry(): Unit = runBlocking { val removed = "c".repeat(64) - try { - MediaBackupLedgerStore(database.absolutePath).also { store -> - store.upsert(record(removed, "pending")) - store.close() - } - var failOpen = true - val cleanup = AndroidMediaBackupAccountCleanup { - if (failOpen) error("synthetic ledger open failure") - MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false) - } + val rows = mutableMapOf(removed to mutableSetOf("pending")) + var failOpen = true + val cleanup = AndroidMediaBackupAccountCleanup { accountId -> + if (failOpen) error("synthetic ledger open failure") + rows.remove(accountId) + } - assertFailsWith { cleanup.removeForAccount(removed) } - failOpen = false - MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false).also { store -> - assertNotNull(store.load(removed, "pending")) - store.close() - } + assertFailsWith { cleanup.removeForAccount(removed) } + assertTrue(rows[removed] == mutableSetOf("pending")) + failOpen = false - cleanup.removeForAccount(removed) + cleanup.removeForAccount(removed) - MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false).also { store -> - assertNull(store.load(removed, "pending")) - store.close() - } - } finally { - database.parentFile.deleteRecursively() - } + assertTrue(rows.isEmpty()) } @Test - fun accountRemovalWaitsForAStartedLedgerWriterAndDeletesItsResult() = runBlocking { - val database = Files.createTempDirectory("android-media-cleanup-race-").resolve("ledger.db").toFile() + fun accountRemovalWaitsForAStartedLedgerWriterAndDeletesItsResult(): Unit = runBlocking { val accountId = "d".repeat(64) + val rows = mutableMapOf>() val guard = AndroidAccountOperationGuard() val writerStarted = CompletableDeferred() val finishWriter = CompletableDeferred() var removalFinished = false - try { - val cleanup = AndroidMediaBackupAccountCleanup { - MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false) - } - val writer = async { - guard.withAccount(accountId) { - writerStarted.complete(Unit) - finishWriter.await() - MediaBackupLedgerStore(database.absolutePath).also { store -> - store.upsert(record(accountId, "late")) - store.close() - } - } - } - writerStarted.await() - val removal = async(start = CoroutineStart.UNDISPATCHED) { - guard.withAccount(accountId) { - cleanup.removeForAccount(accountId) - removalFinished = true - } + val cleanup = AndroidMediaBackupAccountCleanup { removedAccountId -> rows.remove(removedAccountId) } + val writer = async { + guard.withAccount(accountId) { + writerStarted.complete(Unit) + finishWriter.await() + rows.getOrPut(accountId, ::mutableSetOf).add("late") } - - assertFalse(removalFinished) - finishWriter.complete(Unit) - writer.await() - removal.await() - MediaBackupLedgerStore(database.absolutePath, recoverInterruptedTransfers = false).also { store -> - assertNull(store.load(accountId, "late")) - store.close() + } + writerStarted.await() + val removal = async(start = CoroutineStart.UNDISPATCHED) { + guard.withAccount(accountId) { + cleanup.removeForAccount(accountId) + removalFinished = true } - } finally { - database.parentFile.deleteRecursively() } - } - private fun record(accountId: String, key: String) = MediaBackupLedgerRecord( - accountId = accountId, - local = LocalMediaObject( - key = key, - displayName = "$key.jpg", - size = 4, - revision = "generation-1", - ), - receipt = null, - transferState = MediaBackupTransferState.Pending, - attemptCount = 0, - updatedAtEpochMillis = 1, - ) + assertFalse(removalFinished) + finishWriter.complete(Unit) + writer.await() + removal.await() + assertTrue(rows.isEmpty()) + } } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaBackupLedgerTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaBackupLedgerTest.kt index fc5ade0a8..234f87504 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaBackupLedgerTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/MediaBackupLedgerTest.kt @@ -151,6 +151,20 @@ class MediaBackupLedgerTest { store.close() } + @Test + fun deletingAnAccountRetainsOtherAccountsRows() = runBlocking { + val otherAccount = "fedcba9876543210fedcba9876543210" + val store = MediaBackupLedgerStore(BundledSQLiteDriver().open(":memory:")) + store.upsert(pendingRecord(accountId, "external:removed", 1_000)) + store.upsert(pendingRecord(otherAccount, "external:retained", 2_000)) + + store.deleteAccount(accountId) + + assertEquals(null, store.load(accountId, "external:removed")) + assertEquals("external:retained", store.load(otherAccount, "external:retained")?.localKey) + store.close() + } + @Test fun snapshotReturnsSummaryAndPageFromOneLedgerRead() = runBlocking { val store = MediaBackupLedgerStore(BundledSQLiteDriver().open(":memory:")) From 71c60c8e407aed9bbba7dfdc170cf15df1cf0b44 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 16:49:28 +0200 Subject: [PATCH 086/119] fix(account): close residual cleanup races --- .../AndroidAccountOperationGuard.kt | 18 ++ .../AndroidAccountOwnedStateCleanup.kt | 27 ++- .../AndroidMediaBackupAccountCleanup.kt | 1 - .../AndroidNextcloudServices.kt | 5 +- .../AndroidAccountOperationGuardTest.kt | 31 +++ .../AndroidDynamicApiCachePolicyTest.kt | 32 ++++ .../app/DesktopAccountOperationGuard.kt | 7 + .../app/DesktopExternalFileHandoff.kt | 180 ++++++++++++++---- .../app/DesktopNextcloudServices.kt | 31 +-- .../app/DesktopAccountOperationGuardTest.kt | 31 +++ .../app/DesktopExternalFileHandoffTest.kt | 91 ++++++++- 11 files changed, 388 insertions(+), 66 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index 8845af225..face9521a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -158,3 +158,21 @@ internal suspend fun withAndroidAccountPrivateStatePublication( ): Result = credentialMutationMutex.withLock { guard.withExactAccountSession(expectedSession, resolveSession, unavailable) { publish() } } + +internal suspend fun activateAndroidDynamicReadsAfterCredentialSave( + persistedSession: dev.obiente.nextcloudnative.app.NextcloudSession, + credentialMutationMutex: Mutex, + guard: AndroidAccountOperationGuard, + resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, + activate: suspend (String) -> Unit, +) { + withAndroidAccountPrivateStatePublication( + expectedSession = persistedSession, + credentialMutationMutex = credentialMutationMutex, + guard = guard, + resolveSession = resolveSession, + unavailable = {}, + ) { + activate(NextcloudDocumentIds.cacheAccountId(persistedSession)) + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index 19538ec84..28d405054 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -6,6 +6,8 @@ import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.durableMutationAccountScope import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache import java.io.File +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext internal class AndroidAccountOwnedStateCleanup( context: Context, @@ -34,6 +36,7 @@ internal class AndroidAccountOwnedStateCleanup( cacheIdentity, clearPreviewAccount, listOf( + { fenceAndroidDynamicApiStateForRemoval(cacheIdentity, dynamicApiState.coalescer, dynamicApiState.cache) }, { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(session) }, @@ -43,7 +46,6 @@ internal class AndroidAccountOwnedStateCleanup( { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, { mutationRecovery.clearDurableRecoveries(durableMutationAccountScope(session)) }, - { clearDynamicApiState(cacheIdentity) }, { mutationRecovery.clearPendingDynamicMutations(cacheIdentity) }, ), ) @@ -59,6 +61,11 @@ internal class AndroidAccountOwnedStateCleanup( previewCacheIdentity, clearPreviewAccount, listOf( + { + previewCacheIdentity?.let { identity -> + fenceAndroidDynamicApiStateForRemoval(identity, dynamicApiState.coalescer, dynamicApiState.cache) + } + }, { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(accountIdentity, session) }, @@ -68,7 +75,6 @@ internal class AndroidAccountOwnedStateCleanup( { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, - { previewCacheIdentity?.let { clearDynamicApiState(it) } }, { previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) }, ), ) @@ -83,6 +89,11 @@ internal class AndroidAccountOwnedStateCleanup( previewCacheIdentity, clearPreviewAccount, listOf( + { + previewCacheIdentity?.let { identity -> + fenceAndroidDynamicApiStateForRemoval(identity, dynamicApiState.coalescer, dynamicApiState.cache) + } + }, { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(accountIdentity) }, @@ -92,14 +103,10 @@ internal class AndroidAccountOwnedStateCleanup( { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, - { previewCacheIdentity?.let { clearDynamicApiState(it) } }, { previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) }, ), ) } - - private suspend fun clearDynamicApiState(accountIdentity: String) = - clearAndroidDynamicApiState(accountIdentity, dynamicApiState.coalescer, dynamicApiState.cache) } internal suspend fun clearAndroidDynamicApiState( @@ -108,6 +115,14 @@ internal suspend fun clearAndroidDynamicApiState( cache: DynamicApiResponseCache, ) = coalescer.fenceAccount(accountIdentity) { cache.invalidateAccount(accountIdentity) } +internal suspend fun fenceAndroidDynamicApiStateForRemoval( + accountIdentity: String, + coalescer: DynamicApiRequestCoalescer, + cache: DynamicApiResponseCache, +) = withContext(NonCancellable) { + clearAndroidDynamicApiState(accountIdentity, coalescer, cache) +} + internal suspend fun runAndroidAccountOwnedStateCleanups( previewCacheIdentity: String?, clearPreviewAccount: (String) -> Unit, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt index 932d00bb5..5dbe5b564 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMediaBackupAccountCleanup.kt @@ -1,7 +1,6 @@ package dev.obiente.nextcloudnative import android.content.Context -import dev.obiente.nextcloudnative.app.MediaBackupLedgerStore internal class AndroidMediaBackupAccountCleanup( private val removeFromLedger: suspend (String) -> Unit, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 72d83c62e..0207a5e07 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -941,7 +941,10 @@ internal class AndroidNextcloudServices( override suspend fun saveSession(session: NextcloudSession): NextcloudSession { val persisted = accountCredentials.saveSession(session) - dynamicApiRequestCoalescer.activateAccount(NextcloudDocumentIds.cacheAccountId(persisted)) + activateAndroidDynamicReadsAfterCredentialSave( + persisted, ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX, ANDROID_ACCOUNT_OPERATION_GUARD, + { accountCredentials.loadSession(persisted.accountId) }, dynamicApiRequestCoalescer::activateAccount, + ) return persisted } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 4b3fb0105..51f048e8b 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -16,6 +16,37 @@ import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield class AndroidAccountOperationGuardTest { + @Test + fun removalInThePostSaveGapCannotReopenDynamicReads() = runBlocking { + val guard = AndroidAccountOperationGuard() + val credentialMutations = Mutex() + val persisted = NextcloudSession("https://cloud.example.test", "alice", "saved-password") + val saveReturned = CompletableDeferred() + val continueAfterSave = CompletableDeferred() + var current: NextcloudSession? = persisted + var activatedAccountId: String? = null + val save = async { + saveReturned.complete(Unit) + continueAfterSave.await() + activateAndroidDynamicReadsAfterCredentialSave( + persistedSession = persisted, + credentialMutationMutex = credentialMutations, + guard = guard, + resolveSession = { current }, + activate = { activatedAccountId = it }, + ) + } + saveReturned.await() + + credentialMutations.withLock { + guard.withAccount(NextcloudDocumentIds.accountKey(persisted)) { current = null } + } + continueAfterSave.complete(Unit) + save.await() + + assertEquals(null, activatedAccountId) + } + @Test fun lateDurableWriterCannotPublishAfterRemovalAndCredentialReplacement() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt index 878a550bf..341f76c41 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicApiCachePolicyTest.kt @@ -8,6 +8,9 @@ import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache import java.nio.file.Files import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.supervisorScope import kotlin.test.Test @@ -124,6 +127,35 @@ class AndroidDynamicApiCachePolicyTest { } } + @Test + fun `committed removal fences dynamic reads even when cleanup is cancelled`() = runBlocking { + val root = Files.createTempDirectory("android-dynamic-cancelled-cleanup-").toFile() + try { + val accountId = "c".repeat(64) + val requestIdentity = "GET /dashboard/widgets" + val cache = DynamicApiResponseCache(root) + val coalescer = DynamicApiRequestCoalescer() + cache.store( + accountId, + requestIdentity, + CachedDynamicApiResponse(200, "private".encodeToByteArray(), null, null), + ) + + val removal = launch { + currentCoroutineContext().cancel() + fenceAndroidDynamicApiStateForRemoval(accountId, coalescer, cache) + } + removal.join() + + assertFails { + coalescer.execute(accountId, requestIdentity, load = { error("must remain fenced") }) + } + assertNull(cache.load(accountId, requestIdentity, 1_024)) + } finally { + root.deleteRecursively() + } + } + @Test fun `second Android service cannot commit a GET that crossed account removal`() = runBlocking { supervisorScope { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index 777de5439..bfaa5bf7c 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -102,6 +102,13 @@ internal suspend fun DesktopAccountOperationGuard.withAccountPrivateSta if (current == expectedSession) publish() else unavailable() } +internal suspend fun DesktopAccountOperationGuard.persistSessionAndActivateDynamicReads( + persist: suspend () -> NextcloudSession, + activate: suspend (NextcloudSession) -> Unit, +): NextcloudSession = serializeWhenSyncIdle { + persist().also { persisted -> activate(persisted) } +} + internal fun requireDesktopAccountRemovalWritebacksResolved(pendingWritebackCount: Int) { check(pendingWritebackCount == 0) { "Finish or discard pending virtual file changes before removing this account." diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt index 2ab636025..957351e88 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt @@ -7,11 +7,16 @@ import java.awt.Frame import java.awt.GraphicsEnvironment import java.io.File import java.io.FileOutputStream +import java.io.IOException import java.io.RandomAccessFile import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.FileVisitResult import java.nio.file.Files import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.SimpleFileVisitor import java.nio.file.StandardCopyOption +import java.nio.file.attribute.BasicFileAttributes import java.util.UUID import java.util.concurrent.atomic.AtomicReference import kotlinx.coroutines.Dispatchers @@ -105,7 +110,7 @@ internal class DesktopExternalFileHandoff( if (launchFile(staged)) { ExternalFileHandoffResult.Launched(action) } else { - staged.parentFile?.deleteRecursively() + staged.parentFile?.let { deleteDesktopExternalFileTree(it.toPath()) } ExternalFileHandoffResult.NoCompatibleApplication(action) } } @@ -117,7 +122,7 @@ internal class DesktopExternalFileHandoff( ExternalFileHandoffResult.NoCompatibleApplication(action) } } finally { - staged.parentFile?.deleteRecursively() + staged.parentFile?.let { deleteDesktopExternalFileTree(it.toPath()) } } } } @@ -130,14 +135,17 @@ internal class DesktopExternalFileHandoff( download: suspend (FileOutputStream, Long) -> DesktopDetachedDownload, ): File { val canonicalRoot = prepareAccountRoot(accountId) - pruneDesktopExternalFileCache(canonicalRoot, declaredByteCount ?: 0L) + val cacheMaximumBytes = pruneDesktopExternalFileCache( + requireNotNull(canonicalRoot.parentFile), + declaredByteCount ?: 0L, + ) val reservation = reservations.reserve( root = canonicalRoot, declaredByteCount = declaredByteCount, reserveBytes = STAGED_FILE_FREE_SPACE_RESERVE_BYTES, ) reservation.use { - val maximumBytes = reservation.maximumBytes + val maximumBytes = minOf(reservation.maximumBytes, cacheMaximumBytes) val operationDirectory = File(canonicalRoot, UUID.randomUUID().toString()) check(operationDirectory.mkdir()) { "Could not create a private desktop handoff directory." } check(operationDirectory.canonicalFile.parentFile == canonicalRoot) { @@ -174,7 +182,7 @@ internal class DesktopExternalFileHandoff( return target } catch (failure: Throwable) { temporary.delete() - operationDirectory.deleteRecursively() + deleteDesktopExternalFileTree(operationDirectory.toPath()) throw failure } } @@ -183,7 +191,7 @@ internal class DesktopExternalFileHandoff( private fun stageDetachedCopy(accountId: String, sourceName: String, bytes: ByteArray): File { require(bytes.size.toLong() <= MAX_IN_MEMORY_EXTERNAL_FILE_HANDOFF_BYTES) val canonicalRoot = prepareAccountRoot(accountId) - pruneDesktopExternalFileCache(canonicalRoot, bytes.size.toLong()) + pruneDesktopExternalFileCache(requireNotNull(canonicalRoot.parentFile), bytes.size.toLong()) reservations.reserve( root = canonicalRoot, declaredByteCount = bytes.size.toLong(), @@ -215,7 +223,7 @@ internal class DesktopExternalFileHandoff( return target } catch (failure: Throwable) { temporary.delete() - operationDirectory.deleteRecursively() + deleteDesktopExternalFileTree(operationDirectory.toPath()) throw failure } } @@ -237,7 +245,7 @@ internal class DesktopExternalFileHandoff( check(Files.isDirectory(accountPath, LinkOption.NOFOLLOW_LINKS)) { "The desktop external-file account entry is not a directory." } - check(accountPath.toFile().deleteRecursively() && !Files.exists(accountPath, LinkOption.NOFOLLOW_LINKS)) { + check(deleteDesktopExternalFileTree(accountPath) && !Files.exists(accountPath, LinkOption.NOFOLLOW_LINKS)) { "Could not clear this account's desktop external-file copies." } } @@ -245,16 +253,20 @@ internal class DesktopExternalFileHandoff( private fun prepareAccountRoot(accountId: String): File { requireDesktopExternalFileHandoffAccountId(accountId) pruneLegacyDesktopExternalFileCache(root) - check(root.isDirectory || root.mkdirs()) { "Could not create the desktop external-file cache." } - val canonicalRoot = root.canonicalFile - val accountRoot = File(canonicalRoot, accountId) - check(accountRoot.isDirectory || accountRoot.mkdir()) { + val rootPath = root.toPath().toAbsolutePath().normalize() + if (!Files.exists(rootPath, LinkOption.NOFOLLOW_LINKS)) Files.createDirectories(rootPath) + check(Files.isDirectory(rootPath, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(rootPath)) { + "The desktop external-file cache root is not a safe directory." + } + val accountPath = rootPath.resolve(accountId) + if (!Files.exists(accountPath, LinkOption.NOFOLLOW_LINKS)) Files.createDirectory(accountPath) + check(Files.isDirectory(accountPath, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(accountPath)) { "Could not create the desktop external-file account cache." } - check(accountRoot.canonicalFile.parentFile == canonicalRoot) { + check(accountPath.parent == rootPath) { "Unsafe desktop external-file account directory." } - return accountRoot.canonicalFile + return accountPath.toFile() } private sealed interface DesktopStagedExternalFile { @@ -275,11 +287,14 @@ internal suspend fun DesktopAccountOperationGuard.withExternalFileHando ) private fun requireDesktopExternalFileHandoffAccountId(accountId: String) { - require(accountId.length == 64 && accountId.all { character -> - character in '0'..'9' || character in 'a'..'f' - }) { "The desktop external-file account identity is invalid." } + require(accountId.isDesktopExternalFileHandoffAccountId()) { + "The desktop external-file account identity is invalid." + } } +private fun String.isDesktopExternalFileHandoffAccountId(): Boolean = + length == 64 && all { character -> character in '0'..'9' || character in 'a'..'f' } + internal enum class DesktopStagedFileExport { Exported, Cancelled, @@ -309,10 +324,15 @@ internal fun pruneLegacyDesktopExternalFileCache( entries.forEach { entry -> val name = entry.fileName.toString() if (!name.matches(LEGACY_EXTERNAL_FILE_OPERATION_DIRECTORY)) return@forEach - if (!Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry)) return@forEach + if (!Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry)) { + val deleted = deleteDesktopExternalFileTree(entry) + check(!removeAll || deleted) { "Could not clear a legacy desktop external-file cache entry." } + return@forEach + } val modified = Files.getLastModifiedTime(entry, LinkOption.NOFOLLOW_LINKS).toMillis() if (removeAll || nowMillis >= modified && nowMillis - modified > DESKTOP_EXTERNAL_FILE_MAX_AGE_MILLIS) { - entry.toFile().deleteRecursively() + val deleted = deleteDesktopExternalFileTree(entry) + check(!removeAll || deleted) { "Could not clear a legacy desktop external-file copy." } } } } @@ -322,30 +342,107 @@ internal fun pruneDesktopExternalFileCache( root: File, requiredBytes: Long, nowMillis: Long = System.currentTimeMillis(), -) { - require(root.isDirectory) { "The desktop external-file cache root is not a directory." } + maximumBytes: Long = MAX_DESKTOP_EXTERNAL_FILE_CACHE_BYTES, +): Long { + val rootPath = root.toPath().toAbsolutePath().normalize() + require(Files.isDirectory(rootPath, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(rootPath)) { + "The desktop external-file cache root is not a safe directory." + } require(requiredBytes >= 0L) - val entries = root.listFiles().orEmpty().sortedBy(File::lastModified).toMutableList() - entries.filter { nowMillis - it.lastModified() > DESKTOP_EXTERNAL_FILE_MAX_AGE_MILLIS }.forEach { expired -> - expired.deleteRecursively() - entries.remove(expired) + require(maximumBytes > 0L && requiredBytes <= maximumBytes) { + "The desktop external-file copy exceeds the cache limit." } - var storedBytes = entries.fold(0L) { total, entry -> - saturatingDesktopFileBytes(total, desktopRecursiveFileBytes(entry)) + val entries = desktopExternalFileCacheEntries(rootPath).sortedBy(DesktopExternalFileCacheEntry::modifiedAt) + .toMutableList() + entries.filter { entry -> + nowMillis >= entry.modifiedAt && nowMillis - entry.modifiedAt > DESKTOP_EXTERNAL_FILE_MAX_AGE_MILLIS + }.forEach { expired -> + if (deleteDesktopExternalFileTree(expired.path)) entries.remove(expired) } - val retainedBeforeCopy = if (requiredBytes >= MAX_DESKTOP_EXTERNAL_FILE_CACHE_BYTES) { - 0L - } else { - MAX_DESKTOP_EXTERNAL_FILE_CACHE_BYTES - requiredBytes + var storedBytes = entries.fold(0L) { total, entry -> + saturatingDesktopFileBytes(total, entry.bytes) } + val retainedBeforeCopy = maximumBytes - requiredBytes val iterator = entries.filter { entry -> - nowMillis >= entry.lastModified() && - nowMillis - entry.lastModified() >= DESKTOP_EXTERNAL_FILE_MINIMUM_RETENTION_MILLIS + nowMillis >= entry.modifiedAt && + nowMillis - entry.modifiedAt >= DESKTOP_EXTERNAL_FILE_MINIMUM_RETENTION_MILLIS }.iterator() while (storedBytes > retainedBeforeCopy && iterator.hasNext()) { val oldest = iterator.next() - val bytes = desktopRecursiveFileBytes(oldest) - if (oldest.deleteRecursively()) storedBytes = (storedBytes - bytes).coerceAtLeast(0L) + if (deleteDesktopExternalFileTree(oldest.path)) { + storedBytes = (storedBytes - oldest.bytes).coerceAtLeast(0L) + } + } + check(storedBytes <= retainedBeforeCopy) { + "Recent desktop external-file copies already use the cache limit." + } + return maximumBytes - storedBytes +} + +private data class DesktopExternalFileCacheEntry( + val path: Path, + val bytes: Long, + val modifiedAt: Long, +) + +private fun desktopExternalFileCacheEntries(root: Path): List = buildList { + Files.newDirectoryStream(root).use { rootEntries -> + rootEntries.forEach { entry -> + val name = entry.fileName.toString() + when { + name.matches(LEGACY_EXTERNAL_FILE_OPERATION_DIRECTORY) -> addDesktopExternalFileCacheEntry(entry) + name.isDesktopExternalFileHandoffAccountId() -> { + if (!Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry)) { + check(deleteDesktopExternalFileTree(entry)) { + "Could not clear an unsafe desktop external-file account entry." + } + return@forEach + } + Files.newDirectoryStream(entry).use { accountEntries -> + accountEntries.forEach { operation -> addDesktopExternalFileCacheEntry(operation) } + } + } + } + } + } +} + +private fun MutableList.addDesktopExternalFileCacheEntry(path: Path) { + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(path)) { + check(deleteDesktopExternalFileTree(path)) { + "Could not clear an unsafe desktop external-file cache entry." + } + return + } + add( + DesktopExternalFileCacheEntry( + path = path, + bytes = desktopExternalFileTreeBytes(path), + modifiedAt = Files.getLastModifiedTime(path, LinkOption.NOFOLLOW_LINKS).toMillis(), + ), + ) +} + +internal fun deleteDesktopExternalFileTree(root: Path): Boolean { + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) return true + return try { + Files.walkFileTree(root, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + Files.delete(file) + return FileVisitResult.CONTINUE + } + + override fun postVisitDirectory(directory: Path, failure: IOException?): FileVisitResult { + failure?.let { throw it } + Files.delete(directory) + return FileVisitResult.CONTINUE + } + }) + !Files.exists(root, LinkOption.NOFOLLOW_LINKS) + } catch (_: IOException) { + false + } catch (_: SecurityException) { + false } } @@ -443,10 +540,15 @@ private fun moveAtomicallyOrReplace(source: File, destination: File, replaceExis } } -private fun desktopRecursiveFileBytes(file: File): Long = when { - file.isFile -> file.length() - file.isDirectory -> file.listFiles().orEmpty().sumOf(::desktopRecursiveFileBytes) - else -> 0L +private fun desktopExternalFileTreeBytes(root: Path): Long { + var total = 0L + Files.walkFileTree(root, object : SimpleFileVisitor() { + override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + total = saturatingDesktopFileBytes(total, attrs.size()) + return FileVisitResult.CONTINUE + } + }) + return total } private const val DESKTOP_EXTERNAL_FILE_MINIMUM_RETENTION_MILLIS = 60L * 60L * 1000L 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 f78a3b131..02e29c76d 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3504,21 +3504,22 @@ class DesktopNextcloudServices( } override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { - val persistedSession = accountOperationGuard.serializeWhenSyncIdle { - retryPendingAccountSyncPairCleanup(desktopFileCacheAccountId(session)) - sessionPublicationGuard.serialize { - val activeAccountId = accountCredentials.activeAccountId() - val activeSession = activeAccountId?.let(accountCredentials::loadSession) - val invalidatesLiveResources = desktopSessionSaveSwitchesAccount(activeAccountId, session.accountId) || - desktopSessionSaveReplacesActiveCredential(activeSession, session) - requireDesktopSessionSaveAllowed( - !invalidatesLiveResources || !hasLiveAccountResources(), - ::recordSupportDiagnostic, - ) - accountCredentials.saveSession(session).also(accountSessionPublication::publish) - } - } - dynamicApiRequestCoalescer.activateAccount(desktopFileCacheAccountId(persistedSession)) + val persistedSession = accountOperationGuard.persistSessionAndActivateDynamicReads( + persist = { + retryPendingAccountSyncPairCleanup(desktopFileCacheAccountId(session)) + sessionPublicationGuard.serialize { + val activeAccountId = accountCredentials.activeAccountId() + val activeSession = activeAccountId?.let(accountCredentials::loadSession) + val invalidatesLiveResources = desktopSessionSaveSwitchesAccount(activeAccountId, session.accountId) || + desktopSessionSaveReplacesActiveCredential(activeSession, session) + requireDesktopSessionSaveAllowed( + !invalidatesLiveResources || !hasLiveAccountResources(), ::recordSupportDiagnostic, + ) + accountCredentials.saveSession(session).also(accountSessionPublication::publish) + } + }, + activate = { dynamicApiRequestCoalescer.activateAccount(desktopFileCacheAccountId(it)) }, + ) synchronized(fileRangeSessionLock) { sessionClearing = false } startDesktopSyncLifecycle() persistedSession diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index fe9f74f0b..435f58825 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -19,6 +19,37 @@ import java.util.prefs.Preferences import kotlin.concurrent.thread class DesktopAccountOperationGuardTest { + @Test + fun accountRemovalCannotOvertakePostSaveDynamicReadActivation() = runBlocking { + val guard = DesktopAccountOperationGuard() + val persistenceEntered = CompletableDeferred() + val finishPersistence = CompletableDeferred() + val events = mutableListOf() + val session = NextcloudSession("https://cloud.example.test", "alice", "saved-password") + val save = async { + guard.persistSessionAndActivateDynamicReads( + persist = { + persistenceEntered.complete(Unit) + finishPersistence.await() + session + }, + activate = { events += "activate" }, + ) + } + persistenceEntered.await() + val removal = async { + guard.serialize { events += "fence" } + } + yield() + + assertFalse(removal.isCompleted) + finishPersistence.complete(Unit) + assertEquals(session, save.await()) + removal.await() + + assertEquals(listOf("activate", "fence"), events) + } + @Test fun lateDurableWriterCannotPublishAfterRemovalAndCredentialReplacement() = runBlocking { val guard = DesktopAccountOperationGuard() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt index 2bbe537eb..385d41d73 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt @@ -213,9 +213,10 @@ class DesktopExternalFileHandoffTest { fun `desktop cache pruning removes expired detached copies`() { val root = Files.createTempDirectory("nextcloud-desktop-handoff-").toFile() try { - val old = root.resolve("old").apply { mkdir() } + val accountRoot = root.resolve(accountId()).apply { mkdir() } + val old = accountRoot.resolve("old").apply { mkdir() } old.resolve("payload.bin").writeBytes(byteArrayOf(1, 2, 3)) - val recent = root.resolve("recent").apply { mkdir() } + val recent = accountRoot.resolve("recent").apply { mkdir() } recent.resolve("payload.bin").writeBytes(byteArrayOf(4, 5, 6)) val now = 2L * 24L * 60L * 60L * 1000L old.setLastModified(1L) @@ -234,12 +235,15 @@ class DesktopExternalFileHandoffTest { fun `desktop cache pressure preserves newly handed off files`() { val root = Files.createTempDirectory("nextcloud-desktop-handoff-").toFile() try { - val recent = root.resolve("recent").apply { mkdir() } + val accountRoot = root.resolve(accountId()).apply { mkdir() } + val recent = accountRoot.resolve("recent").apply { mkdir() } recent.resolve("payload.bin").writeBytes(byteArrayOf(1, 2, 3)) val now = 10L * 60L * 60L * 1000L recent.setLastModified(now) - pruneDesktopExternalFileCache(root, requiredBytes = Long.MAX_VALUE, nowMillis = now) + assertFailsWith { + pruneDesktopExternalFileCache(root, requiredBytes = 2L, nowMillis = now, maximumBytes = 4L) + } assertTrue(recent.exists()) } finally { @@ -247,6 +251,32 @@ class DesktopExternalFileHandoffTest { } } + @Test + fun `desktop cache pressure prunes operation copies across accounts to one global limit`() { + val root = Files.createTempDirectory("nextcloud-desktop-global-handoff-").toFile() + try { + val older = root.resolve("a".repeat(64)).resolve("older").apply { mkdirs() } + val newer = root.resolve("b".repeat(64)).resolve("newer").apply { mkdirs() } + older.resolve("payload.bin").writeBytes(ByteArray(6)) + newer.resolve("payload.bin").writeBytes(ByteArray(6)) + older.setLastModified(1L) + newer.setLastModified(2L) + + val available = pruneDesktopExternalFileCache( + root = root, + requiredBytes = 4L, + nowMillis = 2L * 60L * 60L * 1000L, + maximumBytes = 10L, + ) + + assertFalse(older.exists()) + assertTrue(newer.exists()) + assertEquals(4L, available) + } finally { + root.deleteRecursively() + } + } + @Test fun `same-filesystem export moves the staged copy without requiring duplicate capacity`() { val root = Files.createTempDirectory("nextcloud-desktop-export-").toFile() @@ -294,6 +324,59 @@ class DesktopExternalFileHandoffTest { } } + @Test + fun `account and legacy cleanup unlink nested symlinks without deleting their targets`() { + val root = Files.createTempDirectory("nextcloud-desktop-handoff-safe-delete-").toFile() + val scopedTarget = Files.createTempDirectory("nextcloud-desktop-handoff-scoped-target-").toFile() + val legacyTarget = Files.createTempDirectory("nextcloud-desktop-handoff-legacy-target-").toFile() + try { + val scoped = root.resolve(accountId()).apply { mkdir() } + val legacy = root.resolve("123e4567-e89b-12d3-a456-426614174000").apply { mkdir() } + scopedTarget.resolve("keep.txt").writeText("scoped-target") + legacyTarget.resolve("keep.txt").writeText("legacy-target") + val linksCreated = runCatching { + Files.createSymbolicLink(scoped.resolve("linked").toPath(), scopedTarget.toPath()) + Files.createSymbolicLink(legacy.resolve("linked").toPath(), legacyTarget.toPath()) + }.isSuccess + if (!linksCreated) return + + DesktopExternalFileHandoff(root).removeAccount(accountId()) + + assertFalse(scoped.exists()) + assertFalse(legacy.exists()) + assertEquals("scoped-target", scopedTarget.resolve("keep.txt").readText()) + assertEquals("legacy-target", legacyTarget.resolve("keep.txt").readText()) + } finally { + deleteDesktopExternalFileTree(root.toPath()) + scopedTarget.deleteRecursively() + legacyTarget.deleteRecursively() + } + } + + @Test + fun `staging rejects an account cache directory replaced by a symlink`() = runBlocking { + val root = Files.createTempDirectory("nextcloud-desktop-handoff-account-link-").toFile() + val target = Files.createTempDirectory("nextcloud-desktop-handoff-account-target-").toFile() + try { + target.resolve("keep.txt").writeText("outside") + val linked = runCatching { + Files.createSymbolicLink(root.resolve(accountId()).toPath(), target.toPath()) + }.isSuccess + if (!linked) return@runBlocking + + assertFailsWith { + DesktopExternalFileHandoff(root).launch( + accountId(), file(), ExternalFileHandoffAction.OpenWith, capability(), + ) { error("download must not start") } + } + + assertEquals("outside", target.resolve("keep.txt").readText()) + } finally { + deleteDesktopExternalFileTree(root.toPath()) + target.deleteRecursively() + } + } + @Test fun `legacy cleanup expires old unscoped copies without deleting account directories`() { val root = Files.createTempDirectory("nextcloud-desktop-legacy-handoff-").toFile() From 8c51d4315f7c26a627b9e5a8edd453e19843a01b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 16:59:04 +0200 Subject: [PATCH 087/119] fix(desktop): reserve external handoff cache budget --- .../DesktopExternalFileCacheReservations.kt | 50 ++++++ .../app/DesktopExternalFileHandoff.kt | 162 ++++++++++-------- .../app/DesktopExternalFileHandoffTest.kt | 60 +++++++ 3 files changed, 198 insertions(+), 74 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileCacheReservations.kt diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileCacheReservations.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileCacheReservations.kt new file mode 100644 index 000000000..7457878e3 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileCacheReservations.kt @@ -0,0 +1,50 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean + +internal class DesktopExternalFileCacheReservations { + private val monitor = Any() + private val reservedBytesByRoot = mutableMapOf() + + fun reserve( + root: File, + availableBytes: Long, + declaredByteCount: Long?, + ): DesktopExternalFileCacheReservation { + require(root.isDirectory) + require(availableBytes >= 0L) + require(declaredByteCount == null || declaredByteCount >= 0L) + val key = root.canonicalFile.path + return synchronized(monitor) { + val alreadyReserved = reservedBytesByRoot[key] ?: 0L + val unreserved = (availableBytes - alreadyReserved).coerceAtLeast(0L) + val reserved = declaredByteCount ?: unreserved + check(reserved <= unreserved) { + "Concurrent desktop external-file copies already use the cache limit." + } + if (reserved > 0L) reservedBytesByRoot[key] = alreadyReserved + reserved + DesktopExternalFileCacheReservation(reserved) { + if (reserved > 0L) { + synchronized(monitor) { + val remaining = requireNotNull(reservedBytesByRoot[key]) - reserved + if (remaining == 0L) reservedBytesByRoot.remove(key) else reservedBytesByRoot[key] = remaining + } + } + } + } + } +} + +internal class DesktopExternalFileCacheReservation( + val maximumBytes: Long, + private val release: () -> Unit, +) : AutoCloseable { + private val closed = AtomicBoolean(false) + + override fun close() { + if (closed.compareAndSet(false, true)) release() + } +} + +internal val sharedDesktopExternalFileCacheReservations = DesktopExternalFileCacheReservations() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt index 957351e88..bf048d68f 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt @@ -33,8 +33,11 @@ internal class DesktopExternalFileHandoff( private val launchFile: (File) -> Boolean = ::launchDesktopFile, private val exportFile: (File) -> DesktopStagedFileExport = ::exportDesktopStagedFile, private val reservations: DesktopStagingSpaceReservations = sharedDesktopStagingSpaceReservations, + private val cacheReservations: DesktopExternalFileCacheReservations = sharedDesktopExternalFileCacheReservations, + private val maximumCacheBytes: Long = MAX_DESKTOP_EXTERNAL_FILE_CACHE_BYTES, ) { init { + require(maximumCacheBytes in 1L..MAX_DESKTOP_EXTERNAL_FILE_CACHE_BYTES) pruneLegacyDesktopExternalFileCache(root) } @@ -135,55 +138,59 @@ internal class DesktopExternalFileHandoff( download: suspend (FileOutputStream, Long) -> DesktopDetachedDownload, ): File { val canonicalRoot = prepareAccountRoot(accountId) + val globalRoot = requireNotNull(canonicalRoot.parentFile) val cacheMaximumBytes = pruneDesktopExternalFileCache( - requireNotNull(canonicalRoot.parentFile), + globalRoot, declaredByteCount ?: 0L, + maximumBytes = maximumCacheBytes, ) - val reservation = reservations.reserve( - root = canonicalRoot, - declaredByteCount = declaredByteCount, - reserveBytes = STAGED_FILE_FREE_SPACE_RESERVE_BYTES, - ) - reservation.use { - val maximumBytes = minOf(reservation.maximumBytes, cacheMaximumBytes) - val operationDirectory = File(canonicalRoot, UUID.randomUUID().toString()) - check(operationDirectory.mkdir()) { "Could not create a private desktop handoff directory." } - check(operationDirectory.canonicalFile.parentFile == canonicalRoot) { - "Unsafe desktop handoff directory." - } - val target = File(operationDirectory, sanitizeExternalFileName(sourceName)) - check(target.canonicalFile.parentFile == operationDirectory.canonicalFile) { - "Unsafe desktop handoff filename." - } - val temporary = File.createTempFile("payload-", ".tmp", operationDirectory) - try { - val downloaded = FileOutputStream(temporary).use { output -> - download(output, maximumBytes).also { - output.fd.sync() - } - } - check(downloaded.byteCount in 0L..maximumBytes) - expectedEtag?.let { expected -> - check(downloaded.etag == expected) { - "The file changed while it was being prepared. Refresh and try again." - } - } - verifyDownloadedDeckAttachmentSize(declaredByteCount, downloaded.byteCount) - check(temporary.length() == downloaded.byteCount) { - "The desktop attachment cache copy is incomplete." + cacheReservations.reserve(globalRoot, cacheMaximumBytes, declaredByteCount).use { cacheReservation -> + val reservation = reservations.reserve( + root = canonicalRoot, + declaredByteCount = declaredByteCount, + reserveBytes = STAGED_FILE_FREE_SPACE_RESERVE_BYTES, + ) + reservation.use { + val maximumBytes = minOf(reservation.maximumBytes, cacheReservation.maximumBytes) + val operationDirectory = File(canonicalRoot, UUID.randomUUID().toString()) + check(operationDirectory.mkdir()) { "Could not create a private desktop handoff directory." } + check(operationDirectory.canonicalFile.parentFile == canonicalRoot) { + "Unsafe desktop handoff directory." } - moveAtomicallyOrReplace(temporary, target, replaceExisting = false) - check(target.isFile && target.length() == downloaded.byteCount) { - "Could not publish the desktop attachment cache copy." + val target = File(operationDirectory, sanitizeExternalFileName(sourceName)) + check(target.canonicalFile.parentFile == operationDirectory.canonicalFile) { + "Unsafe desktop handoff filename." } - check(target.setWritable(false, false) || !target.canWrite()) { - "Could not make the detached desktop attachment read-only." + val temporary = File.createTempFile("payload-", ".tmp", operationDirectory) + try { + val downloaded = FileOutputStream(temporary).use { output -> + download(output, maximumBytes).also { + output.fd.sync() + } + } + check(downloaded.byteCount in 0L..maximumBytes) + expectedEtag?.let { expected -> + check(downloaded.etag == expected) { + "The file changed while it was being prepared. Refresh and try again." + } + } + verifyDownloadedDeckAttachmentSize(declaredByteCount, downloaded.byteCount) + check(temporary.length() == downloaded.byteCount) { + "The desktop attachment cache copy is incomplete." + } + moveAtomicallyOrReplace(temporary, target, replaceExisting = false) + check(target.isFile && target.length() == downloaded.byteCount) { + "Could not publish the desktop attachment cache copy." + } + check(target.setWritable(false, false) || !target.canWrite()) { + "Could not make the detached desktop attachment read-only." + } + return target + } catch (failure: Throwable) { + temporary.delete() + deleteDesktopExternalFileTree(operationDirectory.toPath()) + throw failure } - return target - } catch (failure: Throwable) { - temporary.delete() - deleteDesktopExternalFileTree(operationDirectory.toPath()) - throw failure } } } @@ -191,40 +198,47 @@ internal class DesktopExternalFileHandoff( private fun stageDetachedCopy(accountId: String, sourceName: String, bytes: ByteArray): File { require(bytes.size.toLong() <= MAX_IN_MEMORY_EXTERNAL_FILE_HANDOFF_BYTES) val canonicalRoot = prepareAccountRoot(accountId) - pruneDesktopExternalFileCache(requireNotNull(canonicalRoot.parentFile), bytes.size.toLong()) - reservations.reserve( - root = canonicalRoot, - declaredByteCount = bytes.size.toLong(), - reserveBytes = STAGED_FILE_FREE_SPACE_RESERVE_BYTES, - ).use { - val operationDirectory = File(canonicalRoot, UUID.randomUUID().toString()) - check(operationDirectory.mkdir()) { "Could not create a private desktop handoff directory." } - check(operationDirectory.canonicalFile.parentFile == canonicalRoot) { - "Unsafe desktop handoff directory." - } - val target = File(operationDirectory, sanitizeExternalFileName(sourceName)) - check(target.canonicalFile.parentFile == operationDirectory.canonicalFile) { - "Unsafe desktop handoff filename." - } - val temporary = File.createTempFile("payload-", ".tmp", operationDirectory) - try { - FileOutputStream(temporary).use { output -> - output.write(bytes) - output.fd.sync() + val globalRoot = requireNotNull(canonicalRoot.parentFile) + val cacheMaximumBytes = pruneDesktopExternalFileCache( + globalRoot, + bytes.size.toLong(), + maximumBytes = maximumCacheBytes, + ) + cacheReservations.reserve(globalRoot, cacheMaximumBytes, bytes.size.toLong()).use { + reservations.reserve( + root = canonicalRoot, + declaredByteCount = bytes.size.toLong(), + reserveBytes = STAGED_FILE_FREE_SPACE_RESERVE_BYTES, + ).use { + val operationDirectory = File(canonicalRoot, UUID.randomUUID().toString()) + check(operationDirectory.mkdir()) { "Could not create a private desktop handoff directory." } + check(operationDirectory.canonicalFile.parentFile == canonicalRoot) { + "Unsafe desktop handoff directory." } - check(temporary.length() == bytes.size.toLong()) { "The desktop handoff copy is incomplete." } - moveAtomicallyOrReplace(temporary, target, replaceExisting = false) - check(target.isFile && target.length() == bytes.size.toLong()) { - "Could not publish the desktop handoff copy." + val target = File(operationDirectory, sanitizeExternalFileName(sourceName)) + check(target.canonicalFile.parentFile == operationDirectory.canonicalFile) { + "Unsafe desktop handoff filename." } - check(target.setWritable(false, false) || !target.canWrite()) { - "Could not make the detached desktop copy read-only." + val temporary = File.createTempFile("payload-", ".tmp", operationDirectory) + try { + FileOutputStream(temporary).use { output -> + output.write(bytes) + output.fd.sync() + } + check(temporary.length() == bytes.size.toLong()) { "The desktop handoff copy is incomplete." } + moveAtomicallyOrReplace(temporary, target, replaceExisting = false) + check(target.isFile && target.length() == bytes.size.toLong()) { + "Could not publish the desktop handoff copy." + } + check(target.setWritable(false, false) || !target.canWrite()) { + "Could not make the detached desktop copy read-only." + } + return target + } catch (failure: Throwable) { + temporary.delete() + deleteDesktopExternalFileTree(operationDirectory.toPath()) + throw failure } - return target - } catch (failure: Throwable) { - temporary.delete() - deleteDesktopExternalFileTree(operationDirectory.toPath()) - throw failure } } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt index 385d41d73..535e6a062 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoffTest.kt @@ -277,6 +277,66 @@ class DesktopExternalFileHandoffTest { } } + @Test + fun `concurrent account handoffs reserve one shared cache budget`() = runBlocking { + val root = Files.createTempDirectory("nextcloud-desktop-concurrent-handoff-").toFile() + val firstStarted = CompletableDeferred() + val finishFirst = CompletableDeferred() + var secondDownloadStarted = false + try { + val cacheReservations = DesktopExternalFileCacheReservations() + val firstHandoff = DesktopExternalFileHandoff( + root = root, + launchFile = { true }, + cacheReservations = cacheReservations, + maximumCacheBytes = 10L, + ) + val secondHandoff = DesktopExternalFileHandoff( + root = root, + launchFile = { true }, + cacheReservations = cacheReservations, + maximumCacheBytes = 10L, + ) + val sixByteFile = file().copy(size = 6L) + val first = async { + firstHandoff.launchStreamed( + accountId = "a".repeat(64), + file = sixByteFile, + action = ExternalFileHandoffAction.OpenWith, + capability = capability(), + ) { output, maximumBytes -> + assertEquals(6L, maximumBytes) + firstStarted.complete(Unit) + finishFirst.await() + output.write(ByteArray(6)) + DesktopDetachedDownload(6L, "\"v1\"") + } + } + firstStarted.await() + + assertFailsWith { + secondHandoff.launchStreamed( + accountId = "b".repeat(64), + file = sixByteFile, + action = ExternalFileHandoffAction.OpenWith, + capability = capability(), + ) { _, _ -> + secondDownloadStarted = true + error("the second copy must not start") + } + } + assertFalse(secondDownloadStarted) + finishFirst.complete(Unit) + assertIs(first.await()) + assertEquals( + 4L, + pruneDesktopExternalFileCache(root, requiredBytes = 0L, maximumBytes = 10L), + ) + } finally { + deleteDesktopExternalFileTree(root.toPath()) + } + } + @Test fun `same-filesystem export moves the staged copy without requiring duplicate capacity`() { val root = Files.createTempDirectory("nextcloud-desktop-export-").toFile() From 3ea964b31fe07dbd2e6d555ea144da60992e089f Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:21:22 +0000 Subject: [PATCH 088/119] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 4889c4443..e5d1690bf 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -477,7 +477,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DocumentPreview.kt": "a9a8743dd7a381504282cc6ddc68034425024ccc1ae51667da9734bbfc7b1a79", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DurableMutationRecoveryDialog.kt": "e720eadb477a347762cd1894285788ac9f6820972431fe0a1953d01955667bfe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt": "2b7ef2d18b4a23615686ced0b7c9c621c58dc5edd0202104d0ca55b1ebf61d81", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "a0a82881adf8c7b100c7f1b2f21c63d609e5005fa7b6a2bc0424aeb902bbafc8", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "ddf80ca67d954f6e063c9e88c75794fcb81cbd6d42887c45d1a04b1cefe4f2fd", "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", From ecaf99e03915a6e146891d642ca3575e1c3e9191 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 17:29:46 +0200 Subject: [PATCH 089/119] fix(desktop): recover credential removals --- .../DesktopAccountCredentialPersistence.kt | 56 +++++++++++++------ ...DesktopAccountCredentialPersistenceTest.kt | 31 +++++----- 2 files changed, 54 insertions(+), 33 deletions(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index ce2462389..788c83a91 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -279,7 +279,6 @@ internal class DesktopAccountCredentialPersistence( private fun retryPendingCredentialRemoval() { val pending = readPendingCredentialRemovals() - if (pending.malformed) return pending.accountIds.forEach { accountId -> val registry = readRegistry().registry if (registry == null) { @@ -293,6 +292,7 @@ internal class DesktopAccountCredentialPersistence( clearPendingCredentialRemoval(accountId) return@forEach } + if (!reconcileLegacyAccountMetadata(registry.activeAccount)) return@forEach try { secretStore.clear(desktopAccountSecretReference(accountId)) } catch (cancelled: CancellationException) { @@ -313,15 +313,15 @@ internal class DesktopAccountCredentialPersistence( val encoded = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null) ?: return DesktopPendingCredentialRemovals.Empty val accountIds = linkedSetOf() - var malformed = encoded.isBlank() + val malformedEntries = mutableListOf() encoded.split(',').forEach { storageKey -> try { accountIds += NextcloudAccountId(storageKey) } catch (_: IllegalArgumentException) { - malformed = true + malformedEntries += storageKey } } - if (malformed && malformedCredentialRemovalJournalReported.compareAndSet(false, true)) { + if (malformedEntries.isNotEmpty() && malformedCredentialRemovalJournalReported.compareAndSet(false, true)) { runCatching { recordCredentialDiagnostic( "ACCOUNT_CREDENTIAL_REMOVAL_JOURNAL_INVALID", @@ -329,18 +329,40 @@ internal class DesktopAccountCredentialPersistence( ) } } - return DesktopPendingCredentialRemovals(accountIds, malformed) + return DesktopPendingCredentialRemovals(accountIds, malformedEntries) + } + + private fun reconcileLegacyAccountMetadata(activeAccount: NextcloudAccountRecord?): Boolean { + val previousServer = preferences.get(KEY_SERVER, null) + val previousLogin = preferences.get(KEY_LOGIN, null) + return try { + preferences.putOrRemove(KEY_SERVER, activeAccount?.serverUrl) + preferences.putOrRemove(KEY_LOGIN, activeAccount?.loginName) + flushPreferences() + true + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + preferences.putOrRemove(KEY_SERVER, previousServer) + preferences.putOrRemove(KEY_LOGIN, previousLogin) + runCatching(flushPreferences) + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_CLEAR_FAILED", + "account-credentials.recover", + failure, + ) + false + } } private fun clearPendingCredentialRemoval(accountId: NextcloudAccountId) { val previous = preferences.get(KEY_PENDING_CREDENTIAL_REMOVALS, null) val pending = readPendingCredentialRemovals() - if (pending.malformed) return val remaining = pending.accountIds - accountId try { preferences.putOrRemove( KEY_PENDING_CREDENTIAL_REMOVALS, - if (remaining.isEmpty()) null else remaining.joinToString(",") { pending -> pending.storageKey }, + pending.encode(remaining), ) flushPreferences() } catch (cancelled: CancellationException) { @@ -563,10 +585,7 @@ internal class DesktopAccountCredentialPersistence( ) { val credentialRemovals = pendingCredentialRemoval?.let { accountId -> val pending = readPendingCredentialRemovals() - check(!pending.malformed) { - "The credential removal journal is invalid and must be recovered before removing another account." - } - pending.accountIds + accountId + requireNotNull(pending.encode(pending.accountIds + accountId)) } val previous = DesktopAccountPreferenceSnapshot( registry = registryStore.read(), @@ -582,10 +601,7 @@ internal class DesktopAccountCredentialPersistence( ) } credentialRemovals?.let { removals -> - preferences.put( - KEY_PENDING_CREDENTIAL_REMOVALS, - removals.joinToString(",") { pending -> pending.storageKey }, - ) + preferences.put(KEY_PENDING_CREDENTIAL_REMOVALS, removals) } if (pendingLegacyCleanupAccount != null || pendingCredentialRemoval != null) flushPreferences() registryStore.write(encodedRegistry) @@ -638,10 +654,16 @@ internal class DesktopAccountCredentialPersistence( private data class DesktopPendingCredentialRemovals( val accountIds: Set, - val malformed: Boolean, + val malformedEntries: List, ) { + fun encode(accountIds: Set): String? { + if (accountIds.isEmpty() && malformedEntries.isEmpty()) return null + return (accountIds.map(NextcloudAccountId::storageKey) + malformedEntries) + .joinToString(",") + } + companion object { - val Empty = DesktopPendingCredentialRemovals(emptySet(), malformed = false) + val Empty = DesktopPendingCredentialRemovals(emptySet(), emptyList()) } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index d2727bb5c..bf70199bd 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -659,16 +659,12 @@ class DesktopAccountCredentialPersistenceTest { } @Test - fun malformedCredentialRemovalJournalBlocksRecoveryAndLaterStorageRewrite() = + fun malformedCredentialRemovalEntryDoesNotBlockValidCleanupOrLaterRemoval() = withStore { preferences, secrets -> val removed = firstSession() val retained = secondSession() val diagnostics = mutableListOf() - var persistenceFlushes = 0 - val persistence = persistence(preferences, secrets, diagnostics) { - persistenceFlushes += 1 - preferences.flush() - } + val persistence = persistence(preferences, secrets, diagnostics) persistence.saveSession(removed) persistence.saveSession(retained) DesktopAccountRegistryPreferenceStore(preferences).write( @@ -677,18 +673,17 @@ class DesktopAccountCredentialPersistenceTest { val malformedJournal = "${removed.accountId.storageKey},truncated" preferences.put("accountCredentialRemovals", malformedJournal) preferences.flush() - val flushesBeforeRecovery = persistenceFlushes - assertEquals(retained, persistence.loadActiveSession()) - assertNotNull(secrets.load(desktopAccountSecretReference(removed.accountId))) - assertEquals(malformedJournal, preferences.get("accountCredentialRemovals", null)) + assertNull(secrets.load(desktopAccountSecretReference(removed.accountId))) + assertEquals("truncated", preferences.get("accountCredentialRemovals", null)) - assertFailsWith { persistence.removeAccount(retained.accountId) } + assertTrue(persistence.removeAccount(retained.accountId)) - assertEquals(retained.accountId, persistence.activeAccountId()) - assertNotNull(secrets.load(desktopAccountSecretReference(retained.accountId))) - assertEquals(malformedJournal, preferences.get("accountCredentialRemovals", null)) - assertEquals(flushesBeforeRecovery, persistenceFlushes) + assertNull(persistence.activeAccountId()) + assertNull(secrets.load(desktopAccountSecretReference(retained.accountId))) + assertEquals("truncated", preferences.get("accountCredentialRemovals", null)) + assertNull(preferences.get("server", null)) + assertNull(preferences.get("login", null)) assertEquals( listOf("ACCOUNT_CREDENTIAL_REMOVAL_JOURNAL_INVALID"), diagnostics.mapNotNull { it.code }, @@ -722,13 +717,17 @@ class DesktopAccountCredentialPersistenceTest { assertEquals(removed.serverUrl, preferences.get("accountLegacyCleanupV2.0.server", null)) assertNotNull(secrets.load(desktopAccountSecretReference(removed.accountId))) assertNotNull(secrets.load(desktopSessionSecretReference(removed.serverUrl, removed.loginName))) + assertEquals(removed.serverUrl, preferences.get("server", null)) + assertEquals(removed.loginName, preferences.get("login", null)) crashDuringRemoval = false - persistence(preferences, secrets).loadActiveSession() + assertNull(persistence(preferences, secrets).loadActiveSession()) assertNull(secrets.load(desktopAccountSecretReference(removed.accountId))) assertNull(secrets.load(desktopSessionSecretReference(removed.serverUrl, removed.loginName))) assertNull(preferences.get("accountCredentialRemovals", null)) assertNull(preferences.get("accountLegacyCleanupV2.0.server", null)) + assertNull(preferences.get("server", null)) + assertNull(preferences.get("login", null)) } @Test From cb6c6e6fe69e58af4d2f371cb7d2a775075281cf Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 17:46:41 +0200 Subject: [PATCH 090/119] fix(desktop): validate handoff paths portably --- .../app/DesktopExternalFileHandoff.kt | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt index bf048d68f..82af59f7d 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt @@ -154,13 +154,9 @@ internal class DesktopExternalFileHandoff( val maximumBytes = minOf(reservation.maximumBytes, cacheReservation.maximumBytes) val operationDirectory = File(canonicalRoot, UUID.randomUUID().toString()) check(operationDirectory.mkdir()) { "Could not create a private desktop handoff directory." } - check(operationDirectory.canonicalFile.parentFile == canonicalRoot) { - "Unsafe desktop handoff directory." - } + requireSafeDesktopExternalFileOperation(canonicalRoot, operationDirectory) val target = File(operationDirectory, sanitizeExternalFileName(sourceName)) - check(target.canonicalFile.parentFile == operationDirectory.canonicalFile) { - "Unsafe desktop handoff filename." - } + requireSafeDesktopExternalFileTarget(operationDirectory, target) val temporary = File.createTempFile("payload-", ".tmp", operationDirectory) try { val downloaded = FileOutputStream(temporary).use { output -> @@ -212,13 +208,9 @@ internal class DesktopExternalFileHandoff( ).use { val operationDirectory = File(canonicalRoot, UUID.randomUUID().toString()) check(operationDirectory.mkdir()) { "Could not create a private desktop handoff directory." } - check(operationDirectory.canonicalFile.parentFile == canonicalRoot) { - "Unsafe desktop handoff directory." - } + requireSafeDesktopExternalFileOperation(canonicalRoot, operationDirectory) val target = File(operationDirectory, sanitizeExternalFileName(sourceName)) - check(target.canonicalFile.parentFile == operationDirectory.canonicalFile) { - "Unsafe desktop handoff filename." - } + requireSafeDesktopExternalFileTarget(operationDirectory, target) val temporary = File.createTempFile("payload-", ".tmp", operationDirectory) try { FileOutputStream(temporary).use { output -> @@ -289,6 +281,21 @@ internal class DesktopExternalFileHandoff( } } +private fun requireSafeDesktopExternalFileOperation(accountRoot: File, operationDirectory: File) { + val operationPath = operationDirectory.toPath() + check( + Files.isDirectory(operationPath, LinkOption.NOFOLLOW_LINKS) && + !Files.isSymbolicLink(operationPath) && + Files.isSameFile(requireNotNull(operationPath.parent), accountRoot.toPath()), + ) { "Unsafe desktop handoff directory." } +} + +private fun requireSafeDesktopExternalFileTarget(operationDirectory: File, target: File) { + check(Files.isSameFile(requireNotNull(target.toPath().parent), operationDirectory.toPath())) { + "Unsafe desktop handoff filename." + } +} + internal suspend fun DesktopAccountOperationGuard.withExternalFileHandoffSession( expectedSession: NextcloudSession, resolveSession: suspend () -> NextcloudSession?, From bcd183632f69a83bff4b22d14174ea90d1b889c9 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 17:56:47 +0200 Subject: [PATCH 091/119] fix(accounts): retire deck draft state --- .../AndroidAccountCredentialController.kt | 3 +- .../AndroidAccountOwnedStateCleanup.kt | 5 + ...ndroidAccountRemovalCleanupRecoveryWork.kt | 1 + .../AndroidDeckCardDraftAccountGuard.kt | 16 ++ .../AndroidDeckCardDraftStore.kt | 198 ++++++++++++--- .../AndroidNextcloudServices.kt | 12 +- .../AndroidAccountOperationGuardTest.kt | 42 ++++ .../AndroidDeckCardDraftStoreTest.kt | 150 +++++++++-- .../app/DesktopAccountRemoval.kt | 50 +++- .../app/DesktopDeckCardDraftAccountGuard.kt | 13 + .../app/DesktopDeckCardDraftStore.kt | 237 ++++++++++++++++-- .../app/DesktopNextcloudServices.kt | 27 +- .../app/DesktopAccountOperationGuardTest.kt | 56 ++++- .../app/DesktopDeckCardDraftStoreTest.kt | 157 +++++++++++- 14 files changed, 859 insertions(+), 108 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftAccountGuard.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftAccountGuard.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 10b2dfe58..1e0e10810 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -29,7 +29,7 @@ internal class AndroidAccountCredentialController( private val prepareAccountRemoval: suspend (NextcloudSession) -> Unit, private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String, String?, String?) -> Unit, - private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String, String?, String?) -> Unit, + private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String, String, String?, String?) -> Unit, ) { private val appContext = context.applicationContext private val handoffCleanup = AndroidExternalFileHandoffCleanup(appContext, preferences, ::commitPreferences) @@ -196,6 +196,7 @@ internal class AndroidAccountCredentialController( prepareAccountRemoval = { prepareAccountRemoval(unavailableSession) }, removeAccountOwnedWorkWithoutCredentials = { identity -> retryQueuedUploadsCleanupWithoutCredentials( + pendingCleanup.accountStorageKey, identity, pendingCleanup.previewCacheIdentity, pendingCleanup.durableMutationIdentity, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index 28d405054..e2f000486 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -28,6 +28,7 @@ internal class AndroidAccountOwnedStateCleanup( private val durableUploads = AndroidDurableUploadAccountCleanup(appContext) private val mediaBackupLedger = AndroidMediaBackupAccountCleanup(appContext) private val mutationRecovery = AndroidAccountMutationRecoveryCleanup(appContext) + private val deckCardDrafts = AndroidDeckCardDraftStore(appContext) suspend fun remove(session: NextcloudSession) { val accountIdentity = NextcloudDocumentIds.accountKey(session) @@ -43,6 +44,7 @@ internal class AndroidAccountOwnedStateCleanup( { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, { mediaBackupLedger.removeForAccount(accountIdentity) }, + { deckCardDrafts.removeAccount(session.accountId.storageKey, accountIdentity) }, { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, { mutationRecovery.clearDurableRecoveries(durableMutationAccountScope(session)) }, @@ -72,6 +74,7 @@ internal class AndroidAccountOwnedStateCleanup( { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, { mediaBackupLedger.removeForAccount(accountIdentity) }, + { deckCardDrafts.removeAccount(session.accountId.storageKey, accountIdentity) }, { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, @@ -81,6 +84,7 @@ internal class AndroidAccountOwnedStateCleanup( } suspend fun retryWithoutCredentials( + accountStorageKey: String, accountIdentity: String, previewCacheIdentity: String? = null, durableMutationIdentity: String? = null, @@ -100,6 +104,7 @@ internal class AndroidAccountOwnedStateCleanup( { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, { mediaBackupLedger.removeForAccount(accountIdentity) }, + { deckCardDrafts.removeAccount(accountStorageKey, accountIdentity) }, { fileReadCache.clearAccount(accountIdentity) }, { virtualFileCache.clearAccount(accountIdentity) }, { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt index 9435d8083..f0f3ab546 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt @@ -102,6 +102,7 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( removeAccountOwnedWork = { pending -> ANDROID_ACCOUNT_OPERATION_GUARD.withAccount(pending.workIdentity) { cleanup.retryWithoutCredentials( + pending.accountStorageKey, pending.workIdentity, pending.previewCacheIdentity, pending.durableMutationIdentity, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftAccountGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftAccountGuard.kt new file mode 100644 index 000000000..90a695e4b --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftAccountGuard.kt @@ -0,0 +1,16 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal suspend fun withAndroidDeckCardDraftSession( + expectedSession: NextcloudSession, + accountCredentials: AndroidAccountCredentialController, + action: suspend () -> Result, +): Result = withAndroidAccountPrivateStatePublication( + expectedSession = expectedSession, + credentialMutationMutex = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX, + guard = ANDROID_ACCOUNT_OPERATION_GUARD, + resolveSession = { accountCredentials.loadSession(expectedSession.accountId) }, + unavailable = { error("The account changed before the Deck draft operation could complete.") }, + publish = action, +) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt index f9e4931a1..8826ab134 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt @@ -28,6 +28,7 @@ internal class AndroidDeckCardDraftStore( fun load(session: NextcloudSession, key: DeckCardDraftKey): PersistedDeckCardDraft? = synchronized(STORAGE_LOCK) { + migrateLegacyEntry(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session), key) val storedKey = storageKey(session, key) if (isQuarantined(storedKey)) { storage.remove(setOf(storedKey, quarantineKey(storedKey))) @@ -36,24 +37,27 @@ internal class AndroidDeckCardDraftStore( val encrypted = storage.getString(storedKey) ?: return@synchronized null val stored = decode(encrypted) requireStorageSlot(stored, storedKey) + requireStorageOwner(stored, session.accountId.storageKey) requireResource(stored, key) stored.draft } fun save(session: NextcloudSession, persisted: PersistedDeckCardDraft): Unit = synchronized(STORAGE_LOCK) { + migrateLegacyEntries(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session)) val storedKey = storageKey(session, persisted.key) clearQuarantineBeforeSave(storedKey) val existing = storage.getString(storedKey) existing?.let { val stored = decode(existing) requireStorageSlot(stored, storedKey) + requireStorageOwner(stored, session.accountId.storageKey) requireResource(stored, persisted.key) } if (existing == null) ensureCapacityForNewDraft(session) val updatedAtEpochMillis = nowEpochMillis() require(updatedAtEpochMillis >= 0L) { "The Deck draft timestamp is invalid." } - val encrypted = encode(storedKey, persisted, updatedAtEpochMillis) + val encrypted = encode(session.accountId.storageKey, storedKey, persisted, updatedAtEpochMillis) check(storage.putString(storedKey, encrypted)) { "The Deck card draft could not be saved." } prune(session) } @@ -65,41 +69,87 @@ internal class AndroidDeckCardDraftStore( * preference write must leave the original recovery record available for a later attempt. */ fun migrateLegacyEntries(session: NextcloudSession): Unit = synchronized(STORAGE_LOCK) { + migrateLegacyEntries(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session)) + } + + private fun migrateLegacyEntries(accountStorageKey: String, legacyAccountIdentity: String) { val entries = try { storage.entries() } catch (_: Exception) { - return@synchronized + return } entries.forEach { (storedKey, rawValue) -> - if (!storedKey.startsWith(KEY_PREFIX)) return@forEach + if (!storedKey.matches(LEGACY_DRAFT_KEY_PATTERN)) return@forEach val stored = try { (rawValue as? String)?.let(::decode) } catch (_: AndroidDeckDraftRecoveryException) { null } ?: return@forEach - if (stored.storageKey != null || storageKey(session, stored.draft.key) != storedKey) { - return@forEach - } - val migrated = try { - encode(storedKey, stored.draft, stored.updatedAtEpochMillis) - } catch (_: Exception) { + if ( + stored.accountStorageKey != null || + stored.storageKey != null && stored.storageKey != storedKey || + legacyStorageKey(legacyAccountIdentity, stored.draft.key) != storedKey + ) { return@forEach } try { - check(storage.putString(storedKey, migrated)) + migrateLegacyEntry(accountStorageKey, legacyAccountIdentity, stored.draft.key, stored) } catch (_: Exception) { - // Keep the legacy ciphertext available so a later session load can retry. + // Preserve the legacy record so the migration can be retried. } } } + private fun migrateLegacyEntry( + accountStorageKey: String, + legacyAccountIdentity: String, + key: DeckCardDraftKey, + decodedLegacy: StoredDeckCardDraft? = null, + ) { + val legacyKey = legacyStorageKey(legacyAccountIdentity, key) + val legacyMarker = quarantineKey(legacyKey, LEGACY_KEY_PREFIX, LEGACY_QUARANTINE_PREFIX) + val targetKey = storageKey(accountStorageKey, key) + val targetMarker = quarantineKey(targetKey) + val legacyEncrypted = storage.getString(legacyKey) + if (legacyEncrypted == null) { + val markerValue = storage.entries()[legacyMarker] as? String ?: return + if (storage.putString(targetMarker, markerValue)) storage.remove(setOf(legacyMarker)) + return + } + val legacy = decodedLegacy ?: decode(legacyEncrypted) + if ( + legacy.accountStorageKey != null || + legacy.storageKey != null && legacy.storageKey != legacyKey || + legacy.draft.key != key + ) { + throw AndroidDeckDraftRecoveryException( + IllegalArgumentException("The legacy Deck draft identity does not match."), + ) + } + val migrated = encode(accountStorageKey, targetKey, legacy.draft, legacy.updatedAtEpochMillis) + val markerValue = storage.entries()[legacyMarker] as? String + if (markerValue != null && !storage.putString(targetMarker, markerValue)) return + val existingTarget = storage.getString(targetKey) + if (existingTarget == null) { + if (!storage.putString(targetKey, migrated)) return + } else { + val existing = decode(existingTarget) + requireStorageSlot(existing, targetKey) + requireStorageOwner(existing, accountStorageKey) + requireResource(existing, key) + } + storage.remove(setOf(legacyKey, legacyMarker)) + } + private fun encode( + accountStorageKey: String, storedKey: String, persisted: PersistedDeckCardDraft, updatedAtEpochMillis: Long, ): String { val value = JSONObject() .put("version", FORMAT_VERSION) + .put("accountStorageKey", accountStorageKey) .put("storageKey", storedKey) .put("updatedAtEpochMillis", updatedAtEpochMillis) .put("boardId", persisted.key.boardId) @@ -115,6 +165,7 @@ internal class AndroidDeckCardDraftStore( val encrypted = cipher.encrypt(value) val verified = decode(encrypted) requireStorageSlot(verified, storedKey) + requireStorageOwner(verified, accountStorageKey) requireResource(verified, persisted.key) check(verified.draft == persisted && verified.updatedAtEpochMillis == updatedAtEpochMillis) { "The Deck card draft could not be verified." @@ -127,11 +178,13 @@ internal class AndroidDeckCardDraftStore( key: DeckCardDraftKey, discardUnreadable: Boolean = false, ): Unit = synchronized(STORAGE_LOCK) { + migrateLegacyEntry(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session), key) val storedKey = storageKey(session, key) if (!discardUnreadable) { storage.getString(storedKey)?.let { existing -> val stored = decode(existing) requireStorageSlot(stored, storedKey) + requireStorageOwner(stored, session.accountId.storageKey) requireResource(stored, key) } } @@ -142,6 +195,7 @@ internal class AndroidDeckCardDraftStore( fun quarantineAfterSubmit(session: NextcloudSession, key: DeckCardDraftKey): Unit = synchronized(STORAGE_LOCK) { + migrateLegacyEntry(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session), key) val storedKey = storageKey(session, key) check(storage.putString(quarantineKey(storedKey), QUARANTINE_MARKER)) { "The submitted Deck card draft could not be quarantined." @@ -151,19 +205,57 @@ internal class AndroidDeckCardDraftStore( fun discardAll(): Unit = synchronized(STORAGE_LOCK) { val keys = storage.entries().keys.filterTo(linkedSetOf()) { key -> - key.startsWith(KEY_PREFIX) || key.startsWith(QUARANTINE_PREFIX) + key.startsWith(KEY_PREFIX) || key.startsWith(QUARANTINE_PREFIX) || + key.matches(LEGACY_DRAFT_KEY_PATTERN) || key.matches(LEGACY_QUARANTINE_KEY_PATTERN) } if (keys.isEmpty()) return@synchronized check(storage.remove(keys)) { "Saved Deck card drafts could not be discarded." } } + fun removeAccount(accountStorageKey: String, legacyAccountIdentity: String) = synchronized(STORAGE_LOCK) { + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(accountStorageKey)) + require(LEGACY_ACCOUNT_IDENTITY_PATTERN.matches(legacyAccountIdentity)) + val entries = storage.entries() + val keys = entries.keys.filterTo(linkedSetOf()) { key -> + key.startsWith(accountDraftPrefix(accountStorageKey)) || + key.startsWith(accountQuarantinePrefix(accountStorageKey)) + } + entries.forEach { (storedKey, rawValue) -> + if (!storedKey.matches(LEGACY_DRAFT_KEY_PATTERN)) return@forEach + val stored = try { + (rawValue as? String)?.let(::decode) + } catch (_: AndroidDeckDraftRecoveryException) { + null + } ?: return@forEach + if ( + stored.accountStorageKey == null && + (stored.storageKey == null || stored.storageKey == storedKey) && + legacyStorageKey(legacyAccountIdentity, stored.draft.key) == storedKey + ) { + keys += storedKey + keys += quarantineKey(storedKey, LEGACY_KEY_PREFIX, LEGACY_QUARANTINE_PREFIX) + } + } + if (keys.isNotEmpty()) { + check(storage.remove(keys)) { "Saved Deck card drafts for the account could not be removed." } + } + } + private fun decode(encrypted: String): StoredDeckCardDraft = try { val value = JSONObject(cipher.decrypt(encrypted)) - require(value.getInt("version") == FORMAT_VERSION) { + val version = value.getInt("version") + require(version == LEGACY_FORMAT_VERSION || version == FORMAT_VERSION) { "The Deck draft format is unsupported." } val updatedAtEpochMillis = value.getLong("updatedAtEpochMillis") require(updatedAtEpochMillis >= 0L) { "The Deck draft timestamp is invalid." } + val storageKey = value.optString("storageKey").takeIf(String::isNotBlank) + val accountStorageKey = value.optString("accountStorageKey").takeIf(String::isNotBlank) + require( + version == LEGACY_FORMAT_VERSION && accountStorageKey == null || + version == FORMAT_VERSION && storageKey != null && + accountStorageKey?.matches(ACCOUNT_STORAGE_KEY_PATTERN) == true, + ) { "The Deck draft account storage metadata is invalid." } StoredDeckCardDraft( draft = PersistedDeckCardDraft( key = DeckCardDraftKey( @@ -185,7 +277,8 @@ internal class AndroidDeckCardDraftStore( ), ), updatedAtEpochMillis = updatedAtEpochMillis, - storageKey = value.optString("storageKey").takeIf(String::isNotBlank), + storageKey = storageKey, + accountStorageKey = accountStorageKey, ) } catch (failure: Exception) { throw AndroidDeckDraftRecoveryException(failure) @@ -208,16 +301,25 @@ internal class AndroidDeckCardDraftStore( } } + private fun requireStorageOwner(stored: StoredDeckCardDraft, expected: String) { + if (stored.accountStorageKey != expected) { + throw AndroidDeckDraftRecoveryException( + IllegalArgumentException("The Deck draft account identity does not match."), + ) + } + } + private fun prune(session: NextcloudSession) { var unreadableEntries = 0 + val accountStorageKey = session.accountId.storageKey val metadata = storage.entries().mapNotNull { (key, rawValue) -> - if (!key.startsWith(KEY_PREFIX)) return@mapNotNull null + if (!key.startsWith(accountDraftPrefix(accountStorageKey))) return@mapNotNull null val stored = try { (rawValue as? String)?.let(::decode) } catch (_: AndroidDeckDraftRecoveryException) { null } - if (stored != null && isReadableRetentionEntry(session, key, stored)) { + if (stored != null && isReadableRetentionEntry(accountStorageKey, key, stored)) { DeckCardDraftRetention.Entry(key, stored.updatedAtEpochMillis) } else { // A Keystore or provider failure can make valid ciphertext temporarily unreadable. @@ -237,14 +339,15 @@ internal class AndroidDeckCardDraftStore( } private fun ensureCapacityForNewDraft(session: NextcloudSession) { - val draftEntries = storage.entries().filterKeys { it.startsWith(KEY_PREFIX) } + val accountStorageKey = session.accountId.storageKey + val draftEntries = storage.entries().filterKeys { it.startsWith(accountDraftPrefix(accountStorageKey)) } val overflow = draftEntries.size + 1 - DeckCardDraftRetention.MAX_ENTRIES if (overflow <= 0) return val readableEntries = draftEntries.count { (storedKey, rawValue) -> try { (rawValue as? String) ?.let(::decode) - ?.let { stored -> isReadableRetentionEntry(session, storedKey, stored) } == true + ?.let { stored -> isReadableRetentionEntry(accountStorageKey, storedKey, stored) } == true } catch (_: AndroidDeckDraftRecoveryException) { false } @@ -253,11 +356,10 @@ internal class AndroidDeckCardDraftStore( } private fun isReadableRetentionEntry( - session: NextcloudSession, + accountStorageKey: String, storedKey: String, stored: StoredDeckCardDraft, - ): Boolean = stored.storageKey?.let { recorded -> recorded == storedKey } - ?: (storageKey(session, stored.draft.key) == storedKey) + ): Boolean = stored.storageKey == storedKey && stored.accountStorageKey == accountStorageKey private fun isQuarantined(storedKey: String): Boolean = quarantineKey(storedKey) in storage.entries() @@ -271,36 +373,68 @@ internal class AndroidDeckCardDraftStore( } private fun quarantineKey(storedKey: String): String = - "$QUARANTINE_PREFIX${storedKey.removePrefix(KEY_PREFIX)}" + quarantineKey(storedKey, KEY_PREFIX, QUARANTINE_PREFIX) + + private fun quarantineKey(storedKey: String, draftPrefix: String, markerPrefix: String): String = + "$markerPrefix${storedKey.removePrefix(draftPrefix)}" internal fun storageKey(session: NextcloudSession, key: DeckCardDraftKey): String { + return storageKey(session.accountId.storageKey, key) + } + + private fun storageKey(accountStorageKey: String, key: DeckCardDraftKey): String { val scope = listOf( - NextcloudDocumentIds.accountKey(session), key.boardId.toString(), key.stackId.toString(), key.cardId?.toString() ?: "new", ).joinToString(separator = ":") - val digest = MessageDigest.getInstance("SHA-256") - .digest(scope.toByteArray(Charsets.UTF_8)) - .joinToString(separator = "") { byte -> - (byte.toInt() and 0xff).toString(16).padStart(2, '0') - } - return "$KEY_PREFIX$digest" + return "${accountDraftPrefix(accountStorageKey)}${sha256(scope)}" } + private fun legacyStorageKey(accountIdentity: String, key: DeckCardDraftKey): String { + val scope = listOf( + accountIdentity, + key.boardId.toString(), + key.stackId.toString(), + key.cardId?.toString() ?: "new", + ).joinToString(separator = ":") + return "$LEGACY_KEY_PREFIX${sha256(scope)}" + } + + internal fun legacyStorageKey(session: NextcloudSession, key: DeckCardDraftKey): String = + legacyStorageKey(NextcloudDocumentIds.accountKey(session), key) + + private fun accountDraftPrefix(accountStorageKey: String) = "$KEY_PREFIX${accountStorageKey}_" + + private fun accountQuarantinePrefix(accountStorageKey: String) = "$QUARANTINE_PREFIX${accountStorageKey}_" + + private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256") + .digest(value.toByteArray(Charsets.UTF_8)) + .joinToString(separator = "") { byte -> + (byte.toInt() and 0xff).toString(16).padStart(2, '0') + } + private data class StoredDeckCardDraft( val draft: PersistedDeckCardDraft, val updatedAtEpochMillis: Long, val storageKey: String?, + val accountStorageKey: String?, ) internal companion object { private val STORAGE_LOCK = Any() const val PREFERENCES = "nextcloud_native_deck_drafts" - const val KEY_PREFIX = "draft_" - const val QUARANTINE_PREFIX = "submitted_" + const val KEY_PREFIX = "draft_v2_" + const val QUARANTINE_PREFIX = "submitted_v2_" + const val LEGACY_KEY_PREFIX = "draft_" + const val LEGACY_QUARANTINE_PREFIX = "submitted_" const val QUARANTINE_MARKER = "confirmed" - const val FORMAT_VERSION = 1 + const val LEGACY_FORMAT_VERSION = 1 + const val FORMAT_VERSION = 2 + private val ACCOUNT_STORAGE_KEY_PATTERN = Regex("[0-9a-f]{64}") + private val LEGACY_ACCOUNT_IDENTITY_PATTERN = Regex("[0-9a-f]{32}") + private val LEGACY_DRAFT_KEY_PATTERN = Regex("^draft_[0-9a-f]{64}$") + private val LEGACY_QUARANTINE_KEY_PATTERN = Regex("^submitted_[0-9a-f]{64}$") } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 0207a5e07..9c75a786a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -937,7 +937,9 @@ internal class AndroidNextcloudServices( override fun loadSession(): NextcloudSession? = accountCredentials.loadSession() override suspend fun prepareDeckCardDraftRecovery(session: NextcloudSession) = - withContext(Dispatchers.IO) { deckCardDrafts.migrateLegacyEntries(session) } + withContext(Dispatchers.IO) { + withAndroidDeckCardDraftSession(session, accountCredentials) { deckCardDrafts.migrateLegacyEntries(session) } + } override suspend fun saveSession(session: NextcloudSession): NextcloudSession { val persisted = accountCredentials.saveSession(session) @@ -964,14 +966,14 @@ internal class AndroidNextcloudServices( session: NextcloudSession, key: DeckCardDraftKey, ): PersistedDeckCardDraft? = withContext(Dispatchers.IO) { - deckCardDrafts.load(session, key) + withAndroidDeckCardDraftSession(session, accountCredentials) { deckCardDrafts.load(session, key) } } override suspend fun saveDeckCardDraft( session: NextcloudSession, draft: PersistedDeckCardDraft, ) = withContext(Dispatchers.IO) { - deckCardDrafts.save(session, draft) + withAndroidDeckCardDraftSession(session, accountCredentials) { deckCardDrafts.save(session, draft) } } override suspend fun clearDeckCardDraft( @@ -979,14 +981,14 @@ internal class AndroidNextcloudServices( key: DeckCardDraftKey, discardUnreadable: Boolean, ) = withContext(Dispatchers.IO) { - deckCardDrafts.clear(session, key, discardUnreadable) + withAndroidDeckCardDraftSession(session, accountCredentials) { deckCardDrafts.clear(session, key, discardUnreadable) } } override suspend fun quarantineSubmittedDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, ) = withContext(Dispatchers.IO) { - deckCardDrafts.quarantineAfterSubmit(session, key) + withAndroidDeckCardDraftSession(session, accountCredentials) { deckCardDrafts.quarantineAfterSubmit(session, key) } } override suspend fun discardAllDeckCardDrafts() = withContext(Dispatchers.IO) { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 51f048e8b..a55d138c5 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -16,6 +16,48 @@ import kotlinx.coroutines.withTimeout import kotlinx.coroutines.yield class AndroidAccountOperationGuardTest { + @Test + fun accountRemovalWaitsForCrossingDeckDraftSaveThenDeletesIt() = runBlocking { + val guard = AndroidAccountOperationGuard() + val credentialMutations = Mutex() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val saveEntered = CompletableDeferred() + val releaseSave = CompletableDeferred() + var current: NextcloudSession? = session + var draftExists = false + val save = async { + withAndroidAccountPrivateStatePublication( + expectedSession = session, + credentialMutationMutex = credentialMutations, + guard = guard, + resolveSession = { current }, + unavailable = { false }, + ) { + saveEntered.complete(Unit) + releaseSave.await() + draftExists = true + true + } + } + saveEntered.await() + val removal = async { + credentialMutations.withLock { + guard.withAccount(NextcloudDocumentIds.accountKey(session)) { + current = null + draftExists = false + } + } + } + yield() + + assertFalse(removal.isCompleted) + releaseSave.complete(Unit) + assertTrue(save.await()) + removal.await() + + assertFalse(draftExists) + } + @Test fun removalInThePostSaveGapCannotReopenDynamicReads() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt index 7f28e927d..e4061e727 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt @@ -118,7 +118,7 @@ class AndroidDeckCardDraftStoreTest { cipher = IdentityDeckDraftCipher, nowEpochMillis = { ++now }, ) - val unreadableKey = "${AndroidDeckCardDraftStore.KEY_PREFIX}unreadable" + val unreadableKey = store.storageKey(session, persisted(cardId = 999L).key) storage.values[unreadableKey] = "not-json" val saved = (1L..(DeckCardDraftRetention.MAX_ENTRIES + 3L)).map { cardId -> persisted(cardId = cardId, title = "Draft $cardId").also { @@ -148,7 +148,11 @@ class AndroidDeckCardDraftStoreTest { val mismatchedKey = store.storageKey(session, persisted(cardId = 99L).key) val ciphertext = storage.values.getValue(sourceKey) as String storage.values[mismatchedKey] = if (legacy) { - JSONObject(ciphertext).apply { remove("storageKey") }.toString() + JSONObject(ciphertext).apply { + put("version", AndroidDeckCardDraftStore.LEGACY_FORMAT_VERSION) + remove("accountStorageKey") + remove("storageKey") + }.toString() } else { ciphertext } @@ -182,9 +186,9 @@ class AndroidDeckCardDraftStoreTest { persisted(cardId = cardId, title = "Legacy $cardId").also { draft -> store.save(session, draft) val storedKey = store.storageKey(session, draft.key) - storage.values[storedKey] = JSONObject(storage.values.getValue(storedKey) as String) - .apply { remove("storageKey") } - .toString() + val legacyKey = store.legacyStorageKey(session, draft.key) + storage.values[legacyKey] = legacyCiphertext(storage.values.getValue(storedKey) as String) + storage.values.remove(storedKey) } } val newcomer = persisted(cardId = 1_000L, title = "New draft") @@ -200,7 +204,7 @@ class AndroidDeckCardDraftStoreTest { } @Test - fun `session migration makes legacy drafts readable to cross account retention`() { + fun `legacy drafts from another account do not consume retention`() { val storage = MemoryDeckDraftStorage() var now = 0L val store = AndroidDeckCardDraftStore( @@ -212,9 +216,9 @@ class AndroidDeckCardDraftStoreTest { persisted(cardId = cardId, title = "Legacy $cardId").also { draft -> store.save(session, draft) val storedKey = store.storageKey(session, draft.key) - storage.values[storedKey] = JSONObject(storage.values.getValue(storedKey) as String) - .apply { remove("storageKey") } - .toString() + val legacyKey = store.legacyStorageKey(session, draft.key) + storage.values[legacyKey] = legacyCiphertext(storage.values.getValue(storedKey) as String) + storage.values.remove(storedKey) } } val otherSession = NextcloudSession( @@ -227,21 +231,96 @@ class AndroidDeckCardDraftStoreTest { store.migrateLegacyEntries(otherSession) assertEquals(untouchedLegacyCiphertext, storage.values) - assertFailsWith { - store.save(otherSession, newcomer) - } + store.save(otherSession, newcomer) + assertEquals(newcomer, store.load(otherSession, newcomer.key)) store.migrateLegacyEntries(session) - store.save(otherSession, newcomer) - assertEquals(DeckCardDraftRetention.MAX_ENTRIES, storage.values.size) - assertEquals(newcomer, store.load(otherSession, newcomer.key)) + assertEquals(DeckCardDraftRetention.MAX_ENTRIES + 1, storage.values.size) assertEquals( - DeckCardDraftRetention.MAX_ENTRIES - 1, + DeckCardDraftRetention.MAX_ENTRIES, legacyDrafts.count { draft -> store.load(session, draft.key) != null }, ) } + @Test + fun `each account has its own retention budget`() { + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val otherSession = session.copy(loginName = "bob") + + repeat(DeckCardDraftRetention.MAX_ENTRIES) { index -> + store.save(session, persisted(cardId = 20_000L + index)) + store.save(otherSession, persisted(cardId = 30_000L + index)) + } + + assertEquals(DeckCardDraftRetention.MAX_ENTRIES * 2, storage.values.size) + assertEquals(persisted(cardId = 20_000L), store.load(session, persisted(cardId = 20_000L).key)) + assertEquals(persisted(cardId = 30_000L), store.load(otherSession, persisted(cardId = 30_000L).key)) + } + + @Test + fun `account removal is retryable and preserves another account`() { + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val otherSession = session.copy(loginName = "bob") + val removed = persisted(cardId = 51L) + val retained = persisted(cardId = 52L) + store.save(session, removed) + store.save(otherSession, retained) + storage.removeSucceeds = false + store.quarantineAfterSubmit(session, removed.key) + + assertFailsWith { + store.removeAccount(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session)) + } + assertTrue(storage.values.keys.any { it.contains(session.accountId.storageKey) }) + + storage.removeSucceeds = true + store.removeAccount(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session)) + + assertTrue(storage.values.keys.none { it.contains(session.accountId.storageKey) }) + assertEquals(retained, store.load(otherSession, retained.key)) + } + + @Test + fun `completed migration is not rolled back when legacy deletion must retry`() { + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val original = persisted(title = "Legacy") + store.save(session, original) + val targetKey = store.storageKey(session, original.key) + val legacyKey = store.legacyStorageKey(session, original.key) + storage.values[legacyKey] = legacyCiphertext(storage.values.getValue(targetKey) as String) + storage.values.remove(targetKey) + storage.removeSucceeds = false + + store.migrateLegacyEntries(session) + val updated = original.copy(draft = original.draft.copy(title = "Newer")) + store.save(session, updated) + + assertEquals(updated, store.load(session, original.key)) + assertTrue(legacyKey in storage.values) + } + + @Test + fun `account removal preserves unreadable and unattributable legacy drafts`() { + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val legacyKey = store.legacyStorageKey(session, persisted().key) + val unrelatedKey = store.legacyStorageKey(session.copy(loginName = "bob"), persisted(cardId = 91L).key) + val attributableKey = store.legacyStorageKey(session, persisted(cardId = 92L).key) + storage.values[legacyKey] = "unreadable" + storage.values[unrelatedKey] = legacyCiphertextFor(persisted(cardId = 91L), unrelatedKey) + storage.values[attributableKey] = legacyCiphertextFor(persisted(cardId = 92L), attributableKey) + + store.removeAccount(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session)) + + assertEquals("unreadable", storage.values[legacyKey]) + assertTrue(unrelatedKey in storage.values) + assertTrue(attributableKey !in storage.values) + } + @Test fun `failed legacy migration leaves recovery ciphertext intact`() { val storage = MemoryDeckDraftStorage() @@ -249,24 +328,24 @@ class AndroidDeckCardDraftStoreTest { val legacy = persisted() store.save(session, legacy) val storedKey = store.storageKey(session, legacy.key) - val legacyCiphertext = JSONObject(storage.values.getValue(storedKey) as String) - .apply { remove("storageKey") } - .toString() - storage.values[storedKey] = legacyCiphertext + val legacyKey = store.legacyStorageKey(session, legacy.key) + val legacyCiphertext = legacyCiphertext(storage.values.getValue(storedKey) as String) + storage.values[legacyKey] = legacyCiphertext + storage.values.remove(storedKey) storage.putSucceeds = false store.migrateLegacyEntries(session) - assertEquals(legacyCiphertext, storage.values[storedKey]) + assertEquals(legacyCiphertext, storage.values[legacyKey]) } @Test fun `unreadable drafts can fill but cannot exceed the retention ceiling`() { val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) repeat(DeckCardDraftRetention.MAX_ENTRIES) { index -> - storage.values["${AndroidDeckCardDraftStore.KEY_PREFIX}unreadable-$index"] = "not-json" + storage.values[store.storageKey(session, persisted(cardId = 10_000L + index).key)] = "not-json" } - val store = store(storage, IdentityDeckDraftCipher) assertFailsWith { store.save(session, persisted()) @@ -306,10 +385,10 @@ class AndroidDeckCardDraftStoreTest { @Test fun `explicit reset restores capacity after unreadable drafts fill the store`() { val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) repeat(DeckCardDraftRetention.MAX_ENTRIES) { index -> - storage.values["${AndroidDeckCardDraftStore.KEY_PREFIX}unreadable-$index"] = "not-json" + storage.values[store.storageKey(session, persisted(cardId = 10_000L + index).key)] = "not-json" } - val store = store(storage, IdentityDeckDraftCipher) assertFailsWith { store.save(session, persisted()) } store.discardAll() @@ -428,6 +507,27 @@ class AndroidDeckCardDraftStoreTest { ), ) + private fun legacyCiphertext(current: String): String = JSONObject(current).apply { + put("version", AndroidDeckCardDraftStore.LEGACY_FORMAT_VERSION) + remove("accountStorageKey") + remove("storageKey") + }.toString() + + private fun legacyCiphertextFor(persisted: PersistedDeckCardDraft, storageKey: String): String = JSONObject() + .put("version", AndroidDeckCardDraftStore.LEGACY_FORMAT_VERSION) + .put("storageKey", storageKey) + .put("updatedAtEpochMillis", 100L) + .put("boardId", persisted.key.boardId) + .put("stackId", persisted.key.stackId) + .put("cardId", persisted.key.cardId) + .put("title", persisted.draft.title) + .put("descriptionMarkdown", persisted.draft.descriptionMarkdown) + .put("dueDate", persisted.draft.dueDate) + .put("dueTime", persisted.draft.dueTime) + .put("dueAtBeforeEditing", persisted.draft.dueAtBeforeEditing) + .put("dueFieldsEdited", persisted.draft.dueFieldsEdited) + .toString() + private class MemoryDeckDraftStorage : AndroidDeckDraftStorage { val values = linkedMapOf() var putSucceeds = true diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 5b99862c6..3463653b1 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -38,6 +38,7 @@ internal data class DesktopAccountSyncPairCleanup( val accountId: String, val phase: DesktopAccountSyncPairCleanupPhase, val durableMutationAccountScope: String? = null, + val accountStorageKey: String? = null, ) internal class DesktopAccountSyncPairCleanupJournal( @@ -46,15 +47,23 @@ internal class DesktopAccountSyncPairCleanupJournal( ) { private val malformedReported = AtomicBoolean() - fun prepare(accountId: String, durableMutationAccountScope: String? = null) = - persist(accountId, DesktopAccountSyncPairCleanupPhase.Prepared, durableMutationAccountScope) + fun prepare( + accountId: String, + durableMutationAccountScope: String? = null, + accountStorageKey: String? = null, + ) = persist(accountId, DesktopAccountSyncPairCleanupPhase.Prepared, durableMutationAccountScope, accountStorageKey) fun commit(accountId: String) { val current = decode(accountId, preferences.get(cleanupKey(accountId), null)) check(current.phase != DesktopAccountSyncPairCleanupPhase.Unknown) { "The desktop account sync cleanup journal phase is unsupported." } - persist(accountId, DesktopAccountSyncPairCleanupPhase.Committed, current.durableMutationAccountScope) + persist( + accountId, + DesktopAccountSyncPairCleanupPhase.Committed, + current.durableMutationAccountScope, + current.accountStorageKey, + ) } fun clear(accountId: String) { @@ -99,11 +108,15 @@ internal class DesktopAccountSyncPairCleanupJournal( accountId: String, phase: DesktopAccountSyncPairCleanupPhase, durableMutationAccountScope: String?, + accountStorageKey: String?, ) { validateDesktopSyncPairCleanupAccountId(accountId) require( durableMutationAccountScope == null || durableMutationAccountScope.isCanonicalGroupwareMutationAccountScope(), ) { "The desktop durable mutation cleanup identity is invalid." } + require(accountStorageKey == null || accountStorageKey.matches(ACCOUNT_STORAGE_KEY_PATTERN)) { + "The desktop account storage cleanup identity is invalid." + } val key = cleanupKey(accountId) val current = preferences.get(key, null)?.let { decode(accountId, it) } check(current == null || current.phase != DesktopAccountSyncPairCleanupPhase.Unknown) { @@ -115,7 +128,7 @@ internal class DesktopAccountSyncPairCleanupJournal( } preferences.put( key, - encode(phase, durableMutationAccountScope), + encode(phase, durableMutationAccountScope, accountStorageKey), ) preferences.flush() } @@ -134,15 +147,30 @@ internal class DesktopAccountSyncPairCleanupJournal( else -> DesktopAccountSyncPairCleanupPhase.Unknown } val scope = fields.getOrNull(2)?.takeIf(String::isCanonicalGroupwareMutationAccountScope) + val accountStorageKey = fields.getOrNull(3)?.takeIf { it.matches(ACCOUNT_STORAGE_KEY_PATTERN) } return if (fields.size == 3 && fields[0] == VALUE_VERSION && scope != null) { DesktopAccountSyncPairCleanup(accountId, phase, scope) + } else if ( + fields.size == 4 && fields[0] == VALUE_VERSION_WITH_ACCOUNT_STORAGE && + scope != null && accountStorageKey != null + ) { + DesktopAccountSyncPairCleanup(accountId, phase, scope, accountStorageKey) } else { DesktopAccountSyncPairCleanup(accountId, DesktopAccountSyncPairCleanupPhase.Unknown) } } - private fun encode(phase: DesktopAccountSyncPairCleanupPhase, scope: String?): String { + private fun encode( + phase: DesktopAccountSyncPairCleanupPhase, + scope: String?, + accountStorageKey: String?, + ): String { val encodedPhase = if (phase == DesktopAccountSyncPairCleanupPhase.Prepared) PREPARED else COMMITTED + if (accountStorageKey != null) { + requireNotNull(scope) + return listOf(VALUE_VERSION_WITH_ACCOUNT_STORAGE, encodedPhase, scope, accountStorageKey) + .joinToString(VALUE_SEPARATOR) + } return scope?.let { "$VALUE_VERSION$VALUE_SEPARATOR$encodedPhase$VALUE_SEPARATOR$it" } ?: encodedPhase } @@ -159,7 +187,9 @@ internal class DesktopAccountSyncPairCleanupJournal( const val PREPARED = "prepared" const val COMMITTED = "committed" const val VALUE_VERSION = "v2" - const val VALUE_SEPARATOR = '|' + const val VALUE_VERSION_WITH_ACCOUNT_STORAGE = "v3" + const val VALUE_SEPARATOR = "|" + val ACCOUNT_STORAGE_KEY_PATTERN = Regex("[0-9a-f]{64}") } } @@ -232,7 +262,8 @@ internal fun setDesktopVirtualFileProviderPreference( internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( accountId: String, durableMutationAccountScope: String? = null, - prepareCleanup: suspend (String, String?) -> Unit, + accountStorageKey: String? = null, + prepareCleanup: suspend (String, String?, String?) -> Unit, commitCleanup: suspend (String) -> Unit, clearCleanup: suspend (String) -> Unit, accountOwnership: (String) -> DesktopAccountOwnership, @@ -240,7 +271,7 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, recordCleanupFailure: suspend (Exception) -> Unit, ): Boolean { - prepareCleanup(accountId, durableMutationAccountScope) + prepareCleanup(accountId, durableMutationAccountScope, accountStorageKey) val removed = try { removeCredential() } catch (failure: Throwable) { @@ -264,6 +295,7 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( accountId, DesktopAccountSyncPairCleanupPhase.Committed, durableMutationAccountScope, + accountStorageKey, ), ) clearCleanup(accountId) @@ -278,6 +310,7 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( accountId: String?, durableMutationAccountScope: String? = null, + accountStorageKey: String? = null, cleanupJournal: DesktopAccountSyncPairCleanupJournal, accountOwnership: (String) -> DesktopAccountOwnership, commitRemoval: suspend () -> Unit, @@ -291,6 +324,7 @@ internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( removeDesktopAccountBeforeSyncPairCleanup( accountId = accountId, durableMutationAccountScope = durableMutationAccountScope, + accountStorageKey = accountStorageKey, prepareCleanup = cleanupJournal::prepare, commitCleanup = cleanupJournal::commit, clearCleanup = cleanupJournal::clear, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftAccountGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftAccountGuard.kt new file mode 100644 index 000000000..3846ac880 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftAccountGuard.kt @@ -0,0 +1,13 @@ +package dev.obiente.nextcloudnative.app + +internal suspend fun withDesktopDeckCardDraftSession( + expectedSession: NextcloudSession, + guard: DesktopAccountOperationGuard, + accountCredentials: DesktopAccountCredentialPersistence, + action: suspend () -> Result, +): Result = guard.withAccountPrivateStatePublication( + expectedSession = expectedSession, + resolveSession = { accountCredentials.loadSession(expectedSession.accountId) }, + unavailable = { error("The account changed before the Deck draft operation could complete.") }, + publish = action, +) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt index d3b211317..508f9c462 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt @@ -36,6 +36,7 @@ internal class DesktopDeckCardDraftStore( ) { @Synchronized fun load(session: NextcloudSession, key: DeckCardDraftKey): PersistedDeckCardDraft? { + migrateLegacyEntry(session.accountId.storageKey, desktopFileCacheAccountId(session), key) val file = draftFile(session, key) val quarantine = quarantineFile(file) if (quarantine.exists()) { @@ -44,32 +45,40 @@ internal class DesktopDeckCardDraftStore( } if (!file.exists()) return null val encryptionKey = keyProvider.encryptionKey() - return readAuthenticated(file, encryptionKey, key).draft + return readAuthenticated(file, encryptionKey, key, session.accountId.storageKey).draft } @Synchronized fun save(session: NextcloudSession, persisted: PersistedDeckCardDraft) { + migrateLegacyEntries(session) + migrateLegacyEntry(session.accountId.storageKey, desktopFileCacheAccountId(session), persisted.key) val updatedAtEpochMillis = nowEpochMillis() require(updatedAtEpochMillis >= 0L) { "The Deck draft timestamp is invalid." } val file = draftFile(session, persisted.key) val encryptionKey = keyProvider.encryptionKey() if (file.exists()) { - readAuthenticated(file, encryptionKey, persisted.key) + readAuthenticated(file, encryptionKey, persisted.key, session.accountId.storageKey) } else { - ensureCapacityForNewDraft(encryptionKey) + ensureCapacityForNewDraft(session.accountId.storageKey, encryptionKey) } - val plaintext = encodePlaintext(persisted, updatedAtEpochMillis) + val plaintext = encodePlaintext( + persisted, + updatedAtEpochMillis, + session.accountId.storageKey, + file.name, + ) require(plaintext.size <= MAX_PLAINTEXT_BYTES) { "The Deck card draft is too large." } val envelope = encrypt(plaintext, file.name, encryptionKey) require(envelope.size.toLong() <= MAX_ENVELOPE_BYTES) { "The Deck card draft is too large." } val verified = decode(envelope, file.name, encryptionKey) + requireStorageOwner(verified, session.accountId.storageKey, file.name) check(verified.draft == persisted && verified.updatedAtEpochMillis == updatedAtEpochMillis) { "The Deck card draft could not be verified." } ensurePrivateDirectory() clearQuarantineBeforeSave(file) publish(file, envelope) - prune(encryptionKey) + prune(session.accountId.storageKey, encryptionKey) } @Synchronized @@ -78,10 +87,11 @@ internal class DesktopDeckCardDraftStore( key: DeckCardDraftKey, discardUnreadable: Boolean = false, ) { + migrateLegacyEntry(session.accountId.storageKey, desktopFileCacheAccountId(session), key) val file = draftFile(session, key) if (file.exists() && !discardUnreadable) { val encryptionKey = keyProvider.encryptionKey() - readAuthenticated(file, encryptionKey, key) + readAuthenticated(file, encryptionKey, key, session.accountId.storageKey) } check(!Files.isSymbolicLink(root.toPath())) { "Desktop Deck draft storage must not be a symbolic link." @@ -93,6 +103,7 @@ internal class DesktopDeckCardDraftStore( @Synchronized fun quarantineAfterSubmit(session: NextcloudSession, key: DeckCardDraftKey) { + migrateLegacyEntry(session.accountId.storageKey, desktopFileCacheAccountId(session), key) val file = draftFile(session, key) val quarantine = quarantineFile(file) ensurePrivateDirectory() @@ -109,27 +120,165 @@ internal class DesktopDeckCardDraftStore( val files = checkNotNull(root.listFiles()) { "Desktop Deck draft storage cannot be inspected for reset." }.filter { file -> - file.name.matches(DRAFT_FILE_PATTERN) || file.name.matches(SUBMITTED_FILE_PATTERN) + file.name.matches(DRAFT_FILE_PATTERN) || file.name.matches(SUBMITTED_FILE_PATTERN) || + file.name.matches(LEGACY_DRAFT_FILE_PATTERN) || file.name.matches(LEGACY_SUBMITTED_FILE_PATTERN) } check(files.all(::deleteDurably)) { "Saved Deck card drafts could not be discarded." } } + @Synchronized + fun migrateLegacyEntries(session: NextcloudSession) { + val encryptionKey = try { + keyProvider.encryptionKey() + } catch (_: Exception) { + return + } + root.listFiles().orEmpty() + .filter { file -> file.name.matches(LEGACY_DRAFT_FILE_PATTERN) } + .forEach { file -> + val stored = try { + readAuthenticated(file, encryptionKey) + } catch (_: DesktopDeckDraftRecoveryException) { + null + } ?: return@forEach + if ( + stored.accountStorageKey == null && + stored.storageFileName == null && + legacyStorageFileName(desktopFileCacheAccountId(session), stored.draft.key) == file.name + ) { + migrateLegacyEntry( + session.accountId.storageKey, + desktopFileCacheAccountId(session), + stored.draft.key, + stored, + encryptionKey, + ) + } + } + } + + @Synchronized + fun removeAccount(accountStorageKey: String, legacyAccountIdentity: String) { + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(accountStorageKey)) + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(legacyAccountIdentity)) + if (!root.exists()) return + check(root.isDirectory && !Files.isSymbolicLink(root.toPath())) { + "Desktop Deck draft storage cannot be removed safely." + } + val files = checkNotNull(root.listFiles()) { + "Desktop Deck draft storage cannot be inspected for account removal." + } + val targets = files.filter { file -> + file.name.startsWith(accountDraftPrefix(accountStorageKey)) || + file.name.startsWith(accountSubmittedPrefix(accountStorageKey)) + }.toMutableSet() + val encryptionKey = try { + keyProvider.encryptionKey() + } catch (_: Exception) { + null + } + if (encryptionKey != null) { + files.filter { file -> file.name.matches(LEGACY_DRAFT_FILE_PATTERN) }.forEach { file -> + val stored = try { + readAuthenticated(file, encryptionKey) + } catch (_: DesktopDeckDraftRecoveryException) { + null + } ?: return@forEach + if ( + stored.accountStorageKey == null && + stored.storageFileName == null && + legacyStorageFileName(legacyAccountIdentity, stored.draft.key) == file.name + ) { + targets += file + targets += legacyQuarantineFile(file) + } + } + } + check(targets.all(::deleteDurably)) { + "Saved Deck card drafts for the account could not be removed." + } + } + internal fun storageFileName(session: NextcloudSession, key: DeckCardDraftKey): String { + return storageFileName(session.accountId.storageKey, key) + } + + private fun storageFileName(accountStorageKey: String, key: DeckCardDraftKey): String { + val scope = listOf( + key.boardId.toString(), + key.stackId.toString(), + key.cardId?.toString() ?: "new", + ).joinToString(separator = ":") + return "${accountDraftPrefix(accountStorageKey)}${sha256Hex(scope)}$FILE_SUFFIX" + } + + internal fun legacyStorageFileName(accountIdentity: String, key: DeckCardDraftKey): String { val scope = listOf( - desktopFileCacheAccountId(session), + accountIdentity, key.boardId.toString(), key.stackId.toString(), key.cardId?.toString() ?: "new", ).joinToString(separator = ":") - return "$FILE_PREFIX${sha256Hex(scope)}$FILE_SUFFIX" + return "$LEGACY_FILE_PREFIX${sha256Hex(scope)}$FILE_SUFFIX" } private fun draftFile(session: NextcloudSession, key: DeckCardDraftKey): File = File(root, storageFileName(session, key)) private fun quarantineFile(draftFile: File): File { - val digest = draftFile.name.removePrefix(FILE_PREFIX).removeSuffix(FILE_SUFFIX) - return File(root, "$SUBMITTED_FILE_PREFIX$digest$SUBMITTED_FILE_SUFFIX") + val identity = draftFile.name.removePrefix(FILE_PREFIX).removeSuffix(FILE_SUFFIX) + return File(root, "$SUBMITTED_FILE_PREFIX$identity$SUBMITTED_FILE_SUFFIX") + } + + private fun legacyQuarantineFile(draftFile: File): File { + val digest = draftFile.name.removePrefix(LEGACY_FILE_PREFIX).removeSuffix(FILE_SUFFIX) + return File(root, "$LEGACY_SUBMITTED_FILE_PREFIX$digest$SUBMITTED_FILE_SUFFIX") + } + + private fun migrateLegacyEntry( + accountStorageKey: String, + legacyAccountIdentity: String, + key: DeckCardDraftKey, + decodedLegacy: StoredDeckCardDraft? = null, + providedEncryptionKey: ByteArray? = null, + ) { + val legacyFile = File(root, legacyStorageFileName(legacyAccountIdentity, key)) + val legacyMarker = legacyQuarantineFile(legacyFile) + val target = File(root, storageFileName(accountStorageKey, key)) + val targetMarker = quarantineFile(target) + if (!legacyFile.exists()) { + if (!legacyMarker.exists()) return + ensurePrivateDirectory() + publish(targetMarker, SUBMITTED_MARKER_BYTES) + deleteDurably(legacyMarker) + return + } + val encryptionKey = providedEncryptionKey ?: keyProvider.encryptionKey() + val legacy = decodedLegacy ?: readAuthenticated(legacyFile, encryptionKey, key) + if ( + legacy.accountStorageKey != null || legacy.storageFileName != null || + legacy.draft.key != key + ) { + throw DesktopDeckDraftRecoveryException( + IllegalArgumentException("The legacy Deck draft identity does not match."), + ) + } + val plaintext = encodePlaintext( + legacy.draft, + legacy.updatedAtEpochMillis, + accountStorageKey, + target.name, + ) + val envelope = encrypt(plaintext, target.name, encryptionKey) + ensurePrivateDirectory() + if (legacyMarker.exists()) publish(targetMarker, SUBMITTED_MARKER_BYTES) + if (target.exists()) { + readAuthenticated(target, encryptionKey, key, accountStorageKey) + } else { + publish(target, envelope) + } + deleteDurably(legacyFile) + deleteDurably(legacyMarker) } private fun clearQuarantineBeforeSave(draftFile: File) { @@ -143,8 +292,12 @@ internal class DesktopDeckCardDraftStore( private fun encodePlaintext( persisted: PersistedDeckCardDraft, updatedAtEpochMillis: Long, + accountStorageKey: String, + storageFileName: String, ): ByteArray = JSONObject() .put("version", PLAINTEXT_FORMAT_VERSION) + .put("accountStorageKey", accountStorageKey) + .put("storageFileName", storageFileName) .put("updatedAtEpochMillis", updatedAtEpochMillis) .put("boardId", persisted.key.boardId) .put("stackId", persisted.key.stackId) @@ -162,6 +315,7 @@ internal class DesktopDeckCardDraftStore( file: File, encryptionKey: ByteArray, expectedKey: DeckCardDraftKey? = null, + expectedAccountStorageKey: String? = null, ): StoredDeckCardDraft = try { if (!file.isSafeRegularFile() || file.length() !in 1..MAX_ENVELOPE_BYTES) { throw DesktopDeckDraftRecoveryException( @@ -174,6 +328,7 @@ internal class DesktopDeckCardDraftStore( IllegalArgumentException("The Deck draft resource identity does not match."), ) } + expectedAccountStorageKey?.let { requireStorageOwner(stored, it, file.name) } stored } catch (failure: DesktopDeckDraftRecoveryException) { throw failure @@ -207,11 +362,20 @@ internal class DesktopDeckCardDraftStore( val plaintext = cipher.doFinal(ciphertext) require(plaintext.size <= MAX_PLAINTEXT_BYTES) { "The Deck draft is too large." } val value = JSONObject(plaintext.decodeToString()) - require(value.getInt("version") == PLAINTEXT_FORMAT_VERSION) { + val version = value.getInt("version") + require(version == LEGACY_PLAINTEXT_FORMAT_VERSION || version == PLAINTEXT_FORMAT_VERSION) { "The Deck draft format is unsupported." } val updatedAtEpochMillis = value.getLong("updatedAtEpochMillis") require(updatedAtEpochMillis >= 0L) { "The Deck draft timestamp is invalid." } + val accountStorageKey = value.optString("accountStorageKey").takeIf(String::isNotBlank) + val storageFileName = value.optString("storageFileName").takeIf(String::isNotBlank) + require( + version == LEGACY_PLAINTEXT_FORMAT_VERSION && accountStorageKey == null && storageFileName == null || + version == PLAINTEXT_FORMAT_VERSION && + accountStorageKey?.matches(ACCOUNT_STORAGE_KEY_PATTERN) == true && + storageFileName?.matches(DRAFT_FILE_PATTERN) == true, + ) { "The Deck draft account storage metadata is invalid." } StoredDeckCardDraft( draft = PersistedDeckCardDraft( key = DeckCardDraftKey( @@ -233,11 +397,21 @@ internal class DesktopDeckCardDraftStore( ), ), updatedAtEpochMillis = updatedAtEpochMillis, + accountStorageKey = accountStorageKey, + storageFileName = storageFileName, ) } catch (failure: Exception) { throw DesktopDeckDraftRecoveryException(failure) } + private fun requireStorageOwner(stored: StoredDeckCardDraft, expectedOwner: String, expectedFileName: String) { + if (stored.accountStorageKey != expectedOwner || stored.storageFileName != expectedFileName) { + throw DesktopDeckDraftRecoveryException( + IllegalArgumentException("The Deck draft account storage identity does not match."), + ) + } + } + private fun encrypt( plaintext: ByteArray, fileName: String, @@ -261,12 +435,12 @@ internal class DesktopDeckCardDraftStore( .encodeToByteArray() } - private fun prune(encryptionKey: ByteArray) { + private fun prune(accountStorageKey: String, encryptionKey: ByteArray) { val files = root.listFiles().orEmpty() - .filter { it.name.matches(DRAFT_FILE_PATTERN) } + .filter { it.name.startsWith(accountDraftPrefix(accountStorageKey)) } val entries = files.mapNotNull { file -> val stored = try { - readAuthenticated(file, encryptionKey) + readAuthenticated(file, encryptionKey, expectedAccountStorageKey = accountStorageKey) } catch (_: DesktopDeckDraftRecoveryException) { // A keyring or filesystem failure can make valid ciphertext temporarily unreadable. // Preserve it so a later app process can authenticate and recover the draft. @@ -282,14 +456,14 @@ internal class DesktopDeckCardDraftStore( files.filter { it.name in namesToPrune }.forEach(::deleteDraft) } - private fun ensureCapacityForNewDraft(encryptionKey: ByteArray) { + private fun ensureCapacityForNewDraft(accountStorageKey: String, encryptionKey: ByteArray) { val files = root.listFiles().orEmpty() - .filter { it.name.matches(DRAFT_FILE_PATTERN) } + .filter { it.name.startsWith(accountDraftPrefix(accountStorageKey)) } val overflow = files.size + 1 - DeckCardDraftRetention.MAX_ENTRIES if (overflow <= 0) return val readableFiles = files.count { file -> try { - readAuthenticated(file, encryptionKey) + readAuthenticated(file, encryptionKey, expectedAccountStorageKey = accountStorageKey) true } catch (_: DesktopDeckDraftRecoveryException) { false @@ -374,15 +548,24 @@ internal class DesktopDeckCardDraftStore( private data class StoredDeckCardDraft( val draft: PersistedDeckCardDraft, val updatedAtEpochMillis: Long, + val accountStorageKey: String?, + val storageFileName: String?, ) + private fun accountDraftPrefix(accountStorageKey: String) = "$FILE_PREFIX${accountStorageKey}_" + + private fun accountSubmittedPrefix(accountStorageKey: String) = "$SUBMITTED_FILE_PREFIX${accountStorageKey}_" + internal companion object { - const val FILE_PREFIX = "draft_" + const val FILE_PREFIX = "draft_v2_" + const val LEGACY_FILE_PREFIX = "draft_" const val FILE_SUFFIX = ".json.enc" - const val SUBMITTED_FILE_PREFIX = "submitted_" + const val SUBMITTED_FILE_PREFIX = "submitted_v2_" + const val LEGACY_SUBMITTED_FILE_PREFIX = "submitted_" const val SUBMITTED_FILE_SUFFIX = ".marker" const val ENVELOPE_FORMAT_VERSION = 1 - const val PLAINTEXT_FORMAT_VERSION = 1 + const val LEGACY_PLAINTEXT_FORMAT_VERSION = 1 + const val PLAINTEXT_FORMAT_VERSION = 2 const val AES_KEY_BYTES = 32 const val GCM_NONCE_BYTES = 12 const val GCM_TAG_BYTES = 16 @@ -392,8 +575,11 @@ internal class DesktopDeckCardDraftStore( const val MAX_ENVELOPE_BYTES = 256L * 1024L const val CIPHER_TRANSFORMATION = "AES/GCM/NoPadding" const val AES_ALGORITHM = "AES" - val DRAFT_FILE_PATTERN = Regex("^draft_[0-9a-f]{64}\\.json\\.enc$") - val SUBMITTED_FILE_PATTERN = Regex("^submitted_[0-9a-f]{64}\\.marker$") + val DRAFT_FILE_PATTERN = Regex("^draft_v2_[0-9a-f]{64}_[0-9a-f]{64}\\.json\\.enc$") + val SUBMITTED_FILE_PATTERN = Regex("^submitted_v2_[0-9a-f]{64}_[0-9a-f]{64}\\.marker$") + val LEGACY_DRAFT_FILE_PATTERN = Regex("^draft_[0-9a-f]{64}\\.json\\.enc$") + val LEGACY_SUBMITTED_FILE_PATTERN = Regex("^submitted_[0-9a-f]{64}\\.marker$") + private val ACCOUNT_STORAGE_KEY_PATTERN = Regex("[0-9a-f]{64}") val SUBMITTED_MARKER_BYTES = "confirmed\n".encodeToByteArray() } } @@ -419,7 +605,10 @@ internal fun desktopDeckLegacySecretRequired( if (!root.exists()) return false if (!root.isDirectory) return true val entries = listFiles(root) ?: return true - return entries.any { file -> file.name.matches(DesktopDeckCardDraftStore.DRAFT_FILE_PATTERN) } + return entries.any { file -> + file.name.matches(DesktopDeckCardDraftStore.DRAFT_FILE_PATTERN) || + file.name.matches(DesktopDeckCardDraftStore.LEGACY_DRAFT_FILE_PATTERN) + } } internal fun interface DesktopDeckDraftKeyProvider { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 02e29c76d..6f491db6a 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3597,6 +3597,7 @@ class DesktopNextcloudServices( val removed = removeDesktopAccountBeforeSyncPairCleanup( accountId = providerAccountId, durableMutationAccountScope = durableMutationScope, + accountStorageKey = account.id.storageKey, prepareCleanup = accountSyncPairCleanupJournal::prepare, commitCleanup = accountSyncPairCleanupJournal::commit, clearCleanup = accountSyncPairCleanupJournal::clear, @@ -3648,6 +3649,7 @@ class DesktopNextcloudServices( ?: activeRecord?.let(::desktopFileCacheAccountId) val durableMutationScope = activeSession?.let(::durableMutationAccountScope) ?: activeRecord?.let(::desktopDurableMutationAccountScope) + val accountStorageKey = activeSession?.accountId?.storageKey ?: activeRecord?.id?.storageKey val syncJob = synchronized(this) { val active = backgroundFileSyncJob backgroundFileSyncJob = null @@ -3768,7 +3770,8 @@ class DesktopNextcloudServices( } try { clearDesktopActiveAccountBeforeSyncPairCleanup( - accountId, durableMutationScope, accountSyncPairCleanupJournal, ::desktopAccountOwnership, + accountId, durableMutationScope, accountStorageKey, + accountSyncPairCleanupJournal, ::desktopAccountOwnership, { commitDesktopAccountRemovalBeforeVirtualFileTeardown( commitRemoval = { @@ -3860,6 +3863,7 @@ class DesktopNextcloudServices( clearDesktopDynamicApiState(accountId, dynamicApiRequestCoalescer, dynamicApiReadCache) removeDesktopPendingDynamicMutations(pendingDynamicMutationDirectory, accountId) cleanup.durableMutationAccountScope?.let(durableMutationRecovery::removeAccount) + cleanup.accountStorageKey?.let { deckCardDrafts.removeAccount(it, accountId) } externalFileHandoff.removeAccount(accountId) removeDesktopAccountPrivateStorage(accountId, fileSyncEngine, fileReadCache, virtualRangeCache(accountId)) if (!isWindowsDesktop()) return @@ -3881,30 +3885,43 @@ class DesktopNextcloudServices( private fun desktopAccountOwnership(accountId: String): DesktopAccountOwnership = sessionPublicationGuard.serialize { accountCredentials.accountOwnership(accountId) } + override suspend fun prepareDeckCardDraftRecovery(session: NextcloudSession) = withContext(Dispatchers.IO) { + withDesktopDeckCardDraftSession(session, accountOperationGuard, accountCredentials) { + deckCardDrafts.migrateLegacyEntries(session) + } + } override suspend fun loadDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, ): PersistedDeckCardDraft? = withContext(Dispatchers.IO) { - deckCardDrafts.load(session, key) + withDesktopDeckCardDraftSession(session, accountOperationGuard, accountCredentials) { + deckCardDrafts.load(session, key) + } } override suspend fun saveDeckCardDraft( session: NextcloudSession, draft: PersistedDeckCardDraft, ) = withContext(Dispatchers.IO) { - deckCardDrafts.save(session, draft) + withDesktopDeckCardDraftSession(session, accountOperationGuard, accountCredentials) { + deckCardDrafts.save(session, draft) + } } override suspend fun clearDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, discardUnreadable: Boolean, ) = withContext(Dispatchers.IO) { - deckCardDrafts.clear(session, key, discardUnreadable) + withDesktopDeckCardDraftSession(session, accountOperationGuard, accountCredentials) { + deckCardDrafts.clear(session, key, discardUnreadable) + } } override suspend fun quarantineSubmittedDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, ) = withContext(Dispatchers.IO) { - deckCardDrafts.quarantineAfterSubmit(session, key) + withDesktopDeckCardDraftSession(session, accountOperationGuard, accountCredentials) { + deckCardDrafts.quarantineAfterSubmit(session, key) + } } override suspend fun discardAllDeckCardDrafts() = withContext(Dispatchers.IO) { deckCardDrafts.discardAll() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 435f58825..5435cf2da 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -19,6 +19,43 @@ import java.util.prefs.Preferences import kotlin.concurrent.thread class DesktopAccountOperationGuardTest { + @Test + fun accountRemovalWaitsForCrossingDeckDraftSaveThenDeletesIt() = runBlocking { + val guard = DesktopAccountOperationGuard() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val saveEntered = CompletableDeferred() + val releaseSave = CompletableDeferred() + var current: NextcloudSession? = session + var draftExists = false + val save = async { + guard.withAccountPrivateStatePublication( + expectedSession = session, + resolveSession = { current }, + unavailable = { false }, + ) { + saveEntered.complete(Unit) + releaseSave.await() + draftExists = true + true + } + } + saveEntered.await() + val removal = async { + guard.serialize { + current = null + draftExists = false + } + } + yield() + + assertFalse(removal.isCompleted) + releaseSave.complete(Unit) + assertTrue(save.await()) + removal.await() + + assertFalse(draftExists) + } + @Test fun accountRemovalCannotOvertakePostSaveDynamicReadActivation() = runBlocking { val guard = DesktopAccountOperationGuard() @@ -664,7 +701,7 @@ class DesktopAccountOperationGuardTest { val removed = removeDesktopAccountBeforeSyncPairCleanup( accountId = CLEANUP_ACCOUNT_ID, - prepareCleanup = { _, _ -> events += "prepare-cleanup" }, + prepareCleanup = { _, _, _ -> events += "prepare-cleanup" }, commitCleanup = { events += "commit-cleanup" }, clearCleanup = { events += "clear-cleanup" }, accountOwnership = { DesktopAccountOwnership.Absent }, @@ -699,7 +736,7 @@ class DesktopAccountOperationGuardTest { assertFailsWith { removeDesktopAccountBeforeSyncPairCleanup( accountId = CLEANUP_ACCOUNT_ID, - prepareCleanup = { _, _ -> events += "prepare-cleanup" }, + prepareCleanup = { _, _, _ -> events += "prepare-cleanup" }, commitCleanup = { events += "commit-cleanup" }, clearCleanup = { events += "clear-cleanup" }, accountOwnership = { DesktopAccountOwnership.Absent }, @@ -728,7 +765,7 @@ class DesktopAccountOperationGuardTest { assertFailsWith { removeDesktopAccountBeforeSyncPairCleanup( accountId = CLEANUP_ACCOUNT_ID, - prepareCleanup = { _, _ -> events += "prepare-cleanup" }, + prepareCleanup = { _, _, _ -> events += "prepare-cleanup" }, commitCleanup = { events += "commit-cleanup" }, clearCleanup = { events += "clear-cleanup" }, accountOwnership = { DesktopAccountOwnership.Absent }, @@ -799,7 +836,7 @@ class DesktopAccountOperationGuardTest { var malformedCount = 0 try { preferences.put("fsac.$malformedAccountId", "future-phase") - preferences.put("fsac.$validAccountId", "committed") + preferences.put("fsac.$validAccountId", "v2|committed|$MUTATION_SCOPE") val journal = DesktopAccountSyncPairCleanupJournal(preferences) { malformedCount += 1 } assertEquals( @@ -811,12 +848,13 @@ class DesktopAccountOperationGuardTest { DesktopAccountSyncPairCleanup( validAccountId, DesktopAccountSyncPairCleanupPhase.Committed, + MUTATION_SCOPE, ), ), journal.pending(), ) assertEquals("future-phase", preferences.get("fsac.$malformedAccountId", null)) - assertEquals("committed", preferences.get("fsac.$validAccountId", null)) + assertEquals("v2|committed|$MUTATION_SCOPE", preferences.get("fsac.$validAccountId", null)) assertTrue(journal.blocksAccountActivation(malformedAccountId)) assertFailsWith { requireDesktopAccountActivationAllowed(true) } assertFalse(journal.blocksAccountActivation(validAccountId)) @@ -848,6 +886,7 @@ class DesktopAccountOperationGuardTest { removeDesktopAccountBeforeSyncPairCleanup( accountId = CLEANUP_ACCOUNT_ID, durableMutationAccountScope = MUTATION_SCOPE, + accountStorageKey = ACCOUNT_STORAGE_KEY, prepareCleanup = firstJournal::prepare, commitCleanup = firstJournal::commit, clearCleanup = firstJournal::clear, @@ -866,11 +905,15 @@ class DesktopAccountOperationGuardTest { CLEANUP_ACCOUNT_ID, DesktopAccountSyncPairCleanupPhase.Committed, MUTATION_SCOPE, + ACCOUNT_STORAGE_KEY, ), ), restored.pending(), ) - assertEquals("v2|committed|$MUTATION_SCOPE", preferences.get("fsac.$CLEANUP_ACCOUNT_ID", null)) + assertEquals( + "v3|committed|$MUTATION_SCOPE|$ACCOUNT_STORAGE_KEY", + preferences.get("fsac.$CLEANUP_ACCOUNT_ID", null), + ) val retryEvents = mutableListOf() retryDesktopAccountSyncPairCleanup( @@ -957,5 +1000,6 @@ class DesktopAccountOperationGuardTest { private companion object { const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" const val MUTATION_SCOPE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const val ACCOUNT_STORAGE_KEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt index b48d5cc6f..0e0510e0c 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt @@ -3,6 +3,9 @@ package dev.obiente.nextcloudnative.app import java.nio.file.Files import java.util.Base64 import java.util.concurrent.atomic.AtomicLong +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -10,6 +13,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNull import kotlin.test.assertTrue +import org.json.JSONObject class DesktopDeckCardDraftStoreTest { @Test @@ -122,8 +126,8 @@ class DesktopDeckCardDraftStoreTest { withStore { root, _, store -> val session = session() repeat(DeckCardDraftRetention.MAX_ENTRIES) { index -> - root.resolve("${DesktopDeckCardDraftStore.FILE_PREFIX}${index.toString(16).padStart(64, '0')}" + - DesktopDeckCardDraftStore.FILE_SUFFIX).writeText("not-an-envelope") + root.resolve(store.storageFileName(session, persisted(cardId = 10_000L + index).key)) + .writeText("not-an-envelope") } assertFailsWith { @@ -159,6 +163,119 @@ class DesktopDeckCardDraftStoreTest { } } + @Test + fun `each account has its own retention budget`() = withStore { root, _, store -> + val alice = session() + val bob = session(login = "bob") + + repeat(DeckCardDraftRetention.MAX_ENTRIES) { index -> + store.save(alice, persisted(cardId = 20_000L + index)) + store.save(bob, persisted(cardId = 30_000L + index)) + } + + assertEquals(DeckCardDraftRetention.MAX_ENTRIES * 2, root.listFiles().orEmpty().size) + assertEquals(persisted(cardId = 20_000L), store.load(alice, persisted(cardId = 20_000L).key)) + assertEquals(persisted(cardId = 30_000L), store.load(bob, persisted(cardId = 30_000L).key)) + } + + @Test + fun `account removal is retryable and preserves another account`() { + val root = Files.createTempDirectory("desktop-deck-drafts-removal").toFile() + val key = ByteArray(DesktopDeckCardDraftStore.AES_KEY_BYTES) { (it + 1).toByte() } + val alice = session() + val bob = session(login = "bob") + val removed = persisted(cardId = 51L) + val retained = persisted(cardId = 52L) + try { + val writer = DesktopDeckCardDraftStore(root, fixedKey(key)) + writer.save(alice, removed) + writer.save(bob, retained) + val aliceDraftName = writer.storageFileName(alice, removed.key) + val aliceMarkerName = aliceDraftName + .replaceFirst(DesktopDeckCardDraftStore.FILE_PREFIX, DesktopDeckCardDraftStore.SUBMITTED_FILE_PREFIX) + .removeSuffix(DesktopDeckCardDraftStore.FILE_SUFFIX) + DesktopDeckCardDraftStore.SUBMITTED_FILE_SUFFIX + root.resolve(aliceMarkerName).writeBytes(DesktopDeckCardDraftStore.SUBMITTED_MARKER_BYTES) + val alicePrefix = "${DesktopDeckCardDraftStore.FILE_PREFIX}${alice.accountId.storageKey}_" + val failing = DesktopDeckCardDraftStore( + root = root, + keyProvider = fixedKey(key), + deleteFile = { file -> + if (file.name.startsWith(alicePrefix)) false + else Files.deleteIfExists(file.toPath()) || !file.exists() + }, + ) + + assertFailsWith { + failing.removeAccount(alice.accountId.storageKey, desktopFileCacheAccountId(alice)) + } + + writer.removeAccount(alice.accountId.storageKey, desktopFileCacheAccountId(alice)) + + assertNull(writer.load(alice, removed.key)) + assertEquals(retained, writer.load(bob, retained.key)) + assertTrue(root.listFiles().orEmpty().none { it.name.contains(alice.accountId.storageKey) }) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `completed legacy migration is not rolled back when deletion must retry`() { + val root = Files.createTempDirectory("desktop-deck-drafts-migration").toFile() + val key = ByteArray(DesktopDeckCardDraftStore.AES_KEY_BYTES) { (it + 1).toByte() } + val session = session() + val original = persisted(title = "Legacy") + try { + val probe = DesktopDeckCardDraftStore(root, fixedKey(key)) + val legacy = root.resolve( + probe.legacyStorageFileName(desktopFileCacheAccountId(session), original.key), + ) + writeLegacyDraft(legacy, key, original) + val failing = DesktopDeckCardDraftStore( + root = root, + keyProvider = fixedKey(key), + deleteFile = { file -> + if (file == legacy) false else Files.deleteIfExists(file.toPath()) || !file.exists() + }, + ) + + failing.migrateLegacyEntries(session) + val updated = original.copy(draft = original.draft.copy(title = "Newer")) + failing.save(session, updated) + + assertEquals(updated, failing.load(session, original.key)) + assertTrue(legacy.exists()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `account removal preserves unreadable and other account legacy drafts`() = + withStore { root, key, store -> + val alice = session() + val bob = session(login = "bob") + val aliceLegacy = root.resolve("draft_${"a".repeat(64)}.json.enc").apply { + writeText("unreadable") + } + val bobDraft = persisted(cardId = 91L) + val bobLegacy = root.resolve( + store.legacyStorageFileName(desktopFileCacheAccountId(bob), bobDraft.key), + ) + writeLegacyDraft(bobLegacy, key, bobDraft) + val aliceDraft = persisted(cardId = 92L) + val attributable = root.resolve( + store.legacyStorageFileName(desktopFileCacheAccountId(alice), aliceDraft.key), + ) + writeLegacyDraft(attributable, key, aliceDraft) + + store.removeAccount(alice.accountId.storageKey, desktopFileCacheAccountId(alice)) + + assertTrue(aliceLegacy.exists()) + assertTrue(bobLegacy.exists()) + assertFalse(attributable.exists()) + } + @Test fun `keyring failure does not delete a valid encrypted draft`() = withStore { root, _, store -> @@ -503,6 +620,42 @@ class DesktopDeckCardDraftStoreTest { ), ) + private fun writeLegacyDraft( + file: java.io.File, + key: ByteArray, + persisted: PersistedDeckCardDraft, + ) { + val plaintext = JSONObject() + .put("version", DesktopDeckCardDraftStore.LEGACY_PLAINTEXT_FORMAT_VERSION) + .put("updatedAtEpochMillis", 100L) + .put("boardId", persisted.key.boardId) + .put("stackId", persisted.key.stackId) + .put("cardId", persisted.key.cardId) + .put("title", persisted.draft.title) + .put("descriptionMarkdown", persisted.draft.descriptionMarkdown) + .put("dueDate", persisted.draft.dueDate) + .put("dueTime", persisted.draft.dueTime) + .put("dueAtBeforeEditing", persisted.draft.dueAtBeforeEditing) + .put("dueFieldsEdited", persisted.draft.dueFieldsEdited) + .toString() + .encodeToByteArray() + val nonce = ByteArray(DesktopDeckCardDraftStore.GCM_NONCE_BYTES) { (it + 7).toByte() } + val cipher = Cipher.getInstance(DesktopDeckCardDraftStore.CIPHER_TRANSFORMATION) + cipher.init( + Cipher.ENCRYPT_MODE, + SecretKeySpec(key, DesktopDeckCardDraftStore.AES_ALGORITHM), + GCMParameterSpec(DesktopDeckCardDraftStore.GCM_TAG_BITS, nonce), + ) + cipher.updateAAD(file.name.encodeToByteArray()) + file.writeText( + JSONObject() + .put("version", DesktopDeckCardDraftStore.ENVELOPE_FORMAT_VERSION) + .put("nonce", Base64.getEncoder().encodeToString(nonce)) + .put("ciphertext", Base64.getEncoder().encodeToString(cipher.doFinal(plaintext))) + .toString(), + ) + } + private class ToggleSecretStore : DesktopSecretStore { var secret: ByteArray? = null var failure: RuntimeException? = null From bf4f21f2fd70f27352a12039bbcdeddc42ee844c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 00:17:48 +0200 Subject: [PATCH 092/119] fix(desktop): delete read-only handoff copies --- .../obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt index 82af59f7d..5390427dd 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopExternalFileHandoff.kt @@ -449,6 +449,7 @@ internal fun deleteDesktopExternalFileTree(root: Path): Boolean { return try { Files.walkFileTree(root, object : SimpleFileVisitor() { override fun visitFile(file: Path, attrs: BasicFileAttributes): FileVisitResult { + if (!attrs.isSymbolicLink) file.toFile().setWritable(true, false) Files.delete(file) return FileVisitResult.CONTINUE } From 5598786101ec1d721de0a642dff75d3ba7fc09d6 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 01:27:56 +0200 Subject: [PATCH 093/119] fix(accounts): harden retained account recovery --- .../AndroidAccountCredentialController.kt | 48 ++++--- .../AndroidAccountCredentialRecovery.kt | 10 ++ .../AndroidAccountOperationGuard.kt | 11 ++ .../AndroidAccountRemovalCleanupJournal.kt | 65 ++++++--- ...ndroidAccountRemovalCleanupRecoveryWork.kt | 26 ++-- .../AndroidAccountRetention.kt | 51 ++++++- .../AndroidDurableMultipartUploads.kt | 49 +++---- .../AndroidFileOfflineRepository.kt | 6 +- .../AndroidFileSyncExecutionCoordination.kt | 2 +- .../AndroidNextcloudServices.kt | 101 +++++++------- .../NextcloudFileSyncWorker.kt | 19 ++- .../AndroidAccountRecoveryPriorityTest.kt | 80 +++++++++++ .../AndroidAccountRemovalRecoveryTest.kt | 62 ++++++++- ...AndroidDurableMultipartUploadPolicyTest.kt | 25 +++- .../AndroidFileSyncEngineInvariantTest.kt | 2 +- .../AndroidPersistedSessionTest.kt | 15 ++ .../DesktopAccountCredentialPersistence.kt | 110 +++++++++++---- .../app/DesktopAccountOperationGuard.kt | 9 +- .../app/DesktopAccountRemoval.kt | 13 ++ .../app/DesktopAccountSecretReference.kt | 12 ++ .../app/DesktopNextcloudServices.kt | 22 ++- ...DesktopAccountCredentialPersistenceTest.kt | 130 ++++++++++++++++-- .../app/DesktopAccountOperationGuardTest.kt | 54 ++++++++ 23 files changed, 734 insertions(+), 188 deletions(-) create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 1e0e10810..f187ab105 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -58,7 +58,7 @@ internal class AndroidAccountCredentialController( ) fun accountRetentionSnapshot(): AndroidAccountRetentionSnapshot = readRegistryForCredentialLoad() - ?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts) } + ?.let { registry -> AndroidAccountRetentionSnapshot.Available(registry.accounts, registry.activeAccountId) } ?: AndroidAccountRetentionSnapshot.Unavailable fun activeAccountId(): NextcloudAccountId? = readCredentialFreeRegistry()?.activeAccountId @@ -295,8 +295,12 @@ internal class AndroidAccountCredentialController( else -> clearInvalidStore(read.encrypted) } } - AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> - error("The independent account credential slots could not be recovered.") + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> { + requireAndroidIndependentCredentialStateCanBeExplicitlyReset( + preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null), + ) + clearInvalidStore(null) + } is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } } @@ -314,7 +318,7 @@ internal class AndroidAccountCredentialController( notifyDocumentRootsChanged() } - private suspend fun clearInvalidStore(suspectEncrypted: String) { + private suspend fun clearInvalidStore(suspectEncrypted: String?) { clearPersistedSession( encodedReplacement = null, replacement = AndroidAccountCredentialState.Empty, @@ -688,25 +692,25 @@ internal class AndroidAccountCredentialController( } private suspend fun retryPendingAccountRemovalCleanup(session: NextcloudSession) { - val pending = pendingAndroidAccountRemovalCleanupForSession( - session, accountRemovalCleanupJournal.pending(), - ) ?: return - try { - retryAndroidAccountRemovalCleanup( - accountOwnedByRegistry = androidAccountRemovalCleanupOwnedByRegistry( - pending, readCredentialFreeRegistry()?.accounts, - ), - removeAccountOwnedWork = { - retryAndroidAccountOwnedStateCleanup(session, pending, retryQueuedUploadsCleanup) - }, - clearCleanup = { accountRemovalCleanupJournal.clear(pending.accountStorageKey) }, - ) - } catch (cancelled: CancellationException) { - throw cancelled - } catch (failure: Exception) { - recordAccountRemovalCleanupFailure(failure) - throw androidAccountRemovalCleanupRetryFailure(failure) + val snapshot = accountRemovalCleanupJournal.snapshot() + val pending = pendingAndroidAccountRemovalCleanupForSession(session, snapshot.cleanups) + if (pending != null) { + try { + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = androidAccountRemovalCleanupOwnedByRegistry(pending, readCredentialFreeRegistry()?.accounts), + removeAccountOwnedWork = { + retryAndroidAccountOwnedStateCleanup(session, pending, retryQueuedUploadsCleanup) + }, + clearCleanup = { accountRemovalCleanupJournal.clear(pending.accountStorageKey) }, + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + recordAccountRemovalCleanupFailure(failure) + throw androidAccountRemovalCleanupRetryFailure(failure) + } } + requireAndroidAccountRemovalCleanupJournalAllowsActivation(snapshot) } private fun commitPreferences(editor: SharedPreferences.Editor) = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index d83a5d1d6..c41cb5f8d 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -253,6 +253,16 @@ internal fun androidCredentialStoreAllowsSessionRestore( -> true } +internal fun androidIndependentCredentialStateCanBeExplicitlyReset( + registry: RestoredAndroidCredentialFreeRegistry?, +): Boolean = registry == null || registry.credentialRecoveryRequired + +internal fun requireAndroidIndependentCredentialStateCanBeExplicitlyReset(encodedRegistry: String?) { + check(androidIndependentCredentialStateCanBeExplicitlyReset(encodedRegistry?.let(::restoreAndroidCredentialFreeRegistry))) { + "The independent account credential slots could not be recovered." + } +} + internal class AndroidAccountCredentialStoreGuard { private val monitor = Any() diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index face9521a..69ba2dfee 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -148,6 +148,17 @@ internal suspend fun AndroidAccountOperationGuard.withAuthenticatedMuta action = action, ) +internal suspend fun withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld: Boolean, + expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, + resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, + action: suspend (dev.obiente.nextcloudnative.app.NextcloudSession) -> Result, +): Result = if (accountMutationLeaseHeld) { + action(expectedSession) +} else { + ANDROID_ACCOUNT_OPERATION_GUARD.withAuthenticatedMutationSession(expectedSession, resolveSession, action) +} + internal suspend fun withAndroidAccountPrivateStatePublication( expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, credentialMutationMutex: Mutex, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt index c782e3cc4..f1b32fbf4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt @@ -7,8 +7,15 @@ internal class AndroidAccountRemovalCleanupJournal( private val commit: (SharedPreferences.Editor) -> Unit, private val recordMalformed: () -> Unit, ) { - fun pending(): Set { - val encoded = try { + fun pending(): Set = snapshot().cleanups + + fun snapshot(): RestoredAndroidPendingAccountRemovalCleanups { + val restored = restoreAndroidPendingAccountRemovalCleanups(readEncoded()) + if (restored.malformedEntryCount > 0) runCatching(recordMalformed) + return restored + } + + private fun readEncoded(): Set = try { preferences.getStringSet(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, emptySet()).orEmpty() } catch (failure: Exception) { runCatching(recordMalformed) @@ -17,8 +24,6 @@ internal class AndroidAccountRemovalCleanupJournal( failure, ) } - return requireValidAndroidAccountRemovalCleanupJournal(encoded, recordMalformed) - } fun prepareEdit( editor: SharedPreferences.Editor, @@ -26,23 +31,17 @@ internal class AndroidAccountRemovalCleanupJournal( ): SharedPreferences.Editor = if (pendingCleanup == null) { editor } else { - val retained = pending() - .filterNot { cleanup -> cleanup.accountStorageKey == pendingCleanup.accountStorageKey } - .toSet() + pendingCleanup editor.putStringSet( ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, - retained.mapTo(linkedSetOf(), ::encodeAndroidPendingAccountRemovalCleanup), + replaceAndroidAccountRemovalCleanup(readEncoded(), pendingCleanup, recordMalformed), ) } fun clear(accountStorageKey: String) { - val remaining = pending().filterNot { cleanup -> cleanup.accountStorageKey == accountStorageKey } + val remaining = removeAndroidAccountRemovalCleanup(readEncoded(), accountStorageKey, recordMalformed) val editor = preferences.edit() if (remaining.isEmpty()) editor.remove(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY) - else editor.putStringSet( - ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, - remaining.mapTo(linkedSetOf(), ::encodeAndroidPendingAccountRemovalCleanup), - ) + else editor.putStringSet(ANDROID_PENDING_ACCOUNT_REMOVAL_CLEANUP_KEY, remaining) commit(editor) } } @@ -59,9 +58,43 @@ internal fun requireValidAndroidAccountRemovalCleanupJournal( val restored = restoreAndroidPendingAccountRemovalCleanups(encoded) if (restored.malformedEntryCount > 0) { runCatching(recordMalformed) - throw AndroidAccountRemovalCleanupJournalException( - "The account-removal cleanup journal contains a malformed tombstone.", - ) } return restored.cleanups } + +internal fun requireAndroidAccountRemovalCleanupJournalAllowsActivation( + snapshot: RestoredAndroidPendingAccountRemovalCleanups, +) { + check(snapshot.malformedEntryCount == 0) { + "Reset the malformed account-removal cleanup state before signing in again." + } +} + +internal fun replaceAndroidAccountRemovalCleanup( + encoded: Set, + replacement: AndroidPendingAccountRemovalCleanup, + recordMalformed: () -> Unit, +): Set = removeAndroidAccountRemovalCleanup( + encoded, + replacement.accountStorageKey, + recordMalformed, +) + encodeAndroidPendingAccountRemovalCleanup(replacement) + +internal fun removeAndroidAccountRemovalCleanup( + encoded: Set, + accountStorageKey: String, + recordMalformed: () -> Unit, +): Set { + var malformedFound = false + val remaining = encoded.filterTo(linkedSetOf()) { entry -> + val cleanup = decodeAndroidPendingAccountRemovalCleanup(entry) + if (cleanup == null) { + malformedFound = true + true + } else { + cleanup.accountStorageKey != accountStorageKey + } + } + if (malformedFound) runCatching(recordMalformed) + return remaining +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt index f0f3ab546..b40b6b5eb 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt @@ -88,14 +88,14 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( ?.let(::restoreAndroidCredentialFreeRegistry) ?.registry val cleanup = AndroidAccountOwnedStateCleanup(applicationContext) - val pending = readPendingAndroidAccountRemovalCleanups( - readPending = journal::pending, - recordFailure = { - logAndroidAccountRemovalCleanupRecoveryDeferred { message -> Log.w(LOG_TAG, message) } - }, - ) ?: return@withLock Result.retry() + val snapshot = try { + journal.snapshot() + } catch (failure: Exception) { + logAndroidAccountRemovalCleanupRecoveryDeferred { message -> Log.w(LOG_TAG, message, failure) } + return@withLock Result.retry() + } val completed = recoverPendingAndroidAccountRemovalCleanups( - pending = pending, + pending = snapshot.cleanups, accountOwnedByRegistry = { pendingCleanup -> androidAccountRemovalCleanupOwnedByRegistry(pendingCleanup, registry?.accounts) }, @@ -114,10 +114,20 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( logAndroidAccountRemovalCleanupRecoveryDeferred { message -> Log.w(LOG_TAG, message) } }, ) - if (completed && handoffCompleted) Result.success() else Result.retry() + if (androidAccountRemovalCleanupRecoveryCompleted(completed, snapshot, handoffCompleted)) { + Result.success() + } else { + Result.retry() + } } } +internal fun androidAccountRemovalCleanupRecoveryCompleted( + validCleanupCompleted: Boolean, + snapshot: RestoredAndroidPendingAccountRemovalCleanups, + handoffCompleted: Boolean, +): Boolean = validCleanupCompleted && snapshot.malformedEntryCount == 0 && handoffCompleted + internal suspend fun recoverPendingAndroidAccountRemovalCleanups( pending: Collection, accountOwnedByRegistry: (AndroidPendingAccountRemovalCleanup) -> Boolean?, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt index 1fa18ea63..de9eb4a4a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRetention.kt @@ -1,9 +1,13 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.NextcloudAccountId import dev.obiente.nextcloudnative.app.NextcloudAccountRecord internal sealed interface AndroidAccountRetentionSnapshot { - data class Available(val accounts: List) : AndroidAccountRetentionSnapshot + data class Available( + val accounts: List, + val activeAccountId: NextcloudAccountId? = null, + ) : AndroidAccountRetentionSnapshot data object Unavailable : AndroidAccountRetentionSnapshot } @@ -26,3 +30,48 @@ internal fun shouldRetryIncomingShareForMissingSession( androidAccountIdentityIsRetained(accountIdentity, snapshot.accounts) AndroidAccountRetentionSnapshot.Unavailable -> true } + +internal fun AndroidAccountRetentionSnapshot.expectedAccountState( + accountIdentity: String, +): AndroidExpectedAccountState = when (this) { + is AndroidAccountRetentionSnapshot.Available -> { + val expected = accounts.firstOrNull { account -> + NextcloudDocumentIds.accountKey(account.serverUrl, account.loginName) == accountIdentity + } + when { + expected == null -> AndroidExpectedAccountState.Absent + expected.id == activeAccountId -> AndroidExpectedAccountState.Active + else -> AndroidExpectedAccountState.Inactive + } + } + AndroidAccountRetentionSnapshot.Unavailable -> AndroidExpectedAccountState.Unknown +} + +internal enum class AndroidExpectedAccountState { + Active, + Inactive, + Absent, + Unknown, +} + +internal enum class DurableUploadAccountMismatchOutcome { + RetryAccountRecovery, + DeferAccountActivation, + AccountUnavailable, +} + +internal fun durableUploadAccountMismatchOutcome( + expectedAccountId: String, + accountSnapshot: AndroidAccountRetentionSnapshot, +): DurableUploadAccountMismatchOutcome = when (accountSnapshot.expectedAccountState(expectedAccountId)) { + AndroidExpectedAccountState.Active, + AndroidExpectedAccountState.Unknown, + -> DurableUploadAccountMismatchOutcome.RetryAccountRecovery + AndroidExpectedAccountState.Inactive -> DurableUploadAccountMismatchOutcome.DeferAccountActivation + AndroidExpectedAccountState.Absent -> DurableUploadAccountMismatchOutcome.AccountUnavailable +} + +internal fun shouldRetryAndroidOfflineJobForMissingSession( + expectedAccountId: String, + snapshot: AndroidAccountRetentionSnapshot, +): Boolean = snapshot.expectedAccountState(expectedAccountId) != AndroidExpectedAccountState.Absent diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt index a168b0469..98e5d3d7c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploads.kt @@ -190,16 +190,26 @@ internal class DeckAttachmentUploadWorker( val accountServices = AndroidNextcloudServices(applicationContext) val session = accountServices.loadSession() if (session == null || NextcloudDocumentIds.accountKey(session) != initial.accountId) { - if (durableUploadAccountMismatchOutcome(initial.accountId, accountServices.accountRetentionSnapshot()) == - DurableUploadAccountMismatchOutcome.DeferAccountRecovery - ) { - recordUploadDiagnostic( - severity = SupportDiagnosticSeverity.Warning, - outcome = "account-deferred", - accountId = initial.accountId, - jobId = jobId, - ) - return Result.success() + when (durableUploadAccountMismatchOutcome(initial.accountId, accountServices.accountRetentionSnapshot())) { + DurableUploadAccountMismatchOutcome.RetryAccountRecovery -> { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-retry", + accountId = initial.accountId, + jobId = jobId, + ) + return Result.retry() + } + DurableUploadAccountMismatchOutcome.DeferAccountActivation -> { + recordUploadDiagnostic( + severity = SupportDiagnosticSeverity.Warning, + outcome = "account-deferred", + accountId = initial.accountId, + jobId = jobId, + ) + return Result.success() + } + DurableUploadAccountMismatchOutcome.AccountUnavailable -> Unit } store.transition( jobId, @@ -336,25 +346,6 @@ internal class DeckAttachmentUploadWorker( } } -internal enum class DurableUploadAccountMismatchOutcome { - DeferAccountRecovery, - AccountUnavailable, -} - -internal fun durableUploadAccountMismatchOutcome( - expectedAccountId: String, - accountSnapshot: AndroidAccountRetentionSnapshot, -): DurableUploadAccountMismatchOutcome = when (accountSnapshot) { - is AndroidAccountRetentionSnapshot.Available -> { - if (androidAccountIdentityIsRetained(expectedAccountId, accountSnapshot.accounts)) { - DurableUploadAccountMismatchOutcome.DeferAccountRecovery - } else { - DurableUploadAccountMismatchOutcome.AccountUnavailable - } - } - AndroidAccountRetentionSnapshot.Unavailable -> DurableUploadAccountMismatchOutcome.DeferAccountRecovery -} - internal fun queuedDurableUploadsForAccount( jobs: List, accountId: String, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt index 04ae7f32f..29234a9ca 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileOfflineRepository.kt @@ -401,10 +401,14 @@ internal class AndroidFileOfflineRepository(context: Context) { cancellation: DocumentRequestCancellation, ): AndroidOfflineExecutionOutcome { val services = AndroidNextcloudServices(appContext) + val accountSnapshot = services.accountRetentionSnapshot() val session = resolveStoredAndroidAccountSession( - expectedAccountId, services::listAccounts, services::loadSession, + expectedAccountId, { accountSnapshot.accountsOrEmpty() }, services::loadSession, ) if (session == null) { + if (shouldRetryAndroidOfflineJobForMissingSession(expectedAccountId, accountSnapshot)) { + return AndroidOfflineExecutionOutcome.Retry + } finish( jobId, FileOfflineJobResult.PermanentFailure("Sign in to this account to finish the offline download."), diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt index b30fa6706..ed2e2125b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncExecutionCoordination.kt @@ -287,8 +287,8 @@ internal suspend fun retireConfiguredFileSyncAccountPairs( .distinct() .toList() withContext(NonCancellable) { - persistRetirement() releasedLocalRoots.forEach { localRootId -> releaseLocalGrant(localRootId) } + persistRetirement() } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 9c75a786a..22c40b6bc 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -2677,32 +2677,32 @@ internal class AndroidNextcloudServices( } } - override suspend fun saveTextFile( - session: NextcloudSession, - userId: String, + override suspend fun saveTextFile(session: NextcloudSession, userId: String, path: String, text: String, expectedEtag: String, ): SavedTextFile = withContext(Dispatchers.IO) { - withNoBlockingAndroidDocumentWritebackSuspending(appContext, session, path) { - val specification = textFileDavSaveRequest(text, expectedEtag) - val response = request( - method = "PUT", - url = buildNextcloudFileUrl(session.serverUrl, userId, path), - session = session, - rawBody = specification.body, - contentType = specification.contentType, - headers = specification.headers, - ) - val confirmation = confirmTextFileDavSave(response.status) - val etag = response.etag ?: try { - loadFileEtag(session, userId, path) - } catch (failure: Exception) { - if (failure is CancellationException) throw failure - null + withAndroidAuthenticatedFileMutation(accountMutationLeaseHeld, session, accountCredentials::loadSession) { currentSession -> + withNoBlockingAndroidDocumentWritebackSuspending(appContext, currentSession, path) { + val specification = textFileDavSaveRequest(text, expectedEtag) + val response = request( + method = "PUT", + url = buildNextcloudFileUrl(currentSession.serverUrl, userId, path), + session = currentSession, + rawBody = specification.body, + contentType = specification.contentType, + headers = specification.headers, + ) + val confirmation = confirmTextFileDavSave(response.status) + val etag = response.etag ?: try { + loadFileEtag(currentSession, userId, path) + } catch (failure: Exception) { + if (failure is CancellationException) throw failure + null + } + runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(currentSession), path) } + SavedTextFile(etag, confirmation.created) } - runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(session), path) } - SavedTextFile(etag, confirmation.created) } } @@ -2750,39 +2750,38 @@ internal class AndroidNextcloudServices( true } - override suspend fun executeFileMutation( - session: NextcloudSession, - userId: String, - mutation: NextcloudFileMutation, - ): NextcloudFileMutationResult = withContext(Dispatchers.IO) { + override suspend fun executeFileMutation(session: NextcloudSession, userId: String, mutation: NextcloudFileMutation): + NextcloudFileMutationResult = withContext(Dispatchers.IO) { val spec = mutation.toWebDavMutationSpec() - withNoBlockingAndroidDocumentWritebackSuspending( - appContext, - session, - *listOfNotNull(spec.sourcePath, spec.destinationPath).toTypedArray(), - ) { - val headers = buildMap { - put("Accept", "*/*") - putAll(spec.conflictConditionHeaders()) - spec.destinationPath?.let { destinationPath -> - put("Destination", buildNextcloudFileUrl(session.serverUrl, userId, destinationPath)) - put("Overwrite", if (spec.overwrite) "T" else "F") + withAndroidAuthenticatedFileMutation(accountMutationLeaseHeld, session, accountCredentials::loadSession) { currentSession -> + withNoBlockingAndroidDocumentWritebackSuspending( + appContext, + currentSession, + *listOfNotNull(spec.sourcePath, spec.destinationPath).toTypedArray(), + ) { + val headers = buildMap { + put("Accept", "*/*") + putAll(spec.conflictConditionHeaders()) + spec.destinationPath?.let { destinationPath -> + put("Destination", buildNextcloudFileUrl(currentSession.serverUrl, userId, destinationPath)) + put("Overwrite", if (spec.overwrite) "T" else "F") + } } + val response = request( + method = spec.method, + url = buildNextcloudFileUrl(currentSession.serverUrl, userId, spec.sourcePath), + session = currentSession, + headers = headers, + maxResponseBytes = 64 * 1024, + ) + if (response.status !in 200..299) throw fileOperationException(response.status) + val accountId = NextcloudDocumentIds.accountKey(currentSession) + runCatching { fileReadCache.invalidate(accountId, spec.sourcePath) } + spec.destinationPath?.let { destination -> + runCatching { fileReadCache.invalidate(accountId, destination) } + } + NextcloudFileMutationResult(spec.destinationPath, response.etag) } - val response = request( - method = spec.method, - url = buildNextcloudFileUrl(session.serverUrl, userId, spec.sourcePath), - session = session, - headers = headers, - maxResponseBytes = 64 * 1024, - ) - if (response.status !in 200..299) throw fileOperationException(response.status) - val accountId = NextcloudDocumentIds.accountKey(session) - runCatching { fileReadCache.invalidate(accountId, spec.sourcePath) } - spec.destinationPath?.let { destination -> - runCatching { fileReadCache.invalidate(accountId, destination) } - } - NextcloudFileMutationResult(spec.destinationPath, response.etag) } } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt index fd634b87c..389d16f99 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudFileSyncWorker.kt @@ -178,9 +178,14 @@ internal class AndroidFileSyncScheduleRestorationWorker( val expectedAccountId = inputData.getString(KEY_ACCOUNT_ID)?.takeIf(String::isNotBlank) ?: return@withContext Result.failure() val services = AndroidNextcloudServices(applicationContext) + val accountSnapshot = services.accountRetentionSnapshot() val session = services.loadSession() ?.takeIf { restored -> isAndroidFileSyncScheduleRestorationCurrent(expectedAccountId, restored) } - ?: return@withContext Result.success() + ?: return@withContext if (shouldRetryAndroidFileSyncScheduleRestoration(expectedAccountId, accountSnapshot)) { + Result.retry() + } else { + Result.success() + } runCatching { val userId = services.loadServerInfo(session).userId services.loadFileSyncCenter(session, userId) @@ -203,6 +208,18 @@ internal fun isAndroidFileSyncScheduleRestorationCurrent( session: NextcloudSession, ): Boolean = NextcloudDocumentIds.accountKey(session) == expectedAccountId +internal fun shouldRetryAndroidFileSyncScheduleRestoration( + expectedAccountId: String, + snapshot: AndroidAccountRetentionSnapshot, +): Boolean = when (snapshot.expectedAccountState(expectedAccountId)) { + AndroidExpectedAccountState.Active, + AndroidExpectedAccountState.Unknown, + -> true + AndroidExpectedAccountState.Inactive, + AndroidExpectedAccountState.Absent, + -> false +} + internal fun scheduleRestorationFailureDisposition(runAttemptCount: Int): BackgroundSyncWorkerDisposition { require(runAttemptCount >= 0) return BackgroundSyncWorkerDisposition.Retry diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt new file mode 100644 index 000000000..031f4b21b --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt @@ -0,0 +1,80 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.FileSyncConfiguration +import dev.obiente.nextcloudnative.app.FileSyncPair +import dev.obiente.nextcloudnative.app.NextcloudSession +import dev.obiente.nextcloudnative.app.accountRecord +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking + +class AndroidAccountRecoveryPriorityTest { + @Test + fun scheduleRestorationRetriesOnlyWhenTheExpectedAccountMayStillBeActive() { + val expected = NextcloudSession("https://cloud.example.test/nextcloud", "alice", "secret") + val other = NextcloudSession("https://cloud.example.test/nextcloud", "bob", "other-secret") + val expectedIdentity = NextcloudDocumentIds.accountKey(expected) + + assertTrue( + shouldRetryAndroidFileSyncScheduleRestoration( + expectedIdentity, + AndroidAccountRetentionSnapshot.Available( + accounts = listOf(expected.accountRecord(), other.accountRecord()), + activeAccountId = expected.accountId, + ), + ), + ) + assertFalse( + shouldRetryAndroidFileSyncScheduleRestoration( + expectedIdentity, + AndroidAccountRetentionSnapshot.Available( + accounts = listOf(expected.accountRecord(), other.accountRecord()), + activeAccountId = other.accountId, + ), + ), + ) + assertTrue( + shouldRetryAndroidFileSyncScheduleRestoration( + expectedIdentity, + AndroidAccountRetentionSnapshot.Unavailable, + ), + ) + } + + @Test + fun accountRetirementRetainsPairMappingUntilEverySafGrantReleaseIsAttempted() = runBlocking { + val retiredPairs = listOf( + fileSyncPair("retired-a", "content://documents/first"), + fileSyncPair("retired-b", "content://documents/second"), + ) + val events = mutableListOf() + + assertFailsWith { + retireConfiguredFileSyncAccountPairs( + retiredPairs = retiredPairs, + retainedPairs = emptyList(), + reconcileLocalDownloads = { true }, + cancelSchedule = {}, + cancelNotification = {}, + persistRetirement = { events += "persist-retirement" }, + releaseLocalGrant = { localRootId -> + events += "release-$localRootId" + if (localRootId.endsWith("first")) error("synthetic grant release interruption") + }, + ) + } + + assertEquals(listOf("release-content://documents/first"), events) + } + + private fun fileSyncPair(id: String, localRootId: String) = FileSyncPair( + id = id, + accountId = "removed-account", + localRootId = localRootId, + remoteRootPath = "Documents", + configuration = FileSyncConfiguration(deviceLabel = "Phone"), + ) +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt index e8da5074c..aa75ac84a 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt @@ -158,7 +158,7 @@ class AndroidAccountRemovalRecoveryTest { } @Test - fun malformedCleanupTombstonesRemainBlockingRecoveryState() { + fun malformedCleanupTombstonesDoNotHideValidRecoveryState() { val valid = AndroidPendingAccountRemovalCleanup( accountStorageKey = "a".repeat(64), workIdentity = "1".repeat(32), @@ -166,18 +166,37 @@ class AndroidAccountRemovalRecoveryTest { val encoded = linkedSetOf(encodeAndroidPendingAccountRemovalCleanup(valid), "truncated-row") var malformedRecorded = false - assertFailsWith { + assertEquals( + setOf(valid), requireValidAndroidAccountRemovalCleanupJournal(encoded) { malformedRecorded = true - } - } + }, + ) assertTrue(malformedRecorded) assertTrue("truncated-row" in encoded) + val snapshot = restoreAndroidPendingAccountRemovalCleanups(encoded) + assertEquals(setOf(valid), snapshot.cleanups) + assertEquals(1, snapshot.malformedEntryCount) + assertFailsWith { + requireAndroidAccountRemovalCleanupJournalAllowsActivation(snapshot) + } + assertFalse(androidAccountRemovalCleanupRecoveryCompleted(true, snapshot, true)) } @Test - fun malformedCleanupJournalDoesNotRewriteStoredTombstones() { + fun malformedOnlyCleanupJournalBlocksAccountReactivation() { + val snapshot = restoreAndroidPendingAccountRemovalCleanups(setOf("truncated-row")) + + assertTrue(snapshot.cleanups.isEmpty()) + assertEquals(1, snapshot.malformedEntryCount) + assertFailsWith { + requireAndroidAccountRemovalCleanupJournalAllowsActivation(snapshot) + } + } + + @Test + fun malformedCleanupJournalReadDoesNotRewriteStoredTombstones() { val valid = AndroidPendingAccountRemovalCleanup( accountStorageKey = "a".repeat(64), workIdentity = "1".repeat(32), @@ -204,10 +223,41 @@ class AndroidAccountRemovalRecoveryTest { recordMalformed = {}, ) - assertFailsWith { journal.pending() } + assertEquals(setOf(valid), journal.pending()) assertEquals(0, editCalls) assertEquals(0, commitCalls) assertEquals(linkedSetOf(encodeAndroidPendingAccountRemovalCleanup(valid), "truncated-row"), encoded) } + + @Test + fun cleanupJournalEditsPreserveMalformedPeersWhileReplacingValidTombstones() { + val original = AndroidPendingAccountRemovalCleanup( + accountStorageKey = "a".repeat(64), + workIdentity = "1".repeat(32), + ) + val replacement = original.copy(workIdentity = "2".repeat(32)) + val peer = AndroidPendingAccountRemovalCleanup( + accountStorageKey = "b".repeat(64), + workIdentity = "3".repeat(32), + ) + val malformed = "truncated-row" + var malformedCount = 0 + val encoded = linkedSetOf( + encodeAndroidPendingAccountRemovalCleanup(original), + encodeAndroidPendingAccountRemovalCleanup(peer), + malformed, + ) + + val replaced = replaceAndroidAccountRemovalCleanup(encoded, replacement) { malformedCount += 1 } + val cleared = removeAndroidAccountRemovalCleanup(replaced, replacement.accountStorageKey) { + malformedCount += 1 + } + + assertEquals( + linkedSetOf(encodeAndroidPendingAccountRemovalCleanup(peer), malformed), + cleared, + ) + assertEquals(2, malformedCount) + } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt index c3feb755c..b51979285 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDurableMultipartUploadPolicyTest.kt @@ -324,7 +324,7 @@ class AndroidDurableMultipartUploadPolicyTest { val accountId = NextcloudDocumentIds.accountKey(retainedSession) assertEquals( - DurableUploadAccountMismatchOutcome.DeferAccountRecovery, + DurableUploadAccountMismatchOutcome.DeferAccountActivation, durableUploadAccountMismatchOutcome( accountId, AndroidAccountRetentionSnapshot.Available(listOf(retainedSession.accountRecord())), @@ -335,11 +335,32 @@ class AndroidDurableMultipartUploadPolicyTest { @Test fun `unreadable account registry defers queued upload recovery`() { assertEquals( - DurableUploadAccountMismatchOutcome.DeferAccountRecovery, + DurableUploadAccountMismatchOutcome.RetryAccountRecovery, durableUploadAccountMismatchOutcome(ACCOUNT_A, AndroidAccountRetentionSnapshot.Unavailable), ) } + @Test + fun `active account with unreadable credential keeps its upload scheduled`() { + val retainedSession = NextcloudSession( + serverUrl = "https://cloud.example.test/nextcloud", + loginName = "alice", + appPassword = "fixture-password", + ) + val accountId = NextcloudDocumentIds.accountKey(retainedSession) + + assertEquals( + DurableUploadAccountMismatchOutcome.RetryAccountRecovery, + durableUploadAccountMismatchOutcome( + accountId, + AndroidAccountRetentionSnapshot.Available( + accounts = listOf(retainedSession.accountRecord()), + activeAccountId = retainedSession.accountId, + ), + ), + ) + } + @Test fun `valid account registry without expected account makes upload unavailable`() { val retainedSession = NextcloudSession( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt index b8812c638..dcaccc5d1 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngineInvariantTest.kt @@ -577,8 +577,8 @@ class AndroidFileSyncEngineInvariantTest { "cancel-notification-retired-b", "cancel-retired-c", "cancel-notification-retired-c", - "persist-retirement", "release-$retiredRoot", + "persist-retirement", ), events, ) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index 30c0b55a3..fc14f0f07 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -548,6 +548,21 @@ class AndroidPersistedSessionTest { assertEquals("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED", restored.diagnosticCode) } + @Test + fun explicitResetDiscardsMalformedRegistryButPreservesFutureVersionState() { + assertTrue( + androidIndependentCredentialStateCanBeExplicitlyReset( + restoreAndroidCredentialFreeRegistry("{not-json"), + ), + ) + assertFalse( + androidIndependentCredentialStateCanBeExplicitlyReset( + restoreAndroidCredentialFreeRegistry("""{"version":99,"accounts":[]}"""), + ), + ) + assertTrue(androidIndependentCredentialStateCanBeExplicitlyReset(null)) + } + @Test fun credentialSlotReadDecryptsOnlyTheRequestedAccount() { val first = firstSession() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 788c83a91..6c0be78b4 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -4,6 +4,13 @@ import java.util.prefs.Preferences import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CancellationException +internal class DesktopCredentialRollbackRecoveryUnavailableException( + cause: Throwable? = null, +) : NextcloudSessionStorageUnavailableException( + "The pending desktop credential rollback could not be completed safely.", + cause, +) + internal class DesktopAccountCredentialPersistence( private val preferences: Preferences, private val secretStore: DesktopSecretStore, @@ -95,9 +102,17 @@ internal class DesktopAccountCredentialPersistence( val updatedRegistry = registry.upsertAndSelect(persistedSession.accountRecord()) val encodedRegistry = prepareRegistry(updatedRegistry) val secretReference = desktopAccountSecretReference(persistedSession.accountId) + val rollbackReference = desktopAccountCredentialRollbackReference(persistedSession.accountId) val previousSecret = loadSecretForRollback(secretReference) + check(previousRecord == null || previousSecret != null) { + "The existing account credential could not be read for safe replacement." + } persistPendingCredentialSave(persistedSession) try { + if (previousSecret != null) { + secretStore.save(rollbackReference, previousRecord?.loginName, previousSecret) + } + markPendingCredentialSaveSecretWriting() saveSecret(persistedSession) markPendingCredentialSaveSecretWritten() persistAccountState(encodedRegistry, updatedRegistry.activeAccount) @@ -114,6 +129,7 @@ internal class DesktopAccountCredentialPersistence( previousSecret, ) } + secretStore.clear(rollbackReference) credentialRollbackCompleted = true } catch (rollbackFailure: Exception) { failure.addSuppressed(rollbackFailure) @@ -126,6 +142,7 @@ internal class DesktopAccountCredentialPersistence( if (credentialRollbackCompleted) clearPendingCredentialSave() throw failure } + if (previousSecret != null) secretStore.clear(rollbackReference) clearPendingCredentialSave() return persistedSession } @@ -220,58 +237,77 @@ internal class DesktopAccountCredentialPersistence( val phase = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_PHASE, null) if (server == null && login == null) return if (server.isNullOrBlank() || login.isNullOrBlank()) { - recordCredentialDiagnostic( - "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", - "account-credentials.recover", - ) - return + credentialRollbackRecoveryUnavailable() } val accountId = try { deriveNextcloudAccountId(server, login) } catch (failure: Exception) { - recordCredentialDiagnostic( - "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", - "account-credentials.recover", - failure, - ) - return + credentialRollbackRecoveryUnavailable(failure) } val registryRead = readRegistry() if (registryRead.encoded != null && registryRead.registry == null) { - recordCredentialDiagnostic( - "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", - "account-credentials.recover", - ) - return + credentialRollbackRecoveryUnavailable() + } + val knownPhases = setOf( + null, + CREDENTIAL_SAVE_PREPARED, + CREDENTIAL_SAVE_SECRET_WRITING, + CREDENTIAL_SAVE_SECRET_WRITTEN, + CREDENTIAL_SAVE_ROLLBACK, + ) + if (phase !in knownPhases) { + credentialRollbackRecoveryUnavailable() } val registry = registryRead.registry val credentialCommitted = registry?.accounts?.any { account -> account.id == accountId } == true + val secretReference = desktopAccountSecretReference(accountId) + val rollbackReference = desktopAccountCredentialRollbackReference(accountId) if (!credentialCommitted) { try { - secretStore.clear(desktopAccountSecretReference(accountId)) + secretStore.clear(secretReference) + secretStore.clear(rollbackReference) } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { - recordCredentialDiagnostic( - "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", - "account-credentials.recover", - failure, - ) - return + credentialRollbackRecoveryUnavailable(failure) + } + } else if (phase == CREDENTIAL_SAVE_SECRET_WRITING || phase == CREDENTIAL_SAVE_ROLLBACK) { + val rollbackSecret = try { + secretStore.load(rollbackReference) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + credentialRollbackRecoveryUnavailable(failure) + } + if (rollbackSecret == null) { + credentialRollbackRecoveryUnavailable() + } + try { + secretStore.save(secretReference, registry.accounts.first { it.id == accountId }.loginName, rollbackSecret) + secretStore.clear(rollbackReference) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + credentialRollbackRecoveryUnavailable(failure) } } else if (phase == CREDENTIAL_SAVE_SECRET_WRITTEN) { val selected = requireNotNull(registry.select(accountId)) try { persistAccountState(prepareRegistry(selected), selected.activeAccount) + secretStore.clear(rollbackReference) } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { - recordCredentialDiagnostic( - "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", - "account-credentials.recover", - failure, - ) - return + credentialRollbackRecoveryUnavailable(failure) + } + } + if (phase == CREDENTIAL_SAVE_PREPARED) { + try { + secretStore.clear(rollbackReference) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + credentialRollbackRecoveryUnavailable(failure) } } clearPendingCredentialSave() @@ -408,6 +444,20 @@ internal class DesktopAccountCredentialPersistence( flushPreferences() } + private fun markPendingCredentialSaveSecretWriting() { + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_PHASE, CREDENTIAL_SAVE_SECRET_WRITING) + flushPreferences() + } + + private fun credentialRollbackRecoveryUnavailable(failure: Exception? = null): Nothing { + recordCredentialDiagnostic( + "ACCOUNT_CREDENTIAL_STORE_ROLLBACK_FAILED", + "account-credentials.recover", + failure, + ) + throw DesktopCredentialRollbackRecoveryUnavailableException(failure) + } + private fun markPendingCredentialSaveRollback() { val previousPhase = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_PHASE, null) try { @@ -444,6 +494,7 @@ internal class DesktopAccountCredentialPersistence( "account-credentials.recover", failure, ) + throw DesktopCredentialRollbackRecoveryUnavailableException(failure) } } @@ -695,6 +746,7 @@ internal class DesktopAccountCredentialPersistence( const val KEY_PENDING_CREDENTIAL_SAVE_PHASE = "accountCredentialSavePhase" const val KEY_PENDING_CREDENTIAL_REMOVALS = "accountCredentialRemovals" const val CREDENTIAL_SAVE_PREPARED = "prepared" + const val CREDENTIAL_SAVE_SECRET_WRITING = "secret-writing" const val CREDENTIAL_SAVE_SECRET_WRITTEN = "secret-written" const val CREDENTIAL_SAVE_ROLLBACK = "rollback" } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt index bfaa5bf7c..bd5968a6f 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuard.kt @@ -1,7 +1,11 @@ package dev.obiente.nextcloudnative.app +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext internal class DesktopAccountOperationGuard { private val accountMutationMutex = Mutex() @@ -106,7 +110,10 @@ internal suspend fun DesktopAccountOperationGuard.persistSessionAndActivateDynam persist: suspend () -> NextcloudSession, activate: suspend (NextcloudSession) -> Unit, ): NextcloudSession = serializeWhenSyncIdle { - persist().also { persisted -> activate(persisted) } + val persisted = persist() + withContext(NonCancellable) { activate(persisted) } + currentCoroutineContext().ensureActive() + persisted } internal fun requireDesktopAccountRemovalWritebacksResolved(pendingWritebackCount: Int) { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index 3463653b1..c98071209 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -41,6 +41,11 @@ internal data class DesktopAccountSyncPairCleanup( val accountStorageKey: String? = null, ) +internal fun DesktopAccountSyncPairCleanup.matchesAccountActivation( + accountId: String, + accountStorageKey: String, +): Boolean = this.accountId == accountId || this.accountStorageKey == accountStorageKey + internal class DesktopAccountSyncPairCleanupJournal( private val preferences: Preferences, private val recordMalformed: () -> Unit = {}, @@ -104,6 +109,14 @@ internal class DesktopAccountSyncPairCleanupJournal( return cleanups } + fun pendingForAccountActivation(accountId: String, accountStorageKey: String): List { + validateDesktopSyncPairCleanupAccountId(accountId) + require(accountStorageKey.matches(ACCOUNT_STORAGE_KEY_PATTERN)) { + "The desktop account storage cleanup identity is invalid." + } + return pending().filter { cleanup -> cleanup.matchesAccountActivation(accountId, accountStorageKey) } + } + private fun persist( accountId: String, phase: DesktopAccountSyncPairCleanupPhase, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountSecretReference.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountSecretReference.kt index 12c3806b4..6e684dc58 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountSecretReference.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountSecretReference.kt @@ -11,3 +11,15 @@ internal fun desktopAccountSecretReference(accountId: NextcloudAccountId): Deskt "schema" to "2", ), ) + +internal fun desktopAccountCredentialRollbackReference(accountId: NextcloudAccountId): DesktopSecretReference = + DesktopSecretReference( + targetName = "Obiente/NextcloudNative/session-rollback/v1/${accountId.storageKey}", + label = "Nextcloud Native account credential rollback", + attributes = linkedMapOf( + "application" to "dev.obiente.nextcloudnative", + "purpose" to "account-session-rollback", + "account" to accountId.storageKey, + "schema" to "1", + ), + ) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 6f491db6a..0836c7285 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3506,7 +3506,10 @@ class DesktopNextcloudServices( override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { val persistedSession = accountOperationGuard.persistSessionAndActivateDynamicReads( persist = { - retryPendingAccountSyncPairCleanup(desktopFileCacheAccountId(session)) + retryPendingAccountSyncPairCleanup( + desktopFileCacheAccountId(session), + session.accountId.storageKey, + ) sessionPublicationGuard.serialize { val activeAccountId = accountCredentials.activeAccountId() val activeSession = activeAccountId?.let(accountCredentials::loadSession) @@ -3518,10 +3521,12 @@ class DesktopNextcloudServices( accountCredentials.saveSession(session).also(accountSessionPublication::publish) } }, - activate = { dynamicApiRequestCoalescer.activateAccount(desktopFileCacheAccountId(it)) }, + activate = { + dynamicApiRequestCoalescer.activateAccount(desktopFileCacheAccountId(it)) + synchronized(fileRangeSessionLock) { sessionClearing = false } + startDesktopSyncLifecycle() + }, ) - synchronized(fileRangeSessionLock) { sessionClearing = false } - startDesktopSyncLifecycle() persistedSession } override fun listAccounts() = sessionPublicationGuard.serialize(accountCredentials::listAccounts) @@ -3557,7 +3562,10 @@ class DesktopNextcloudServices( accountCredentials.listAccounts().firstOrNull { account -> account.id == accountId } } selectedRecord?.let { record -> - retryPendingAccountSyncPairCleanup(desktopFileCacheAccountId(record)) + retryPendingAccountSyncPairCleanup( + desktopFileCacheAccountId(record), + record.id.storageKey, + ) } sessionPublicationGuard.serialize { accountCredentials.selectAccount(accountId)?.also { session -> @@ -3827,8 +3835,8 @@ class DesktopNextcloudServices( } } } - private suspend fun retryPendingAccountSyncPairCleanup(accountId: String) { - accountSyncPairCleanupJournal.pending().singleOrNull { it.accountId == accountId }?.let { cleanup -> + private suspend fun retryPendingAccountSyncPairCleanup(accountId: String, accountStorageKey: String) { + accountSyncPairCleanupJournal.pendingForAccountActivation(accountId, accountStorageKey).forEach { cleanup -> retryDesktopAccountSyncPairCleanup( cleanup, ::desktopAccountOwnership, ::removeDesktopAccountOwnedState, accountSyncPairCleanupJournal::clear, ) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index bf70199bd..a4412fc3f 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -98,7 +98,7 @@ class DesktopAccountCredentialPersistenceTest { persistence.saveSession(secondSession()) assertEquals(firstSession(), persistence.selectAccount(firstSession().accountId)) - assertEquals(12, flushCount) + assertEquals(14, flushCount) assertEquals(firstSession().serverUrl, preferences.get("server", null)) assertEquals(firstSession().loginName, preferences.get("login", null)) } @@ -122,7 +122,7 @@ class DesktopAccountCredentialPersistenceTest { var flushCount = 0 val persistence = persistence(preferences, secrets) { flushCount += 1 - if (flushCount == 2) error("synthetic registry flush failure") + if (flushCount == 3) error("synthetic registry flush failure") preferences.flush() } secrets.failClears = true @@ -203,7 +203,7 @@ class DesktopAccountCredentialPersistenceTest { } @Test - fun startupRecoveryPreservesPendingCredentialWhenRegistryVersionIsUnreadable() = + fun startupRecoveryBlocksCredentialAccessWhenRegistryVersionIsUnreadable() = withStore { preferences, secrets -> val session = firstSession() preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, """{"version":2,"accounts":[]}""") @@ -215,7 +215,9 @@ class DesktopAccountCredentialPersistenceTest { session.appPassword.encodeToByteArray(), ) - assertNull(persistence(preferences, secrets).loadActiveSession()) + assertFailsWith { + persistence(preferences, secrets).loadActiveSession() + } assertNotNull(secrets.load(desktopAccountSecretReference(session.accountId))) assertEquals(session.serverUrl, preferences.get("accountCredentialSaveServer", null)) @@ -245,7 +247,7 @@ class DesktopAccountCredentialPersistenceTest { } @Test - fun failedReplacementRollbackIsFinalizedFromTheCredentialJournalOnRestart() = + fun failedReplacementRollbackRestoresThePreviousCredentialFromTheSecureJournalOnRestart() = withStore { preferences, secrets -> val original = firstSession() val replacement = original.copy(appPassword = "replacement-password") @@ -257,8 +259,8 @@ class DesktopAccountCredentialPersistenceTest { preferences.flush() } persistence.saveSession(original) - secrets.failSaveOnAttempt = secrets.saveCount + 2 - failFlushOnAttempt = flushCount + 2 + secrets.failSaveOnAttempt = secrets.saveCount + 3 + failFlushOnAttempt = flushCount + 3 assertFailsWith { persistence.saveSession(replacement) } @@ -269,7 +271,8 @@ class DesktopAccountCredentialPersistenceTest { assertEquals(original.serverUrl, preferences.get("accountCredentialSaveServer", null)) failFlushOnAttempt = null - assertEquals(replacement, persistence(preferences, secrets).loadActiveSession()) + assertEquals(original, persistence(preferences, secrets).loadActiveSession()) + assertNull(secrets.load(desktopAccountCredentialRollbackReference(original.accountId))) assertNull(preferences.get("accountCredentialSaveServer", null)) assertNull(preferences.get("accountCredentialSaveLogin", null)) } @@ -289,7 +292,7 @@ class DesktopAccountCredentialPersistenceTest { persistence.saveSession(inactive) persistence.saveSession(active) failFlushOnAttempt = flushCount + 3 - secrets.crashSaveOnAttempt = secrets.saveCount + 2 + secrets.crashSaveOnAttempt = secrets.saveCount + 3 assertFailsWith { persistence.saveSession(inactive.copy(appPassword = "replacement-password")) @@ -300,6 +303,8 @@ class DesktopAccountCredentialPersistenceTest { val restarted = persistence(preferences, secrets) assertEquals(active, restarted.loadActiveSession()) assertEquals(active.accountId, restarted.activeAccountId()) + assertEquals(inactive, restarted.loadSession(inactive.accountId)) + assertNull(secrets.load(desktopAccountCredentialRollbackReference(inactive.accountId))) assertNull(preferences.get("accountCredentialSavePhase", null)) } @@ -751,7 +756,7 @@ class DesktopAccountCredentialPersistenceTest { var flushAttempts = 0 val persistence = persistence(preferences, secrets) { flushAttempts += 1 - if (flushAttempts == 12) error("synthetic removal flush failure") + if (flushAttempts == 14) error("synthetic removal flush failure") preferences.flush() } persistence.saveSession(first) @@ -803,6 +808,78 @@ class DesktopAccountCredentialPersistenceTest { assertDiagnosticsExcludePrivateValues(diagnostics) } + @Test + fun missingRollbackCredentialBlocksEveryFollowingCredentialOperation() = + withStore { preferences, secrets -> + val original = firstSession() + val replacement = original.copy(appPassword = "replacement-password") + val persistence = persistence(preferences, secrets) + persistence.saveSession(original) + preparePendingRollback(preferences, secrets, original, replacement, includeRollback = false) + + assertFailsWith { + persistence.loadActiveSession() + } + assertFailsWith { + persistence.saveSession(replacement) + } + assertEquals("rollback", preferences.get("accountCredentialSavePhase", null)) + } + + @Test + fun rollbackCredentialLoadFailureBlocksSelectionUntilRecoveryCanRetry() = + withStore { preferences, secrets -> + val original = firstSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(original) + preparePendingRollback(preferences, secrets, original, original.copy(appPassword = "replacement-password")) + secrets.failLoadTarget = desktopAccountCredentialRollbackReference(original.accountId).targetName + + assertFailsWith { + persistence.loadActiveSession() + } + assertFailsWith { + persistence.selectAccount(original.accountId) + } + assertEquals("rollback", preferences.get("accountCredentialSavePhase", null)) + } + + @Test + fun rollbackCredentialSaveFailureBlocksRemovalUntilRecoveryCanRetry() = + withStore { preferences, secrets -> + val original = firstSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(original) + preparePendingRollback(preferences, secrets, original, original.copy(appPassword = "replacement-password")) + secrets.failSaveTarget = desktopAccountSecretReference(original.accountId).targetName + + assertFailsWith { + persistence.loadActiveSession() + } + assertFailsWith { + persistence.removeAccount(original.accountId) + } + assertEquals("rollback", preferences.get("accountCredentialSavePhase", null)) + } + + @Test + fun rollbackCredentialClearFailureBlocksASubsequentSave() = withStore { preferences, secrets -> + val original = firstSession() + val persistence = persistence(preferences, secrets) + persistence.saveSession(original) + preparePendingRollback(preferences, secrets, original, original.copy(appPassword = "replacement-password")) + secrets.failClearTarget = desktopAccountCredentialRollbackReference(original.accountId).targetName + + assertFailsWith { + persistence.loadActiveSession() + } + assertFailsWith { + persistence.saveSession(original) + } + assertEquals(original.appPassword, secrets.load(desktopAccountSecretReference(original.accountId))?.decodeToString()) + assertEquals("rollback", preferences.get("accountCredentialSavePhase", null)) + } + private fun persistence( preferences: Preferences, secrets: MemorySecretStore, @@ -810,6 +887,31 @@ class DesktopAccountCredentialPersistenceTest { flushPreferences: () -> Unit = preferences::flush, ) = DesktopAccountCredentialPersistence(preferences, secrets, diagnostics::add, flushPreferences) + private fun preparePendingRollback( + preferences: Preferences, + secrets: MemorySecretStore, + original: NextcloudSession, + replacement: NextcloudSession, + includeRollback: Boolean = true, + ) { + preferences.put("accountCredentialSaveServer", original.serverUrl) + preferences.put("accountCredentialSaveLogin", original.loginName) + preferences.put("accountCredentialSavePhase", "rollback") + secrets.save( + desktopAccountSecretReference(original.accountId), + original.loginName, + replacement.appPassword.encodeToByteArray(), + ) + if (includeRollback) { + secrets.save( + desktopAccountCredentialRollbackReference(original.accountId), + original.loginName, + original.appPassword.encodeToByteArray(), + ) + } + preferences.flush() + } + private fun putLegacySession( preferences: Preferences, secrets: MemorySecretStore, @@ -868,6 +970,9 @@ class DesktopAccountCredentialPersistenceTest { var crashSaveOnAttempt: Int? = null var failClears = false var loadFailure: RuntimeException? = null + var failLoadTarget: String? = null + var failSaveTarget: String? = null + var failClearTarget: String? = null var loadCount = 0 private set var saveCount = 0 @@ -878,13 +983,14 @@ class DesktopAccountCredentialPersistenceTest { override fun load(reference: DesktopSecretReference): ByteArray? { loadCount += 1 loadFailure?.let { throw it } + if (reference.targetName == failLoadTarget) error("synthetic targeted secret load failure") return values[reference.targetName]?.copyOf() } override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { saveCount += 1 if (saveCount == crashSaveOnAttempt) throw SimulatedProcessExit() - if (failSaves || saveCount == failSaveOnAttempt) { + if (failSaves || saveCount == failSaveOnAttempt || reference.targetName == failSaveTarget) { error("private-app-password at cloud.example.test for alice") } values[reference.targetName] = secret.copyOf() @@ -892,7 +998,7 @@ class DesktopAccountCredentialPersistenceTest { override fun clear(reference: DesktopSecretReference) { clearCount += 1 - if (failClears) error("synthetic secret deletion failure") + if (failClears || reference.targetName == failClearTarget) error("synthetic secret deletion failure") values.remove(reference.targetName) } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 5435cf2da..74d38d49a 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -87,6 +87,34 @@ class DesktopAccountOperationGuardTest { assertEquals(listOf("activate", "fence"), events) } + @Test + fun cancelledSaveFinishesPostCommitActivationBeforeReleasingTheAccountFence() = runBlocking { + val guard = DesktopAccountOperationGuard() + val activationEntered = CompletableDeferred() + val finishActivation = CompletableDeferred() + val events = mutableListOf() + val session = NextcloudSession("https://cloud.example.test", "alice", "saved-password") + val save = async { + guard.persistSessionAndActivateDynamicReads( + persist = { + events += "persist" + session + }, + activate = { + activationEntered.complete(Unit) + finishActivation.await() + events += "activate" + }, + ) + } + activationEntered.await() + save.cancel() + finishActivation.complete(Unit) + + assertFailsWith { save.await() } + assertEquals(listOf("persist", "activate"), events) + } + @Test fun lateDurableWriterCannotPublishAfterRemovalAndCredentialReplacement() = runBlocking { val guard = DesktopAccountOperationGuard() @@ -876,6 +904,32 @@ class DesktopAccountOperationGuardTest { } } + @Test + fun canonicalAccountStorageIdentityBlocksReactivationUntilPriorCleanupRuns() { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val oldCacheIdentity = "1".repeat(64) + val canonicalCacheIdentity = "2".repeat(64) + try { + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + journal.prepare(oldCacheIdentity, MUTATION_SCOPE, ACCOUNT_STORAGE_KEY) + journal.commit(oldCacheIdentity) + + assertEquals( + listOf( + DesktopAccountSyncPairCleanup( + oldCacheIdentity, + DesktopAccountSyncPairCleanupPhase.Committed, + MUTATION_SCOPE, + ACCOUNT_STORAGE_KEY, + ), + ), + journal.pendingForAccountActivation(canonicalCacheIdentity, ACCOUNT_STORAGE_KEY), + ) + } finally { + preferences.removeNode() + } + } + @Test fun committedPairCleanupFailureSurvivesRestartAndBlocksReactivationUntilRetry() = runBlocking { val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") From 8e55759b02505b15fe0cad02e229647ee87c8e7d Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 03:56:46 +0200 Subject: [PATCH 094/119] fix(accounts): gate credential recovery transitions --- .../AndroidAccountCredentialController.kt | 55 ++++++----- .../AndroidAccountRemovalCleanupJournal.kt | 31 +++++++ .../AndroidAccountRemovalRecoveryTest.kt | 91 +++++++++++++++++++ .../DesktopAccountCredentialPersistence.kt | 2 +- ...DesktopAccountCredentialPersistenceTest.kt | 31 +++++++ 5 files changed, 181 insertions(+), 29 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index f187ab105..43efbfb37 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -74,32 +74,33 @@ internal class AndroidAccountCredentialController( registry: NextcloudAccountRegistry, ): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { if (registry.accounts.none { account -> account.id == accountId }) return@serialize null - val aggregateRead = readStore() - if (!androidCredentialStoreAllowsSessionRestore(aggregateRead)) return@serialize null - val aggregate = (aggregateRead as? AndroidAccountCredentialStoreRead.Available)?.state - val slotRead = readCredentialSlot(accountId) - if (slotRead is AndroidAccountCredentialSlotRead.Unsupported) return@serialize null - val storedSlot = (slotRead as? AndroidAccountCredentialSlotRead.Available)?.session - val restoredSlot = recoverAndroidAccountCredentialSlot(accountId, registry, storedSlot, aggregate = null) - val session = restoredSlot ?: recoverAndroidAccountCredentialSlot( - accountId, - registry, - storedSlot = null, - aggregate = aggregate, - ) ?: return@serialize null - if (storedSlot != session) { - runCatching { - commitPreferences( - preferences.edit().putString( - androidAccountCredentialSlotKey(accountId), - encryptCredentialSlot(session), - ), - ) + restoreAndroidSessionAfterRemovalCleanup(accountId, accountRemovalCleanupJournal::snapshot) { + val aggregateRead = readStore() + if (!androidCredentialStoreAllowsSessionRestore(aggregateRead)) return@restoreAndroidSessionAfterRemovalCleanup null + val aggregate = (aggregateRead as? AndroidAccountCredentialStoreRead.Available)?.state + val slotRead = readCredentialSlot(accountId) + if (slotRead is AndroidAccountCredentialSlotRead.Unsupported) return@restoreAndroidSessionAfterRemovalCleanup null + val storedSlot = (slotRead as? AndroidAccountCredentialSlotRead.Available)?.session + val restoredSlot = recoverAndroidAccountCredentialSlot(accountId, registry, storedSlot, aggregate = null) + val session = restoredSlot ?: recoverAndroidAccountCredentialSlot( + accountId, + registry, + storedSlot = null, + aggregate = aggregate, + ) ?: return@restoreAndroidSessionAfterRemovalCleanup null + if (storedSlot != session) { + runCatching { + commitPreferences( + preferences.edit().putString( + androidAccountCredentialSlotKey(accountId), + encryptCredentialSlot(session), + ), + ) + } } + session.also(registerSessionPrivateValues) } - session.also(registerSessionPrivateValues) } - suspend fun saveSession(session: NextcloudSession): NextcloudSession = ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX.withLock { retryPendingAccountRemovalCleanup(session) @@ -142,10 +143,9 @@ internal class AndroidAccountCredentialController( ) requireSupportedCredentialSlots(current.registry) val selected = current.select(accountId) ?: return@withLock null - val session = requireNotNull(selected.activeSession) - registerSessionPrivateValues(session) - replaceActiveState(selected, current.activeSession, suspectEncrypted) - session + selectAndroidAccountAfterRemovalCleanup( + requireNotNull(selected.activeSession), ::retryPendingAccountRemovalCleanup, registerSessionPrivateValues, + ) { replaceActiveState(selected, current.activeSession, suspectEncrypted) } } suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = @@ -712,7 +712,6 @@ internal class AndroidAccountCredentialController( } requireAndroidAccountRemovalCleanupJournalAllowsActivation(snapshot) } - private fun commitPreferences(editor: SharedPreferences.Editor) = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { try { requireCommittedAndroidAccountCredentialEdit(editor) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt index f1b32fbf4..2dc42bd83 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupJournal.kt @@ -1,6 +1,8 @@ package dev.obiente.nextcloudnative import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudAccountId +import dev.obiente.nextcloudnative.app.NextcloudSession internal class AndroidAccountRemovalCleanupJournal( private val preferences: SharedPreferences, @@ -70,6 +72,35 @@ internal fun requireAndroidAccountRemovalCleanupJournalAllowsActivation( } } +internal inline fun restoreAndroidSessionAfterRemovalCleanup( + accountId: NextcloudAccountId, + loadSnapshot: () -> RestoredAndroidPendingAccountRemovalCleanups, + restoreSession: () -> Session?, +): Session? { + val snapshot = try { + loadSnapshot() + } catch (_: Exception) { + return null + } + if ( + snapshot.malformedEntryCount > 0 || + snapshot.cleanups.any { cleanup -> cleanup.accountStorageKey == accountId.storageKey } + ) return null + return restoreSession() +} + +internal suspend fun selectAndroidAccountAfterRemovalCleanup( + session: NextcloudSession, + retryPendingCleanup: suspend (NextcloudSession) -> Unit, + registerSessionPrivateValues: (NextcloudSession) -> Unit, + persistSelection: suspend () -> Unit, +): NextcloudSession { + retryPendingCleanup(session) + registerSessionPrivateValues(session) + persistSelection() + return session +} + internal fun replaceAndroidAccountRemovalCleanup( encoded: Set, replacement: AndroidPendingAccountRemovalCleanup, diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt index aa75ac84a..2fb112ccb 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalRecoveryTest.kt @@ -6,6 +6,8 @@ import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.accountRecord import java.lang.reflect.Proxy import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -195,6 +197,95 @@ class AndroidAccountRemovalRecoveryTest { } } + @Test + fun malformedCleanupBlocksSelectionBeforePersistencePublicationOrUploadResume() = runBlocking { + val events = mutableListOf() + val snapshot = restoreAndroidPendingAccountRemovalCleanups(setOf("truncated-row")) + + assertFailsWith { + selectAndroidAccountAfterRemovalCleanup( + session = NextcloudSession("https://cloud.example.test", "alice", "secret"), + retryPendingCleanup = { requireAndroidAccountRemovalCleanupJournalAllowsActivation(snapshot) }, + registerSessionPrivateValues = { events += "publish-private-state" }, + persistSelection = { events += listOf("persist-selection", "publish-account", "resume-uploads") }, + ) + } + + assertTrue(events.isEmpty()) + } + + @Test + fun malformedCleanupBlocksStartupAndExplicitCredentialLoadsBeforePrivatePublication() { + val session = NextcloudSession("https://cloud.example.test", "alice", "secret") + val snapshot = restoreAndroidPendingAccountRemovalCleanups(setOf("truncated-row")) + var privatePublications = 0 + var publicSessionPublications = 0 + val restore = { + restoreAndroidSessionAfterRemovalCleanup(session.accountId, { snapshot }) { + privatePublications += 1 + session + } + } + + val startup = AndroidFileSyncSessionSchedulingGuard().restorePersistedSession( + load = restore, + accountIdOf = NextcloudDocumentIds::accountKey, + publishAccount = { restored, _ -> if (restored != null) publicSessionPublications += 1 }, + ) + + assertEquals(null, startup) + assertEquals(null, restore()) + assertEquals(0, privatePublications) + assertEquals(0, publicSessionPublications) + } + + @Test + fun credentialLoadsStayBlockedWhileAsyncCleanupStillOwnsTheMatchingTombstone() = runBlocking { + val session = NextcloudSession("https://cloud.example.test", "alice", "secret") + val pending = AndroidPendingAccountRemovalCleanup( + accountStorageKey = session.accountId.storageKey, + workIdentity = NextcloudDocumentIds.accountKey(session), + ) + var encoded = setOf(encodeAndroidPendingAccountRemovalCleanup(pending)) + val cleanupEntered = CompletableDeferred() + val releaseCleanup = CompletableDeferred() + val worker = async { + recoverPendingAndroidAccountRemovalCleanups( + pending = setOf(pending), + accountOwnedByRegistry = { true }, + removeAccountOwnedWork = {}, + clearCleanup = { + cleanupEntered.complete(Unit) + releaseCleanup.await() + encoded = emptySet() + }, + recordFailure = {}, + ) + } + cleanupEntered.await() + var privatePublications = 0 + val restore = { + restoreAndroidSessionAfterRemovalCleanup( + session.accountId, + { restoreAndroidPendingAccountRemovalCleanups(encoded) }, + ) { + privatePublications += 1 + session + } + } + + assertEquals( + null, + AndroidFileSyncSessionSchedulingGuard().restorePersistedSession(restore, NextcloudDocumentIds::accountKey), + ) + assertEquals(null, restore()) + assertEquals(0, privatePublications) + releaseCleanup.complete(Unit) + assertTrue(worker.await()) + assertEquals(session, restore()) + assertEquals(1, privatePublications) + } + @Test fun malformedCleanupJournalReadDoesNotRewriteStoredTombstones() { val valid = AndroidPendingAccountRemovalCleanup( diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 6c0be78b4..a6bf1e449 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -235,7 +235,7 @@ internal class DesktopAccountCredentialPersistence( val server = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_SERVER, null) val login = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_LOGIN, null) val phase = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_PHASE, null) - if (server == null && login == null) return + if (server == null && login == null && phase == null) return if (server.isNullOrBlank() || login.isNullOrBlank()) { credentialRollbackRecoveryUnavailable() } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index a4412fc3f..648700347 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -880,6 +880,16 @@ class DesktopAccountCredentialPersistenceTest { assertEquals("rollback", preferences.get("accountCredentialSavePhase", null)) } + @Test + fun phaseOnlyRollbackJournalBlocksEveryCredentialOperation() { + assertPhaseOnlyJournalBlocksCredentialOperations("rollback") + } + + @Test + fun phaseOnlyUnknownJournalBlocksEveryCredentialOperation() { + assertPhaseOnlyJournalBlocksCredentialOperations("unexpected-phase") + } + private fun persistence( preferences: Preferences, secrets: MemorySecretStore, @@ -940,6 +950,27 @@ class DesktopAccountCredentialPersistenceTest { assertFalse(rendered.contains("cloud.example.test")) } + private fun assertPhaseOnlyJournalBlocksCredentialOperations(phase: String) = + withStore { preferences, secrets -> + val session = firstSession() + val persistence = persistence(preferences, secrets) + preferences.put("accountCredentialSavePhase", phase) + preferences.flush() + val operations: List<() -> Unit> = listOf( + { persistence.loadActiveSession() }, + { persistence.saveSession(session) }, + { persistence.selectAccount(session.accountId) }, + { persistence.removeAccount(session.accountId) }, + ) + + operations.forEach { operation -> + assertFailsWith { operation() } + assertEquals(phase, preferences.get("accountCredentialSavePhase", null)) + assertNull(preferences.get("accountCredentialSaveServer", null)) + assertNull(preferences.get("accountCredentialSaveLogin", null)) + } + } + private fun firstSession() = NextcloudSession( serverUrl = "https://cloud.example.test", loginName = "alice", From c167e28a751ed300a38309190d87f959806c32ff Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 06:01:01 +0200 Subject: [PATCH 095/119] chore(architecture): lower Android service baseline --- tools/kotlin-file-size-baseline.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 4ec23d401..78f3d39d8 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|4260 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4258 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|1003 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1224 From f501e087925c133fc66c5a43f7506d723d44107b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 06:08:31 +0200 Subject: [PATCH 096/119] fix(android): serialize conditional file creation --- .../AndroidAccountOperationGuard.kt | 3 +- .../AndroidNextcloudServices.kt | 73 +++++++++---------- .../AndroidAccountOperationGuardTest.kt | 53 ++++++++++++++ tools/kotlin-file-size-baseline.txt | 2 +- 4 files changed, 92 insertions(+), 39 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index 69ba2dfee..6a83c4642 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -152,11 +152,12 @@ internal suspend fun withAndroidAuthenticatedFileMutation( accountMutationLeaseHeld: Boolean, expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, action: suspend (dev.obiente.nextcloudnative.app.NextcloudSession) -> Result, ): Result = if (accountMutationLeaseHeld) { action(expectedSession) } else { - ANDROID_ACCOUNT_OPERATION_GUARD.withAuthenticatedMutationSession(expectedSession, resolveSession, action) + guard.withAuthenticatedMutationSession(expectedSession, resolveSession, action) } internal suspend fun withAndroidAccountPrivateStatePublication( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 22c40b6bc..95c2f1a6c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -2706,49 +2706,48 @@ internal class AndroidNextcloudServices( } } - override suspend fun createTextFileIfAbsent( - session: NextcloudSession, - userId: String, - path: String, - text: String, - ): SavedTextFile = withContext(Dispatchers.IO) { + override suspend fun createTextFileIfAbsent(session: NextcloudSession, userId: String, path: String, text: String): + SavedTextFile = withContext(Dispatchers.IO) { val utf8 = text.toByteArray(StandardCharsets.UTF_8) require(utf8.size.toLong() <= MAX_EDITABLE_TEXT_BYTES) { "Text files larger than ${MAX_EDITABLE_TEXT_BYTES / (1024 * 1024)} MiB cannot be created in the app." } - val response = request( - method = "PUT", - url = buildNextcloudFileUrl(session.serverUrl, userId, path), - session = session, - rawBody = utf8, - contentType = "text/plain; charset=utf-8", - headers = mapOf("Accept" to "*/*", "If-None-Match" to "*"), - ) - if (response.status == 412) return@withContext SavedTextFile(etag = null, wasCreated = false) - check(response.status in 200..299) { "Creating the text file failed (HTTP ${response.status})." } - check(response.status == 201) { "The server did not confirm that a new text file was created." } - runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(session), path) } - SavedTextFile(response.etag, wasCreated = true) + withAndroidAuthenticatedFileMutation(accountMutationLeaseHeld, session, accountCredentials::loadSession) { currentSession -> + val response = request( + method = "PUT", + url = buildNextcloudFileUrl(currentSession.serverUrl, userId, path), + session = currentSession, + rawBody = utf8, + contentType = "text/plain; charset=utf-8", + headers = mapOf("Accept" to "*/*", "If-None-Match" to "*"), + ) + if (response.status == 412) return@withAndroidAuthenticatedFileMutation SavedTextFile(null, false) + check(response.status in 200..299) { "Creating the text file failed (HTTP ${response.status})." } + check(response.status == 201) { "The server did not confirm that a new text file was created." } + runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(currentSession), path) } + SavedTextFile(response.etag, wasCreated = true) + } } - override suspend fun createDirectoryIfAbsent( - session: NextcloudSession, - userId: String, - path: String, - ): Boolean = withContext(Dispatchers.IO) { - val response = request( - method = "MKCOL", - url = buildNextcloudFileUrl(session.serverUrl, userId, path), - session = session, - headers = mapOf("Accept" to "*/*", "If-None-Match" to "*"), - maxResponseBytes = 64 * 1024, - ) - if (response.status in setOf(405, 412)) return@withContext false - if (response.status !in 200..299) throw fileOperationException(response.status) - check(response.status == 201) { "The server did not confirm that a new folder was created." } - runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(session), path) } - true - } + override suspend fun createDirectoryIfAbsent(session: NextcloudSession, userId: String, path: String): Boolean = + withContext(Dispatchers.IO) { + withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld, session, accountCredentials::loadSession, + ) { currentSession -> + val response = request( + method = "MKCOL", + url = buildNextcloudFileUrl(currentSession.serverUrl, userId, path), + session = currentSession, + headers = mapOf("Accept" to "*/*", "If-None-Match" to "*"), + maxResponseBytes = 64 * 1024, + ) + if (response.status in setOf(405, 412)) return@withAndroidAuthenticatedFileMutation false + if (response.status !in 200..299) throw fileOperationException(response.status) + check(response.status == 201) { "The server did not confirm that a new folder was created." } + runCatching { fileReadCache.invalidate(NextcloudDocumentIds.accountKey(currentSession), path) } + true + } + } override suspend fun executeFileMutation(session: NextcloudSession, userId: String, mutation: NextcloudFileMutation): NextcloudFileMutationResult = withContext(Dispatchers.IO) { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index a55d138c5..7c0c29ca2 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -9,6 +9,7 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -612,4 +613,56 @@ class AndroidAccountOperationGuardTest { assertTrue(mutation.await().isFailure) assertFalse(requestSent) } + + @Test + fun textFileCreationWaitsForSelectionAndRejectsTheStaleSession() = runBlocking { + assertCreateMutationWaitsForAccountTransition( + transition = { NextcloudSession("https://other.example.test", "bob", "new-password") }, + method = "PUT", + ) + } + + @Test + fun directoryCreationWaitsForRemovalAndRejectsTheStaleSession() = runBlocking { + assertCreateMutationWaitsForAccountTransition(transition = { null }, method = "MKCOL") + } + + private suspend fun assertCreateMutationWaitsForAccountTransition( + transition: () -> NextcloudSession?, + method: String, + ) = coroutineScope { + val guard = AndroidAccountOperationGuard() + val original = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val transitionEntered = CompletableDeferred() + val releaseTransition = CompletableDeferred() + var current: NextcloudSession? = original + var requestMethod: String? = null + val transitionJob = async { + guard.withAccount(NextcloudDocumentIds.accountKey(original)) { + current = transition() + transitionEntered.complete(Unit) + releaseTransition.await() + } + } + transitionEntered.await() + val mutation = async { + runCatching { + withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld = false, + expectedSession = original, + resolveSession = { current }, + guard = guard, + ) { + requestMethod = method + } + } + } + yield() + + assertFalse(mutation.isCompleted) + releaseTransition.complete(Unit) + transitionJob.await() + assertTrue(mutation.await().isFailure) + assertEquals(null, requestMethod) + } } diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 78f3d39d8..487d38c01 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|4258 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4257 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|1003 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1224 From bc317fa0e072bd91c590678c4fcd1ab9faab5ca6 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 06:10:53 +0200 Subject: [PATCH 097/119] test(android): cover offline account recovery policy --- .../AndroidAccountRecoveryPriorityTest.kt | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt index 031f4b21b..9187d2588 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRecoveryPriorityTest.kt @@ -44,6 +44,47 @@ class AndroidAccountRecoveryPriorityTest { ) } + @Test + fun offlineJobRetriesUntilTheExpectedAccountIsConfirmedAbsent() { + val expected = NextcloudSession("https://cloud.example.test/nextcloud", "alice", "secret") + val other = NextcloudSession("https://cloud.example.test/nextcloud", "bob", "other-secret") + val expectedIdentity = NextcloudDocumentIds.accountKey(expected) + + assertTrue( + shouldRetryAndroidOfflineJobForMissingSession( + expectedIdentity, + AndroidAccountRetentionSnapshot.Available( + accounts = listOf(expected.accountRecord(), other.accountRecord()), + activeAccountId = expected.accountId, + ), + ), + ) + assertTrue( + shouldRetryAndroidOfflineJobForMissingSession( + expectedIdentity, + AndroidAccountRetentionSnapshot.Unavailable, + ), + ) + assertTrue( + shouldRetryAndroidOfflineJobForMissingSession( + expectedIdentity, + AndroidAccountRetentionSnapshot.Available( + accounts = listOf(expected.accountRecord(), other.accountRecord()), + activeAccountId = other.accountId, + ), + ), + ) + assertFalse( + shouldRetryAndroidOfflineJobForMissingSession( + expectedIdentity, + AndroidAccountRetentionSnapshot.Available( + accounts = listOf(other.accountRecord()), + activeAccountId = other.accountId, + ), + ), + ) + } + @Test fun accountRetirementRetainsPairMappingUntilEverySafGrantReleaseIsAttempted() = runBlocking { val retiredPairs = listOf( From 449489febf06d2b2a766d27078da12b17cadcc84 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 06:45:28 +0200 Subject: [PATCH 098/119] refactor(desktop): preserve compact session boundary --- .../dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt | 1 - 1 file changed, 1 deletion(-) 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 0836c7285..33f1dc398 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3502,7 +3502,6 @@ class DesktopNextcloudServices( } session } - override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { val persistedSession = accountOperationGuard.persistSessionAndActivateDynamicReads( persist = { From f3a26a1ca206740bdde8488d1eaab7ee5e7dcb3d Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:54:29 +0000 Subject: [PATCH 099/119] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index e5d1690bf..9cb74700b 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -202,6 +202,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaCollections.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaTransferCenterHost.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeNotesHierarchy.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudChunkUploadPolicy.kt", @@ -539,10 +540,10 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspaceToolbar.kt": "644e576f094b73a78dae541d15621edf86e75c0408553f6319d4fcefe55ed3d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GenericNativeFallback.kt": "768f8704a9999b45d06e39c61529bd9af370315ff1f33d7eb4f8b463aaaedbbf", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarComponents.kt": "84cca2c35c707d0c7915595d3fd7ebc463f40a31b59f44117210cb82b61716c2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "86d8e7b52c78d6008a84fc1ba9c901659f1b657405358c4e5d9f027361373b8a", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "9905f2412761723e7dfe715e6260c3490c80c3fc16426d9f663742ec0eed5f85", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactDetailDialog.kt": "ab6826a14ac9fbf62677bed2f6e93ef0fb1a57f1f074bb1a9e79fef212f715b3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "096a154e9d6169da4cc82c0f87d2c572a1a5e97e396cae9eb739d05ce05b0d82", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "94d4b63c903d181f8e91bd2876fdee8bd7eefe011ba416acaa0a30c64b1473d4", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "1d206c76800e92662b8980a41cd7792684e4c8b8ef94d0119109d5420da343c8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt": "88f84a03a3c2d95b130601d5aac62fad4b4e1ed559c7851d7d47a3b44d1d2bc9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavMutation.kt": "f32d31bb3564ef2e4a565f840c0db227e2632f6e15251a29151f233c0b25f718", @@ -553,7 +554,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksDav.kt": "83fe24e21779ca5a50ab2ae839692bb7d96de63b6c997ee941ca925ac6fe4546", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksLoading.kt": "7ad2ff8b58245db2b678fc79969c70327dccbe07e8b98a5b83fcfb8ec0b45fd1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksRecovery.kt": "475acdbe17f41fa2f1aecbeee73f1b5ba124e22c2fbc75368b8fbf0c575ec43b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt": "ca9e58778d5c68eeb6ce7f105b4e74226b3820135de721994819173e1558cf02", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksScreen.kt": "2a8a18f9a1a933505f775763f8aa1b7dbd2d615eda15c6b2d82ea32385252ca1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareTasksState.kt": "ccd6e1f9b2cf2931f5431380bc2749b5f68cbc5781e7de3d0f34621005caca6e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceActions.kt": "211cc20d9e7f60cc9337591acfad3e63653bff3c97aa6a693b6f647b976f0dfb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceHeader.kt": "0cacd1a4887bc8c830a1667e445bc11339a4c3b888feebaee8625ddc708965ab", @@ -617,8 +618,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaCollections.kt": "0d57ec1eaa6802513aafb88153f23e603a64fd7d1028e586227986ed155c6b14", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaTransferCenterHost.kt": "85b0eb13a4376d0f3d2c101063e6bfffe05a61a97d7109a5079e68ff11d05a64", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeNotesHierarchy.kt": "b753f1e3517a34f57d90f6a4c4067b8bfcc8085af080e99c33b3f4d29dffcbf4", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountCredentialServices.kt": "0886c2513430f6940fd4eefe6f85091215122ef7129d9c218ab6a00823a59434", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt": "99315e08ee2d9abbdcee0527abd61e614201a2ac81e39c180c34f2ff23480afe", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt": "b95f2863c026b25e545af677720d7f81cf57b1bd4a58bfbf2d99935b7499366b", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt": "569376bf76a4df6a5ca76efeb9bca5308d771f737272eea679fde32f7f5278bd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudChunkUploadPolicy.kt": "2635374193979991fa6b0e4d244a248b27d9ff4fcca148b47412530cb3031d87", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt": "570371d4b41907de1c2abd202a2c767dbe7d734d4c098266380b530c41aba75c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileListing.kt": "1f3de4f31d44d3ead139686d3fbcfbd27ccb520a9dab06ccc1d3e582ee25fb71", @@ -626,12 +628,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "5b29a90b69bb32aba118ef6c8b3f9d6eb26c03835823119b4a0f5bb1c1f4cb17", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "fb424bf8979ac292ef30daba64e905e6f5123066cda793aa0244907e11b465c9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "923cae9286489364664dfbf28bdf7e7c12ce1cc8e3a7d1a1a51a0397950cbcd2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "b53e2505663f0957dea9ad231b006d03d08f0405dc459974d85eb4768ef03484", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "d4dfd3c6a6793ab9c702564f2564c9323ef2909fc381e32f5fb9c24f702fb85b", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "6e39b83171c589ceee55f2f6aa635ea23126ee8f24135bce5bdfe6055774a9de", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "ac2206703b224364c1a3ff4097026c9c81d856042c20d31e5c28358016c3062d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "873201e412de895571b4982ec1afe029afe348950afd7c1b2491904f015f2068", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "4fdb230c0fa832a73bb7f7bb916ac8fdec7f53ba22aa519bb5de1b7b4243a97d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "fead6cbf4f723ea46f99c9155b190967205d24f198687b7bfd5a213c474374b8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "c96941c7582218754243f780a0bb0954ba31484719d8045f1e609f5d8c04a7c5", From 6383844ad8d0779c3a346f74fc7d5c4a9a6f1492 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 07:40:17 +0200 Subject: [PATCH 100/119] fix(accounts): purge removed private state --- .../AndroidAccountOwnedStateCleanup.kt | 4 + .../AndroidNextcloudServices.kt | 19 -- .../nextcloudnative/AndroidResponseReading.kt | 24 +++ tools/kotlin-file-size-baseline.txt | 10 +- .../app/AccountPrivateMemoryCleanup.kt | 19 ++ .../app/AccountWorkspaceMemoryCaches.kt | 174 ++++++++++++++++++ .../nextcloudnative/app/DashboardStatus.kt | 4 + .../app/DashboardStatusScreens.kt | 29 --- .../app/DeckWorkspaceMemoryCache.kt | 7 +- .../app/DynamicNativeMemoryCache.kt | 7 + .../app/GroupwareCalendarScreen.kt | 41 ----- .../app/GroupwareContactsState.kt | 4 + .../nextcloudnative/app/NextcloudNativeApp.kt | 79 -------- .../app/NextcloudNotesCache.kt | 6 + .../app/OfficeDocumentWorkflow.kt | 4 + .../nextcloudnative/app/PreviewMemoryCache.kt | 6 + .../app/SupportSettingsDraftRegistry.kt | 4 + .../app/AccountPrivateMemoryCleanupTest.kt | 109 +++++++++++ .../app/DesktopAccountCacheRemoval.kt | 27 +++ .../app/DesktopNextcloudServices.kt | 16 +- .../app/DesktopAccountCacheRemovalTest.kt | 22 +++ 21 files changed, 428 insertions(+), 187 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidResponseReading.kt create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt create mode 100644 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index e2f000486..65918d82c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -2,6 +2,7 @@ package dev.obiente.nextcloudnative import android.content.Context import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer +import dev.obiente.nextcloudnative.app.AccountPrivateMemoryCleanup import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.durableMutationAccountScope import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache @@ -49,6 +50,7 @@ internal class AndroidAccountOwnedStateCleanup( { virtualFileCache.clearAccount(accountIdentity) }, { mutationRecovery.clearDurableRecoveries(durableMutationAccountScope(session)) }, { mutationRecovery.clearPendingDynamicMutations(cacheIdentity) }, + { AccountPrivateMemoryCleanup.removeAccount(session.accountId.storageKey) }, ), ) } @@ -79,6 +81,7 @@ internal class AndroidAccountOwnedStateCleanup( { virtualFileCache.clearAccount(accountIdentity) }, { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, { previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) }, + { AccountPrivateMemoryCleanup.removeAccount(session.accountId.storageKey) }, ), ) } @@ -109,6 +112,7 @@ internal class AndroidAccountOwnedStateCleanup( { virtualFileCache.clearAccount(accountIdentity) }, { durableMutationIdentity?.let(mutationRecovery::clearDurableRecoveries) }, { previewCacheIdentity?.let(mutationRecovery::clearPendingDynamicMutations) }, + { AccountPrivateMemoryCleanup.removeAccount(accountStorageKey) }, ), ) } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 95c2f1a6c..edbc6e889 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -3738,25 +3738,6 @@ internal class AndroidNextcloudServices( ) } - private fun elapsedMillis(startedNanos: Long): Long = - (System.nanoTime() - startedNanos).coerceAtLeast(0L) / 1_000_000L - - private fun java.io.InputStream.readBounded(maxBytes: Long, responseStatus: Int? = null): ByteArray { - val output = ByteArrayOutputStream(minOf(maxBytes, DEFAULT_BUFFER_CAPACITY.toLong()).toInt()) - val buffer = ByteArray(DEFAULT_BUFFER_CAPACITY) - var total = 0L - while (true) { - val read = read(buffer) - if (read == -1) break - total += read - if (total > maxBytes) { - throw NextcloudResponseTooLargeException(maxBytes, responseStatus) - } - output.write(buffer, 0, read) - } - return output.toByteArray() - } - private fun parseDavFiles(xml: ByteArray, userId: String): List { val document = SafeXmlParser.parse(xml) val responses = document.getElementsByTagNameNS(DAV_NAMESPACE, "response") diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidResponseReading.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidResponseReading.kt new file mode 100644 index 000000000..ec6791522 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidResponseReading.kt @@ -0,0 +1,24 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudResponseTooLargeException +import java.io.ByteArrayOutputStream +import java.io.InputStream + +internal fun elapsedMillis(startedNanos: Long): Long = + (System.nanoTime() - startedNanos).coerceAtLeast(0L) / 1_000_000L + +internal fun InputStream.readBounded(maxBytes: Long, responseStatus: Int? = null): ByteArray { + val output = ByteArrayOutputStream(minOf(maxBytes, ANDROID_RESPONSE_BUFFER_BYTES.toLong()).toInt()) + val buffer = ByteArray(ANDROID_RESPONSE_BUFFER_BYTES) + var total = 0L + while (true) { + val read = read(buffer) + if (read == -1) break + total += read + if (total > maxBytes) throw NextcloudResponseTooLargeException(maxBytes, responseStatus) + output.write(buffer, 0, read) + } + return output.toByteArray() +} + +private const val ANDROID_RESPONSE_BUFFER_BYTES = 8 * 1024 diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 487d38c01..c0f73bf8c 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|4257 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4238 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|1003 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1224 @@ -8,14 +8,14 @@ contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/Signed ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/AndroidCompatibilityVideoPlaybackService.kt|808 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityWorkspace.kt|1157 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt|919 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt|1838 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt|1809 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeActions.kt|1158 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt|2512 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileOfflineCenterScreen.kt|2600 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncCoordinator.kt|973 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FileSyncExperience.kt|1386 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/FilesWorkspace.kt|567 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt|1111 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt|1070 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt|1107 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt|1293 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt|2646 @@ -25,7 +25,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface. ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt|1234 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1892 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12432 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12348 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt|808 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1724 @@ -53,7 +53,7 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.k ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt|884 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|883 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStore.kt|808 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6280 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6241 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2759 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1085 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt new file mode 100644 index 000000000..7a1c762aa --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt @@ -0,0 +1,19 @@ +package dev.obiente.nextcloudnative.app + +/** Removes process-local private state after credential removal has committed. */ +object AccountPrivateMemoryCleanup { + fun removeAccount(accountStorageKey: String) { + require(accountStorageKey.length == 64 && accountStorageKey.all { it in '0'..'9' || it in 'a'..'f' }) + PreviewMemoryCache.removeAccount(accountStorageKey) + sharedNextcloudNotesCache.removeAccount(accountStorageKey) + sharedDynamicNativeMemoryCache.removeAccount(accountStorageKey) + sharedDashboardStatusMemoryCache.removeAccount(accountStorageKey) + ContactsWorkspaceMemoryCache.removeAccount(accountStorageKey) + DeckWorkspaceMemoryCache.removeAccount(accountStorageKey) + sharedDocumentEditingCapabilitiesCache.removeAccount(accountStorageKey) + SupportSettingsDraftRegistry.removeAccount(accountStorageKey) + removeCalendarWorkspaceMemory(accountStorageKey) + removeUserStatusWorkspaceMemory(accountStorageKey) + removeNextcloudNativeWorkspaceMemory(accountStorageKey) + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt new file mode 100644 index 000000000..251b7fa65 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt @@ -0,0 +1,174 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.mutableStateOf + +internal class PhotoTimelineUiState { + val timeline = mutableStateOf(PhotoTimelineState(pageSize = MAX_PHOTO_TIMELINE_PAGE_SIZE)) + val backupStatuses = mutableStateOf>(emptyMap()) + val initialLoadCompleted = mutableStateOf(false) +} + +internal object PhotoTimelineUiStateRepository { + private const val MAXIMUM_ACCOUNT_STATES = 4 + private val accountStates = linkedMapOf() + + fun stateFor(session: NextcloudSession): PhotoTimelineUiState { + val accountKey = previewCacheDigest(session) + accountStates.remove(accountKey)?.let { existing -> + accountStates[accountKey] = existing + return existing + } + val created = PhotoTimelineUiState() + accountStates[accountKey] = created + while (accountStates.size > MAXIMUM_ACCOUNT_STATES) accountStates.remove(accountStates.keys.first()) + return created + } + + fun removeAccount(accountStorageKey: String) { + accountStates.remove(accountStorageKey) + } +} + +internal sealed interface CalendarLoadState { + data object Loading : CalendarLoadState + data class Ready( + val month: CalendarMonth, + val timeWindow: GroupwareDavTimeWindow, + val calendars: List, + val events: List, + ) : CalendarLoadState + data class Error(val message: String) : CalendarLoadState +} + +internal object CalendarWorkspaceMemoryCache { + private val entries = linkedMapOf, CalendarLoadState.Ready>() + + fun get( + session: NextcloudSession, + userId: String, + month: CalendarMonth, + timeWindow: GroupwareDavTimeWindow, + ): CalendarLoadState.Ready? { + val key = key(session, userId, month, timeWindow) + return entries.remove(key)?.also { entries[key] = it } + } + + fun store(session: NextcloudSession, userId: String, value: CalendarLoadState.Ready) { + val key = key(session, userId, value.month, value.timeWindow) + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_CALENDAR_MONTHS) entries.remove(entries.keys.first()) + } + + fun removeAccount(accountStorageKey: String) { + entries.keys.removeAll { (account) -> account.storageKey == accountStorageKey } + } + + private fun key( + session: NextcloudSession, + userId: String, + month: CalendarMonth, + timeWindow: GroupwareDavTimeWindow, + ): Pair = session.accountId to + "$userId\n${month.year}-${month.month}\n${timeWindow.startUtc}-${timeWindow.endUtc}" +} + +internal sealed interface UserStatusSurfaceState { + data object Loading : UserStatusSurfaceState + data class Available( + val capabilities: NativeUserStatusCapabilities, + val status: NativeUserStatus, + val predefined: List, + ) : UserStatusSurfaceState + data class Failed(val message: String) : UserStatusSurfaceState +} + +internal object UserStatusWorkspaceMemoryCache { + private val entries = linkedMapOf() + + fun get(session: NextcloudSession): UserStatusSurfaceState.Available? { + val key = session.accountId + return entries.remove(key)?.also { entries[key] = it } + } + + fun store(session: NextcloudSession, value: UserStatusSurfaceState.Available) { + val key = session.accountId + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_STATUS_ACCOUNTS) entries.remove(entries.keys.first()) + } + + fun removeAccount(accountStorageKey: String) { + entries.keys.removeAll { account -> account.storageKey == accountStorageKey } + } +} + +internal object ActivityWorkspaceMemoryCache { + private val entries = linkedMapOf, ActivityTimelineState>() + + fun get(session: NextcloudSession, filterId: String): ActivityTimelineState? { + val key = session.accountId to filterId + return entries.remove(key)?.also { entries[key] = it } + } + + fun store(session: NextcloudSession, filterId: String, value: ActivityTimelineState) { + val key = session.accountId to filterId + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_ACTIVITY_ACCOUNTS) entries.remove(entries.keys.first()) + } + + fun removeAccount(accountStorageKey: String) { + entries.keys.removeAll { (account) -> account.storageKey == accountStorageKey } + } +} + +internal object TalkWorkspaceMemoryCache { + private val rooms = linkedMapOf>() + private val messages = linkedMapOf, List>() + + fun rooms(session: NextcloudSession): List? = touch(rooms, session.accountId) + + fun storeRooms(session: NextcloudSession, value: List) { + store(rooms, session.accountId, value, MAXIMUM_RETAINED_TALK_ACCOUNTS) + } + + fun messages(session: NextcloudSession, roomToken: String): List? = + touch(messages, session.accountId to roomToken) + + fun storeMessages(session: NextcloudSession, roomToken: String, value: List) { + store(messages, session.accountId to roomToken, value, MAXIMUM_RETAINED_TALK_ROOMS) + } + + fun removeAccount(accountStorageKey: String) { + rooms.keys.removeAll { account -> account.storageKey == accountStorageKey } + messages.keys.removeAll { (account) -> account.storageKey == accountStorageKey } + } + + private fun touch(entries: LinkedHashMap, key: Key): T? = + entries.remove(key)?.also { entries[key] = it } + + private fun store(entries: LinkedHashMap, key: Key, value: T, maximum: Int) { + entries.remove(key) + entries[key] = value + while (entries.size > maximum) entries.remove(entries.keys.first()) + } +} + +internal fun removeCalendarWorkspaceMemory(accountStorageKey: String) = + CalendarWorkspaceMemoryCache.removeAccount(accountStorageKey) + +internal fun removeUserStatusWorkspaceMemory(accountStorageKey: String) = + UserStatusWorkspaceMemoryCache.removeAccount(accountStorageKey) + +internal fun removeNextcloudNativeWorkspaceMemory(accountStorageKey: String) { + PhotoTimelineUiStateRepository.removeAccount(accountStorageKey) + ActivityWorkspaceMemoryCache.removeAccount(accountStorageKey) + TalkWorkspaceMemoryCache.removeAccount(accountStorageKey) +} + +private const val MAXIMUM_RETAINED_CALENDAR_MONTHS = 24 +private const val MAXIMUM_RETAINED_STATUS_ACCOUNTS = 4 +private const val MAXIMUM_RETAINED_ACTIVITY_ACCOUNTS = 4 +private const val MAXIMUM_RETAINED_TALK_ACCOUNTS = 4 +private const val MAXIMUM_RETAINED_TALK_ROOMS = 16 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt index d9168ff01..2e3eca763 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt @@ -701,6 +701,10 @@ internal class DashboardStatusMemoryCache( fun invalidate(session: NextcloudSession) { entries.remove(session.accountId) } + + fun removeAccount(accountStorageKey: String) { + entries.keys.removeAll { account -> account.storageKey == accountStorageKey } + } } internal fun retainedDashboardRefreshSnapshot( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt index 58b5e8d26..34ced8455 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt @@ -1397,34 +1397,6 @@ private fun DashboardFailure(message: String, onRetry: () -> Unit) { } } -private sealed interface UserStatusSurfaceState { - data object Loading : UserStatusSurfaceState - data class Available( - val capabilities: NativeUserStatusCapabilities, - val status: NativeUserStatus, - val predefined: List, - ) : UserStatusSurfaceState - data class Failed(val message: String) : UserStatusSurfaceState -} - -private object UserStatusWorkspaceMemoryCache { - private val entries = linkedMapOf() - - fun get(session: NextcloudSession): UserStatusSurfaceState.Available? { - val key = key(session) - return entries.remove(key)?.also { entries[key] = it } - } - - fun store(session: NextcloudSession, value: UserStatusSurfaceState.Available) { - val key = key(session) - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_STATUS_ACCOUNTS) entries.remove(entries.keys.first()) - } - - private fun key(session: NextcloudSession): NextcloudAccountId = session.accountId -} - private enum class StatusExpiryChoice(val label: String, val seconds: Long?) { Never("No expiry", null), OneHour("1 hour", 60L * 60L), @@ -1778,7 +1750,6 @@ internal fun NativeUserStatusScreen( } } -private const val MAXIMUM_RETAINED_STATUS_ACCOUNTS = 4 @Composable private fun CurrentUserStatusCard(status: NativeUserStatus) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt index 135eeca99..3f9a44819 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt @@ -27,6 +27,9 @@ internal object DeckWorkspaceMemoryCache { while (entries.size > MAXIMUM_RETAINED_DECK_ACCOUNTS) entries.remove(entries.keys.first()) } - private fun key(session: NextcloudSession): String = - "${session.serverUrl.trimEnd('/')}\n${session.loginName}" + fun removeAccount(accountStorageKey: String) { + entries.remove(accountStorageKey) + } + + private fun key(session: NextcloudSession): String = session.accountId.storageKey } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt index d4ca8722d..24d059958 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt @@ -106,6 +106,13 @@ internal class DynamicNativeMemoryCache( } } + fun removeAccount(accountStorageKey: String) { + discoveries.keys.removeAll { key -> key.account == accountStorageKey } + discoveryMetadata.keys.removeAll { key -> key.account == accountStorageKey } + discoveryFailures.keys.removeAll { key -> key.account == accountStorageKey } + screens.keys.removeAll { key -> key.account == accountStorageKey } + } + private fun DynamicScreenSnapshot.bounded(): DynamicScreenSnapshot { val boundedRelated = relatedRecords.entries .take(MAXIMUM_RELATED_RESOURCES) 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 9c7682024..5079de7aa 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt @@ -80,46 +80,6 @@ internal data class CalendarMonth(val year: Int, val month: Int) { fun days(): Int = groupwareCalendarDaysInMonth(year, month) } -private sealed interface CalendarLoadState { - data object Loading : CalendarLoadState - data class Ready( - val month: CalendarMonth, - val timeWindow: GroupwareDavTimeWindow, - val calendars: List, - val events: List, - ) : CalendarLoadState - data class Error(val message: String) : CalendarLoadState -} - -private object CalendarWorkspaceMemoryCache { - private val entries = linkedMapOf, CalendarLoadState.Ready>() - - fun get( - session: NextcloudSession, - userId: String, - month: CalendarMonth, - timeWindow: GroupwareDavTimeWindow, - ): CalendarLoadState.Ready? { - val key = key(session, userId, month, timeWindow) - return entries.remove(key)?.also { entries[key] = it } - } - - fun store(session: NextcloudSession, userId: String, value: CalendarLoadState.Ready) { - val key = key(session, userId, value.month, value.timeWindow) - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_CALENDAR_MONTHS) entries.remove(entries.keys.first()) - } - - private fun key( - session: NextcloudSession, - userId: String, - month: CalendarMonth, - timeWindow: GroupwareDavTimeWindow, - ): Pair = session.accountId to - "$userId\n${month.year}-${month.month}\n${timeWindow.startUtc}-${timeWindow.endUtc}" -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun NativeGroupwareCalendarScreen( @@ -1105,7 +1065,6 @@ private val MONTH_NAMES = listOf( "July", "August", "September", "October", "November", "December", ) private val WEEK_DAYS = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") -private const val MAXIMUM_RETAINED_CALENDAR_MONTHS = 24 private const val CALENDAR_MUTATION_RESULT_UNKNOWN_MESSAGE = "The server response was interrupted, so the calendar result is unknown. " + "Refresh to verify it before trying another change." diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt index 09be3cb67..de118f941 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt @@ -23,6 +23,10 @@ internal object ContactsWorkspaceMemoryCache { entries[key] = value while (entries.size > MAXIMUM_RETAINED_CONTACT_ACCOUNTS) entries.remove(entries.keys.first()) } + + fun removeAccount(accountStorageKey: String) { + entries.keys.removeAll { (account) -> account.storageKey == accountStorageKey } + } } internal fun contactEditRequiresFullLoad( 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 015d311ca..d637d7f73 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -304,31 +304,6 @@ internal fun NativeAppSchema.forDynamicContractVersion( private const val DYNAMIC_MUTATION_AUTHORITATIVE_READ_DELAY_MILLIS = 500L -private class PhotoTimelineUiState { - val timeline = mutableStateOf(PhotoTimelineState(pageSize = MAX_PHOTO_TIMELINE_PAGE_SIZE)) - val backupStatuses = mutableStateOf>(emptyMap()) - val initialLoadCompleted = mutableStateOf(false) -} - -private object PhotoTimelineUiStateRepository { - private const val MAXIMUM_ACCOUNT_STATES = 4 - private val accountStates = linkedMapOf() - - fun stateFor(session: NextcloudSession): PhotoTimelineUiState { - val accountKey = previewCacheDigest(session) - accountStates.remove(accountKey)?.let { existing -> - accountStates[accountKey] = existing - return existing - } - val created = PhotoTimelineUiState() - accountStates[accountKey] = created - while (accountStates.size > MAXIMUM_ACCOUNT_STATES) { - accountStates.remove(accountStates.keys.first()) - } - return created - } -} - private val mediaViewerNavigationRepository = MediaViewerNavigationRepository() private inline fun > enumSaver() = Saver( @@ -7115,25 +7090,6 @@ internal fun shouldShowDynamicRecordFallbackDetail( viewComponent != NativeComponent.form && selectedRecordResourceId?.sameDynamicResourceAs(viewResourceId) == true -private object ActivityWorkspaceMemoryCache { - private val entries = linkedMapOf, ActivityTimelineState>() - - fun get(session: NextcloudSession, filterId: String): ActivityTimelineState? { - val key = key(session, filterId) - return entries.remove(key)?.also { entries[key] = it } - } - - fun store(session: NextcloudSession, filterId: String, value: ActivityTimelineState) { - val key = key(session, filterId) - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_ACTIVITY_ACCOUNTS) entries.remove(entries.keys.first()) - } - - private fun key(session: NextcloudSession, filterId: String): Pair = - session.accountId to filterId -} - @Composable private fun ActivityScreen( services: NextcloudPlatformServices, @@ -11886,38 +11842,6 @@ private enum class MarkdownFileViewMode { Edit, } -internal object TalkWorkspaceMemoryCache { - private val rooms = linkedMapOf>() - private val messages = linkedMapOf, List>() - - fun rooms(session: NextcloudSession): List? = touch(rooms, session.accountId) - - fun storeRooms(session: NextcloudSession, value: List) { - store(rooms, session.accountId, value, MAXIMUM_RETAINED_TALK_ACCOUNTS) - } - - fun messages(session: NextcloudSession, roomToken: String): List? = - touch(messages, session.accountId to roomToken) - - fun storeMessages(session: NextcloudSession, roomToken: String, value: List) { - store( - messages, - session.accountId to roomToken, - value, - MAXIMUM_RETAINED_TALK_ROOMS, - ) - } - - private fun touch(entries: LinkedHashMap, key: Key): T? = - entries.remove(key)?.also { entries[key] = it } - - private fun store(entries: LinkedHashMap, key: Key, value: T, maximum: Int) { - entries.remove(key) - entries[key] = value - while (entries.size > maximum) entries.remove(entries.keys.first()) - } -} - @Composable private fun TalkScreen( services: NextcloudPlatformServices, @@ -12422,6 +12346,3 @@ internal fun formatBytes(bytes: Long?): String = when { } private const val MAX_DYNAMIC_BATCH_RELATION_ERROR_LENGTH = 1_024 -private const val MAXIMUM_RETAINED_ACTIVITY_ACCOUNTS = 4 -private const val MAXIMUM_RETAINED_TALK_ACCOUNTS = 4 -private const val MAXIMUM_RETAINED_TALK_ROOMS = 16 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt index cab3dd22a..aa86b3baa 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt @@ -35,6 +35,12 @@ internal class NextcloudNotesCache { noteLists[account] = noteLists[account]?.filterNot { note -> note.id == noteId } ?: return noteListEtags.remove(account) } + + fun removeAccount(accountStorageKey: String) { + noteLists.keys.removeAll { account -> account.storageKey == accountStorageKey } + noteListEtags.keys.removeAll { account -> account.storageKey == accountStorageKey } + noteDetails.keys.removeAll { (account) -> account.storageKey == accountStorageKey } + } } internal val sharedNextcloudNotesCache = NextcloudNotesCache() diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt index 85f9720b1..a4d53c13d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt @@ -247,6 +247,10 @@ internal class NextcloudDocumentEditingCapabilitiesCache { etag = etag?.takeIf(String::isNotBlank), ) } + + fun removeAccount(accountStorageKey: String) { + entries.remove(accountStorageKey) + } } internal val sharedDocumentEditingCapabilitiesCache = NextcloudDocumentEditingCapabilitiesCache() diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt index ddaa23722..0c3d76244 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt @@ -28,6 +28,12 @@ internal object PreviewMemoryCache { bytes -= entries.remove(oldestKey)?.size ?: 0 } } + + fun removeAccount(accountStorageKey: String) { + entries.keys.filter { key -> key.account == accountStorageKey }.forEach { key -> + bytes -= entries.remove(key)?.size ?: 0 + } + } } internal data class PreviewCacheKey( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt index 2a1b8c1fd..8e785bdd7 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt @@ -24,6 +24,10 @@ internal object SupportSettingsDraftRegistry { } } } + + fun removeAccount(accountStorageKey: String) { + states.remove(accountStorageKey)?.clearDrafts() + } } private const val MAX_RETAINED_SUPPORT_DRAFT_ACCOUNTS = 4 diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt new file mode 100644 index 000000000..727143405 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt @@ -0,0 +1,109 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertNull +import kotlin.test.assertSame + +class AccountPrivateMemoryCleanupTest { + @Test + fun `removal purges one account from shared workspace memory`() { + val removed = session("removed") + val retained = session("retained") + val removedKey = removed.accountId.storageKey + val retainedKey = retained.accountId.storageKey + val removedPreview = PreviewCacheKey(removedKey, "core", 1L, "etag", 64, 64) + val retainedPreview = PreviewCacheKey(retainedKey, "core", 2L, "etag", 64, 64) + val removedPhotoState = PhotoTimelineUiStateRepository.stateFor(removed) + val retainedPhotoState = PhotoTimelineUiStateRepository.stateFor(retained) + try { + PreviewMemoryCache.put(removedPreview, byteArrayOf(1)) + PreviewMemoryCache.put(retainedPreview, byteArrayOf(2)) + sharedNextcloudNotesCache.storeDetail(removed, note(1L, "Removed")) + sharedNextcloudNotesCache.storeDetail(retained, note(2L, "Retained")) + sharedDynamicNativeMemoryCache.storeScreen(dynamicKey(removed), dynamicSnapshot(1)) + sharedDynamicNativeMemoryCache.storeScreen(dynamicKey(retained), dynamicSnapshot(2)) + sharedDashboardStatusMemoryCache.store(removed, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 1L) + sharedDashboardStatusMemoryCache.store(retained, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 1L) + ContactsWorkspaceMemoryCache.store(removed, "removed", ContactsLoadState.Ready(emptyList(), emptyList())) + ContactsWorkspaceMemoryCache.store(retained, "retained", ContactsLoadState.Ready(emptyList(), emptyList())) + DeckWorkspaceMemoryCache.store(removed, deckSnapshot()) + DeckWorkspaceMemoryCache.store(retained, deckSnapshot()) + sharedDocumentEditingCapabilitiesCache.store( + removed, NextcloudDocumentEditingCapabilities.Unavailable, null, + ) + sharedDocumentEditingCapabilitiesCache.store( + retained, NextcloudDocumentEditingCapabilities.Unavailable, null, + ) + ActivityWorkspaceMemoryCache.store(removed, "all", ActivityTimelineState(initialized = true)) + ActivityWorkspaceMemoryCache.store(retained, "all", ActivityTimelineState(initialized = true)) + TalkWorkspaceMemoryCache.storeRooms(removed, listOf(TalkRoom("removed", "Removed", null, 0))) + TalkWorkspaceMemoryCache.storeRooms(retained, listOf(TalkRoom("retained", "Retained", null, 0))) + + AccountPrivateMemoryCleanup.removeAccount(removedKey) + + assertNull(PreviewMemoryCache.get(removedPreview)) + assertContentEquals(byteArrayOf(2), PreviewMemoryCache.get(retainedPreview)) + assertNull(sharedNextcloudNotesCache.detail(removed, 1L)) + assertEquals("Retained", sharedNextcloudNotesCache.detail(retained, 2L)?.title) + assertNull(sharedDynamicNativeMemoryCache.screen(dynamicKey(removed))) + assertNotNull(sharedDynamicNativeMemoryCache.screen(dynamicKey(retained))) + assertNull(sharedDashboardStatusMemoryCache.get(removed, 1L)) + assertNotNull(sharedDashboardStatusMemoryCache.get(retained, 1L)) + assertNull(ContactsWorkspaceMemoryCache.get(removed, "removed")) + assertNotNull(ContactsWorkspaceMemoryCache.get(retained, "retained")) + assertNull(DeckWorkspaceMemoryCache.get(removed)) + assertNotNull(DeckWorkspaceMemoryCache.get(retained)) + assertNull(sharedDocumentEditingCapabilitiesCache.get(removed)) + assertNotNull(sharedDocumentEditingCapabilitiesCache.get(retained)) + assertNull(ActivityWorkspaceMemoryCache.get(removed, "all")) + assertNotNull(ActivityWorkspaceMemoryCache.get(retained, "all")) + assertNull(TalkWorkspaceMemoryCache.rooms(removed)) + assertEquals("retained", TalkWorkspaceMemoryCache.rooms(retained)?.single()?.token) + assertNotSame(removedPhotoState, PhotoTimelineUiStateRepository.stateFor(removed)) + assertSame(retainedPhotoState, PhotoTimelineUiStateRepository.stateFor(retained)) + } finally { + AccountPrivateMemoryCleanup.removeAccount(removedKey) + AccountPrivateMemoryCleanup.removeAccount(retainedKey) + } + } + + private fun session(name: String) = NextcloudSession( + serverUrl = "https://$name.private-memory.example.test", + loginName = name, + appPassword = "password", + ) + + private fun note(id: Long, title: String) = NextcloudNote( + id = id, + title = title, + modified = 1L, + category = "Personal", + favorite = false, + readOnly = false, + content = "private", + etag = "etag-$id", + ) + + private fun dynamicKey(session: NextcloudSession) = + dynamicScreenCacheKey(session, "dashboard", "widgets", null, emptyMap()) + + private fun dynamicSnapshot(page: Int) = DynamicScreenSnapshot( + records = emptyList(), + relatedRecords = emptyMap(), + pagination = DynamicPaginationCheckpoint(page, "page-$page"), + ) + + private fun deckSnapshot() = DeckWorkspaceMemorySnapshot( + state = DeckWorkspaceState.Loading, + loadedBoards = emptyList(), + capabilities = null, + activeRoute = null, + requestedBoard = null, + requestedBoardId = null, + requestedCardId = null, + ) +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt index 515da0101..7c6762b25 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemoval.kt @@ -6,6 +6,11 @@ import java.nio.file.Files import java.nio.file.LinkOption import java.nio.file.StandardOpenOption import java.security.MessageDigest +import java.util.prefs.Preferences + +private const val KEY_VIRTUAL_FILE_ROOT_PREFIX = "vfp-root." +private const val KEY_VIRTUAL_FILE_PRIMARY_CACHE_PREFIX = "vfpc-primary." +private const val KEY_VIRTUAL_FILE_OVERFLOW_CACHE_PREFIX = "vfpc-overflow." internal data class VirtualRangeRevision( val relativePath: String, @@ -56,10 +61,32 @@ internal suspend fun removeDesktopAccountPrivateStorage( syncEngine: DesktopFileSyncEngine, files: DesktopFileReadCache, ranges: DesktopVirtualRangeCache, + preferences: Preferences, ) { syncEngine.removeAccountPairs(accountId) files.removeAccount(accountId) ranges.removeAccount(accountId) + removeDesktopAccountVirtualFilePreferences(preferences, accountId) +} + +internal fun removeDesktopAccountVirtualFilePreferences(preferences: Preferences, accountId: String) { + preferences.remove(virtualFileProviderRootPreferenceKey(accountId)) + preferences.remove(virtualFileCachePreferenceKey(KEY_VIRTUAL_FILE_PRIMARY_CACHE_PREFIX, accountId)) + preferences.remove(virtualFileCachePreferenceKey(KEY_VIRTUAL_FILE_OVERFLOW_CACHE_PREFIX, accountId)) + preferences.flush() +} + +internal fun virtualFileProviderRootPreferenceKey(accountId: String): String = + desktopAccountPreferenceKey(KEY_VIRTUAL_FILE_ROOT_PREFIX, accountId) + +internal fun virtualFileCachePreferenceKey(prefix: String, accountId: String): String { + require(prefix == KEY_VIRTUAL_FILE_PRIMARY_CACHE_PREFIX || prefix == KEY_VIRTUAL_FILE_OVERFLOW_CACHE_PREFIX) + return desktopAccountPreferenceKey(prefix, accountId) +} + +private fun desktopAccountPreferenceKey(prefix: String, accountId: String): String { + require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) + return "$prefix$accountId".also { key -> check(key.length <= Preferences.MAX_KEY_LENGTH) } } private fun desktopCacheRoot(): File { 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 33f1dc398..1f08a42e5 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -125,7 +125,6 @@ private const val VIRTUAL_FOLDER_REFRESH_RETRY_MILLIS = 30L * 60L * 1_000L private const val KEY_WINDOWS_CLOUD_FILES_PRESERVED_ROOT_PREFIX = "wcfpr." private const val KEY_WINDOWS_CLOUD_FILES_RECOVERY_CURSOR = "windows-cloud-files-recovery-cursor" private const val MAX_WINDOWS_CLOUD_FILES_RECOVERY_ROOTS_PER_ATTEMPT = 16 -private const val KEY_VIRTUAL_FILE_ROOT_PREFIX = "vfp-root." private const val KEY_VIRTUAL_FILE_PRIMARY_CACHE_PREFIX = "vfpc-primary." private const val KEY_VIRTUAL_FILE_OVERFLOW_CACHE_PREFIX = "vfpc-overflow." private const val VIRTUAL_FILE_PRIMARY_PREFERENCE_VERSION = "v2" @@ -160,16 +159,6 @@ private fun desktopLinuxVirtualFileMountPoint( File(location.parentPath, location.folderName).absoluteFile.normalize() } -private fun virtualFileProviderRootPreferenceKey(accountId: String): String { - require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) - return "$KEY_VIRTUAL_FILE_ROOT_PREFIX$accountId".also { key -> check(key.length <= Preferences.MAX_KEY_LENGTH) } -} - -private fun virtualFileCachePreferenceKey(prefix: String, accountId: String): String { - require(accountId.length == 64 && accountId.all { it in '0'..'9' || it in 'a'..'f' }) - return "$prefix$accountId".also { key -> check(key.length <= Preferences.MAX_KEY_LENGTH) } -} - private data class DesktopVirtualFileCacheTiers( val configuration: VirtualFileCacheTierConfiguration, val primaryIdentity: String?, @@ -3871,8 +3860,11 @@ class DesktopNextcloudServices( removeDesktopPendingDynamicMutations(pendingDynamicMutationDirectory, accountId) cleanup.durableMutationAccountScope?.let(durableMutationRecovery::removeAccount) cleanup.accountStorageKey?.let { deckCardDrafts.removeAccount(it, accountId) } + cleanup.accountStorageKey?.let(AccountPrivateMemoryCleanup::removeAccount) externalFileHandoff.removeAccount(accountId) - removeDesktopAccountPrivateStorage(accountId, fileSyncEngine, fileReadCache, virtualRangeCache(accountId)) + removeDesktopAccountPrivateStorage( + accountId, fileSyncEngine, fileReadCache, virtualRangeCache(accountId), preferences, + ) if (!isWindowsDesktop()) return try { unregisterWindowsCloudFilesRootsForAccountRemoval( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemovalTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemovalTest.kt index 775db96c0..5c2b735e5 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemovalTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCacheRemovalTest.kt @@ -71,6 +71,28 @@ class DesktopAccountCacheRemovalTest { } } + @Test + fun accountRemovalPurgesVirtualFileLocationPreferences() { + val preferences = Preferences.userRoot().node("desktop-account-locations-${UUID.randomUUID()}") + try { + val rootKey = virtualFileProviderRootPreferenceKey(ACCOUNT_ID) + val primaryKey = "vfpc-primary.$ACCOUNT_ID" + val overflowKey = "vfpc-overflow.$ACCOUNT_ID" + preferences.put(rootKey, "/private/mount") + preferences.put(primaryKey, "/private/primary") + preferences.put(overflowKey, "/private/overflow") + preferences.flush() + + removeDesktopAccountVirtualFilePreferences(preferences, ACCOUNT_ID) + + assertNull(preferences.get(rootKey, null)) + assertNull(preferences.get(primaryKey, null)) + assertNull(preferences.get(overflowKey, null)) + } finally { + preferences.removeNode() + } + } + private companion object { const val ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" From ca9b1d984b4963a5b739813d738506e352feb86b Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 05:46:33 +0000 Subject: [PATCH 101/119] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 9cb74700b..27d6c4af9 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -12,7 +12,9 @@ "settings.gradle.kts", "tools/marketing-capture-inputs.txt", "ui/build.gradle.kts", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityFilters.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityHistoryPresentation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivitySemantics.kt", @@ -428,7 +430,9 @@ "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": "b45bb5e694a2b893818b3e23f86056cc9f165bda29fabfeb79e4efd6d156e976", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt": "7b93d571553fa8c364681f172edecde3109b45db9a4ff6f9f6fb12f8f6280a0e", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt": "eeafb3c0436338c2965ba2a74100e2f9ad9dca7afd2fe94de96b344800a701da", "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", @@ -457,9 +461,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "84a6e5ca035ac7796427c25f4b4ed1162832c4febbc44606dc06413cc2e33b58", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChoresWorkspaceContext.kt": "f22d2bfc408f52eb61f3587e9bcf79ba7cb4c7c6afd1717f04f35731f1906391", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardOcsResponse.kt": "25780cc210c8e2f56fb2e45ab23a29a9030f6350a9a1ea29412650106a0d111f", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt": "427dd6352a5958a5fd31b9b8ed8cd0f8d1eac1b25800ea33b8bad171125e8f5e", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt": "0bd46e04e07a06ae4483101db6741dcb24a7bc6150797d73814bcdae6a8853a7", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusPresentation.kt": "96eb3aa478be8932e695e2dfe2067cc7b8dccf5ffa6cc1370f2db27b8119e0ff", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt": "cf84b77e3d239161c1c84eae7ae949caeb5206c3f09346e61ead6a3ec99533c1", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt": "4cad163162b3075320b800a2bd28d4371b0074b4e5b7d294f1cc7830cb4901fd", "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", @@ -471,7 +475,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckMutationSafety.kt": "224582721737a29428ef2dca64fe489491c158765f5dd7f2eb66b3ce5d2ff5b5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeActions.kt": "5108b8bb8b4573e2711f83ba444418b1d2549e2b58cac1fa941559eb94b2b1fc", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckNativeFoundation.kt": "0ffee64690d6632b8ecd35761f9018205090f9e5256e6083e6a6d9a3e02e34c0", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt": "e1bdd14558456fd38504ae28b0da2b752f29d48ecc2a79fef07cbc987e841edc", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt": "234a843934cf27499c623c6022528cc003b4bfb42b70defb3f097ba1fc863d77", "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", @@ -487,7 +491,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt": "468f9bf41ea353dab6f816d333c3a6179d8d8e1f01f98f8eaf0890d5c0d381d6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt": "e0e7b54e02869eabe797c1af6c77a78b0f7eaf7cb609c3cce8d35a8a710e6511", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "21a9dbe66b1ad067c10df887bd97034b8d13b6e9dfe14cfced89b02f53575ff4", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "88b66d6102b7325903521ae7ab85bf74a24a8234ccc6406d2692d76070b74e14", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "09debe6409b538c3944a63b1e2c5022dc3e0584d746ef288183bfe0983fb1f7b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", @@ -540,11 +544,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": "9905f2412761723e7dfe715e6260c3490c80c3fc16426d9f663742ec0eed5f85", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "047c10233277632f1ff5e4dd7a77739c71629f05850c03070d20229adabd443d", "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": "94d4b63c903d181f8e91bd2876fdee8bd7eefe011ba416acaa0a30c64b1473d4", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "1d206c76800e92662b8980a41cd7792684e4c8b8ef94d0119109d5420da343c8", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "cd9d7bc716f23b3931f5c9b9ff95515fa509c42db11481cf28c4a2e407faa249", "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", @@ -628,9 +632,9 @@ "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": "d4dfd3c6a6793ab9c702564f2564c9323ef2909fc381e32f5fb9c24f702fb85b", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "d0ffb485a5b09ed8a02d470bb66acac8f1847a7d1d23df346e67cbf8bdeaf81f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "6e39b83171c589ceee55f2f6aa635ea23126ee8f24135bce5bdfe6055774a9de", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "ac2206703b224364c1a3ff4097026c9c81d856042c20d31e5c28358016c3062d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "2d338b92d8bfe1895374af0a7d43a984e92a8f07fc22572c24a8746a50fcf05b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "4fdb230c0fa832a73bb7f7bb916ac8fdec7f53ba22aa519bb5de1b7b4243a97d", @@ -640,7 +644,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudStatusMessages.kt": "03d7d79e38bdd9ac2e6a90e7172d2e9b7ea361df4d3e3d1a75d64584adc590ec", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesApi.kt": "e3c685b02592ea791b0c9a478a098fc5bf428893aa4764110c793a78040f341e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesFolderOperations.kt": "db6a04cfcd3b25b17c996cdc6dba21847d14f5970e06e6c24a3e2c85139dbe1d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt": "48057ebf53e45a042c4283aaff9a2ca5c3bb46fff3356e962540409f8c7b3b04", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt": "d8546d5f9519321e47ea6433224653f7532a1b76ba69d35cc7a9767995c30ff5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeEditRevalidation.kt": "5f35e2efb61c541546a6c3d206d7d018929fdaca4cc2ba8d7b16c42c174892ae", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt": "2cc0f0f28dee9f74ed88633a571dd298e596e859614dc2b9496bd515d57ccf96", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspaceLocation.kt": "b6e5e87939a7bf7c87ef82c2d0b11870128035b7efd9d75c784bff16c4e78787", @@ -668,7 +672,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PlatformEmbeddedWebApp.kt": "a069dce940071b07df4cb3773d0c29a5b8c1be1785206d2ff9a7c17af33d911a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PlatformImage.kt": "5c1ebb3dc168c0a53d6db05c54b7329a571dec808aef3de7fba1a52bd93b2d3d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PlatformVideoPlayback.kt": "8f103ac182fdca3c78f1ffe7a3b15f06f05abb3be173747255fe4a9c84726758", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt": "a30e7fc55f40b13883d2ceb56de0ca72b67e8fb1489d2d261eb66b0a05daef48", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt": "97bf3dd028d0fbb81da3b8060f956c41f098b8121af3eae8558f171ae187b2cf", "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", @@ -687,7 +691,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportReplyRecovery.kt": "e9eda3fc329c210d25706ee457ad499ac18767417ee974cbb9fd105c33f3ec98", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportRequestsEmptyState.kt": "397b6752a3fb99117574dfa83a861f906e174d50ab9ea7b94fdebc9b9ce624f3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportRuntimeDiagnostics.kt": "02f2a6c2d54335fd166aa9825c1521ec00faa60fda74494ec6728ea9460adf3d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt": "b02224640b3abcac0f37e1dc2c1ab63da5b2d17acffcb5be7113ccebe876ad08", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftRegistry.kt": "ec4c0872d30e09602443d0172707fb4251bd92274eb3145f20f99dadc4f15a38", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SupportSettingsDraftState.kt": "deeb8def69d225ba9663e4f712be9e2b461c67bf738a4d391a80b78af218e99e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SystemTags.kt": "452abd9589c627dc7ae9dae03265a3bfdb837809ba0ca82da0698ef73db64332", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/TalkAttachmentReader.kt": "aa8c5adab59da93a84fb86ade68e12cb8455a23d7c847aeb8411c68e49551925", From c4b9068465050f488831927c2d9f63d70986eaba Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 09:00:14 +0200 Subject: [PATCH 102/119] fix(accounts): purge private state on removal --- .../AndroidAccountOwnedStateCleanup.kt | 16 +++ .../AndroidDynamicDiscoveryCache.kt | 94 +++++++++++++ .../nextcloudnative/AndroidFileSyncEngine.kt | 9 +- .../nextcloudnative/AndroidFileSyncStaging.kt | 32 +++++ .../AndroidNextcloudServices.kt | 53 +++----- ...roidDynamicDiscoveryCacheRetirementTest.kt | 66 +++++++++ .../AndroidFileSyncStagingRetirementTest.kt | 46 +++++++ tools/kotlin-file-size-baseline.txt | 2 +- .../app/AccountPrivateMemoryCleanup.kt | 8 +- .../app/AccountPrivateMemoryLifecycle.kt | 67 +++++++++ .../app/AccountWorkspaceMemoryCaches.kt | 98 ++++++++++---- .../obiente/nextcloudnative/app/ChatScreen.kt | 5 +- .../app/DynamicNativeMemoryCache.kt | 126 +++++++++++++---- .../app/DynamicNativeMemoryCacheLock.kt | 12 ++ .../app/DynamicSelectionParameters.kt | 15 ++ .../app/GroupwareCalendarScreen.kt | 4 +- .../nextcloudnative/app/NextcloudNativeApp.kt | 54 +++----- .../nextcloudnative/app/NextcloudNotes.kt | 77 +++++------ .../app/NextcloudNotesCache.kt | 78 ++++++++--- .../nextcloudnative/app/NextcloudPlatform.kt | 1 + .../app/AccountPrivateMemoryCleanupTest.kt | 60 ++++++-- .../app/DynamicNativeMemoryCacheTest.kt | 128 ++++++++++++++++++ .../app/NextcloudAccountIdentityTest.kt | 2 +- .../app/NextcloudNotesCacheTest.kt | 113 +++++++++++++++- .../app/DesktopAccountRemoval.kt | 14 +- .../app/DesktopNextcloudServices.kt | 43 +++--- .../app/WindowsCloudFilesRecoveryPaging.kt | 22 +++ .../app/DesktopAccountMemoryRetirementTest.kt | 55 ++++++++ .../JvmSupportAccountStorageCleanupTest.kt | 74 ++++++++++ .../app/DynamicNativeMemoryCacheLock.jvm.kt | 6 + .../app/JvmSupportAccountStorageCleanup.kt | 76 +++++++++++ .../nextcloudnative/app/JvmSupportIntake.kt | 77 +++++++---- .../app/ProgressRequestBody.kt | 31 +++++ 33 files changed, 1304 insertions(+), 260 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCache.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStagingRetirementTest.kt create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRecoveryPaging.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanupTest.kt create mode 100644 ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.jvm.kt create mode 100644 ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanup.kt create mode 100644 ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/ProgressRequestBody.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index 65918d82c..8a5f82b2b 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -22,6 +22,10 @@ internal class AndroidAccountOwnedStateCleanup( private val dynamicApiState: AndroidDynamicApiProcessState = androidDynamicApiProcessState( File(context.applicationContext.cacheDir, "dynamic-api-v1"), ), + private val dynamicDiscoveryCache: AndroidDynamicDiscoveryCache = AndroidDynamicDiscoveryCacheCoordinator.get( + File(context.applicationContext.filesDir, "contracts/discoveries-v1"), + ), + private val removeSupportAccount: suspend (String) -> Unit = {}, ) { private val appContext = context.applicationContext private val fileOffline = AndroidFileOfflineAccountCleanup(appContext) @@ -39,11 +43,15 @@ internal class AndroidAccountOwnedStateCleanup( clearPreviewAccount, listOf( { fenceAndroidDynamicApiStateForRemoval(cacheIdentity, dynamicApiState.coalescer, dynamicApiState.cache) }, + { dynamicDiscoveryCache.retireAccount(session.accountId.storageKey, cacheIdentity) }, + { removeSupportAccount(accountIdentity) }, { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(session) }, { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { removeLegacyAndroidFileSyncStaging(File(appContext.cacheDir, "file-sync-staging")) }, + { removeAndroidFileSyncAccountStaging(File(appContext.cacheDir, "file-sync-staging"), accountIdentity) }, { mediaBackupLedger.removeForAccount(accountIdentity) }, { deckCardDrafts.removeAccount(session.accountId.storageKey, accountIdentity) }, { fileReadCache.clearAccount(accountIdentity) }, @@ -70,11 +78,15 @@ internal class AndroidAccountOwnedStateCleanup( fenceAndroidDynamicApiStateForRemoval(identity, dynamicApiState.coalescer, dynamicApiState.cache) } }, + { dynamicDiscoveryCache.retireAccount(session.accountId.storageKey, previewCacheIdentity) }, + { removeSupportAccount(accountIdentity) }, { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(accountIdentity, session) }, { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { removeLegacyAndroidFileSyncStaging(File(appContext.cacheDir, "file-sync-staging")) }, + { removeAndroidFileSyncAccountStaging(File(appContext.cacheDir, "file-sync-staging"), accountIdentity) }, { mediaBackupLedger.removeForAccount(accountIdentity) }, { deckCardDrafts.removeAccount(session.accountId.storageKey, accountIdentity) }, { fileReadCache.clearAccount(accountIdentity) }, @@ -101,11 +113,15 @@ internal class AndroidAccountOwnedStateCleanup( fenceAndroidDynamicApiStateForRemoval(identity, dynamicApiState.coalescer, dynamicApiState.cache) } }, + { dynamicDiscoveryCache.retireAccount(accountStorageKey, previewCacheIdentity) }, + { removeSupportAccount(accountIdentity) }, { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(accountIdentity) }, { durableUploads.removeForAccount(accountIdentity) }, { retireAndroidFileSyncAccountPairs(appContext, accountIdentity) }, + { removeLegacyAndroidFileSyncStaging(File(appContext.cacheDir, "file-sync-staging")) }, + { removeAndroidFileSyncAccountStaging(File(appContext.cacheDir, "file-sync-staging"), accountIdentity) }, { mediaBackupLedger.removeForAccount(accountIdentity) }, { deckCardDrafts.removeAccount(accountStorageKey, accountIdentity) }, { fileReadCache.clearAccount(accountIdentity) }, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCache.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCache.kt new file mode 100644 index 000000000..7d23b6097 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCache.kt @@ -0,0 +1,94 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.isSafeDynamicDiscoveryCacheAppId +import dev.obiente.nextcloudnative.app.MAX_PERSISTED_DYNAMIC_DISCOVERY_BYTES +import dev.obiente.nextcloudnative.app.DynamicNativeMemoryCacheProducer +import java.io.File +import java.io.FileOutputStream + +/** Serializes persisted dynamic discovery publications with account retirement. */ +internal class AndroidDynamicDiscoveryCache(private val root: File) { + private val lock = Any() + private val retiredAccounts = mutableSetOf() + private val accountIncarnations = mutableMapOf() + + fun load(accountStorageKey: String, cacheAccountId: String, appId: String): String? = synchronized(lock) { + if (accountStorageKey in retiredAccounts) return@synchronized null + val target = cacheFile(cacheAccountId, appId) ?: return@synchronized null + if (!target.isFile || target.length() !in 1..MAX_PERSISTED_DYNAMIC_DISCOVERY_BYTES.toLong()) { + return@synchronized null + } + runCatching(target::readText).getOrNull() + } + + fun save( + accountStorageKey: String, + cacheAccountId: String, + appId: String, + encoded: String, + producer: DynamicNativeMemoryCacheProducer?, + ) = synchronized(lock) { + val current = producer ?: return@synchronized + require(current.accountStorageKey == accountStorageKey) + if ( + accountStorageKey in retiredAccounts || + current.incarnation != (accountIncarnations[accountStorageKey] ?: 0L) + ) { + return@synchronized + } + val target = cacheFile(cacheAccountId, appId) ?: return@synchronized + check(root.mkdirs() || root.isDirectory) { "Could not create the dynamic contract cache." } + val temporary = File(root, "${target.name}.part") + try { + FileOutputStream(temporary).use { output -> + output.write(encoded.encodeToByteArray()) + output.fd.sync() + } + check(temporary.renameTo(target) || runCatching { + temporary.copyTo(target, overwrite = true) + check(temporary.delete() || !temporary.exists()) + }.isSuccess) { "Could not publish the dynamic contract cache." } + } finally { + temporary.delete() + } + } + + fun retireAccount(accountStorageKey: String, cacheAccountId: String?) = synchronized(lock) { + if (retiredAccounts.add(accountStorageKey)) { + accountIncarnations[accountStorageKey] = (accountIncarnations[accountStorageKey] ?: 0L) + 1L + } + if (!root.exists()) return@synchronized + check(root.isDirectory) { "The dynamic contract cache is unavailable." } + val files = root.listFiles() ?: error("Could not inspect the dynamic contract cache.") + files.forEach { file -> check(file.isFile && file.name.matches(ACCOUNT_CACHE_FILE)) { + "The dynamic contract cache contains an unexpected entry." + } } + files.filter { cacheAccountId == null || it.name.startsWith("$cacheAccountId-") } + .forEach { file -> + check(file.delete() || !file.exists()) { "Could not clear the dynamic contract cache." } + } + } + + fun activateAccount(accountStorageKey: String) = synchronized(lock) { + retiredAccounts -= accountStorageKey + } + + private fun cacheFile(cacheAccountId: String, appId: String): File? { + if (!cacheAccountId.matches(ACCOUNT_CACHE_ID) || !appId.isSafeDynamicDiscoveryCacheAppId()) return null + return File(root, "$cacheAccountId-$appId.json") + } + + private companion object { + val ACCOUNT_CACHE_ID = Regex("[0-9a-f]{32}(?:[0-9a-f]{32})?") + val ACCOUNT_CACHE_FILE = Regex("${ACCOUNT_CACHE_ID.pattern}-[A-Za-z0-9._-]+\\.json(?:\\.part)?") + } +} + +internal object AndroidDynamicDiscoveryCacheCoordinator { + private val instances = mutableMapOf() + + fun get(root: File): AndroidDynamicDiscoveryCache = synchronized(this) { + val key = root.absoluteFile.normalize().path + instances.getOrPut(key) { AndroidDynamicDiscoveryCache(root) } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt index 6feb78ac5..06cb6de16 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt @@ -720,6 +720,7 @@ internal class AndroidFileSyncEngine(context: Context) { ): FileSyncExecutionSuccess { val pair = state.pairs.first { it.id == command.pairId } val work = pair.workItems.first { it.id == command.workId } + val accountStagingRoot = androidFileSyncAccountStagingRoot(stagingRoot, pair.accountId) require(isAndroidFileSyncExecutionAllowed(pair.localRootId, command.operation)) { "Detected media folders permit upload operations only." } @@ -733,10 +734,10 @@ internal class AndroidFileSyncEngine(context: Context) { } remote.createDirectory(operation.relativePath, operation.expectedRemoteEtag.takeUnless { replacingType }) } else { - withAndroidFileSyncStagingFile(stagingRoot, "upload") { staged -> + withAndroidFileSyncStagingFile(accountStagingRoot, "upload") { staged -> val exactLocal = local.stageForUpload( operation.relativePath, staged, - androidFileSyncStagingTransferLimit(stagingRoot, source.size), + androidFileSyncStagingTransferLimit(accountStagingRoot, source.size), remote::shouldContinueTransfer, ) val protectedDirectoryReplacement = @@ -779,6 +780,7 @@ internal class AndroidFileSyncEngine(context: Context) { local, remote, contentReadBudget, + accountStagingRoot, ) is FileSyncOperation.NeedsDecision, is FileSyncOperation.Skipped, @@ -792,8 +794,9 @@ internal class AndroidFileSyncEngine(context: Context) { local: AndroidFileSyncLocalTree, remote: AndroidFileSyncRemoteTree, contentReadBudget: AndroidFileSyncContentReadBudget, + accountStagingRoot: File, ): FileSyncExecutionSuccess { - executeAndroidFileSyncKeepBoth(operation, work, local, remote, stagingRoot) + executeAndroidFileSyncKeepBoth(operation, work, local, remote, accountStagingRoot) return FileSyncExecutionSuccess( synchronizedBaselines = listOf( verifiedBaseline( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStaging.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStaging.kt index 79d2b3ba6..48e7b5ac9 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStaging.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStaging.kt @@ -3,6 +3,35 @@ package dev.obiente.nextcloudnative import dev.obiente.nextcloudnative.app.stagedFileTransferLimit import java.io.File +internal fun androidFileSyncAccountStagingRoot(stagingRoot: File, accountId: String): File { + require(accountId.matches(ANDROID_FILE_SYNC_STAGING_ACCOUNT_ID)) + return File(stagingRoot, accountId) +} + +internal fun removeAndroidFileSyncAccountStaging(stagingRoot: File, accountId: String) { + val accountRoot = androidFileSyncAccountStagingRoot(stagingRoot, accountId) + if (!accountRoot.exists()) return + check(accountRoot.isDirectory) { "The account sync staging storage is unavailable." } + accountRoot.listFiles()?.forEach { staged -> + check(staged.isFile && staged.name.matches(ANDROID_FILE_SYNC_STAGING_FILE)) { + "The account sync staging storage contains an unexpected entry." + } + check(staged.delete() || !staged.exists()) { "Could not clear account sync staging storage." } + } ?: error("Could not inspect account sync staging storage.") + check(accountRoot.delete() || !accountRoot.exists()) { "Could not clear account sync staging storage." } +} + +internal fun removeLegacyAndroidFileSyncStaging(stagingRoot: File) { + if (!stagingRoot.exists()) return + check(stagingRoot.isDirectory) { "The sync staging storage is unavailable." } + stagingRoot.listFiles()?.filter(File::isFile)?.forEach { staged -> + check(staged.name.matches(ANDROID_FILE_SYNC_STAGING_FILE)) { + "The sync staging storage contains an unexpected file." + } + check(staged.delete() || !staged.exists()) { "Could not clear legacy sync staging storage." } + } ?: error("Could not inspect sync staging storage.") +} + internal inline fun withAndroidFileSyncStagingFile( stagingRoot: File, prefix: String, @@ -17,6 +46,9 @@ internal inline fun withAndroidFileSyncStagingFile( } } +private val ANDROID_FILE_SYNC_STAGING_ACCOUNT_ID = Regex("[0-9a-f]{32}(?:[0-9a-f]{32})?") +private val ANDROID_FILE_SYNC_STAGING_FILE = Regex("(?:upload|keep-local|keep-remote)-[A-Za-z0-9._-]+\\.tmp") + internal fun androidFileSyncStagingTransferLimit(stagingRoot: File, declaredByteCount: Long?): Long { check(stagingRoot.isDirectory || stagingRoot.mkdirs()) { "Could not create sync staging storage." } return stagedFileTransferLimit( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index edbc6e889..d0794d2e8 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -14,6 +14,7 @@ import android.provider.Settings import android.util.Base64 import android.util.Log import dev.obiente.nextcloudnative.app.AcquiredOpenApiContract +import dev.obiente.nextcloudnative.app.AccountPrivateMemoryLifecycle import dev.obiente.nextcloudnative.app.AcquiredOpenApiContractSourceKind import dev.obiente.nextcloudnative.app.AcquiredContractKind import dev.obiente.nextcloudnative.app.DeckAttachment @@ -75,7 +76,6 @@ import dev.obiente.nextcloudnative.app.FileVersionHistory import dev.obiente.nextcloudnative.app.FileVersionRestoreHttpResult import dev.obiente.nextcloudnative.app.NextcloudFileVersion import dev.obiente.nextcloudnative.app.classifyFileVersionRestoreHttpResponse -import dev.obiente.nextcloudnative.app.isSafeDynamicDiscoveryCacheAppId import dev.obiente.nextcloudnative.app.MAX_PERSISTED_DYNAMIC_MUTATION_BYTES import dev.obiente.nextcloudnative.app.decodePersistedDynamicMutation import dev.obiente.nextcloudnative.app.encodePersistedDynamicMutation @@ -416,7 +416,9 @@ internal class AndroidNextcloudServices( catalogCache = FileAppStoreCatalogCache(File(appContext.filesDir, "contracts/catalogs")), verifiedContractCache = FileVerifiedContractCache(File(appContext.filesDir, "contracts/verified")), ) - private val dynamicDiscoveryCacheDirectory = File(appContext.filesDir, "contracts/discoveries-v1") + private val dynamicDiscoveryCache = AndroidDynamicDiscoveryCacheCoordinator.get( + File(appContext.filesDir, "contracts/discoveries-v1"), + ) private val pendingDynamicMutationDirectory = File(appContext.filesDir, "mutations/dynamic-v1") private val fileOfflineRepository = AndroidFileOfflineRepository(appContext) private val fileReadCache = AndroidFileReadCache(File(appContext.cacheDir, "files-read-v1")) @@ -426,11 +428,12 @@ internal class AndroidNextcloudServices( private val dynamicApiState = androidDynamicApiProcessState(File(appContext.cacheDir, "dynamic-api-v1")) private val dynamicApiReadCache = dynamicApiState.cache private val dynamicApiRequestCoalescer = dynamicApiState.coalescer - private val accountOwnedStateCleanup = + private val accountOwnedStateCleanup by lazy { AndroidAccountOwnedStateCleanup( appContext, fileReadCache, virtualFileCache, nativeMediaPreviewCache::clearAccount, - dynamicApiState, + dynamicApiState, dynamicDiscoveryCache, supportIntake::removeAccount, ) + } private val nativeMediaPreviewDecodeMutex = Mutex() private val mediaTimelineCarryoverStore = MediaTimelineDavCarryoverStore() private val memoriesTimeline = MemoriesPreferredTimelineReadService { session, request -> @@ -805,42 +808,26 @@ internal class AndroidNextcloudServices( session: NextcloudSession, appId: String, ): DynamicDescriptorDiscovery? = withContext(Dispatchers.IO) { - val target = dynamicDiscoveryCacheFile(session, appId) ?: return@withContext null - if (!target.isFile || target.length() !in 1..MAX_PERSISTED_DYNAMIC_DISCOVERY_BYTES.toLong()) { - return@withContext null - } - runCatching { target.readText() } - .getOrNull() + dynamicDiscoveryCache.load( + session.accountId.storageKey, + NextcloudDocumentIds.cacheAccountId(session), + appId, + ) ?.let { encoded -> decodePersistedDynamicDiscovery(encoded, appId, session.serverUrl) } } override suspend fun saveCachedDynamicAppDiscovery( session: NextcloudSession, discovery: DynamicDescriptorDiscovery, + producer: dev.obiente.nextcloudnative.app.DynamicNativeMemoryCacheProducer?, ) = withContext(Dispatchers.IO) { val encoded = encodePersistedDynamicDiscovery(discovery) ?: return@withContext - val target = dynamicDiscoveryCacheFile(session, discovery.descriptor.app.id) ?: return@withContext - check(dynamicDiscoveryCacheDirectory.mkdirs() || dynamicDiscoveryCacheDirectory.isDirectory) { - "Could not create the dynamic contract cache." - } - val temporary = File(dynamicDiscoveryCacheDirectory, "${target.name}.part") - FileOutputStream(temporary).use { output -> - output.write(encoded.encodeToByteArray()) - output.fd.sync() - } - check(temporary.renameTo(target) || runCatching { - temporary.copyTo(target, overwrite = true) - temporary.delete() - }.isSuccess) { - "Could not publish the dynamic contract cache." - } - } - - private fun dynamicDiscoveryCacheFile(session: NextcloudSession, appId: String): File? { - if (!appId.isSafeDynamicDiscoveryCacheAppId()) return null - return File( - dynamicDiscoveryCacheDirectory, - "${NextcloudDocumentIds.cacheAccountId(session)}-$appId.json", + dynamicDiscoveryCache.save( + session.accountId.storageKey, + NextcloudDocumentIds.cacheAccountId(session), + discovery.descriptor.app.id, + encoded, + producer, ) } @@ -947,6 +934,8 @@ internal class AndroidNextcloudServices( persisted, ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX, ANDROID_ACCOUNT_OPERATION_GUARD, { accountCredentials.loadSession(persisted.accountId) }, dynamicApiRequestCoalescer::activateAccount, ) + AccountPrivateMemoryLifecycle.activateAccount(persisted.accountId.storageKey) + dynamicDiscoveryCache.activateAccount(persisted.accountId.storageKey) return persisted } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt new file mode 100644 index 000000000..8282725f1 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDynamicDiscoveryCacheRetirementTest.kt @@ -0,0 +1,66 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.DynamicNativeMemoryCacheProducer +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AndroidDynamicDiscoveryCacheRetirementTest { + @Test + fun `retirement deletes one account prefix and rejects stale publication until activation`() { + val root = Files.createTempDirectory("dynamic-discovery").toFile() + val cache = AndroidDynamicDiscoveryCache(root) + val removedStorageKey = "a".repeat(64) + val removedCacheId = "1".repeat(64) + val retainedStorageKey = "b".repeat(64) + val retainedCacheId = "2".repeat(64) + val removedProducer = DynamicNativeMemoryCacheProducer(removedStorageKey, 0L) + val retainedProducer = DynamicNativeMemoryCacheProducer(retainedStorageKey, 0L) + try { + cache.save(removedStorageKey, removedCacheId, "deck", "removed", removedProducer) + cache.save(retainedStorageKey, retainedCacheId, "deck", "retained", retainedProducer) + + cache.retireAccount(removedStorageKey, removedCacheId) + cache.activateAccount(removedStorageKey) + cache.save(removedStorageKey, removedCacheId, "deck", "stale", removedProducer) + + assertNull(cache.load(removedStorageKey, removedCacheId, "deck")) + assertEquals("retained", cache.load(retainedStorageKey, retainedCacheId, "deck")) + assertFalse(root.resolve("$removedCacheId-deck.json").exists()) + assertTrue(root.resolve("$retainedCacheId-deck.json").isFile) + + cache.save( + removedStorageKey, removedCacheId, "deck", "current", + DynamicNativeMemoryCacheProducer(removedStorageKey, 1L), + ) + assertEquals("current", cache.load(removedStorageKey, removedCacheId, "deck")) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `legacy cleanup without a persisted cache identity removes all discovery metadata`() { + val root = Files.createTempDirectory("dynamic-discovery-legacy").toFile() + val cache = AndroidDynamicDiscoveryCache(root) + try { + cache.save( + "a".repeat(64), "1".repeat(64), "deck", "first", + DynamicNativeMemoryCacheProducer("a".repeat(64), 0L), + ) + cache.save( + "b".repeat(64), "2".repeat(64), "talk", "second", + DynamicNativeMemoryCacheProducer("b".repeat(64), 0L), + ) + + cache.retireAccount("a".repeat(64), null) + + assertTrue(root.listFiles().orEmpty().isEmpty()) + } finally { + root.deleteRecursively() + } + } +} diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStagingRetirementTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStagingRetirementTest.kt new file mode 100644 index 000000000..7cb137398 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncStagingRetirementTest.kt @@ -0,0 +1,46 @@ +package dev.obiente.nextcloudnative + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AndroidFileSyncStagingRetirementTest { + @Test + fun `account retirement removes only its crashed staging files`() { + val root = Files.createTempDirectory("sync-staging").toFile() + val removed = "a".repeat(64) + val retained = "b".repeat(64) + try { + val removedRoot = androidFileSyncAccountStagingRoot(root, removed).apply { mkdirs() } + val retainedRoot = androidFileSyncAccountStagingRoot(root, retained).apply { mkdirs() } + val removedStage = java.io.File(removedRoot, "upload-crashed.tmp").apply { writeText("private") } + val retainedStage = java.io.File(retainedRoot, "keep-local-running.tmp").apply { writeText("other") } + + removeAndroidFileSyncAccountStaging(root, removed) + + assertFalse(removedStage.exists()) + assertFalse(removedRoot.exists()) + assertTrue(retainedStage.isFile) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `legacy crash files are reclaimed without entering account directories`() { + val root = Files.createTempDirectory("sync-staging-legacy").toFile() + try { + val legacy = java.io.File(root, "keep-remote-crashed.tmp").apply { writeText("private") } + val retainedRoot = androidFileSyncAccountStagingRoot(root, "c".repeat(64)).apply { mkdirs() } + val retained = java.io.File(retainedRoot, "upload-running.tmp").apply { writeText("other") } + + removeLegacyAndroidFileSyncStaging(root) + + assertFalse(legacy.exists()) + assertTrue(retained.isFile) + } finally { + root.deleteRecursively() + } + } +} diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index c0f73bf8c..768d3b7c0 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -53,7 +53,7 @@ ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.k ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncEngine.kt|884 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncRemoteTree.kt|883 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileSyncStore.kt|808 -ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6241 +ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt|6236 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopVirtualRangeCache.kt|2759 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt|1697 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesJna.kt|1085 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 7a1c762aa..7f608561a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt @@ -2,11 +2,13 @@ package dev.obiente.nextcloudnative.app /** Removes process-local private state after credential removal has committed. */ object AccountPrivateMemoryCleanup { - fun removeAccount(accountStorageKey: String) { + fun removeAccount(accountStorageKey: String) = AccountPrivateMemoryLifecycle.retireAccount(accountStorageKey) + + internal fun purgeRetiredAccount(accountStorageKey: String) { require(accountStorageKey.length == 64 && accountStorageKey.all { it in '0'..'9' || it in 'a'..'f' }) PreviewMemoryCache.removeAccount(accountStorageKey) - sharedNextcloudNotesCache.removeAccount(accountStorageKey) - sharedDynamicNativeMemoryCache.removeAccount(accountStorageKey) + sharedNextcloudNotesCache.purgeRetiredAccount(accountStorageKey) + sharedDynamicNativeMemoryCache.retireAccount(accountStorageKey) sharedDashboardStatusMemoryCache.removeAccount(accountStorageKey) ContactsWorkspaceMemoryCache.removeAccount(accountStorageKey) DeckWorkspaceMemoryCache.removeAccount(accountStorageKey) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt new file mode 100644 index 000000000..9cccb8d17 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt @@ -0,0 +1,67 @@ +package dev.obiente.nextcloudnative.app + +/** One account incarnation allowed to publish into process-local private-memory stores. */ +internal class AccountPrivateMemoryProducer internal constructor( + val accountStorageKey: String, + internal val incarnation: Long, +) + +/** Serializes private-memory access with retirement and rejects stale async producers. */ +internal class AccountPrivateMemoryGate { + private val lock = DynamicNativeMemoryCacheLock() + private val closedAccounts = mutableSetOf() + private val accountIncarnations = mutableMapOf() + + fun producer(accountStorageKey: String): AccountPrivateMemoryProducer? = lock.withLock { + if (accountStorageKey in closedAccounts) return@withLock null + AccountPrivateMemoryProducer(accountStorageKey, accountIncarnations[accountStorageKey] ?: 0L) + } + + fun read(accountStorageKey: String, unavailable: T, action: () -> T): T = lock.withLock { + if (accountStorageKey in closedAccounts) unavailable else action() + } + + fun mutate( + accountStorageKey: String, + producer: AccountPrivateMemoryProducer?, + action: () -> Unit, + ): Boolean = lock.withLock { + val current = producer ?: return@withLock false + require(current.accountStorageKey == accountStorageKey) { + "The private-memory producer belongs to another account." + } + if (!accepts(current)) return@withLock false + action() + true + } + + fun retireAccount(accountStorageKey: String, purge: () -> Unit) = lock.withLock { + if (closedAccounts.add(accountStorageKey)) { + accountIncarnations[accountStorageKey] = (accountIncarnations[accountStorageKey] ?: 0L) + 1L + } + purge() + } + + fun activateAccount(accountStorageKey: String, prepare: () -> Unit = {}) = lock.withLock { + prepare() + closedAccounts.remove(accountStorageKey) + } + + private fun accepts(producer: AccountPrivateMemoryProducer): Boolean = + producer.accountStorageKey !in closedAccounts && + (accountIncarnations[producer.accountStorageKey] ?: 0L) == producer.incarnation +} + +internal val sharedAccountPrivateMemoryGate = AccountPrivateMemoryGate() + +/** Cross-platform lifecycle boundary for account-private process memory. */ +object AccountPrivateMemoryLifecycle { + fun retireAccount(accountStorageKey: String) = sharedAccountPrivateMemoryGate.retireAccount(accountStorageKey) { + AccountPrivateMemoryCleanup.purgeRetiredAccount(accountStorageKey) + } + + fun activateAccount(accountStorageKey: String) = sharedAccountPrivateMemoryGate.activateAccount( + accountStorageKey, + prepare = { sharedDynamicNativeMemoryCache.activateAccount(accountStorageKey) }, + ) +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt index 251b7fa65..46ad04621 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt @@ -41,26 +41,37 @@ internal sealed interface CalendarLoadState { } internal object CalendarWorkspaceMemoryCache { + private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf, CalendarLoadState.Ready>() + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + fun get( session: NextcloudSession, userId: String, month: CalendarMonth, timeWindow: GroupwareDavTimeWindow, - ): CalendarLoadState.Ready? { + ): CalendarLoadState.Ready? = gate.read(session.accountId.storageKey, null) { val key = key(session, userId, month, timeWindow) - return entries.remove(key)?.also { entries[key] = it } + entries.remove(key)?.also { entries[key] = it } } - fun store(session: NextcloudSession, userId: String, value: CalendarLoadState.Ready) { - val key = key(session, userId, value.month, value.timeWindow) - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_CALENDAR_MONTHS) entries.remove(entries.keys.first()) + fun store( + session: NextcloudSession, + userId: String, + value: CalendarLoadState.Ready, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + val key = key(session, userId, value.month, value.timeWindow) + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_CALENDAR_MONTHS) entries.remove(entries.keys.first()) + } } - fun removeAccount(accountStorageKey: String) { + internal fun purgeRetiredAccount(accountStorageKey: String) { entries.keys.removeAll { (account) -> account.storageKey == accountStorageKey } } @@ -104,43 +115,74 @@ internal object UserStatusWorkspaceMemoryCache { } internal object ActivityWorkspaceMemoryCache { + private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf, ActivityTimelineState>() - fun get(session: NextcloudSession, filterId: String): ActivityTimelineState? { - val key = session.accountId to filterId - return entries.remove(key)?.also { entries[key] = it } - } + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) - fun store(session: NextcloudSession, filterId: String, value: ActivityTimelineState) { - val key = session.accountId to filterId - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_ACTIVITY_ACCOUNTS) entries.remove(entries.keys.first()) + fun get(session: NextcloudSession, filterId: String): ActivityTimelineState? = + gate.read(session.accountId.storageKey, null) { + val key = session.accountId to filterId + entries.remove(key)?.also { entries[key] = it } + } + + fun store( + session: NextcloudSession, + filterId: String, + value: ActivityTimelineState, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + val key = session.accountId to filterId + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_ACTIVITY_ACCOUNTS) entries.remove(entries.keys.first()) + } } - fun removeAccount(accountStorageKey: String) { + internal fun purgeRetiredAccount(accountStorageKey: String) { entries.keys.removeAll { (account) -> account.storageKey == accountStorageKey } } } internal object TalkWorkspaceMemoryCache { + private val gate = sharedAccountPrivateMemoryGate private val rooms = linkedMapOf>() private val messages = linkedMapOf, List>() - fun rooms(session: NextcloudSession): List? = touch(rooms, session.accountId) + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun rooms(session: NextcloudSession): List? = gate.read(session.accountId.storageKey, null) { + touch(rooms, session.accountId) + } - fun storeRooms(session: NextcloudSession, value: List) { - store(rooms, session.accountId, value, MAXIMUM_RETAINED_TALK_ACCOUNTS) + fun storeRooms( + session: NextcloudSession, + value: List, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + store(rooms, session.accountId, value, MAXIMUM_RETAINED_TALK_ACCOUNTS) + } } fun messages(session: NextcloudSession, roomToken: String): List? = - touch(messages, session.accountId to roomToken) + gate.read(session.accountId.storageKey, null) { touch(messages, session.accountId to roomToken) } - fun storeMessages(session: NextcloudSession, roomToken: String, value: List) { - store(messages, session.accountId to roomToken, value, MAXIMUM_RETAINED_TALK_ROOMS) + fun storeMessages( + session: NextcloudSession, + roomToken: String, + value: List, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + store(messages, session.accountId to roomToken, value, MAXIMUM_RETAINED_TALK_ROOMS) + } } - fun removeAccount(accountStorageKey: String) { + internal fun purgeRetiredAccount(accountStorageKey: String) { rooms.keys.removeAll { account -> account.storageKey == accountStorageKey } messages.keys.removeAll { (account) -> account.storageKey == accountStorageKey } } @@ -156,15 +198,15 @@ internal object TalkWorkspaceMemoryCache { } internal fun removeCalendarWorkspaceMemory(accountStorageKey: String) = - CalendarWorkspaceMemoryCache.removeAccount(accountStorageKey) + CalendarWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) internal fun removeUserStatusWorkspaceMemory(accountStorageKey: String) = UserStatusWorkspaceMemoryCache.removeAccount(accountStorageKey) internal fun removeNextcloudNativeWorkspaceMemory(accountStorageKey: String) { PhotoTimelineUiStateRepository.removeAccount(accountStorageKey) - ActivityWorkspaceMemoryCache.removeAccount(accountStorageKey) - TalkWorkspaceMemoryCache.removeAccount(accountStorageKey) + ActivityWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) + TalkWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) } private const val MAXIMUM_RETAINED_CALENDAR_MONTHS = 24 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt index a320af6f6..2db11ea45 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt @@ -72,9 +72,10 @@ internal fun ChatScreen( val historyHeaderVisible = (hasMoreHistory && olderCursor != null) || historyError != null suspend fun refresh() { + val cacheProducer = TalkWorkspaceMemoryCache.producer(session) val page = services.listTalkMessagePage(session, room.token) messages = page.messages - TalkWorkspaceMemoryCache.storeMessages(session, room.token, page.messages) + TalkWorkspaceMemoryCache.storeMessages(session, room.token, page.messages, cacheProducer) olderCursor = page.olderCursor hasMoreHistory = page.hasMoreHistory } @@ -154,6 +155,7 @@ internal fun ChatScreen( loadingEarlier = true historyError = null scope.launch { + val cacheProducer = TalkWorkspaceMemoryCache.producer(session) runCatchingUnlessCancelled { services.listTalkMessagePage( session = session, @@ -169,6 +171,7 @@ internal fun ChatScreen( session, room.token, messages.orEmpty(), + cacheProducer, ) olderCursor = page.olderCursor hasMoreHistory = page.hasMoreHistory diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt index 24d059958..6a4bbb87a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt @@ -1,6 +1,8 @@ package dev.obiente.nextcloudnative.app import dev.obiente.nextcloudnative.nativeui.runtime.NativeRecord +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.sameDynamicResourceAs import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -29,37 +31,51 @@ internal class DynamicNativeMemoryCache( val storedAt: TimeMark, ) - private val discoveries = linkedMapOf() + private val lock = DynamicNativeMemoryCacheLock() private val discoveryMetadata = linkedMapOf() private val discoveryFailures = linkedMapOf() private val screens = linkedMapOf() + private val closedAccounts = mutableSetOf() + private val accountIncarnations = mutableMapOf() + + fun producer(session: NextcloudSession): DynamicNativeMemoryCacheProducer? = + producer(session.dynamicAccountKey()) + + fun producer(key: DynamicScreenCacheKey): DynamicNativeMemoryCacheProducer? = producer(key.account) fun discovery( session: NextcloudSession, appId: String, freshOnly: Boolean = false, allowStaleDiscovery: Boolean = true, - ): DynamicDescriptorDiscovery? { + ): DynamicDescriptorDiscovery? = lock.withLock { val key = DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId) - val entry = discoveryMetadata.touch(key) ?: return null - if (!allowStaleDiscovery && freshOnly && entry.storedAt.elapsedNow() > discoveryFreshFor) return null - if (freshOnly && entry.storedAt.elapsedNow() > discoveryFreshFor) return null - return entry.discovery + if (key.account in closedAccounts) return@withLock null + val entry = discoveryMetadata.touch(key) ?: return@withLock null + if (!allowStaleDiscovery && freshOnly && entry.storedAt.elapsedNow() > discoveryFreshFor) { + return@withLock null + } + if (freshOnly && entry.storedAt.elapsedNow() > discoveryFreshFor) return@withLock null + entry.discovery } fun isDiscoveryFresh( session: NextcloudSession, appId: String, - ): Boolean = discoveryMetadata[DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId)] - ?.takeIf { entry -> + ): Boolean = lock.withLock { + val key = DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId) + if (key.account in closedAccounts) return@withLock false + discoveryMetadata[key]?.let { entry -> entry.discovery.versionStatus == DynamicContractVersionStatus.VerifiedCurrent && entry.storedAt.elapsedNow() <= discoveryFreshFor - } != null + } == true + } - fun shouldRetryDiscovery(session: NextcloudSession, appId: String): Boolean { + fun shouldRetryDiscovery(session: NextcloudSession, appId: String): Boolean = lock.withLock { val key = DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId) - val failure = discoveryFailures[key] ?: return true - return if (failure.elapsedNow() >= discoveryFailureCooldown) { + if (key.account in closedAccounts) return@withLock false + val failure = discoveryFailures[key] ?: return@withLock true + if (failure.elapsedNow() >= discoveryFailureCooldown) { discoveryFailures.remove(key) true } else { @@ -67,52 +83,86 @@ internal class DynamicNativeMemoryCache( } } - fun storeDiscovery(session: NextcloudSession, appId: String, discovery: DynamicDescriptorDiscovery) { + fun storeDiscovery( + session: NextcloudSession, + appId: String, + discovery: DynamicDescriptorDiscovery, + producer: DynamicNativeMemoryCacheProducer?, + ) = lock.withLock { val key = DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId) + val currentProducer = producer ?: return@withLock + require(currentProducer.accountStorageKey == key.account) { "The dynamic cache producer belongs to another account." } + if (!accepts(currentProducer)) return@withLock discoveryMetadata.remove(key) discoveryMetadata[key] = DiscoveryEntry(discovery = discovery, storedAt = timeSource.markNow()) - discoveries[key] = discovery while (discoveryMetadata.size > MAXIMUM_DISCOVERIES) discoveryMetadata.remove(discoveryMetadata.keys.first()) - while (discoveries.size > MAXIMUM_DISCOVERIES) discoveries.remove(discoveries.keys.first()) discoveryFailures.remove(key) } - fun screen(key: DynamicScreenCacheKey, freshOnly: Boolean = false): DynamicScreenSnapshot? { - if (!key.cacheable) return null - val entry = screens.touch(key) ?: return null - if (freshOnly && entry.storedAt.elapsedNow() > freshFor) return null - return entry.snapshot + fun screen(key: DynamicScreenCacheKey, freshOnly: Boolean = false): DynamicScreenSnapshot? = lock.withLock { + if (!key.cacheable || key.account in closedAccounts) return@withLock null + val entry = screens.touch(key) ?: return@withLock null + if (freshOnly && entry.storedAt.elapsedNow() > freshFor) return@withLock null + entry.snapshot } - fun markDiscoveryFailure(session: NextcloudSession, appId: String) { + fun markDiscoveryFailure( + session: NextcloudSession, + appId: String, + producer: DynamicNativeMemoryCacheProducer?, + ) = lock.withLock { val key = DynamicDiscoveryCacheKey(session.dynamicAccountKey(), appId) + val currentProducer = producer ?: return@withLock + require(currentProducer.accountStorageKey == key.account) { "The dynamic cache producer belongs to another account." } + if (!accepts(currentProducer)) return@withLock discoveryFailures.remove(key) discoveryFailures[key] = timeSource.markNow() - while (discoveries.size > MAXIMUM_DISCOVERIES) discoveries.remove(discoveries.keys.first()) while (discoveryFailures.size > MAXIMUM_DISCOVERIES) discoveryFailures.remove(discoveryFailures.keys.first()) } - fun storeScreen(key: DynamicScreenCacheKey, snapshot: DynamicScreenSnapshot) { - if (!key.cacheable) return + fun storeScreen( + key: DynamicScreenCacheKey, + snapshot: DynamicScreenSnapshot, + producer: DynamicNativeMemoryCacheProducer?, + ) = lock.withLock { + val currentProducer = producer ?: return@withLock + require(currentProducer.accountStorageKey == key.account) { "The dynamic cache producer belongs to another account." } + if (!key.cacheable || !accepts(currentProducer)) return@withLock screens.remove(key) screens[key] = ScreenEntry(snapshot.bounded(), timeSource.markNow()) while (screens.size > maximumScreens) screens.remove(screens.keys.first()) } - fun invalidateScreens(session: NextcloudSession, appId: String) { + fun invalidateScreens(session: NextcloudSession, appId: String) = lock.withLock { val account = session.dynamicAccountKey() screens.keys.removeAll { key -> key.account == account && key.appId == appId } } - fun removeAccount(accountStorageKey: String) { - discoveries.keys.removeAll { key -> key.account == accountStorageKey } + /** Purges process-local state and rejects stale completions until exact credential activation. */ + fun retireAccount(accountStorageKey: String) = lock.withLock { + if (closedAccounts.add(accountStorageKey)) { + accountIncarnations[accountStorageKey] = (accountIncarnations[accountStorageKey] ?: 0L) + 1L + } discoveryMetadata.keys.removeAll { key -> key.account == accountStorageKey } discoveryFailures.keys.removeAll { key -> key.account == accountStorageKey } screens.keys.removeAll { key -> key.account == accountStorageKey } } + /** Reopens an empty account cache only after the platform has persisted its exact credentials. */ + fun activateAccount(accountStorageKey: String) = lock.withLock { + closedAccounts.remove(accountStorageKey) + } + + private fun producer(accountStorageKey: String): DynamicNativeMemoryCacheProducer? = lock.withLock { + if (accountStorageKey in closedAccounts) return@withLock null + DynamicNativeMemoryCacheProducer(accountStorageKey, accountIncarnations[accountStorageKey] ?: 0L) + } + + private fun accepts(producer: DynamicNativeMemoryCacheProducer): Boolean = + producer.accountStorageKey !in closedAccounts && + (accountIncarnations[producer.accountStorageKey] ?: 0L) == producer.incarnation private fun DynamicScreenSnapshot.bounded(): DynamicScreenSnapshot { val boundedRelated = relatedRecords.entries .take(MAXIMUM_RELATED_RESOURCES) @@ -136,6 +186,11 @@ internal class DynamicNativeMemoryCache( } } +data class DynamicNativeMemoryCacheProducer( + val accountStorageKey: String, + val incarnation: Long, +) + internal data class DynamicDiscoveryCacheKey( val account: String, val appId: String, @@ -289,6 +344,16 @@ internal fun NativeRecord.dynamicPaginationRecordIdentity(resourceId: String): S } } +internal fun shouldShowDynamicRecordFallbackDetail( + viewResourceId: String, + viewComponent: NativeComponent, + selectedRecord: NativeRecord?, + selectedRecordResourceId: String?, +): Boolean = selectedRecord != null && + viewComponent != NativeComponent.detail && + viewComponent != NativeComponent.form && + selectedRecordResourceId?.sameDynamicResourceAs(viewResourceId) == true + private val DYNAMIC_SCREEN_SCOPE_RELATIONS = setOf( "accountid", "mailaccountid", @@ -306,3 +371,10 @@ private val DYNAMIC_SCREEN_SCOPE_RELATIONS = setOf( private fun NextcloudSession.dynamicAccountKey(): String = accountId.storageKey internal val sharedDynamicNativeMemoryCache = DynamicNativeMemoryCache() + +/** Compatibility entry point for callers that previously retired only the dynamic UI cache. */ +object DynamicNativeMemoryAccountLifecycle { + fun retireAccount(accountStorageKey: String) = AccountPrivateMemoryLifecycle.retireAccount(accountStorageKey) + + fun activateAccount(accountStorageKey: String) = AccountPrivateMemoryLifecycle.activateAccount(accountStorageKey) +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt new file mode 100644 index 000000000..cb5fe8470 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt @@ -0,0 +1,12 @@ +package dev.obiente.nextcloudnative.app + +/** Platform lock for synchronous cache access from Compose and background JVM owners. */ +internal class DynamicNativeMemoryCacheLock { + private val monitor = dynamicNativeMemoryCacheMonitor() + + fun withLock(action: () -> T): T = withDynamicNativeMemoryCacheLock(monitor, action) +} + +internal expect fun dynamicNativeMemoryCacheMonitor(): Any + +internal expect fun withDynamicNativeMemoryCacheLock(monitor: Any, action: () -> T): T diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt new file mode 100644 index 000000000..7c60f7603 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt @@ -0,0 +1,15 @@ +package dev.obiente.nextcloudnative.app + +/** + * Selecting a record without a destination keeps the current collection on screen. Its path + * bindings still belong to that collection and must survive the selection. Otherwise a child's + * generic `id` can replace the parent's generic `id` when the collection reloads. + */ +internal fun resolveDynamicRecordSelectionParameters( + currentViewId: String, + nextViewId: String, + currentParameters: Map, + explicitTargetParameters: Map?, + fallbackTargetParameters: Map, +): Map = explicitTargetParameters + ?: if (nextViewId == currentViewId) currentParameters else fallbackTargetParameters diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt index 5079de7aa..59f84b9c6 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt @@ -272,8 +272,8 @@ fun NativeGroupwareCalendarScreen( selectedDate = today selectedEventId = null } - suspend fun reload() { + val cacheProducer = CalendarWorkspaceMemoryCache.producer(session) val reconciliationConfirmed = mutationPostcondition?.let { postcondition -> runCatchingPreservingCancellation { val response = services.executeGroupwareDav( @@ -318,7 +318,7 @@ fun NativeGroupwareCalendarScreen( CalendarLoadState.Ready(month, queryWindow, calendars, events) }.onSuccess { loaded -> state = loaded - CalendarWorkspaceMemoryCache.store(session, userId, loaded) + CalendarWorkspaceMemoryCache.store(session, userId, loaded, cacheProducer) if (mutationPostcondition != null) { if (reconciliationConfirmed) { if (!clearMutationRecovery()) return@onSuccess 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 d637d7f73..1670d485a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -2432,7 +2432,6 @@ private fun AuthenticatedApp( val cached = cachedAppDiscoveries[current.app.id] if (cached == null || candidate.acquisition != DynamicDescriptorAcquisition.MetadataFallback) { cachedAppDiscoveries[current.app.id] = candidate - sharedDynamicNativeMemoryCache.storeDiscovery(session, current.app.id, candidate) } val liveServerVersion = serverInfo?.version val active = screen as? Screen.AppInfo @@ -2864,7 +2863,6 @@ private fun AppInfoScreen( discoveryAttempt += 1 onRetryServerInfo() } - LaunchedEffect( app.id, session, @@ -2873,6 +2871,7 @@ private fun AppInfoScreen( serverVersionVerified, discoveryAttempt, ) { + val cacheProducer = sharedDynamicNativeMemoryCache.producer(session) discoveryProgress = DynamicDescriptorDiscoveryProgress( DynamicDescriptorDiscoveryPhase.CachedWorkspace, "Checking the saved workspace", @@ -2888,7 +2887,7 @@ private fun AppInfoScreen( if (retainedDiscovery != null) { discovery = retainedDiscovery onDiscovery(retainedDiscovery) - sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, retainedDiscovery) + sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, retainedDiscovery, cacheProducer) } val shouldRetry = discoveryAttempt > 0 || sharedDynamicNativeMemoryCache.shouldRetryDiscovery(session, app.id) || !sharedDynamicNativeMemoryCache.isDiscoveryFresh(session, app.id) @@ -2917,9 +2916,9 @@ private fun AppInfoScreen( val retainedCachedContract = resolvedDiscovery !== candidate onDiscovery(resolvedDiscovery) discovery = resolvedDiscovery - sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, resolvedDiscovery) + sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, resolvedDiscovery, cacheProducer) runCatching { - services.saveCachedDynamicAppDiscovery(session, resolvedDiscovery) + services.saveCachedDynamicAppDiscovery(session, resolvedDiscovery, cacheProducer) } if (retainedCachedContract) { discoveryError = @@ -2935,9 +2934,9 @@ private fun AppInfoScreen( if (retainedReadOnly != null) { onDiscovery(retainedReadOnly) discovery = retainedReadOnly - sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, retainedReadOnly) + sharedDynamicNativeMemoryCache.storeDiscovery(session, app.id, retainedReadOnly, cacheProducer) } - sharedDynamicNativeMemoryCache.markDiscoveryFailure(session, app.id) + sharedDynamicNativeMemoryCache.markDiscoveryFailure(session, app.id, cacheProducer) discoveryError = if (retainedDiscovery == null) { failure.message ?: "Could not discover this app's native API." } else { @@ -2946,7 +2945,6 @@ private fun AppInfoScreen( } } } - Column(modifier = Modifier.fillMaxSize().safeDrawingPadding()) { val resolved = discovery // The discovered screen owns its own contextual header. Keeping the @@ -3386,6 +3384,7 @@ private fun DynamicDiscoveredAppScreen( formRelationLoadAttempt, loadAttempt, ) { + val cacheProducer = sharedDynamicNativeMemoryCache.producer(session) val view = selectedView ?: return@LaunchedEffect val retainedMailPagination = retainedMailPaginationSnapshot( hasMailWorkspaceSemantics = descriptor.hasNativeMailWorkspaceSemantics(), @@ -3523,6 +3522,7 @@ private fun DynamicDiscoveredAppScreen( sharedDynamicNativeMemoryCache.storeScreen( cacheKey, DynamicScreenSnapshot(records, updatedRecords), + cacheProducer, ) } }.onFailure { failure -> @@ -3617,6 +3617,7 @@ private fun DynamicDiscoveredAppScreen( sharedDynamicNativeMemoryCache.storeScreen( cacheKey, DynamicScreenSnapshot(rows, updatedRecords), + cacheProducer, ) } }.onFailure { failure -> @@ -3640,6 +3641,7 @@ private fun DynamicDiscoveredAppScreen( sharedDynamicNativeMemoryCache.storeScreen( cacheKey, DynamicScreenSnapshot(records, updatedRecords), + cacheProducer, ) return@LaunchedEffect } @@ -3705,6 +3707,7 @@ private fun DynamicDiscoveredAppScreen( relatedRecords = updatedRecords, pagination = nextPagination?.toCheckpoint(), ), + cacheProducer, ) } }.onFailure { failure -> @@ -4690,6 +4693,7 @@ private fun DynamicDiscoveredAppScreen( pathParameters = pagingPathParameters, cacheable = pagingCacheable, ) + val cacheProducer = sharedDynamicNativeMemoryCache.producer(pagingRequestIdentity.cacheKey) val pagingRuntimeValues = pagingRecord?.toDynamicRuntimeValues().orEmpty().toMap() val values = pagingRuntimeValues + pagingPathParameters + @@ -4785,6 +4789,7 @@ private fun DynamicDiscoveredAppScreen( relatedRecords = updatedRecords, pagination = nextPagination?.toCheckpoint(), ), + cacheProducer, ) loadingMore = false }.onFailure { failure -> @@ -7066,30 +7071,6 @@ internal fun inheritDynamicParentParameters( !key.equals("id", ignoreCase = true) && key.endsWith("Id", ignoreCase = true) } -/** - * Selecting a record without a destination keeps the current collection on screen. Its path - * bindings still belong to that collection and must survive the selection. Otherwise a child's - * generic `id` can replace the parent's generic `id` when the collection reloads. - */ -internal fun resolveDynamicRecordSelectionParameters( - currentViewId: String, - nextViewId: String, - currentParameters: Map, - explicitTargetParameters: Map?, - fallbackTargetParameters: Map, -): Map = explicitTargetParameters - ?: if (nextViewId == currentViewId) currentParameters else fallbackTargetParameters - -internal fun shouldShowDynamicRecordFallbackDetail( - viewResourceId: String, - viewComponent: NativeComponent, - selectedRecord: NativeRecord?, - selectedRecordResourceId: String?, -): Boolean = selectedRecord != null && - viewComponent != NativeComponent.detail && - viewComponent != NativeComponent.form && - selectedRecordResourceId?.sameDynamicResourceAs(viewResourceId) == true - @Composable private fun ActivityScreen( services: NextcloudPlatformServices, @@ -7174,6 +7155,7 @@ private fun ActivityScreen( LaunchedEffect(session, activityInstalled, selectedServerFilterId, loadAttempt) { if (!activityInstalled) return@LaunchedEffect val filterId = selectedServerFilterId + val cacheProducer = ActivityWorkspaceMemoryCache.producer(session) timeline = timeline.beginActivityRefresh() runCatching { loadNextcloudActivityPage(filterId = filterId) { request -> @@ -7183,7 +7165,7 @@ private fun ActivityScreen( .onSuccess { page -> if (selectedServerFilterId != filterId) return@onSuccess timeline = timeline.applyActivityRefresh(page) - ActivityWorkspaceMemoryCache.store(session, filterId, timeline) + ActivityWorkspaceMemoryCache.store(session, filterId, timeline, cacheProducer) } .onFailure { failure -> if (selectedServerFilterId != filterId || failure is CancellationException) return@onFailure @@ -7195,6 +7177,7 @@ private fun ActivityScreen( if (!activityInstalled || olderPageAttempt == 0) return@LaunchedEffect val filterId = selectedServerFilterId val cursor = timeline.nextSince ?: return@LaunchedEffect + val cacheProducer = ActivityWorkspaceMemoryCache.producer(session) timeline = timeline.beginNextActivityPage() runCatching { loadNextcloudActivityPage(since = cursor, filterId = filterId) { request -> @@ -7204,7 +7187,7 @@ private fun ActivityScreen( .onSuccess { page -> if (selectedServerFilterId != filterId) return@onSuccess timeline = timeline.applyNextActivityPage(page) - ActivityWorkspaceMemoryCache.store(session, filterId, timeline) + ActivityWorkspaceMemoryCache.store(session, filterId, timeline, cacheProducer) } .onFailure { failure -> if (selectedServerFilterId != filterId || failure is CancellationException) return@onFailure @@ -11854,12 +11837,13 @@ private fun TalkScreen( var refreshing by remember(session) { mutableStateOf(false) } var loadAttempt by remember(session) { mutableStateOf(0) } LaunchedEffect(loadAttempt) { + val cacheProducer = TalkWorkspaceMemoryCache.producer(session) refreshing = rooms != null error = null runCatching { services.listTalkRooms(session) } .onSuccess { rooms = it - TalkWorkspaceMemoryCache.storeRooms(session, it) + TalkWorkspaceMemoryCache.storeRooms(session, it, cacheProducer) } .onFailure { error = it.message ?: "Could not load Talk conversations." } refreshing = false diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt index e6e4ef4c5..21087bd3b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt @@ -118,9 +118,7 @@ internal fun NextcloudNotesScreen( navigationCommitInProgress: Boolean = false, onMutationInProgressChanged: (Boolean) -> Unit = {}, ) { - val accountScope = remember(session.serverUrl, session.loginName) { - durableMutationAccountScope(session) - } + val accountScope = remember(session.serverUrl, session.loginName) { durableMutationAccountScope(session) } var deletionRecoveryLoaded by remember(accountScope, services) { mutableStateOf(false) } var deletionRecoveryState by remember(accountScope, services) { mutableStateOf(null) } val deletionRecovery = remember(accountScope, deletionRecoveryState) { @@ -153,7 +151,6 @@ internal fun NextcloudNotesScreen( mutationInProgress, navigationCommitInProgress, ) - LaunchedEffect(accountScope, services, loadAttempt) { deletionRecoveryLoaded = false deletionRecoveryState = null @@ -169,14 +166,12 @@ internal fun NextcloudNotesScreen( error = "Note recovery storage could not be read securely. Check local storage and retry." } } - LaunchedEffect(accountScope, deletionRecoveryLoaded, deletionRecoveryState, deletionRecovery) { if (deletionRecoveryLoaded && deletionRecoveryState != null && deletionRecovery == null) { error = "The previous note-deletion recovery record cannot be read. Writes remain blocked." showRecoveryOptions = true } } - LaunchedEffect(pendingNavigationGuardActive, createdNoteToOpen) { onMutationInProgressChanged(pendingNavigationGuardActive) if (!pendingNavigationGuardActive) { @@ -196,9 +191,9 @@ internal fun NextcloudNotesScreen( DisposableEffect(Unit) { onDispose { onMutationInProgressChanged(false) } } - LaunchedEffect(session, loadAttempt, deletionRecoveryLoaded, deletionRecoveryState) { if (!deletionRecoveryLoaded) return@LaunchedEffect + val cacheProducer = sharedNextcloudNotesCache.producer(session) error = null refreshing = true val cachedEtag = notes?.let { sharedNextcloudNotesCache.listEtag(session) } @@ -206,7 +201,7 @@ internal fun NextcloudNotesScreen( when (val result = services.listNotesConditionally(session, cachedEtag)) { is NextcloudConditionalRead.Modified -> { notes = result.value - sharedNextcloudNotesCache.storeList(session, result.value, result.responseEtag) + sharedNextcloudNotesCache.storeList(session, result.value, cacheProducer, result.responseEtag) } NextcloudConditionalRead.NotModified -> Unit } @@ -221,7 +216,7 @@ internal fun NextcloudNotesScreen( ) ) { notes = removeVerifiedDeletedNote(notes, recovery.noteId) - sharedNextcloudNotesCache.remove(session, recovery.noteId) + sharedNextcloudNotesCache.remove(session, recovery.noteId, cacheProducer) deletionRecoveryState = null } else { error = "The verified note-deletion recovery record could not be cleared safely. Refreshing the current pending change." @@ -229,7 +224,7 @@ internal fun NextcloudNotesScreen( } } is NextcloudNotePresence.Present -> { - sharedNextcloudNotesCache.storeDetail(session, presence.note) + sharedNextcloudNotesCache.storeDetail(session, presence.note, cacheProducer) onOpenNote(presence.note) } } @@ -404,10 +399,10 @@ internal fun NextcloudNotesScreen( services = services, session = session, onDismiss = { createNoteInPath = null }, - onCreated = { created -> + onCreated = { created, cacheProducer -> notes = (notes.orEmpty() + created).distinctBy(NextcloudNote::id) - sharedNextcloudNotesCache.storeList(session, notes.orEmpty()) - sharedNextcloudNotesCache.storeDetail(session, created) + sharedNextcloudNotesCache.storeList(session, notes.orEmpty(), cacheProducer) + sharedNextcloudNotesCache.storeDetail(session, created, cacheProducer) createNoteInPath = null createdNoteToOpen = created }, @@ -423,11 +418,11 @@ internal fun NextcloudNotesScreen( services = services, session = session, onDismiss = { renameFolder = null }, - onReconciled = { refreshed -> + onReconciled = { refreshed, cacheProducer -> notes = refreshed - sharedNextcloudNotesCache.storeList(session, refreshed) + sharedNextcloudNotesCache.storeList(session, refreshed, cacheProducer) }, - onRenamed = { destination -> + onRenamed = { destination, cacheProducer -> val oldPrefix = folder.path + "/" notes = notes.orEmpty().map { note -> when { @@ -437,7 +432,7 @@ internal fun NextcloudNotesScreen( else -> note } } - sharedNextcloudNotesCache.storeList(session, notes.orEmpty()) + sharedNextcloudNotesCache.storeList(session, notes.orEmpty(), cacheProducer) currentPath = destination renameFolder = null }, @@ -453,16 +448,16 @@ internal fun NextcloudNotesScreen( services = services, session = session, onDismiss = { deleteFolder = null }, - onReconciled = { refreshed -> + onReconciled = { refreshed, cacheProducer -> notes = refreshed - sharedNextcloudNotesCache.storeList(session, refreshed) + sharedNextcloudNotesCache.storeList(session, refreshed, cacheProducer) }, - onDeleted = { + onDeleted = { cacheProducer -> val prefix = folder.path + "/" notes = notes.orEmpty().filterNot { note -> note.category == folder.path || note.category.startsWith(prefix) } - sharedNextcloudNotesCache.storeList(session, notes.orEmpty()) + sharedNextcloudNotesCache.storeList(session, notes.orEmpty(), cacheProducer) if (currentPath == folder.path || currentPath.startsWith(prefix)) { currentPath = noteFolderParent(folder.path) } @@ -604,7 +599,7 @@ private fun CreateNoteDialog( services: NextcloudPlatformServices, session: NextcloudSession, onDismiss: () -> Unit, - onCreated: (NextcloudNote) -> Unit, + onCreated: (NextcloudNote, AccountPrivateMemoryProducer?) -> Unit, onSubmittingChanged: (Boolean) -> Unit, ) { var title by remember(category) { mutableStateOf("") } @@ -648,12 +643,13 @@ private fun CreateNoteDialog( Button( enabled = title.isNotBlank() && !submitting, onClick = { + val cacheProducer = sharedNextcloudNotesCache.producer(session) submitting = true onSubmittingChanged(true) error = null scope.launch { try { - onCreated(services.createNote(session, title, content, category)) + onCreated(services.createNote(session, title, content, category), cacheProducer) } catch (failure: CancellationException) { throw failure } catch (failure: Exception) { @@ -682,8 +678,8 @@ private fun RenameNoteFolderDialog( services: NextcloudPlatformServices, session: NextcloudSession, onDismiss: () -> Unit, - onReconciled: (List) -> Unit, - onRenamed: (String) -> Unit, + onReconciled: (List, AccountPrivateMemoryProducer?) -> Unit, + onRenamed: (String, AccountPrivateMemoryProducer?) -> Unit, onSubmittingChanged: (Boolean) -> Unit, ) { var name by remember(folder.path) { mutableStateOf(folder.name) } @@ -715,18 +711,19 @@ private fun RenameNoteFolderDialog( val destination = runCatching { noteFolderRenameTarget(folder.path, name) } .onFailure { error = it.message } .getOrNull() ?: return@Button + val cacheProducer = sharedNextcloudNotesCache.producer(session) submitting = true onSubmittingChanged(true) scope.launch { try { services.renameNoteCategory(session, folder.path, destination) - onRenamed(destination) + onRenamed(destination, cacheProducer) } catch (failure: CancellationException) { throw failure } catch (failure: Exception) { (failure as? PartialNoteFolderMutationException) ?.refreshedSummaries - ?.let(onReconciled) + ?.let { refreshed -> onReconciled(refreshed, cacheProducer) } error = failure.message ?: "Could not rename the folder." } finally { submitting = false @@ -746,8 +743,8 @@ private fun DeleteNoteFolderDialog( services: NextcloudPlatformServices, session: NextcloudSession, onDismiss: () -> Unit, - onReconciled: (List) -> Unit, - onDeleted: () -> Unit, + onReconciled: (List, AccountPrivateMemoryProducer?) -> Unit, + onDeleted: (AccountPrivateMemoryProducer?) -> Unit, onSubmittingChanged: (Boolean) -> Unit, ) { var submitting by remember(folder.path) { mutableStateOf(false) } @@ -772,18 +769,19 @@ private fun DeleteNoteFolderDialog( Button( enabled = !submitting, onClick = { + val cacheProducer = sharedNextcloudNotesCache.producer(session) submitting = true onSubmittingChanged(true) scope.launch { try { services.deleteNoteCategory(session, folder.path) - onDeleted() + onDeleted(cacheProducer) } catch (failure: CancellationException) { throw failure } catch (failure: Exception) { (failure as? PartialNoteFolderMutationException) ?.refreshedSummaries - ?.let(onReconciled) + ?.let { refreshed -> onReconciled(refreshed, cacheProducer) } error = failure.message ?: "Could not delete the folder." } finally { submitting = false @@ -932,7 +930,6 @@ internal fun NextcloudNoteEditor( saveError = message } } - LaunchedEffect(accountScope, deletionRecoveryLoaded, deletionRecoveryState, deletionRecovery) { if (deletionRecoveryLoaded && deletionRecoveryState != null && deletionRecovery == null) { deleteError = "The previous note-deletion recovery record cannot be read. Writes remain blocked." @@ -951,6 +948,7 @@ internal fun NextcloudNoteEditor( ) return@LaunchedEffect } + val cacheProducer = sharedNextcloudNotesCache.producer(session) showDeleteConfirmation = true deleting = true try { @@ -964,7 +962,7 @@ internal fun NextcloudNoteEditor( ) ) { deletionRecoveryState = null - sharedNextcloudNotesCache.remove(session, recovery.noteId) + sharedNextcloudNotesCache.remove(session, recovery.noteId, cacheProducer) completeVerifiedNoteDeletion( onDeletingChanged = { deleting = it }, onMutationInProgressChanged = onMutationInProgressChanged, @@ -976,7 +974,7 @@ internal fun NextcloudNoteEditor( } } is NextcloudNotePresence.Present -> { - sharedNextcloudNotesCache.storeDetail(session, presence.note) + sharedNextcloudNotesCache.storeDetail(session, presence.note, cacheProducer) deleteError = "The previous deletion was not confirmed by the server. Retry it before leaving this note." } } @@ -990,6 +988,7 @@ internal fun NextcloudNoteEditor( } LaunchedEffect(note.id, session, loadAttempt) { + val cacheProducer = sharedNextcloudNotesCache.producer(session) loadError = null refreshing = true val expectedEtag = loaded.content?.let { loaded.etag } @@ -997,7 +996,7 @@ internal fun NextcloudNoteEditor( .onSuccess { result -> if (result is NextcloudConditionalRead.NotModified) return@onSuccess val fullNote = (result as NextcloudConditionalRead.Modified).value - sharedNextcloudNotesCache.storeDetail(session, fullNote) + sharedNextcloudNotesCache.storeDetail(session, fullNote, cacheProducer) val preserveDraft = title != originalTitle || noteDraftIsDirty( initialized = draftInitialized, content = content.text, @@ -1086,6 +1085,7 @@ internal fun NextcloudNoteEditor( } fun saveNote() { if (!dirty || readOnly || mutationInProgress || contentBytes > MAX_NOTE_BYTES) return + val cacheProducer = sharedNextcloudNotesCache.producer(session) saving = true saveError = null scope.launch { @@ -1101,7 +1101,7 @@ internal fun NextcloudNoteEditor( ) }.onSuccess { saved -> val savedContent = saved.content ?: content.text - sharedNextcloudNotesCache.storeDetail(session, saved.copy(content = savedContent)) + sharedNextcloudNotesCache.storeDetail(session, saved.copy(content = savedContent), cacheProducer) loaded = saved originalTitle = saved.title title = saved.title @@ -1359,6 +1359,7 @@ internal fun NextcloudNoteEditor( Button( enabled = deletionRecoveryLoaded && !deleting && deletionPreconditionAvailable, onClick = delete@{ + val cacheProducer = sharedNextcloudNotesCache.producer(session) deleting = true deleteError = null scope.launch { @@ -1449,7 +1450,7 @@ internal fun NextcloudNoteEditor( return@launch } deletionRecoveryState = null - sharedNextcloudNotesCache.remove(session, note.id) + sharedNextcloudNotesCache.remove(session, note.id, cacheProducer) showDeleteConfirmation = false completeVerifiedNoteDeletion( onDeletingChanged = { deleting = it }, @@ -1458,7 +1459,7 @@ internal fun NextcloudNoteEditor( ) } is NextcloudNotePresence.Present -> { - sharedNextcloudNotesCache.storeDetail(session, presence.note) + sharedNextcloudNotesCache.storeDetail(session, presence.note, cacheProducer) deleteError = requestFailure ?: "The deletion has not appeared on the server yet. Retry it before leaving this note." loadAttempt += 1 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt index aa86b3baa..b8514d17f 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt @@ -1,46 +1,82 @@ package dev.obiente.nextcloudnative.app /** Small process-local cache used for stale-while-revalidate Notes screens. */ -internal class NextcloudNotesCache { +internal class NextcloudNotesCache( + private val gate: AccountPrivateMemoryGate = AccountPrivateMemoryGate(), +) { private val noteLists = mutableMapOf>() private val noteListEtags = mutableMapOf() private val noteDetails = mutableMapOf, NextcloudNote>() - fun list(session: NextcloudSession): List? = noteLists[session.accountId] + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = gate.producer(session.accountId.storageKey) - fun listEtag(session: NextcloudSession): String? = noteListEtags[session.accountId] + fun list(session: NextcloudSession): List? = + gate.read(session.accountId.storageKey, null) { noteLists[session.accountId] } + + fun listEtag(session: NextcloudSession): String? = + gate.read(session.accountId.storageKey, null) { noteListEtags[session.accountId] } fun detail(session: NextcloudSession, noteId: Long): NextcloudNote? = - noteDetails[session.accountId to noteId] + gate.read(session.accountId.storageKey, null) { noteDetails[session.accountId to noteId] } - fun storeList(session: NextcloudSession, notes: List, etag: String? = null) { + fun storeList( + session: NextcloudSession, + notes: List, + producer: AccountPrivateMemoryProducer?, + etag: String? = null, + ) { val account = session.accountId - noteLists[account] = notes - etag?.takeIf(String::isNotBlank)?.let { noteListEtags[account] = it } - ?: noteListEtags.remove(account) - notes.filter { it.content != null }.forEach { noteDetails[account to it.id] = it } + gate.mutate(account.storageKey, producer) { + noteLists[account] = notes + etag?.takeIf(String::isNotBlank)?.let { noteListEtags[account] = it } + ?: noteListEtags.remove(account) + notes.filter { it.content != null }.forEach { noteDetails[account to it.id] = it } + } } - fun storeDetail(session: NextcloudSession, note: NextcloudNote) { + fun storeDetail( + session: NextcloudSession, + note: NextcloudNote, + producer: AccountPrivateMemoryProducer?, + ) { val account = session.accountId - noteDetails[account to note.id] = note - noteLists[account] = noteLists[account]?.map { listed -> - if (listed.id == note.id) note.copy(content = null) else listed - } ?: return + gate.mutate(account.storageKey, producer) { + noteDetails[account to note.id] = note + noteLists[account]?.let { listedNotes -> + noteLists[account] = listedNotes.map { listed -> + if (listed.id == note.id) note.copy(content = null) else listed + } + } + } } - fun remove(session: NextcloudSession, noteId: Long) { + fun remove( + session: NextcloudSession, + noteId: Long, + producer: AccountPrivateMemoryProducer?, + ) { val account = session.accountId - noteDetails.remove(account to noteId) - noteLists[account] = noteLists[account]?.filterNot { note -> note.id == noteId } ?: return - noteListEtags.remove(account) + gate.mutate(account.storageKey, producer) { + noteDetails.remove(account to noteId) + noteLists[account]?.let { listedNotes -> + noteLists[account] = listedNotes.filterNot { note -> note.id == noteId } + noteListEtags.remove(account) + } + } + } + + fun retireAccount(accountStorageKey: String) = gate.retireAccount(accountStorageKey) { + purgeRetiredAccount(accountStorageKey) } - fun removeAccount(accountStorageKey: String) { + fun activateAccount(accountStorageKey: String) = gate.activateAccount(accountStorageKey) + + internal fun purgeRetiredAccount(accountStorageKey: String) { noteLists.keys.removeAll { account -> account.storageKey == accountStorageKey } noteListEtags.keys.removeAll { account -> account.storageKey == accountStorageKey } - noteDetails.keys.removeAll { (account) -> account.storageKey == accountStorageKey } + noteDetails.keys.removeAll { (account, _) -> account.storageKey == accountStorageKey } } + } -internal val sharedNextcloudNotesCache = NextcloudNotesCache() +internal val sharedNextcloudNotesCache = NextcloudNotesCache(sharedAccountPrivateMemoryGate) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt index 744f4ccdc..be3b4d09a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -646,6 +646,7 @@ interface NextcloudPlatformServices : NextcloudAccountCredentialServices, DeckCa suspend fun saveCachedDynamicAppDiscovery( session: NextcloudSession, discovery: DynamicDescriptorDiscovery, + producer: DynamicNativeMemoryCacheProducer? = null, ) = Unit /** Loads one exact account/app/action/record mutation staged before a non-idempotent send. */ 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 727143405..8476e0af3 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt @@ -3,6 +3,7 @@ package dev.obiente.nextcloudnative.app import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNotSame import kotlin.test.assertNull @@ -15,17 +16,27 @@ class AccountPrivateMemoryCleanupTest { val retained = session("retained") val removedKey = removed.accountId.storageKey val retainedKey = retained.accountId.storageKey + AccountPrivateMemoryLifecycle.activateAccount(removedKey) + AccountPrivateMemoryLifecycle.activateAccount(retainedKey) val removedPreview = PreviewCacheKey(removedKey, "core", 1L, "etag", 64, 64) val retainedPreview = PreviewCacheKey(retainedKey, "core", 2L, "etag", 64, 64) val removedPhotoState = PhotoTimelineUiStateRepository.stateFor(removed) val retainedPhotoState = PhotoTimelineUiStateRepository.stateFor(retained) + val removedProducer = sharedAccountPrivateMemoryGate.producer(removedKey) + val retainedProducer = sharedAccountPrivateMemoryGate.producer(retainedKey) + val removedDynamicProducer = sharedDynamicNativeMemoryCache.producer(removed) + val retainedDynamicProducer = sharedDynamicNativeMemoryCache.producer(retained) try { PreviewMemoryCache.put(removedPreview, byteArrayOf(1)) PreviewMemoryCache.put(retainedPreview, byteArrayOf(2)) - sharedNextcloudNotesCache.storeDetail(removed, note(1L, "Removed")) - sharedNextcloudNotesCache.storeDetail(retained, note(2L, "Retained")) - sharedDynamicNativeMemoryCache.storeScreen(dynamicKey(removed), dynamicSnapshot(1)) - sharedDynamicNativeMemoryCache.storeScreen(dynamicKey(retained), dynamicSnapshot(2)) + sharedNextcloudNotesCache.storeDetail(removed, note(1L, "Removed"), removedProducer) + sharedNextcloudNotesCache.storeDetail(retained, note(2L, "Retained"), retainedProducer) + sharedDynamicNativeMemoryCache.storeScreen( + dynamicKey(removed), dynamicSnapshot(1), removedDynamicProducer, + ) + sharedDynamicNativeMemoryCache.storeScreen( + dynamicKey(retained), dynamicSnapshot(2), retainedDynamicProducer, + ) sharedDashboardStatusMemoryCache.store(removed, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 1L) sharedDashboardStatusMemoryCache.store(retained, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 1L) ContactsWorkspaceMemoryCache.store(removed, "removed", ContactsLoadState.Ready(emptyList(), emptyList())) @@ -38,10 +49,18 @@ class AccountPrivateMemoryCleanupTest { sharedDocumentEditingCapabilitiesCache.store( retained, NextcloudDocumentEditingCapabilities.Unavailable, null, ) - ActivityWorkspaceMemoryCache.store(removed, "all", ActivityTimelineState(initialized = true)) - ActivityWorkspaceMemoryCache.store(retained, "all", ActivityTimelineState(initialized = true)) - TalkWorkspaceMemoryCache.storeRooms(removed, listOf(TalkRoom("removed", "Removed", null, 0))) - TalkWorkspaceMemoryCache.storeRooms(retained, listOf(TalkRoom("retained", "Retained", null, 0))) + ActivityWorkspaceMemoryCache.store( + removed, "all", ActivityTimelineState(initialized = true), removedProducer, + ) + ActivityWorkspaceMemoryCache.store( + retained, "all", ActivityTimelineState(initialized = true), retainedProducer, + ) + TalkWorkspaceMemoryCache.storeRooms( + removed, listOf(TalkRoom("removed", "Removed", null, 0)), removedProducer, + ) + TalkWorkspaceMemoryCache.storeRooms( + retained, listOf(TalkRoom("retained", "Retained", null, 0)), retainedProducer, + ) AccountPrivateMemoryCleanup.removeAccount(removedKey) @@ -68,9 +87,34 @@ class AccountPrivateMemoryCleanupTest { } finally { AccountPrivateMemoryCleanup.removeAccount(removedKey) AccountPrivateMemoryCleanup.removeAccount(retainedKey) + AccountPrivateMemoryLifecycle.activateAccount(removedKey) + AccountPrivateMemoryLifecycle.activateAccount(retainedKey) } } + @Test + fun `stale workspace completion cannot repopulate a reactivated account`() { + val account = session("crossing") + val accountKey = account.accountId.storageKey + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + val staleProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(accountKey)) + + AccountPrivateMemoryLifecycle.retireAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + TalkWorkspaceMemoryCache.storeRooms( + account, listOf(TalkRoom("stale", "Stale", null, 0)), staleProducer, + ) + + assertNull(TalkWorkspaceMemoryCache.rooms(account)) + val currentProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(accountKey)) + TalkWorkspaceMemoryCache.storeRooms( + account, listOf(TalkRoom("current", "Current", null, 0)), currentProducer, + ) + assertEquals("current", TalkWorkspaceMemoryCache.rooms(account)?.single()?.token) + assertFalse(staleProducer == currentProducer) + AccountPrivateMemoryCleanup.removeAccount(accountKey) + } + private fun session(name: String) = NextcloudSession( serverUrl = "https://$name.private-memory.example.test", loginName = name, diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt index 23a475c78..5747d1879 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheTest.kt @@ -10,8 +10,13 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlin.time.Duration.Companion.minutes import kotlin.time.TestTimeSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking class DynamicNativeMemoryCacheTest { private val session = NextcloudSession("https://cloud.example.test", "alice", "never-cache-this") @@ -227,6 +232,96 @@ class DynamicNativeMemoryCacheTest { assertEquals("3", cache.screen(otherAccount)?.records?.single()?.id) } + @Test + fun `retirement purges only the exact account across every cache class`() { + val cache = DynamicNativeMemoryCache() + val otherSession = session.copy(loginName = "bob") + val targetScreen = dynamicScreenCacheKey(session, "mail", "messages.list", null, emptyMap()) + val otherScreen = dynamicScreenCacheKey(otherSession, "mail", "messages.list", null, emptyMap()) + cache.storeDiscovery(session, "mail", discovery("mail")) + cache.storeDiscovery(otherSession, "mail", discovery("mail")) + cache.markDiscoveryFailure(session, "mail") + cache.markDiscoveryFailure(otherSession, "mail") + cache.storeScreen(targetScreen, snapshot("target")) + cache.storeScreen(otherScreen, snapshot("other")) + + cache.retireAccount(session.accountId.storageKey) + + assertNull(cache.discovery(session, "mail")) + assertNull(cache.screen(targetScreen)) + assertFalse(cache.shouldRetryDiscovery(session, "mail")) + assertEquals("mail", cache.discovery(otherSession, "mail")?.descriptor?.app?.id) + assertEquals("other", cache.screen(otherScreen)?.records?.single()?.id) + assertFalse(cache.shouldRetryDiscovery(otherSession, "mail")) + + cache.activateAccount(session.accountId.storageKey) + + assertNull(cache.discovery(session, "mail")) + assertNull(cache.screen(targetScreen)) + assertTrue(cache.shouldRetryDiscovery(session, "mail")) + assertEquals("other", cache.screen(otherScreen)?.records?.single()?.id) + } + + @Test + fun `completion crossing retirement and reactivation cannot store into the new incarnation`() { + val cache = DynamicNativeMemoryCache() + val key = dynamicScreenCacheKey(session, "mail", "messages.list", null, emptyMap()) + val staleProducer = requireNotNull(cache.producer(session)) + + cache.retireAccount(session.accountId.storageKey) + cache.activateAccount(session.accountId.storageKey) + cache.storeDiscovery(session, "mail", discovery("mail"), staleProducer) + cache.markDiscoveryFailure(session, "mail", staleProducer) + cache.storeScreen(key, snapshot("late"), staleProducer) + + assertNull(cache.discovery(session, "mail")) + assertNull(cache.screen(key)) + assertTrue(cache.shouldRetryDiscovery(session, "mail")) + + val currentProducer = requireNotNull(cache.producer(session)) + cache.storeDiscovery(session, "mail", discovery("mail"), currentProducer) + cache.storeScreen(key, snapshot("current"), currentProducer) + + assertEquals("mail", cache.discovery(session, "mail")?.descriptor?.app?.id) + assertEquals("current", cache.screen(key)?.records?.single()?.id) + } + + @Test + fun `concurrent cache access stays safe across retirement`() = runBlocking { + val cache = DynamicNativeMemoryCache(maximumScreens = 8) + val accountStorageKey = session.accountId.storageKey + + List(12) { worker -> + async(Dispatchers.Default) { + repeat(200) { iteration -> + val appId = "app-${iteration % 4}" + val key = dynamicScreenCacheKey( + session, + appId, + "view-$worker", + iteration.toString(), + emptyMap(), + ) + cache.storeDiscovery(session, appId, discovery(appId)) + cache.markDiscoveryFailure(session, appId) + cache.storeScreen(key, snapshot("$worker-$iteration")) + cache.discovery(session, appId) + cache.isDiscoveryFresh(session, appId) + cache.shouldRetryDiscovery(session, appId) + cache.screen(key) + if (iteration % 11 == 0) cache.invalidateScreens(session, appId) + } + } + }.awaitAll() + + cache.retireAccount(accountStorageKey) + + repeat(4) { app -> + assertNull(cache.discovery(session, "app-$app")) + assertFalse(cache.shouldRetryDiscovery(session, "app-$app")) + } + } + @Test fun `dynamic response identity is stable across query order and contains no credentials`() { val first = NextcloudApiRequest( @@ -246,4 +341,37 @@ class DynamicNativeMemoryCacheTest { first.copy(maximumResponseBytes = first.maximumResponseBytes + 1L).dynamicReadCacheIdentity(), ) } + + private fun discovery(appId: String) = DynamicDescriptorDiscovery( + descriptor = DynamicAppDescriptor( + descriptorVersion = DYNAMIC_APP_DESCRIPTOR_VERSION, + app = AppIdentity(appId, appId, "1.0.0"), + endpointPolicy = EndpointPolicy( + serverOrigin = "https://cloud.example.test", + approvedApiPrefixes = listOf("/ocs/v2.php/apps/$appId"), + ), + ), + sourcePath = "signed-package/openapi.json", + acquisition = DynamicDescriptorAcquisition.SignedAppStorePackage, + versionStatus = DynamicContractVersionStatus.VerifiedCurrent, + ) + + private fun snapshot(id: String) = DynamicScreenSnapshot( + records = listOf(NativeRecord(id, mapOf("id" to id))), + relatedRecords = emptyMap(), + ) + + private fun DynamicNativeMemoryCache.storeDiscovery( + session: NextcloudSession, + appId: String, + discovery: DynamicDescriptorDiscovery, + ) = storeDiscovery(session, appId, discovery, requireNotNull(producer(session))) + + private fun DynamicNativeMemoryCache.markDiscoveryFailure(session: NextcloudSession, appId: String) = + markDiscoveryFailure(session, appId, requireNotNull(producer(session))) + + private fun DynamicNativeMemoryCache.storeScreen( + key: DynamicScreenCacheKey, + snapshot: DynamicScreenSnapshot, + ) = storeScreen(key, snapshot, requireNotNull(producer(key))) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt index ab5a1ceda..4a3d044a7 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt @@ -98,7 +98,7 @@ class NextcloudAccountIdentityTest { val cache = NextcloudNotesCache() val upper = session() val lower = upper.copy(serverUrl = "https://cloud.example.test/cloud") - cache.storeList(upper, listOf(note(id = 1, title = "Upper"))) + cache.storeList(upper, listOf(note(id = 1, title = "Upper")), cache.producer(upper)) assertNotNull(cache.list(upper)) assertNull(cache.list(lower)) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCacheTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCacheTest.kt index cf9ed154d..946dd78d6 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCacheTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCacheTest.kt @@ -1,5 +1,11 @@ package dev.obiente.nextcloudnative.app +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -11,7 +17,7 @@ class NextcloudNotesCacheTest { val firstLogin = session("alice", "first password") val sameLoginNewPassword = session("alice", "rotated password") val otherLogin = session("bob", "first password") - cache.storeList(firstLogin, listOf(note(1, "Alice note"))) + cache.storeList(firstLogin, listOf(note(1, "Alice note")), requireNotNull(cache.producer(firstLogin))) assertEquals("Alice note", cache.list(sameLoginNewPassword)?.single()?.title) assertNull(cache.list(otherLogin)) @@ -21,9 +27,10 @@ class NextcloudNotesCacheTest { fun savedDetailUpdatesListMetadataButKeepsFullContentSeparate() { val cache = NextcloudNotesCache() val session = session("alice", "password") - cache.storeList(session, listOf(note(1, "Before"))) + val producer = requireNotNull(cache.producer(session)) + cache.storeList(session, listOf(note(1, "Before")), producer) - cache.storeDetail(session, note(1, "After", content = "# Full content")) + cache.storeDetail(session, note(1, "After", content = "# Full content"), producer) assertEquals("After", cache.list(session)?.single()?.title) assertNull(cache.list(session)?.single()?.content) @@ -34,15 +41,111 @@ class NextcloudNotesCacheTest { fun listEtagTracksTheMetadataPayloadWithoutEnteringDetailCache() { val cache = NextcloudNotesCache() val session = session("alice", "password") + val producer = requireNotNull(cache.producer(session)) - cache.storeList(session, listOf(note(1, "Metadata only")), etag = "\"list-v1\"") + cache.storeList(session, listOf(note(1, "Metadata only")), producer, etag = "\"list-v1\"") assertEquals("\"list-v1\"", cache.listEtag(session)) assertNull(cache.detail(session, 1)) - cache.storeList(session, listOf(note(1, "Changed metadata")), etag = null) + cache.storeList(session, listOf(note(1, "Changed metadata")), producer, etag = null) assertNull(cache.listEtag(session)) } + @Test + fun `retirement purges target note data and preserves another account`() { + val cache = NextcloudNotesCache() + val target = session("alice", "password") + val other = session("bob", "password") + val targetProducer = requireNotNull(cache.producer(target)) + val otherProducer = requireNotNull(cache.producer(other)) + cache.storeList(target, listOf(note(1, "Target", content = "private")), targetProducer, "target-etag") + cache.storeDetail(target, note(1, "Target", content = "private"), targetProducer) + cache.storeList(other, listOf(note(2, "Other", content = "retained")), otherProducer, "other-etag") + cache.storeDetail(other, note(2, "Other", content = "retained"), otherProducer) + + cache.retireAccount(target.accountId.storageKey) + + assertNull(cache.list(target)) + assertNull(cache.listEtag(target)) + assertNull(cache.detail(target, 1L)) + assertEquals("Other", cache.list(other)?.single()?.title) + assertEquals("other-etag", cache.listEtag(other)) + assertEquals("retained", cache.detail(other, 2L)?.content) + } + + @Test + fun `stale note producer cannot write or remove after reactivation`() { + val cache = NextcloudNotesCache() + val session = session("alice", "password") + val staleProducer = requireNotNull(cache.producer(session)) + cache.storeList(session, listOf(note(1, "Before")), staleProducer, "before-etag") + + cache.retireAccount(session.accountId.storageKey) + cache.storeList(session, listOf(note(1, "Closed")), staleProducer, "closed-etag") + assertNull(cache.list(session)) + cache.activateAccount(session.accountId.storageKey) + + val currentProducer = requireNotNull(cache.producer(session)) + cache.storeList(session, listOf(note(1, "Current")), currentProducer, "current-etag") + cache.storeDetail(session, note(1, "Current", content = "current body"), currentProducer) + cache.storeList(session, listOf(note(1, "Late")), staleProducer, "late-etag") + cache.storeDetail(session, note(1, "Late", content = "late body"), staleProducer) + cache.remove(session, 1L, staleProducer) + + assertEquals("Current", cache.list(session)?.single()?.title) + assertEquals("current-etag", cache.listEtag(session)) + assertEquals("current body", cache.detail(session, 1L)?.content) + } + + @Test + fun `same screen can cache a new request after a crossing completion is rejected`() = runBlocking { + val cache = NextcloudNotesCache() + val session = session("reactivated", "password") + val started = CompletableDeferred() + val release = CompletableDeferred() + val staleProducer = requireNotNull(cache.producer(session)) + val crossingRequest = async(start = CoroutineStart.UNDISPATCHED) { + started.complete(Unit) + release.await() + cache.storeDetail(session, note(1, "Crossing", content = "stale"), staleProducer) + } + started.await() + + cache.retireAccount(session.accountId.storageKey) + cache.activateAccount(session.accountId.storageKey) + release.complete(Unit) + crossingRequest.await() + assertNull(cache.detail(session, 1L)) + + val retryProducer = requireNotNull(cache.producer(session)) + cache.storeDetail(session, note(1, "Retried", content = "current"), retryProducer) + + assertEquals("current", cache.detail(session, 1L)?.content) + } + + @Test + fun `concurrent note access remains safe across retirement`() = runBlocking { + val cache = NextcloudNotesCache() + val session = session("parallel", "password") + val producer = requireNotNull(cache.producer(session)) + val workers = List(8) { worker -> + async(Dispatchers.Default) { + repeat(100) { iteration -> + val id = (worker * 100 + iteration).toLong() + cache.storeDetail(session, note(id, "Note $id", content = "body"), producer) + cache.detail(session, id) + cache.remove(session, id, producer) + } + } + } + val retirement = async(Dispatchers.Default) { cache.retireAccount(session.accountId.storageKey) } + (workers + retirement).awaitAll() + + assertNull(cache.list(session)) + assertNull(cache.listEtag(session)) + repeat(800) { id -> assertNull(cache.detail(session, id.toLong())) } + } + private fun session(login: String, password: String) = NextcloudSession( serverUrl = "https://cloud.example.test/", loginName = login, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index c98071209..b367ca065 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -282,14 +282,19 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( accountOwnership: (String) -> DesktopAccountOwnership, removeCredential: suspend () -> Boolean, removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, + retireCommittedAccount: () -> Unit = {}, recordCleanupFailure: suspend (Exception) -> Unit, ): Boolean { prepareCleanup(accountId, durableMutationAccountScope, accountStorageKey) val removed = try { removeCredential() } catch (failure: Throwable) { + val ownership = runCatching { accountOwnership(accountId) } + if (ownership.getOrNull() == DesktopAccountOwnership.Absent) { + runCatching(retireCommittedAccount).exceptionOrNull()?.let(failure::addSuppressed) + } runCatching { - when (accountOwnership(accountId)) { + when (ownership.getOrThrow()) { DesktopAccountOwnership.Present -> clearCleanup(accountId) DesktopAccountOwnership.Absent -> commitCleanup(accountId) DesktopAccountOwnership.Unknown -> Unit @@ -301,6 +306,7 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( clearCleanup(accountId) return false } + retireCommittedAccount() try { commitCleanup(accountId) removeSyncPairs( @@ -329,6 +335,7 @@ internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( commitRemoval: suspend () -> Unit, removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, + retireCommittedAccount: () -> Unit = {}, ) { if (accountId == null) { commitRemoval() @@ -347,6 +354,7 @@ internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( true }, removeSyncPairs = removeSyncPairs, + retireCommittedAccount = retireCommittedAccount, recordCleanupFailure = { failure -> recordDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(accountId, failure)) }, @@ -407,11 +415,13 @@ internal suspend fun retryDesktopAccountSyncPairCleanup( accountOwnership: (String) -> DesktopAccountOwnership, removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, clearCleanup: suspend (String) -> Unit, + reactivatePresentAccount: (DesktopAccountSyncPairCleanup) -> Unit = {}, ) { if (cleanup.phase != DesktopAccountSyncPairCleanupPhase.Committed) { when (accountOwnership(cleanup.accountId)) { DesktopAccountOwnership.Present -> { clearCleanup(cleanup.accountId) + reactivatePresentAccount(cleanup) return } DesktopAccountOwnership.Unknown -> return @@ -427,6 +437,7 @@ internal suspend fun retryPendingDesktopAccountSyncPairCleanups( accountOwnership: (String) -> DesktopAccountOwnership, removeSyncPairs: suspend (DesktopAccountSyncPairCleanup) -> Unit, recordCleanupFailure: (String, Exception) -> Unit, + reactivatePresentAccount: (DesktopAccountSyncPairCleanup) -> Unit = {}, ) { cleanupJournal.pending().forEach { cleanup -> try { @@ -435,6 +446,7 @@ internal suspend fun retryPendingDesktopAccountSyncPairCleanups( accountOwnership, removeSyncPairs, cleanupJournal::clear, + reactivatePresentAccount, ) } catch (cancelled: CancellationException) { throw cancelled 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 1f08a42e5..df1edaa73 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -124,7 +124,6 @@ private const val VIRTUAL_FOLDER_REFRESH_INTERVAL_MILLIS = 6L * 60L * 60L * 1_00 private const val VIRTUAL_FOLDER_REFRESH_RETRY_MILLIS = 30L * 60L * 1_000L private const val KEY_WINDOWS_CLOUD_FILES_PRESERVED_ROOT_PREFIX = "wcfpr." private const val KEY_WINDOWS_CLOUD_FILES_RECOVERY_CURSOR = "windows-cloud-files-recovery-cursor" -private const val MAX_WINDOWS_CLOUD_FILES_RECOVERY_ROOTS_PER_ATTEMPT = 16 private const val KEY_VIRTUAL_FILE_PRIMARY_CACHE_PREFIX = "vfpc-primary." private const val KEY_VIRTUAL_FILE_OVERFLOW_CACHE_PREFIX = "vfpc-overflow." private const val VIRTUAL_FILE_PRIMARY_PREFERENCE_VERSION = "v2" @@ -456,23 +455,6 @@ internal fun persistedWindowsCloudFilesRecoveryRoots( } .toMap() -internal fun pageWindowsCloudFilesRecoveryRoots( - roots: Map, - startAfterAccountId: String?, - limit: Int = MAX_WINDOWS_CLOUD_FILES_RECOVERY_ROOTS_PER_ATTEMPT, -): Map { - require(limit > 0) - if (roots.isEmpty()) return emptyMap() - val ordered = roots.entries.sortedBy(Map.Entry::key) - val startIndex = startAfterAccountId - ?.let { cursor -> ordered.indexOfFirst { it.key > cursor } } - ?.takeIf { it >= 0 } - ?: 0 - return (0 until minOf(limit, ordered.size)) - .map { offset -> ordered[(startIndex + offset) % ordered.size] } - .associate(Map.Entry::toPair) -} - internal fun pagedPersistedWindowsCloudFilesRecoveryRoots( preferences: Preferences = Preferences.userRoot().node("dev/obiente/nextcloudnative"), ): Map { @@ -3356,10 +3338,10 @@ class DesktopNextcloudServices( .getOrNull() ?.let { encoded -> decodePersistedDynamicDiscovery(encoded, appId, session.serverUrl) } } - override suspend fun saveCachedDynamicAppDiscovery( session: NextcloudSession, discovery: DynamicDescriptorDiscovery, + producer: DynamicNativeMemoryCacheProducer?, ) = withContext(Dispatchers.IO) { val encoded = encodePersistedDynamicDiscovery(discovery) ?: return@withContext val target = dynamicDiscoveryCacheFile(session, discovery.descriptor.app.id) ?: return@withContext @@ -3510,6 +3492,7 @@ class DesktopNextcloudServices( } }, activate = { + AccountPrivateMemoryLifecycle.activateAccount(it.accountId.storageKey) dynamicApiRequestCoalescer.activateAccount(desktopFileCacheAccountId(it)) synchronized(fileRangeSessionLock) { sessionClearing = false } startDesktopSyncLifecycle() @@ -3606,6 +3589,9 @@ class DesktopNextcloudServices( } } }, removeSyncPairs = ::removeDesktopAccountOwnedState, + retireCommittedAccount = { + AccountPrivateMemoryLifecycle.retireAccount(account.id.storageKey) + }, ) { recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(providerAccountId, it)) } @@ -3794,7 +3780,11 @@ class DesktopNextcloudServices( teardownVirtualFiles = finishCommittedRemoval, ) }, - ::removeDesktopAccountOwnedState, ::recordSupportDiagnostic, + ::removeDesktopAccountOwnedState, + ::recordSupportDiagnostic, + retireCommittedAccount = { + accountStorageKey?.let(AccountPrivateMemoryLifecycle::retireAccount) + }, ) } finally { if (cleared && accountId != null) schedulePendingAccountSyncPairCleanupRetry() @@ -3826,20 +3816,25 @@ class DesktopNextcloudServices( private suspend fun retryPendingAccountSyncPairCleanup(accountId: String, accountStorageKey: String) { accountSyncPairCleanupJournal.pendingForAccountActivation(accountId, accountStorageKey).forEach { cleanup -> retryDesktopAccountSyncPairCleanup( - cleanup, ::desktopAccountOwnership, ::removeDesktopAccountOwnedState, accountSyncPairCleanupJournal::clear, + cleanup, ::desktopAccountOwnership, ::removeDesktopAccountOwnedState, + accountSyncPairCleanupJournal::clear, ::reactivateDesktopMemoryAfterAbortedRemoval, ) } requireDesktopAccountActivationAllowed(accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) } - private suspend fun retryPendingAccountSyncPairCleanups() = retryPendingDesktopAccountSyncPairCleanups( accountSyncPairCleanupJournal, ::desktopAccountOwnership, ::removeDesktopAccountOwnedState, { accountId, failure -> recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(accountId, failure)) }, + ::reactivateDesktopMemoryAfterAbortedRemoval, ) + private fun reactivateDesktopMemoryAfterAbortedRemoval(cleanup: DesktopAccountSyncPairCleanup) { + cleanup.accountStorageKey?.let(AccountPrivateMemoryLifecycle::activateAccount) + } + private fun schedulePendingAccountSyncPairCleanupRetry() = serviceScope.launch { retryDesktopAccountSyncPairCleanupsBounded { var pending = true @@ -3853,14 +3848,14 @@ class DesktopNextcloudServices( pending } } - private suspend fun removeDesktopAccountOwnedState(cleanup: DesktopAccountSyncPairCleanup) { val accountId = cleanup.accountId clearDesktopDynamicApiState(accountId, dynamicApiRequestCoalescer, dynamicApiReadCache) + supportIntake.removeAccount(accountId) removeDesktopPendingDynamicMutations(pendingDynamicMutationDirectory, accountId) cleanup.durableMutationAccountScope?.let(durableMutationRecovery::removeAccount) cleanup.accountStorageKey?.let { deckCardDrafts.removeAccount(it, accountId) } - cleanup.accountStorageKey?.let(AccountPrivateMemoryCleanup::removeAccount) + cleanup.accountStorageKey?.let(AccountPrivateMemoryLifecycle::retireAccount) externalFileHandoff.removeAccount(accountId) removeDesktopAccountPrivateStorage( accountId, fileSyncEngine, fileReadCache, virtualRangeCache(accountId), preferences, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRecoveryPaging.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRecoveryPaging.kt new file mode 100644 index 000000000..3dc1e555f --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/WindowsCloudFilesRecoveryPaging.kt @@ -0,0 +1,22 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Path + +internal fun pageWindowsCloudFilesRecoveryRoots( + roots: Map, + startAfterAccountId: String?, + limit: Int = MAX_WINDOWS_CLOUD_FILES_RECOVERY_ROOTS_PER_ATTEMPT, +): Map { + require(limit > 0) + if (roots.isEmpty()) return emptyMap() + val ordered = roots.entries.sortedBy(Map.Entry::key) + val startIndex = startAfterAccountId + ?.let { cursor -> ordered.indexOfFirst { it.key > cursor } } + ?.takeIf { it >= 0 } + ?: 0 + return (0 until minOf(limit, ordered.size)) + .map { offset -> ordered[(startIndex + offset) % ordered.size] } + .associate(Map.Entry::toPair) +} + +private const val MAX_WINDOWS_CLOUD_FILES_RECOVERY_ROOTS_PER_ATTEMPT = 16 diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt new file mode 100644 index 000000000..2c41e6f1d --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt @@ -0,0 +1,55 @@ +package dev.obiente.nextcloudnative.app + +import java.io.IOException +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopAccountMemoryRetirementTest { + @Test + fun `committed credential removal retires memory before journal commit can fail`() = runBlocking { + val events = mutableListOf() + + val removed = removeDesktopAccountBeforeSyncPairCleanup( + accountId = "a".repeat(64), + accountStorageKey = "b".repeat(64), + prepareCleanup = { _, _, _ -> events += "prepare" }, + commitCleanup = { events += "commit"; throw IOException("disk full") }, + clearCleanup = { events += "clear" }, + accountOwnership = { DesktopAccountOwnership.Absent }, + removeCredential = { events += "credential"; true }, + removeSyncPairs = { events += "cleanup" }, + retireCommittedAccount = { events += "retire" }, + recordCleanupFailure = { events += "failure" }, + ) + + assertTrue(removed) + assertEquals(listOf("prepare", "credential", "retire", "commit", "failure"), events) + } + + @Test + fun `post-commit credential throw retires but confirmed presence does not`() = runBlocking { + suspend fun attempt(ownership: DesktopAccountOwnership): Boolean { + var retired = false + runCatching { + removeDesktopAccountBeforeSyncPairCleanup( + accountId = "c".repeat(64), + prepareCleanup = { _, _, _ -> }, + commitCleanup = {}, + clearCleanup = {}, + accountOwnership = { ownership }, + removeCredential = { throw IOException("credential result lost") }, + removeSyncPairs = {}, + retireCommittedAccount = { retired = true }, + recordCleanupFailure = {}, + ) + } + return retired + } + + assertTrue(attempt(DesktopAccountOwnership.Absent)) + assertFalse(attempt(DesktopAccountOwnership.Present)) + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanupTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanupTest.kt new file mode 100644 index 000000000..ee7a133eb --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanupTest.kt @@ -0,0 +1,74 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JvmSupportAccountStorageCleanupTest { + @Test + fun `removal purges only descriptors and archive proven to belong to the account`() { + val root = Files.createTempDirectory("support-retirement").toFile() + val removed = "a".repeat(64) + val retained = "b".repeat(64) + var syncs = 0 + try { + val archive = root.resolve("support-00000000-0000-0000-0000-000000000001.zip") + .apply { writeText("private") } + root.resolve("pending.json").writeText( + """{"originAccountIdentity":"$removed","archiveName":"${archive.name}"}""", + ) + val removedCompleted = root.resolve("completed-00000000-0000-0000-0000-000000000002.json") + .apply { writeText("""{"originAccountIdentity":"$removed"}""") } + val retainedCompleted = root.resolve("completed-00000000-0000-0000-0000-000000000003.json") + .apply { writeText("""{"originAccountIdentity":"$retained"}""") } + + JvmSupportAccountStorageCleanup(root, { syncs += 1 }).removeAccount(removed, archive) + + assertFalse(root.resolve("pending.json").exists()) + assertFalse(archive.exists()) + assertFalse(removedCompleted.exists()) + assertTrue(retainedCompleted.isFile) + assertEquals(1, syncs) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `unreadable ownership fails closed without deleting the descriptor`() { + val root = Files.createTempDirectory("support-retirement-invalid").toFile() + val descriptor = root.resolve("pending.json").apply { writeText("not-json") } + try { + assertFails { + JvmSupportAccountStorageCleanup(root, {}).removeAccount("a".repeat(64), null) + } + assertTrue(descriptor.isFile) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `archive deletion failure preserves the descriptor for cleanup retry`() { + val root = Files.createTempDirectory("support-retirement-delete-failure").toFile() + val account = "c".repeat(64) + val archive = root.resolve("support-00000000-0000-0000-0000-000000000004.zip") + .apply { writeText("private") } + val descriptor = root.resolve("pending.json").apply { + writeText("""{"originAccountIdentity":"$account","archiveName":"${archive.name}"}""") + } + try { + assertFails { + JvmSupportAccountStorageCleanup(root, {}, deleteFile = { false }) + .removeAccount(account, archive) + } + assertTrue(descriptor.isFile) + assertTrue(archive.isFile) + } finally { + root.deleteRecursively() + } + } +} diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.jvm.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.jvm.kt new file mode 100644 index 000000000..24bd319de --- /dev/null +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.jvm.kt @@ -0,0 +1,6 @@ +package dev.obiente.nextcloudnative.app + +internal actual fun dynamicNativeMemoryCacheMonitor(): Any = Any() + +internal actual fun withDynamicNativeMemoryCacheLock(monitor: Any, action: () -> T): T = + synchronized(monitor, action) diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanup.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanup.kt new file mode 100644 index 000000000..6b00398e5 --- /dev/null +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanup.kt @@ -0,0 +1,76 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.attribute.BasicFileAttributes +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** Deletes only durable support artifacts whose descriptor proves ownership by one account. */ +internal class JvmSupportAccountStorageCleanup( + private val root: File, + private val directorySync: (File) -> Unit, + private val deleteFile: (File) -> Boolean = File::delete, +) { + private val json = Json { ignoreUnknownKeys = true } + + fun removeAccount(accountIdentity: String, inMemoryArchive: File?) { + require(accountIdentity.matches(ACCOUNT_IDENTITY)) + if (!root.exists()) return + check(root.isDirectory) { "Private support submission storage is unavailable." } + var changed = false + val pending = File(root, "pending.json") + if (pending.exists() && descriptorAccount(pending, MAX_PENDING_DESCRIPTOR_BYTES) == accountIdentity) { + val archiveName = descriptorString(pending, "archiveName") + archiveName?.let { name -> + require(name.matches(SUPPORT_ARCHIVE)) + deletePrivate(File(root, name)) + } + deleteDurably(pending) + changed = true + } + root.listFiles()?.filter { it.name.matches(COMPLETED_DESCRIPTOR) }?.forEach { descriptor -> + if (descriptorAccount(descriptor, MAX_COMPLETED_DESCRIPTOR_BYTES) == accountIdentity) { + deleteDurably(descriptor) + changed = true + } + } ?: throw IOException("Could not inspect private support submission storage.") + inMemoryArchive?.let { archive -> + require(archive.absoluteFile.normalize().parentFile == root.absoluteFile.normalize()) + changed = changed || archive.exists() + deletePrivate(archive) + } + if (changed) directorySync(root) + } + + private fun descriptorAccount(descriptor: File, maximumBytes: Long): String { + val attributes = Files.readAttributes(descriptor.toPath(), BasicFileAttributes::class.java) + require(attributes.isRegularFile && attributes.size() in 1..maximumBytes) + return descriptorString(descriptor, "originAccountIdentity") + ?.takeIf { it.matches(ACCOUNT_IDENTITY) } + ?: error("The private support recovery descriptor is invalid.") + } + + private fun descriptorString(descriptor: File, name: String): String? = + json.parseToJsonElement(descriptor.readText()).jsonObject[name]?.jsonPrimitive?.content + + private fun deleteDurably(file: File) { + Files.deleteIfExists(file.toPath()) + } + + private fun deletePrivate(file: File) { + check(!file.exists() || deleteFile(file) || !file.exists()) { + "Could not clear private support submission storage." + } + } + + private companion object { + val ACCOUNT_IDENTITY = Regex("[0-9a-f]{32}(?:[0-9a-f]{32})?") + val SUPPORT_ARCHIVE = Regex("support-[0-9a-f-]{36}\\.zip") + val COMPLETED_DESCRIPTOR = Regex("completed-[0-9a-f-]{36}\\.json") + const val MAX_PENDING_DESCRIPTOR_BYTES = 4L * 1024L * 1024L + const val MAX_COMPLETED_DESCRIPTOR_BYTES = 64L * 1024L + } +} diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt index c89196f92..30681851a 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportIntake.kt @@ -52,7 +52,6 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okio.Buffer import okio.BufferedSink -import okio.buffer class JvmSupportIntake( private val diagnostics: AsyncJvmSupportDiagnostics, @@ -121,6 +120,7 @@ class JvmSupportIntake( private var completedSubmissions: List = emptyList() private var completedExpiryJob: Job? = null private var storageUnavailableMessage: String? = null + private val retiredAccountIdentities = mutableSetOf() init { require(descriptorCleanupRetryMillis > 0L) @@ -154,10 +154,41 @@ class JvmSupportIntake( fun setActiveAccountIdentity(accountIdentity: String?) { synchronized(lock) { activeAccountIdentity = accountIdentity?.takeIf(String::isNotBlank) + activeAccountIdentity?.let(retiredAccountIdentities::remove) refreshVisibleStateLocked() } } + suspend fun removeAccount(accountIdentity: String) = withContext(Dispatchers.IO) { + awaitInitialization() + require(accountIdentity.matches(SUPPORT_ACCOUNT_IDENTITY_PATTERN)) + check(synchronized(lock) { storageUnavailableMessage } == null) { + "Private support submission recovery is unavailable." + } + var call: Call? = null + var target: PendingSubmission? = null + synchronized(lock) { + retiredAccountIdentities += accountIdentity + target = pending?.takeIf { it.originAccountIdentity == accountIdentity } + if (target != null || actualStateAccountIdentity == accountIdentity) { + cancellationRequested.set(true) + call = activeCall.getAndSet(null) + } + } + call?.cancel() + synchronized(persistenceLock) { + JvmSupportAccountStorageCleanup(temporaryRoot, directorySync, privateFileDelete) + .removeAccount(accountIdentity, target?.archive) + synchronized(lock) { + if (pending === target) pending = null + completedSubmissions = completedSubmissions.filterNot { it.originAccountIdentity == accountIdentity } + refreshVisibleStateLocked() + } + } + check(synchronized(lock) { !operationActive.get() || actualStateAccountIdentity != accountIdentity }) { + "The private support operation is still stopping." + } + } internal suspend fun awaitInitialization() = initialized.await() suspend fun submit( @@ -1438,6 +1469,10 @@ class JvmSupportIntake( } private fun finishSubmitted(submission: PendingSubmission, receipt: SupportIntakeReceipt) { + if (synchronized(lock) { submission.originAccountIdentity in retiredAccountIdentities }) { + finishTerminal(submission) + return + } validateReceipt(receipt) val existingCompletion = synchronized(lock) { completedSubmissions.firstOrNull { completed -> @@ -1470,9 +1505,17 @@ class JvmSupportIntake( ) return } - synchronized(lock) { - completedSubmissions = completedSubmissions + completedSubmission - scheduleCompletedExpiryLocked() + val retained = synchronized(lock) { + if (submission.originAccountIdentity in retiredAccountIdentities) false else { + completedSubmissions = completedSubmissions + completedSubmission + scheduleCompletedExpiryLocked() + true + } + } + if (!retained) { + deleteCompletedDescriptorSafely(completedDescriptor(completedSubmission.recordId)) + finishTerminal(submission) + return } finishTerminal(submission) publishState(submittedStateFor(submission.originAccountIdentity), submission.originAccountIdentity) @@ -2376,32 +2419,6 @@ private fun syncPosixDirectoryEntry(directory: File) { } } -private fun Long.saturatingAdd(increment: Long): Long = - if (this > Long.MAX_VALUE - increment) Long.MAX_VALUE else this + increment - -private class ProgressRequestBody( - private val delegate: RequestBody, - private val onProgress: (Long, Long) -> Unit, -) : RequestBody() { - override fun contentType() = delegate.contentType() - override fun contentLength(): Long = delegate.contentLength() - - override fun writeTo(sink: BufferedSink) { - val total = contentLength() - val forwarding = object : okio.ForwardingSink(sink) { - var uploaded = 0L - override fun write(source: okio.Buffer, byteCount: Long) { - super.write(source, byteCount) - uploaded += byteCount - onProgress(uploaded, total) - } - } - val buffered = forwarding.buffer() - delegate.writeTo(buffered) - buffered.flush() - } -} - internal class OneShotSupportMessageRequestBody( private val content: ByteArray, ) : RequestBody() { diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/ProgressRequestBody.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/ProgressRequestBody.kt new file mode 100644 index 000000000..9d174b9ba --- /dev/null +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/ProgressRequestBody.kt @@ -0,0 +1,31 @@ +package dev.obiente.nextcloudnative.app + +import okhttp3.RequestBody +import okio.BufferedSink +import okio.buffer + +internal class ProgressRequestBody( + private val delegate: RequestBody, + private val onProgress: (Long, Long) -> Unit, +) : RequestBody() { + override fun contentType() = delegate.contentType() + override fun contentLength(): Long = delegate.contentLength() + + override fun writeTo(sink: BufferedSink) { + val total = contentLength() + val forwarding = object : okio.ForwardingSink(sink) { + var uploaded = 0L + override fun write(source: okio.Buffer, byteCount: Long) { + super.write(source, byteCount) + uploaded += byteCount + onProgress(uploaded, total) + } + } + val buffered = forwarding.buffer() + delegate.writeTo(buffered) + buffered.flush() + } +} + +internal fun Long.saturatingAdd(increment: Long): Long = + if (this > Long.MAX_VALUE - increment) Long.MAX_VALUE else this + increment From c54a0ad26697344569970cbfc9d3e426edb45308 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 07:09:13 +0000 Subject: [PATCH 103/119] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 27d6c4af9..9f75bfa1a 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -13,6 +13,7 @@ "tools/marketing-capture-inputs.txt", "ui/build.gradle.kts", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryLifecycle.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountSettingsScreen.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityFilters.kt", @@ -74,8 +75,10 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt", @@ -430,9 +433,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": "b45bb5e694a2b893818b3e23f86056cc9f165bda29fabfeb79e4efd6d156e976", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt": "a048449071e71ff5bacecadd8e7b54430d1ef2cefae87feb0850ad3f16da7af8", + "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": "eeafb3c0436338c2965ba2a74100e2f9ad9dca7afd2fe94de96b344800a701da", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt": "582b81c26f5d330c6137235203de0f332936bf661fbaf003ed3ea032a048b664", "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", @@ -458,7 +462,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarScheduleViews.kt": "f37848200d712829405848db3606b5bec49c1421cc00de6144403f0f390ef5ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarWorkspaceNotice.kt": "b5c0cbbd46371eac5835758c836b41c7878d55f2e5da8f29679dde3aa210bf33", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarWorkspacePresentation.kt": "cd4638b118da879acfa711eed9bfcd643df4a847774925e8adae20bdd5c90b3b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "84a6e5ca035ac7796427c25f4b4ed1162832c4febbc44606dc06413cc2e33b58", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "fbefd12e3574060cfc6d7f1abb27f7173e9f8eb2fa8de3c80f85e650757d6d4f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChoresWorkspaceContext.kt": "f22d2bfc408f52eb61f3587e9bcf79ba7cb4c7c6afd1717f04f35731f1906391", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardOcsResponse.kt": "25780cc210c8e2f56fb2e45ab23a29a9030f6350a9a1ea29412650106a0d111f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt": "0bd46e04e07a06ae4483101db6741dcb24a7bc6150797d73814bcdae6a8853a7", @@ -491,9 +495,11 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicFormRelations.kt": "468f9bf41ea353dab6f816d333c3a6179d8d8e1f01f98f8eaf0890d5c0d381d6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationPersistence.kt": "e0e7b54e02869eabe797c1af6c77a78b0f7eaf7cb609c3cce8d35a8a710e6511", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicMutationRefresh.kt": "3e345cd7126e1270ef568afda401f317d9b6b9b9f5425cd61053230324a61872", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "88b66d6102b7325903521ae7ab85bf74a24a8234ccc6406d2692d76070b74e14", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "2cb445b752faa33f051cfd618bfac3c9f39fc20abce3176f782fe032aef4a98c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCacheLock.kt": "00059e704cff3e11ce89b097a6aaa239d4346d149c687c6bdc39699aa9dabe50", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeRuntime.kt": "09debe6409b538c3944a63b1e2c5022dc3e0584d746ef288183bfe0983fb1f7b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicRecordImagePreview.kt": "7ca77fe7c8424d7058502ac09b5f305ee551a101847776044b3aef84530e4633", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicSelectionParameters.kt": "f17e72830fe92739997e79a0bba099a91c801efe49d0910b1a2102b87e4ecddd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExifOrientation.kt": "028cb9c99b0b64b690c705935e4ec4ae263078340f4af77978f4d8cdd461698d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareEditDraft.kt": "df5c2b3ef90a8a7d0ea02d6587b563ebde8e84d471073f718a7d901f2c0a65fb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ExistingFileShareManager.kt": "fc7f8ac5ffb094da9d9a9f05bb2d072b13ff9da9281708f247b546258e0fc2e2", @@ -544,7 +550,7 @@ "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": "047c10233277632f1ff5e4dd7a77739c71629f05850c03070d20229adabd443d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "404d55e3cb691609cdbde00eaaddf230ec4e1626c342512b935e7c383630c8da", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactDetailDialog.kt": "ab6826a14ac9fbf62677bed2f6e93ef0fb1a57f1f074bb1a9e79fef212f715b3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "94d4b63c903d181f8e91bd2876fdee8bd7eefe011ba416acaa0a30c64b1473d4", @@ -632,12 +638,12 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "5b29a90b69bb32aba118ef6c8b3f9d6eb26c03835823119b4a0f5bb1c1f4cb17", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "fb424bf8979ac292ef30daba64e905e6f5123066cda793aa0244907e11b465c9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "d0ffb485a5b09ed8a02d470bb66acac8f1847a7d1d23df346e67cbf8bdeaf81f", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "6e39b83171c589ceee55f2f6aa635ea23126ee8f24135bce5bdfe6055774a9de", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "2d338b92d8bfe1895374af0a7d43a984e92a8f07fc22572c24a8746a50fcf05b", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "63a26e6b034604a525a358d8e422a3870d9fc3dc6a88c2cdf97a095259236bbf", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "29b0b80824eeb2d154f52a9eacc10bd4d7068b7eca3bd9427aec67903bf6ee0d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "aa9ec330c3a569e2bc9948858ddde1d939358069d38fbd7a0319b1ecc3baa363", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "4fdb230c0fa832a73bb7f7bb916ac8fdec7f53ba22aa519bb5de1b7b4243a97d", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "3685bbab002ab692fba9c0ad4309ff5da3e81be2a3576ede3185788b540e7f95", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "fead6cbf4f723ea46f99c9155b190967205d24f198687b7bfd5a213c474374b8", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "c96941c7582218754243f780a0bb0954ba31484719d8045f1e609f5d8c04a7c5", From 7055dd5601dbbdc01cd1fec8b83ec1d6ec86d5bc Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 10:08:52 +0200 Subject: [PATCH 104/119] fix(accounts): fence private cache retirement --- .../app/AccountPrivateMemoryCleanup.kt | 8 +- .../app/AccountWorkspaceMemoryCaches.kt | 33 +++++--- .../nextcloudnative/app/DashboardStatus.kt | 46 +--------- .../app/DashboardStatusMemoryCache.kt | 54 ++++++++++++ .../app/DashboardStatusScreens.kt | 8 +- .../app/GroupwareContactsScreen.kt | 3 +- .../app/GroupwareContactsState.kt | 32 ++++--- .../app/NextcloudDocumentPreview.kt | 2 + .../app/OfficeDocumentWorkflow.kt | 27 ++++-- .../nextcloudnative/app/OfficeWorkspace.kt | 3 +- .../nextcloudnative/app/PreviewMemoryCache.kt | 30 ++++--- .../app/AccountPrivateMemoryCleanupTest.kt | 83 +++++++++++++++++-- .../app/DashboardStatusTest.kt | 4 +- .../app/OfficeDocumentWorkflowTest.kt | 4 +- .../app/PreviewMemoryCacheTest.kt | 74 +++++++++++++++++ 15 files changed, 303 insertions(+), 108 deletions(-) create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt 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 7f608561a..e5f33b44b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt @@ -6,13 +6,13 @@ object AccountPrivateMemoryCleanup { internal fun purgeRetiredAccount(accountStorageKey: String) { require(accountStorageKey.length == 64 && accountStorageKey.all { it in '0'..'9' || it in 'a'..'f' }) - PreviewMemoryCache.removeAccount(accountStorageKey) + PreviewMemoryCache.purgeRetiredAccount(accountStorageKey) sharedNextcloudNotesCache.purgeRetiredAccount(accountStorageKey) sharedDynamicNativeMemoryCache.retireAccount(accountStorageKey) - sharedDashboardStatusMemoryCache.removeAccount(accountStorageKey) - ContactsWorkspaceMemoryCache.removeAccount(accountStorageKey) + sharedDashboardStatusMemoryCache.purgeRetiredAccount(accountStorageKey) + ContactsWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) DeckWorkspaceMemoryCache.removeAccount(accountStorageKey) - sharedDocumentEditingCapabilitiesCache.removeAccount(accountStorageKey) + sharedDocumentEditingCapabilitiesCache.purgeRetiredAccount(accountStorageKey) SupportSettingsDraftRegistry.removeAccount(accountStorageKey) removeCalendarWorkspaceMemory(accountStorageKey) removeUserStatusWorkspaceMemory(accountStorageKey) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt index 46ad04621..1bb7c3010 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt @@ -95,21 +95,32 @@ internal sealed interface UserStatusSurfaceState { } internal object UserStatusWorkspaceMemoryCache { + private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf() - fun get(session: NextcloudSession): UserStatusSurfaceState.Available? { - val key = session.accountId - return entries.remove(key)?.also { entries[key] = it } - } + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) - fun store(session: NextcloudSession, value: UserStatusSurfaceState.Available) { - val key = session.accountId - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_STATUS_ACCOUNTS) entries.remove(entries.keys.first()) + fun get(session: NextcloudSession): UserStatusSurfaceState.Available? = + gate.read(session.accountId.storageKey, null) { + val key = session.accountId + entries.remove(key)?.also { entries[key] = it } + } + + fun store( + session: NextcloudSession, + value: UserStatusSurfaceState.Available, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + val key = session.accountId + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_STATUS_ACCOUNTS) entries.remove(entries.keys.first()) + } } - fun removeAccount(accountStorageKey: String) { + internal fun purgeRetiredAccount(accountStorageKey: String) { entries.keys.removeAll { account -> account.storageKey == accountStorageKey } } } @@ -201,7 +212,7 @@ internal fun removeCalendarWorkspaceMemory(accountStorageKey: String) = CalendarWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) internal fun removeUserStatusWorkspaceMemory(accountStorageKey: String) = - UserStatusWorkspaceMemoryCache.removeAccount(accountStorageKey) + UserStatusWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) internal fun removeNextcloudNativeWorkspaceMemory(accountStorageKey: String) { PhotoTimelineUiStateRepository.removeAccount(accountStorageKey) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt index 2e3eca763..d07f798b8 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt @@ -665,48 +665,6 @@ fun planUserStatusEdit( return request.requireSafe() } -data class CachedDashboardStatus( - val dashboard: NativeDashboardSnapshot, - val status: NativeUserStatus?, - val storedAtEpochSeconds: Long, -) - -/** Account-private process cache. It stores no password and expires quickly. */ -internal class DashboardStatusMemoryCache( - private val ttlSeconds: Long = DASHBOARD_STATUS_CACHE_TTL_SECONDS, -) { - private val entries = mutableMapOf() - - fun get(session: NextcloudSession, nowEpochSeconds: Long): CachedDashboardStatus? { - val entry = entries[session.accountId] ?: return null - return entry.takeIf { - nowEpochSeconds >= it.storedAtEpochSeconds && - nowEpochSeconds - it.storedAtEpochSeconds <= ttlSeconds - } ?: run { - entries.remove(session.accountId) - null - } - } - - fun store( - session: NextcloudSession, - dashboard: NativeDashboardSnapshot, - status: NativeUserStatus?, - nowEpochSeconds: Long, - ) { - require(nowEpochSeconds >= 0L) { "The dashboard cache timestamp is invalid." } - entries[session.accountId] = CachedDashboardStatus(dashboard, status, nowEpochSeconds) - } - - fun invalidate(session: NextcloudSession) { - entries.remove(session.accountId) - } - - fun removeAccount(accountStorageKey: String) { - entries.keys.removeAll { account -> account.storageKey == accountStorageKey } - } -} - internal fun retainedDashboardRefreshSnapshot( cached: CachedDashboardStatus?, displayed: NativeDashboardSnapshot?, @@ -731,8 +689,6 @@ internal fun DashboardResponseBudget.settleFailedRead( } } -internal val sharedDashboardStatusMemoryCache = DashboardStatusMemoryCache() - private fun statusMutationRequest( method: NextcloudApiMethod, path: String, @@ -913,5 +869,5 @@ private const val MAX_PREDEFINED_STATUSES = 128 private const val MAX_STATUS_MESSAGE_LENGTH = 512 private const val MAX_STATUS_ICON_LENGTH = 32 private const val MAX_STATUS_EXPIRY_SECONDS = 366L * 24L * 60L * 60L -private const val DASHBOARD_STATUS_CACHE_TTL_SECONDS = 60L +internal const val DASHBOARD_STATUS_CACHE_TTL_SECONDS = 60L private const val STATUS_HEX = "0123456789ABCDEF" diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt new file mode 100644 index 000000000..9396f3362 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt @@ -0,0 +1,54 @@ +package dev.obiente.nextcloudnative.app + +data class CachedDashboardStatus( + val dashboard: NativeDashboardSnapshot, + val status: NativeUserStatus?, + val storedAtEpochSeconds: Long, +) + +/** Account-private process cache. It stores no password and expires quickly. */ +internal class DashboardStatusMemoryCache( + private val ttlSeconds: Long = DASHBOARD_STATUS_CACHE_TTL_SECONDS, + private val gate: AccountPrivateMemoryGate = AccountPrivateMemoryGate(), +) { + private val entries = mutableMapOf() + + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun get(session: NextcloudSession, nowEpochSeconds: Long): CachedDashboardStatus? = + gate.read(session.accountId.storageKey, null) { + val entry = entries[session.accountId] ?: return@read null + entry.takeIf { cached -> + nowEpochSeconds >= cached.storedAtEpochSeconds && + nowEpochSeconds - cached.storedAtEpochSeconds <= ttlSeconds + } ?: run { + entries.remove(session.accountId) + null + } + } + + fun store( + session: NextcloudSession, + dashboard: NativeDashboardSnapshot, + status: NativeUserStatus?, + nowEpochSeconds: Long, + producer: AccountPrivateMemoryProducer?, + ) { + require(nowEpochSeconds >= 0L) { "The dashboard cache timestamp is invalid." } + gate.mutate(session.accountId.storageKey, producer) { + entries[session.accountId] = CachedDashboardStatus(dashboard, status, nowEpochSeconds) + } + } + + fun invalidate(session: NextcloudSession) { + gate.read(session.accountId.storageKey, Unit) { entries.remove(session.accountId) } + } + + internal fun purgeRetiredAccount(accountStorageKey: String) { + entries.keys.removeAll { account -> account.storageKey == accountStorageKey } + } +} + +internal val sharedDashboardStatusMemoryCache = + DashboardStatusMemoryCache(gate = sharedAccountPrivateMemoryGate) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt index 34ced8455..ded33e7a0 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt @@ -444,6 +444,7 @@ internal fun rememberNativeDashboardState( val cached = sharedDashboardStatusMemoryCache.get(session, now) val previousSnapshot = retainedDashboardRefreshSnapshot(cached, displayed?.snapshot) val previousStatus = cached?.status ?: displayed?.status + val cacheProducer = sharedDashboardStatusMemoryCache.producer(session) val cachePolicy = if (refreshAttempt > 0 || recoveryAttempt > 0) { NextcloudApiCachePolicy.RefreshNetwork } else { @@ -510,7 +511,6 @@ internal fun rememberNativeDashboardState( loadingWidgetIds = pendingWidgetIds, ) state = DashboardSurfaceState.Available(snapshot, previousStatus) - val completedResults = Channel(capacity = plans.size) val requestLimiter = Semaphore(MAX_CONCURRENT_DASHBOARD_ITEM_REQUESTS) val responseBudget = DashboardResponseBudget() @@ -625,6 +625,7 @@ internal fun rememberNativeDashboardState( dashboard = result.snapshot, status = result.status, nowEpochSeconds = currentDashboardEpochSeconds(), + producer = cacheProducer, ) } state = DashboardSurfaceState.Available( @@ -1430,8 +1431,8 @@ internal fun NativeUserStatusScreen( var mutationInProgress by remember(session) { mutableStateOf(false) } var mutationError by remember(session) { mutableStateOf(null) } val scope = rememberCoroutineScope() - LaunchedEffect(session, refreshAttempt) { + val cacheProducer = UserStatusWorkspaceMemoryCache.producer(session) val cached = UserStatusWorkspaceMemoryCache.get(session) if (cached != null) state = cached val retained = cached ?: state as? UserStatusSurfaceState.Available @@ -1465,7 +1466,7 @@ internal fun NativeUserStatusScreen( } }.onSuccess { loaded -> state = loaded - UserStatusWorkspaceMemoryCache.store(session, loaded) + UserStatusWorkspaceMemoryCache.store(session, loaded, cacheProducer) if (!draftInitialized) { customMessage = loaded.status.message.orEmpty() customIcon = loaded.status.icon.orEmpty().takeIf { @@ -1483,7 +1484,6 @@ internal fun NativeUserStatusScreen( } refreshing = false } - Column(modifier = Modifier.fillMaxSize()) { DashboardHeader( title = "User Status", diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt index 85c3d10e9..bba0dbdee 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt @@ -215,6 +215,7 @@ fun NativeGroupwareContactsScreen( LaunchedEffect(session, userId, loadAttempt, mutationRecoveryLoaded) { if (!mutationRecoveryLoaded) return@LaunchedEffect + val cacheProducer = ContactsWorkspaceMemoryCache.producer(session) val reconciliationConfirmed = mutationPostcondition?.let { postcondition -> runCatchingPreservingCancellation { val response = services.executeGroupwareDav( @@ -258,7 +259,7 @@ fun NativeGroupwareContactsScreen( ContactsLoadState.Ready(addressBooks, contacts) to concurrentlyDeletedObjectCount }.onSuccess { loaded -> state = loaded.first - ContactsWorkspaceMemoryCache.store(session, userId, loaded.first) + ContactsWorkspaceMemoryCache.store(session, userId, loaded.first, cacheProducer) if (loaded.second > 0) { refreshError = "${loaded.second} contacts changed during refresh; the remaining contacts are current." } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt index de118f941..8f04dcd29 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt @@ -10,21 +10,33 @@ internal sealed interface ContactsLoadState { } internal object ContactsWorkspaceMemoryCache { + private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf, ContactsLoadState.Ready>() - fun get(session: NextcloudSession, userId: String): ContactsLoadState.Ready? { - val key = session.accountId to userId - return entries.remove(key)?.also { entries[key] = it } - } + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun get(session: NextcloudSession, userId: String): ContactsLoadState.Ready? = + gate.read(session.accountId.storageKey, null) { + val key = session.accountId to userId + entries.remove(key)?.also { entries[key] = it } + } - fun store(session: NextcloudSession, userId: String, value: ContactsLoadState.Ready) { - val key = session.accountId to userId - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_CONTACT_ACCOUNTS) entries.remove(entries.keys.first()) + fun store( + session: NextcloudSession, + userId: String, + value: ContactsLoadState.Ready, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + val key = session.accountId to userId + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_CONTACT_ACCOUNTS) entries.remove(entries.keys.first()) + } } - fun removeAccount(accountStorageKey: String) { + internal fun purgeRetiredAccount(accountStorageKey: String) { entries.keys.removeAll { (account) -> account.storageKey == accountStorageKey } } } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt index e4a70acd9..0fa1a39fb 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt @@ -86,6 +86,7 @@ fun NextcloudDocumentPreview( ) } LaunchedEffect(services, session.serverUrl, session.loginName) { + val cacheProducer = sharedDocumentEditingCapabilitiesCache.producer(session) runCatching { services.loadDocumentEditingCapabilities( session, @@ -100,6 +101,7 @@ fun NextcloudDocumentPreview( session, result.value, result.responseEtag, + cacheProducer, ) } NextcloudConditionalRead.NotModified -> Unit diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt index a4d53c13d..c81d30ea7 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt @@ -232,25 +232,36 @@ internal data class CachedDocumentEditingCapabilities( ) /** Small process-local capability cache; it never stores edit or WOPI tokens. */ -internal class NextcloudDocumentEditingCapabilitiesCache { +internal class NextcloudDocumentEditingCapabilitiesCache( + private val gate: AccountPrivateMemoryGate = AccountPrivateMemoryGate(), +) { private val entries = mutableMapOf() - fun get(session: NextcloudSession): CachedDocumentEditingCapabilities? = entries[previewCacheDigest(session)] + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun get(session: NextcloudSession): CachedDocumentEditingCapabilities? = + gate.read(session.accountId.storageKey, null) { entries[previewCacheDigest(session)] } fun store( session: NextcloudSession, capabilities: NextcloudDocumentEditingCapabilities, etag: String?, + producer: AccountPrivateMemoryProducer?, ) { - entries[previewCacheDigest(session)] = CachedDocumentEditingCapabilities( - capabilities = capabilities, - etag = etag?.takeIf(String::isNotBlank), - ) + gate.mutate(session.accountId.storageKey, producer) { + entries[previewCacheDigest(session)] = CachedDocumentEditingCapabilities( + capabilities = capabilities, + etag = etag?.takeIf(String::isNotBlank), + ) + } } - fun removeAccount(accountStorageKey: String) { + internal fun purgeRetiredAccount(accountStorageKey: String) { entries.remove(accountStorageKey) } } -internal val sharedDocumentEditingCapabilitiesCache = NextcloudDocumentEditingCapabilitiesCache() +internal val sharedDocumentEditingCapabilitiesCache = NextcloudDocumentEditingCapabilitiesCache( + gate = sharedAccountPrivateMemoryGate, +) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt index 0c29be531..7f5c2c481 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt @@ -98,10 +98,11 @@ internal fun officeWorkspaceOperations( cachedFiles = { services.listFilesCachedWithSource(session, userId, it) }, files = { services.listFilesWithSource(session, userId, it) }, capabilities = { + val cacheProducer = sharedDocumentEditingCapabilitiesCache.producer(session) val cached = sharedDocumentEditingCapabilitiesCache.get(session) when (val result = services.loadDocumentEditingCapabilities(session, cached?.etag, cached?.capabilities)) { is NextcloudConditionalRead.Modified -> result.value.also { - sharedDocumentEditingCapabilitiesCache.store(session, it, result.responseEtag) + sharedDocumentEditingCapabilitiesCache.store(session, it, result.responseEtag, cacheProducer) } NextcloudConditionalRead.NotModified -> cached?.capabilities ?: error("Document editor metadata was not returned.") diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt index 0c3d76244..fc0d14a69 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt @@ -9,27 +9,32 @@ import kotlinx.coroutines.CancellationException */ internal object PreviewMemoryCache { private const val MAX_BYTES = 24 * 1024 * 1024 + private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf() private var bytes = 0 - fun get(key: PreviewCacheKey): ByteArray? { - val value = entries.remove(key) ?: return null + fun producer(accountStorageKey: String): AccountPrivateMemoryProducer? = gate.producer(accountStorageKey) + + fun get(key: PreviewCacheKey): ByteArray? = gate.read(key.account, null) { + val value = entries.remove(key) ?: return@read null entries[key] = value - return value + value } - fun put(key: PreviewCacheKey, value: ByteArray) { + fun put(key: PreviewCacheKey, value: ByteArray, producer: AccountPrivateMemoryProducer?) { if (value.size > MAX_BYTES) return - entries.remove(key)?.let { bytes -= it.size } - entries[key] = value - bytes += value.size - while (bytes > MAX_BYTES && entries.isNotEmpty()) { - val oldestKey = entries.keys.first() - bytes -= entries.remove(oldestKey)?.size ?: 0 + gate.mutate(key.account, producer) { + entries.remove(key)?.let { bytes -= it.size } + entries[key] = value + bytes += value.size + while (bytes > MAX_BYTES && entries.isNotEmpty()) { + val oldestKey = entries.keys.first() + bytes -= entries.remove(oldestKey)?.size ?: 0 + } } } - fun removeAccount(accountStorageKey: String) { + internal fun purgeRetiredAccount(accountStorageKey: String) { entries.keys.filter { key -> key.account == accountStorageKey }.forEach { key -> bytes -= entries.remove(key)?.size ?: 0 } @@ -69,7 +74,8 @@ internal suspend fun loadPreviewMemoryCached( load: suspend () -> ByteArray, ): ByteArray { if (key == null) return load() - return PreviewMemoryCache.get(key) ?: load().also { PreviewMemoryCache.put(key, it) } + val producer = PreviewMemoryCache.producer(key.account) + return PreviewMemoryCache.get(key) ?: load().also { PreviewMemoryCache.put(key, it, producer) } } internal suspend fun NextcloudPlatformServices.loadPreviewCached( diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt index 8476e0af3..0106d8cbc 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt @@ -27,8 +27,8 @@ class AccountPrivateMemoryCleanupTest { val removedDynamicProducer = sharedDynamicNativeMemoryCache.producer(removed) val retainedDynamicProducer = sharedDynamicNativeMemoryCache.producer(retained) try { - PreviewMemoryCache.put(removedPreview, byteArrayOf(1)) - PreviewMemoryCache.put(retainedPreview, byteArrayOf(2)) + PreviewMemoryCache.put(removedPreview, byteArrayOf(1), removedProducer) + PreviewMemoryCache.put(retainedPreview, byteArrayOf(2), retainedProducer) sharedNextcloudNotesCache.storeDetail(removed, note(1L, "Removed"), removedProducer) sharedNextcloudNotesCache.storeDetail(retained, note(2L, "Retained"), retainedProducer) sharedDynamicNativeMemoryCache.storeScreen( @@ -37,17 +37,25 @@ class AccountPrivateMemoryCleanupTest { sharedDynamicNativeMemoryCache.storeScreen( dynamicKey(retained), dynamicSnapshot(2), retainedDynamicProducer, ) - sharedDashboardStatusMemoryCache.store(removed, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 1L) - sharedDashboardStatusMemoryCache.store(retained, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 1L) - ContactsWorkspaceMemoryCache.store(removed, "removed", ContactsLoadState.Ready(emptyList(), emptyList())) - ContactsWorkspaceMemoryCache.store(retained, "retained", ContactsLoadState.Ready(emptyList(), emptyList())) + sharedDashboardStatusMemoryCache.store( + removed, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 1L, removedProducer, + ) + sharedDashboardStatusMemoryCache.store( + retained, NativeDashboardSnapshot(emptyList(), emptyMap()), null, 1L, retainedProducer, + ) + ContactsWorkspaceMemoryCache.store( + removed, "removed", ContactsLoadState.Ready(emptyList(), emptyList()), removedProducer, + ) + ContactsWorkspaceMemoryCache.store( + retained, "retained", ContactsLoadState.Ready(emptyList(), emptyList()), retainedProducer, + ) DeckWorkspaceMemoryCache.store(removed, deckSnapshot()) DeckWorkspaceMemoryCache.store(retained, deckSnapshot()) sharedDocumentEditingCapabilitiesCache.store( - removed, NextcloudDocumentEditingCapabilities.Unavailable, null, + removed, NextcloudDocumentEditingCapabilities.Unavailable, null, removedProducer, ) sharedDocumentEditingCapabilitiesCache.store( - retained, NextcloudDocumentEditingCapabilities.Unavailable, null, + retained, NextcloudDocumentEditingCapabilities.Unavailable, null, retainedProducer, ) ActivityWorkspaceMemoryCache.store( removed, "all", ActivityTimelineState(initialized = true), removedProducer, @@ -115,6 +123,50 @@ class AccountPrivateMemoryCleanupTest { AccountPrivateMemoryCleanup.removeAccount(accountKey) } + @Test + fun `stale private reads cannot repopulate removed caches after reactivation`() { + val account = session("private-cache-race") + val accountKey = account.accountId.storageKey + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + val staleProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(accountKey)) + val dashboard = NativeDashboardSnapshot(emptyList(), emptyMap()) + val contacts = ContactsLoadState.Ready(emptyList(), emptyList()) + val status = userStatusState() + + AccountPrivateMemoryLifecycle.retireAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + sharedDashboardStatusMemoryCache.store(account, dashboard, null, 1L, staleProducer) + ContactsWorkspaceMemoryCache.store(account, "user", contacts, staleProducer) + UserStatusWorkspaceMemoryCache.store(account, status, staleProducer) + sharedDocumentEditingCapabilitiesCache.store( + account, NextcloudDocumentEditingCapabilities.Unavailable, null, staleProducer, + ) + PreviewMemoryCache.put( + PreviewCacheKey(accountKey, "core", 1L, "etag", 64, 64), byteArrayOf(1), staleProducer, + ) + + assertNull(sharedDashboardStatusMemoryCache.get(account, 1L)) + assertNull(ContactsWorkspaceMemoryCache.get(account, "user")) + assertNull(UserStatusWorkspaceMemoryCache.get(account)) + assertNull(sharedDocumentEditingCapabilitiesCache.get(account)) + assertNull(PreviewMemoryCache.get(PreviewCacheKey(accountKey, "core", 1L, "etag", 64, 64))) + + val currentProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(accountKey)) + sharedDashboardStatusMemoryCache.store(account, dashboard, null, 2L, currentProducer) + ContactsWorkspaceMemoryCache.store(account, "user", contacts, currentProducer) + UserStatusWorkspaceMemoryCache.store(account, status, currentProducer) + sharedDocumentEditingCapabilitiesCache.store( + account, NextcloudDocumentEditingCapabilities.Unavailable, null, currentProducer, + ) + + assertNotNull(sharedDashboardStatusMemoryCache.get(account, 2L)) + assertNotNull(ContactsWorkspaceMemoryCache.get(account, "user")) + assertNotNull(UserStatusWorkspaceMemoryCache.get(account)) + assertNotNull(sharedDocumentEditingCapabilitiesCache.get(account)) + AccountPrivateMemoryCleanup.removeAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + } + private fun session(name: String) = NextcloudSession( serverUrl = "https://$name.private-memory.example.test", loginName = name, @@ -141,6 +193,21 @@ class AccountPrivateMemoryCleanupTest { pagination = DynamicPaginationCheckpoint(page, "page-$page"), ) + private fun userStatusState() = UserStatusSurfaceState.Available( + capabilities = NativeUserStatusCapabilities(true, true, true, true), + status = NativeUserStatus( + userId = "user", + presence = NativeUserPresence.Online, + message = "Private status", + icon = null, + messageId = null, + clearAtEpochSeconds = null, + messageIsPredefined = false, + statusIsUserDefined = true, + ), + predefined = emptyList(), + ) + private fun deckSnapshot() = DeckWorkspaceMemorySnapshot( state = DeckWorkspaceState.Loading, loadedBoards = emptyList(), diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusTest.kt index 44da132d3..6324aa9b2 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusTest.kt @@ -201,7 +201,7 @@ class DashboardStatusTest { val session = NextcloudSession("https://cloud.example.test", "person", "secret") val snapshot = NativeDashboardSnapshot(listOf(widget("calendar", setOf(2))), emptyMap()) val cache = DashboardStatusMemoryCache(ttlSeconds = 60L) - cache.store(session, snapshot, status = null, nowEpochSeconds = 100L) + cache.store(session, snapshot, status = null, nowEpochSeconds = 100L, producer = cache.producer(session)) val expired = cache.get(session, nowEpochSeconds = 161L) @@ -688,7 +688,7 @@ class DashboardStatusTest { ) val dashboard = NativeDashboardSnapshot(listOf(widget), mapOf("calendar" to emptyList())) - cache.store(first, dashboard, status = null, nowEpochSeconds = 1_000) + cache.store(first, dashboard, status = null, nowEpochSeconds = 1_000, producer = cache.producer(first)) assertEquals(dashboard, cache.get(rotated, 1_030)?.dashboard) assertNull(cache.get(second, 1_030)) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflowTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflowTest.kt index 01a609a33..b98ab2cd6 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflowTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflowTest.kt @@ -162,7 +162,7 @@ class OfficeDocumentWorkflowTest { val first = NextcloudSession("https://cloud.example", "ada", "secret") val second = NextcloudSession("https://cloud.example", "grace", "secret") - cache.store(first, officeCapabilities(), "\"cap-v1\"") + cache.store(first, officeCapabilities(), "\"cap-v1\"", cache.producer(first)) assertEquals("\"cap-v1\"", cache.get(first)?.etag) assertEquals(null, cache.get(second)) @@ -175,7 +175,7 @@ class OfficeDocumentWorkflowTest { fun capabilityCacheDoesNotConflateCaseSensitiveServerInstallationPaths() { val cache = NextcloudDocumentEditingCapabilitiesCache() val session = NextcloudSession("https://cloud.example/Cloud", "ada", "secret") - cache.store(session, officeCapabilities(), "\"cap-v1\"") + cache.store(session, officeCapabilities(), "\"cap-v1\"", cache.producer(session)) assertEquals(null, cache.get(session.copy(serverUrl = "https://cloud.example/cloud"))) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCacheTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCacheTest.kt index b0021acff..b7233b0dd 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCacheTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCacheTest.kt @@ -1,6 +1,12 @@ package dev.obiente.nextcloudnative.app +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -65,4 +71,72 @@ class PreviewMemoryCacheTest { assertContentEquals(byteArrayOf(1), repeated) assertContentEquals(byteArrayOf(3), second) } + + @Test + fun loadStartedBeforeRetirementCannotPublishAfterReactivation() = runBlocking { + val session = NextcloudSession("https://preview-incarnation.example.test", "user", "secret") + val accountKey = session.accountId.storageKey + val key = PreviewCacheKey(accountKey, "core", 103L, "etag", 64, 64) + val loadStarted = CompletableDeferred() + val allowLoadToFinish = CompletableDeferred() + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + try { + val pending = async(Dispatchers.Default) { + loadPreviewMemoryCached(key) { + loadStarted.complete(Unit) + allowLoadToFinish.await() + byteArrayOf(4) + } + } + loadStarted.await() + + AccountPrivateMemoryLifecycle.retireAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + allowLoadToFinish.complete(Unit) + + assertContentEquals(byteArrayOf(4), pending.await()) + assertNull(PreviewMemoryCache.get(key)) + + assertContentEquals(byteArrayOf(5), loadPreviewMemoryCached(key) { byteArrayOf(5) }) + assertContentEquals(byteArrayOf(5), PreviewMemoryCache.get(key)) + } finally { + allowLoadToFinish.complete(Unit) + AccountPrivateMemoryCleanup.removeAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + } + } + + @Test + fun concurrentRetirementAndPublicationKeepsThePreviewMapConsistent(): Unit = runBlocking { + val session = NextcloudSession("https://preview-race.example.test", "user", "secret") + val accountKey = session.accountId.storageKey + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + try { + withContext(Dispatchers.Default) { + coroutineScope { + val publishers = List(4) { publisher -> + async { + repeat(100) { revision -> + val key = PreviewCacheKey( + accountKey, "core", publisher.toLong(), "etag-$revision", 64, 64, + ) + loadPreviewMemoryCached(key) { byteArrayOf(revision.toByte()) } + PreviewMemoryCache.get(key) + } + } + } + val retirements = async { + repeat(50) { + AccountPrivateMemoryLifecycle.retireAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + } + } + (publishers + retirements).awaitAll() + } + } + } finally { + AccountPrivateMemoryCleanup.removeAccount(accountKey) + AccountPrivateMemoryLifecycle.activateAccount(accountKey) + } + } } From f2c5359b9f992f3eb34f17d588cfa5df211b985f Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:15:01 +0000 Subject: [PATCH 105/119] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 9f75bfa1a..5b4d0fc92 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -45,6 +45,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChoresWorkspaceContext.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardOcsResponse.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusPresentation.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardWidgetsAcquisition.kt", @@ -433,10 +434,10 @@ "settings.gradle.kts": "0acbe4b907815189abfedb2256c8659558e5a7e6995a3681a2bdfb05e335fd1a", "tools/marketing-capture-inputs.txt": "3c96e83e1ba2d715b1cda9cedf036fc97b78c3ca63b7fc930325ed536940c1f3", "ui/build.gradle.kts": "2ecda1dd8c3ea78d3249c6c562cf8338a9a89f56dbd91d2db1af6b27eee8fb72", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt": "a048449071e71ff5bacecadd8e7b54430d1ef2cefae87feb0850ad3f16da7af8", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt": "697c1c248d79745ddb9ab7d96d8ae9a581800831d72fc17ac2d450b05f209773", "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": "582b81c26f5d330c6137235203de0f332936bf661fbaf003ed3ea032a048b664", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountWorkspaceMemoryCaches.kt": "11e6f0eab522b4fc799a67bf6e3881f96a62f458a9fff07648bacb5e3eaba9d5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityFilters.kt": "569265895b9442292c043f5ecbe2cdd55a9da6761a77b19b5f81fff34e999a10", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivityHistoryPresentation.kt": "88f25cd079f7d7fc1553f8969818740816e2788542b70377ea11f64201c44474", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ActivitySemantics.kt": "625e281281f28e5a2d0497626efc882f4fb2b5e778fcbdcac34425c853f83730", @@ -465,9 +466,10 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChatScreen.kt": "fbefd12e3574060cfc6d7f1abb27f7173e9f8eb2fa8de3c80f85e650757d6d4f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ChoresWorkspaceContext.kt": "f22d2bfc408f52eb61f3587e9bcf79ba7cb4c7c6afd1717f04f35731f1906391", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardOcsResponse.kt": "25780cc210c8e2f56fb2e45ab23a29a9030f6350a9a1ea29412650106a0d111f", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt": "0bd46e04e07a06ae4483101db6741dcb24a7bc6150797d73814bcdae6a8853a7", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt": "1a0a6b50c2b1f8b528d637520cb95acab42d1a6f4edde4fd47ced7a47bd4ddad", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusMemoryCache.kt": "9ae6444a94a51c711bb69a63705b596569b90f4f728794afe97f18bab6f75c1d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusPresentation.kt": "96eb3aa478be8932e695e2dfe2067cc7b8dccf5ffa6cc1370f2db27b8119e0ff", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt": "4cad163162b3075320b800a2bd28d4371b0074b4e5b7d294f1cc7830cb4901fd", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt": "d257b5d8fe04300bc095a16fedc2cceeb27515795d1156a19d3f3c46d5b215b5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardWidgetsAcquisition.kt": "7b646f0b992dddf9e16abbb497fc3832e284401d65fcc556a771aef00fc88a95", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckAsyncSafety.kt": "f6d939c1d1cf41b2421aad6906e9de7fb64800b146906312210472eebd22a93e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckCardDraftPersistence.kt": "f8aa5c05244022efd2b2c9cf356275f1a96812ebd5d44ec6a501d39c90413a0b", @@ -553,8 +555,8 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "404d55e3cb691609cdbde00eaaddf230ec4e1626c342512b935e7c383630c8da", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactDetailDialog.kt": "ab6826a14ac9fbf62677bed2f6e93ef0fb1a57f1f074bb1a9e79fef212f715b3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "94d4b63c903d181f8e91bd2876fdee8bd7eefe011ba416acaa0a30c64b1473d4", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "cd9d7bc716f23b3931f5c9b9ff95515fa509c42db11481cf28c4a2e407faa249", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "c0b42fa450b8748281c7208385f7257d286bc1f842588bc737f3c296dd4ec1d9", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "5ec7d1326c216a1f4af813bd5d1132063654cada621ed5ae54805ac5c63953e1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDav.kt": "88f84a03a3c2d95b130601d5aac62fad4b4e1ed559c7851d7d47a3b44d1d2bc9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavMutation.kt": "f32d31bb3564ef2e4a565f840c0db227e2632f6e15251a29151f233c0b25f718", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavResourceStatus.kt": "816453d49fb983cf4eca6c93335d102570f037a6e064188f89f9b734ec1ebea0", @@ -632,7 +634,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt": "99315e08ee2d9abbdcee0527abd61e614201a2ac81e39c180c34f2ff23480afe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt": "569376bf76a4df6a5ca76efeb9bca5308d771f737272eea679fde32f7f5278bd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudChunkUploadPolicy.kt": "2635374193979991fa6b0e4d244a248b27d9ff4fcca148b47412530cb3031d87", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt": "570371d4b41907de1c2abd202a2c767dbe7d734d4c098266380b530c41aba75c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt": "b9b73c66436a686381072162c9656c5158af7ca40c38311ec15193d8a652f145", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileListing.kt": "1f3de4f31d44d3ead139686d3fbcfbd27ccb520a9dab06ccc1d3e582ee25fb71", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt": "0704f87e909bbb9950e02b9ec21e3cab58e8a5438f2b900c94e296527f35c157", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "5b29a90b69bb32aba118ef6c8b3f9d6eb26c03835823119b4a0f5bb1c1f4cb17", @@ -650,9 +652,9 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudStatusMessages.kt": "03d7d79e38bdd9ac2e6a90e7172d2e9b7ea361df4d3e3d1a75d64584adc590ec", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesApi.kt": "e3c685b02592ea791b0c9a478a098fc5bf428893aa4764110c793a78040f341e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesFolderOperations.kt": "db6a04cfcd3b25b17c996cdc6dba21847d14f5970e06e6c24a3e2c85139dbe1d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt": "d8546d5f9519321e47ea6433224653f7532a1b76ba69d35cc7a9767995c30ff5", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeDocumentWorkflow.kt": "2ddc486b9a948ed1ff5de48d64471df54d55f2bdb055e2446da79b5fb656cdf3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeEditRevalidation.kt": "5f35e2efb61c541546a6c3d206d7d018929fdaca4cc2ba8d7b16c42c174892ae", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt": "2cc0f0f28dee9f74ed88633a571dd298e596e859614dc2b9496bd515d57ccf96", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspace.kt": "87714aad1d9cf4f541cb90a2eff5c30a41b696005cc6e9b4c3a60991b1838e75", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspaceLocation.kt": "b6e5e87939a7bf7c87ef82c2d0b11870128035b7efd9d75c784bff16c4e78787", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/OfficeWorkspaceScreen.kt": "117ae6c25d484bea042da87e1940ba906ed044659fb91297f0e9491868987fe1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PeopleActionPlanning.kt": "d73e2a4557b844e73ad27c43ea38695310d33c59e7dac8c51b140d7f3aa148f8", @@ -678,7 +680,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PlatformEmbeddedWebApp.kt": "a069dce940071b07df4cb3773d0c29a5b8c1be1785206d2ff9a7c17af33d911a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PlatformImage.kt": "5c1ebb3dc168c0a53d6db05c54b7329a571dec808aef3de7fba1a52bd93b2d3d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PlatformVideoPlayback.kt": "8f103ac182fdca3c78f1ffe7a3b15f06f05abb3be173747255fe4a9c84726758", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt": "97bf3dd028d0fbb81da3b8060f956c41f098b8121af3eae8558f171ae187b2cf", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt": "5839b1dfc65ecefde01d9b851219426a734286966c150d6ec8dcce967bedc01e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ProjectNewsAndUpdates.kt": "2ed4cdbd06b23ad2240f5bf245f24ad582d450b716604f6682d4fd785b92f809", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PublicContentDigest.kt": "175cbd645bb72bbf59085e5f749835397f29d6a39a289015fb5b7071d5a0688d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RawPhotoPreview.kt": "73c49576766e266ac5b29e072db6b2e987c2a11107460f7a8019fb8ef4f923ff", From 2f09ad24d4f3c6c2f6cbcd6650b7816859c83030 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 10:59:15 +0200 Subject: [PATCH 106/119] fix(accounts): finish private state cleanup --- .../AndroidAccountCredentialController.kt | 5 +-- .../AndroidAccountCredentialRecovery.kt | 18 ++++++++++- .../AndroidAccountCredentialTransitions.kt | 10 ++++-- .../AndroidAccountOperationGuard.kt | 8 +++-- .../AndroidAccountOwnedStateCleanup.kt | 26 ++++++++++++++- ...ndroidAccountRemovalCleanupRecoveryWork.kt | 8 ++++- .../AndroidNextcloudServices.kt | 18 ++++++++--- .../AndroidSupportDiagnostics.kt | 32 ++++++++++++++++++- .../AndroidAccountOperationGuardTest.kt | 26 ++++++++++++++- ...ndroidAccountPreviewCleanupRecoveryTest.kt | 12 +++++-- ...idAccountRemovalCleanupRecoveryWorkTest.kt | 26 +++++++++++++++ tools/kotlin-file-size-baseline.txt | 4 +-- .../app/HomeWorkspacePersistence.android.kt | 17 ++++++++++ .../app/AccountPrivateMemoryCleanup.kt | 2 +- .../app/DeckWorkspaceMemoryCache.kt | 27 +++++++++++----- .../app/HomeWorkspacePersistence.kt | 15 +++++++++ .../nextcloudnative/app/NativeDeckScreen.kt | 2 ++ .../app/AccountPrivateMemoryCleanupTest.kt | 8 +++-- .../app/HomeWorkspaceLayoutTest.kt | 20 ++++++++++++ .../app/JvmSupportAccountStorageCleanup.kt | 2 +- 20 files changed, 254 insertions(+), 32 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 43efbfb37..9d3db4bdd 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -28,8 +28,8 @@ internal class AndroidAccountCredentialController( private val resumeQueuedUploads: suspend (String) -> Unit, private val prepareAccountRemoval: suspend (NextcloudSession) -> Unit, private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, - private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String, String?, String?) -> Unit, - private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String, String, String?, String?) -> Unit, + private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String, String?, String?, String?) -> Unit, + private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String, String, String?, String?, String?) -> Unit, ) { private val appContext = context.applicationContext private val handoffCleanup = AndroidExternalFileHandoffCleanup(appContext, preferences, ::commitPreferences) @@ -200,6 +200,7 @@ internal class AndroidAccountCredentialController( identity, pendingCleanup.previewCacheIdentity, pendingCleanup.durableMutationIdentity, + pendingCleanup.legacyAccountScopeDigest, ) }, persistRemoval = { persistState(recovered.remove(accountId), pendingCleanup) }, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index c41cb5f8d..b5b84084c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -8,6 +8,7 @@ import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.accountRecord import dev.obiente.nextcloudnative.app.durableMutationAccountScope import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry +import java.security.MessageDigest import kotlinx.coroutines.sync.Mutex internal sealed interface AndroidAccountCredentialStoreRead { @@ -61,6 +62,7 @@ internal data class AndroidPendingAccountRemovalCleanup( val workIdentity: String, val previewCacheIdentity: String? = null, val durableMutationIdentity: String? = null, + val legacyAccountScopeDigest: String? = null, ) { init { require(ACCOUNT_STORAGE_KEY_PATTERN.matches(accountStorageKey)) @@ -73,6 +75,10 @@ internal data class AndroidPendingAccountRemovalCleanup( require(ACCOUNT_STORAGE_KEY_PATTERN.matches(identity)) require(previewCacheIdentity != null) } + legacyAccountScopeDigest?.let { identity -> + require(ACCOUNT_STORAGE_KEY_PATTERN.matches(identity)) + require(durableMutationIdentity != null) + } } } @@ -111,8 +117,16 @@ internal fun pendingAndroidAccountRemovalCleanup( workIdentity = NextcloudDocumentIds.accountKey(session), previewCacheIdentity = NextcloudDocumentIds.cacheAccountId(session), durableMutationIdentity = durableMutationAccountScope(session), + legacyAccountScopeDigest = legacyAndroidAccountPersistenceScopeDigest(session), ) +internal fun legacyAndroidAccountPersistenceScopeDigest(session: NextcloudSession): String? { + val identity = session.serverUrl.trimEnd('/') + "\u0000" + session.loginName + val digest = MessageDigest.getInstance("SHA-256").digest(identity.encodeToByteArray()) + .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } + return digest.takeUnless { it == session.accountId.storageKey } +} + internal fun encodeAndroidPendingAccountRemovalCleanup( cleanup: AndroidPendingAccountRemovalCleanup, ): String = listOfNotNull( @@ -120,19 +134,21 @@ internal fun encodeAndroidPendingAccountRemovalCleanup( cleanup.workIdentity, cleanup.previewCacheIdentity, cleanup.durableMutationIdentity, + cleanup.legacyAccountScopeDigest, ).joinToString(":") internal fun decodeAndroidPendingAccountRemovalCleanup( encoded: String, ): AndroidPendingAccountRemovalCleanup? { val fields = encoded.split(':') - if (fields.size !in 2..4) return null + if (fields.size !in 2..5) return null return runCatching { AndroidPendingAccountRemovalCleanup( accountStorageKey = fields[0], workIdentity = fields[1], previewCacheIdentity = fields.getOrNull(2), durableMutationIdentity = fields.getOrNull(3), + legacyAccountScopeDigest = fields.getOrNull(4), ) }.getOrNull() } diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index c4da0f73d..dc3796a2a 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -42,9 +42,15 @@ internal fun androidAccountRemovalCleanupRetryFailure(failure: Exception) = Ille internal suspend fun retryAndroidAccountOwnedStateCleanup( session: NextcloudSession, pending: AndroidPendingAccountRemovalCleanup, - retry: suspend (NextcloudSession, String, String?, String?) -> Unit, + retry: suspend (NextcloudSession, String, String?, String?, String?) -> Unit, ) { - retry(session, pending.workIdentity, pending.previewCacheIdentity, pending.durableMutationIdentity) + retry( + session, + pending.workIdentity, + pending.previewCacheIdentity, + pending.durableMutationIdentity, + pending.legacyAccountScopeDigest, + ) } internal suspend fun resumeAndroidQueuedUploadsAfterSelection( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index 6a83c4642..2cadce801 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -153,11 +153,13 @@ internal suspend fun withAndroidAuthenticatedFileMutation( expectedSession: dev.obiente.nextcloudnative.app.NextcloudSession, resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, - action: suspend (dev.obiente.nextcloudnative.app.NextcloudSession) -> Result, + action: suspend (dev.obiente.nextcloudnative.app.NextcloudSession, Boolean) -> Result, ): Result = if (accountMutationLeaseHeld) { - action(expectedSession) + action(expectedSession, true) } else { - guard.withAuthenticatedMutationSession(expectedSession, resolveSession, action) + guard.withAuthenticatedMutationSession(expectedSession, resolveSession) { currentSession -> + action(currentSession, true) + } } internal suspend fun withAndroidAccountPrivateStatePublication( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt index 8a5f82b2b..f81ad074e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOwnedStateCleanup.kt @@ -5,6 +5,7 @@ import dev.obiente.nextcloudnative.app.DynamicApiRequestCoalescer import dev.obiente.nextcloudnative.app.AccountPrivateMemoryCleanup import dev.obiente.nextcloudnative.app.NextcloudSession import dev.obiente.nextcloudnative.app.durableMutationAccountScope +import dev.obiente.nextcloudnative.app.removeAndroidHomeWorkspaceAccountPreferences import dev.obiente.nextcloudnative.contracts.DynamicApiResponseCache import java.io.File import kotlinx.coroutines.NonCancellable @@ -25,7 +26,7 @@ internal class AndroidAccountOwnedStateCleanup( private val dynamicDiscoveryCache: AndroidDynamicDiscoveryCache = AndroidDynamicDiscoveryCacheCoordinator.get( File(context.applicationContext.filesDir, "contracts/discoveries-v1"), ), - private val removeSupportAccount: suspend (String) -> Unit = {}, + private val removeSupportAccount: suspend (String) -> Unit, ) { private val appContext = context.applicationContext private val fileOffline = AndroidFileOfflineAccountCleanup(appContext) @@ -45,6 +46,13 @@ internal class AndroidAccountOwnedStateCleanup( { fenceAndroidDynamicApiStateForRemoval(cacheIdentity, dynamicApiState.coalescer, dynamicApiState.cache) }, { dynamicDiscoveryCache.retireAccount(session.accountId.storageKey, cacheIdentity) }, { removeSupportAccount(accountIdentity) }, + { + removeAndroidHomeWorkspaceAccountPreferences( + appContext, + session.accountId.storageKey, + legacyAndroidAccountPersistenceScopeDigest(session), + ) + }, { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(session) }, @@ -68,6 +76,7 @@ internal class AndroidAccountOwnedStateCleanup( accountIdentity: String, previewCacheIdentity: String?, durableMutationIdentity: String?, + legacyAccountScopeDigest: String?, ) { runAndroidAccountOwnedStateCleanups( previewCacheIdentity, @@ -80,6 +89,13 @@ internal class AndroidAccountOwnedStateCleanup( }, { dynamicDiscoveryCache.retireAccount(session.accountId.storageKey, previewCacheIdentity) }, { removeSupportAccount(accountIdentity) }, + { + removeAndroidHomeWorkspaceAccountPreferences( + appContext, + session.accountId.storageKey, + legacyAccountScopeDigest, + ) + }, { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(accountIdentity, session) }, @@ -103,6 +119,7 @@ internal class AndroidAccountOwnedStateCleanup( accountIdentity: String, previewCacheIdentity: String? = null, durableMutationIdentity: String? = null, + legacyAccountScopeDigest: String? = null, ) { runAndroidAccountOwnedStateCleanups( previewCacheIdentity, @@ -115,6 +132,13 @@ internal class AndroidAccountOwnedStateCleanup( }, { dynamicDiscoveryCache.retireAccount(accountStorageKey, previewCacheIdentity) }, { removeSupportAccount(accountIdentity) }, + { + removeAndroidHomeWorkspaceAccountPreferences( + appContext, + accountStorageKey, + legacyAccountScopeDigest, + ) + }, { revokeAndroidAccountDocumentGrants(appContext, accountIdentity) }, { fileOffline.removeForAccount(accountIdentity) }, { incomingShares.removeForAccount(accountIdentity) }, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt index b40b6b5eb..5948234fd 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWork.kt @@ -87,7 +87,12 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( val registry = preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null) ?.let(::restoreAndroidCredentialFreeRegistry) ?.registry - val cleanup = AndroidAccountOwnedStateCleanup(applicationContext) + val cleanup = AndroidAccountOwnedStateCleanup( + applicationContext, + removeSupportAccount = { accountIdentity -> + AndroidSupportIntakeCoordinator.removeAccount(applicationContext, accountIdentity) + }, + ) val snapshot = try { journal.snapshot() } catch (failure: Exception) { @@ -106,6 +111,7 @@ internal class AndroidAccountRemovalCleanupRecoveryWorker( pending.workIdentity, pending.previewCacheIdentity, pending.durableMutationIdentity, + pending.legacyAccountScopeDigest, ) } }, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index d0794d2e8..2e388b0a4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -2671,7 +2671,9 @@ internal class AndroidNextcloudServices( text: String, expectedEtag: String, ): SavedTextFile = withContext(Dispatchers.IO) { - withAndroidAuthenticatedFileMutation(accountMutationLeaseHeld, session, accountCredentials::loadSession) { currentSession -> + withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld, session, accountCredentials::loadSession, + ) { currentSession, accountMutationSerialized -> withNoBlockingAndroidDocumentWritebackSuspending(appContext, currentSession, path) { val specification = textFileDavSaveRequest(text, expectedEtag) val response = request( @@ -2681,6 +2683,7 @@ internal class AndroidNextcloudServices( rawBody = specification.body, contentType = specification.contentType, headers = specification.headers, + accountMutationSerialized = accountMutationSerialized, ) val confirmation = confirmTextFileDavSave(response.status) val etag = response.etag ?: try { @@ -2701,7 +2704,9 @@ internal class AndroidNextcloudServices( require(utf8.size.toLong() <= MAX_EDITABLE_TEXT_BYTES) { "Text files larger than ${MAX_EDITABLE_TEXT_BYTES / (1024 * 1024)} MiB cannot be created in the app." } - withAndroidAuthenticatedFileMutation(accountMutationLeaseHeld, session, accountCredentials::loadSession) { currentSession -> + withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld, session, accountCredentials::loadSession, + ) { currentSession, accountMutationSerialized -> val response = request( method = "PUT", url = buildNextcloudFileUrl(currentSession.serverUrl, userId, path), @@ -2709,6 +2714,7 @@ internal class AndroidNextcloudServices( rawBody = utf8, contentType = "text/plain; charset=utf-8", headers = mapOf("Accept" to "*/*", "If-None-Match" to "*"), + accountMutationSerialized = accountMutationSerialized, ) if (response.status == 412) return@withAndroidAuthenticatedFileMutation SavedTextFile(null, false) check(response.status in 200..299) { "Creating the text file failed (HTTP ${response.status})." } @@ -2722,13 +2728,14 @@ internal class AndroidNextcloudServices( withContext(Dispatchers.IO) { withAndroidAuthenticatedFileMutation( accountMutationLeaseHeld, session, accountCredentials::loadSession, - ) { currentSession -> + ) { currentSession, accountMutationSerialized -> val response = request( method = "MKCOL", url = buildNextcloudFileUrl(currentSession.serverUrl, userId, path), session = currentSession, headers = mapOf("Accept" to "*/*", "If-None-Match" to "*"), maxResponseBytes = 64 * 1024, + accountMutationSerialized = accountMutationSerialized, ) if (response.status in setOf(405, 412)) return@withAndroidAuthenticatedFileMutation false if (response.status !in 200..299) throw fileOperationException(response.status) @@ -2741,7 +2748,9 @@ internal class AndroidNextcloudServices( override suspend fun executeFileMutation(session: NextcloudSession, userId: String, mutation: NextcloudFileMutation): NextcloudFileMutationResult = withContext(Dispatchers.IO) { val spec = mutation.toWebDavMutationSpec() - withAndroidAuthenticatedFileMutation(accountMutationLeaseHeld, session, accountCredentials::loadSession) { currentSession -> + withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld, session, accountCredentials::loadSession, + ) { currentSession, accountMutationSerialized -> withNoBlockingAndroidDocumentWritebackSuspending( appContext, currentSession, @@ -2761,6 +2770,7 @@ internal class AndroidNextcloudServices( session = currentSession, headers = headers, maxResponseBytes = 64 * 1024, + accountMutationSerialized = accountMutationSerialized, ) if (response.status !in 200..299) throw fileOperationException(response.status) val accountId = NextcloudDocumentIds.accountKey(currentSession) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt index dba0c9a26..e2e7e4087 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidSupportDiagnostics.kt @@ -8,6 +8,7 @@ import android.content.Intent import androidx.core.content.FileProvider import dev.obiente.nextcloudnative.app.AsyncJvmSupportDiagnostics import dev.obiente.nextcloudnative.app.JvmSupportIntake +import dev.obiente.nextcloudnative.app.JvmSupportAccountStorageCleanup import dev.obiente.nextcloudnative.app.SupportDiagnosticComponent import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft import dev.obiente.nextcloudnative.app.SupportDiagnosticFieldDraft @@ -18,8 +19,12 @@ import dev.obiente.nextcloudnative.app.boundedSupportDiagnosticsEnvironment import dev.obiente.nextcloudnative.app.toSupportDiagnosticExceptionDraft import dev.obiente.nextcloudnative.app.runWithCleanupBeforeHandoff import java.io.File +import java.nio.channels.FileChannel +import java.nio.file.StandardOpenOption import java.util.UUID import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -81,6 +86,7 @@ internal object AndroidSupportDiagnostics { * restoring or mutating that directory while an earlier facade is still packaging or uploading. */ internal object AndroidSupportIntakeCoordinator { + private val lock = ReentrantLock() @Volatile private var instance: JvmSupportIntake? = null @@ -88,7 +94,7 @@ internal object AndroidSupportIntakeCoordinator { context: Context, diagnostics: AsyncJvmSupportDiagnostics, client: OkHttpClient, - ): JvmSupportIntake = instance ?: synchronized(this) { + ): JvmSupportIntake = instance ?: lock.withLock { val appContext = context.applicationContext ?: context instance ?: JvmSupportIntake( diagnostics = diagnostics, @@ -98,6 +104,30 @@ internal object AndroidSupportIntakeCoordinator { supportMutationsAllowed = appContext.cloudMutationGate(), ).also { instance = it } } + + suspend fun removeAccount(context: Context, accountIdentity: String) { + instance?.let { intake -> + intake.removeAccount(accountIdentity) + return + } + val intakeCreatedWhileWaiting = withContext(Dispatchers.IO) { + lock.withLock { + instance ?: run { + val appContext = context.applicationContext ?: context + JvmSupportAccountStorageCleanup( + root = File(appContext.noBackupFilesDir, "support-submissions"), + directorySync = ::syncAndroidSupportDirectory, + ).removeAccount(accountIdentity, inMemoryArchive = null) + null + } + } + } + intakeCreatedWhileWaiting?.removeAccount(accountIdentity) + } +} + +private fun syncAndroidSupportDirectory(directory: File) { + FileChannel.open(directory.toPath(), StandardOpenOption.READ).use { channel -> channel.force(true) } } internal fun androidSupportDiagnosticsEnvironment(): SupportDiagnosticsEnvironment = diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 7c0c29ca2..bc60db02b 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -652,7 +652,7 @@ class AndroidAccountOperationGuardTest { expectedSession = original, resolveSession = { current }, guard = guard, - ) { + ) { _, _ -> requestMethod = method } } @@ -665,4 +665,28 @@ class AndroidAccountOperationGuardTest { assertTrue(mutation.await().isFailure) assertEquals(null, requestMethod) } + + @Test + fun authenticatedFileMutationPropagatesItsHeldLeaseToTheRequestBoundary() = runBlocking { + val guard = AndroidAccountOperationGuard() + val session = NextcloudSession("https://cloud.example.test", "alice", "fixture-password") + var requestObservedSerializedLease = false + + withAndroidAuthenticatedFileMutation( + accountMutationLeaseHeld = false, + expectedSession = session, + resolveSession = { session }, + guard = guard, + ) { _, accountMutationSerialized -> + requestObservedSerializedLease = accountMutationSerialized + val nestedLeaseAvailable = guard.tryWithAccount( + NextcloudDocumentIds.accountKey(session), + unavailable = { false }, + action = { true }, + ) + assertFalse(nestedLeaseAvailable) + } + + assertTrue(requestObservedSerializedLease) + } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt index a65ea9a89..2b006f805 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountPreviewCleanupRecoveryTest.kt @@ -18,12 +18,14 @@ class AndroidAccountPreviewCleanupRecoveryTest { val pending = pendingAndroidAccountRemovalCleanup(removed) val retried = mutableListOf>() - retryAndroidAccountOwnedStateCleanup(readded, pending) { _, workIdentity, previewIdentity, _ -> + retryAndroidAccountOwnedStateCleanup(readded, pending) { _, workIdentity, previewIdentity, _, _ -> retried += workIdentity to previewIdentity } assertEquals(removed.accountId, readded.accountId) assertFalse(NextcloudDocumentIds.cacheAccountId(removed) == NextcloudDocumentIds.cacheAccountId(readded)) + assertEquals(legacyAndroidAccountPersistenceScopeDigest(removed), pending.legacyAccountScopeDigest) + assertTrue(requireNotNull(pending.legacyAccountScopeDigest) != pending.accountStorageKey) assertEquals( NextcloudDocumentIds.accountKey(removed) to NextcloudDocumentIds.cacheAccountId(removed), retried.single(), @@ -40,7 +42,7 @@ class AndroidAccountPreviewCleanupRecoveryTest { ) val retriedPreviewIdentities = mutableListOf() - retryAndroidAccountOwnedStateCleanup(readded, legacy) { _, _, previewIdentity, _ -> + retryAndroidAccountOwnedStateCleanup(readded, legacy) { _, _, previewIdentity, _, _ -> retriedPreviewIdentities += previewIdentity } @@ -54,6 +56,7 @@ class AndroidAccountPreviewCleanupRecoveryTest { assertEquals(64, requireNotNull(pending.previewCacheIdentity).length) assertEquals(64, requireNotNull(pending.durableMutationIdentity).length) + assertNull(pending.legacyAccountScopeDigest) assertTrue(pending.previewCacheIdentity.startsWith(pending.workIdentity)) assertEquals(pending, decodeAndroidPendingAccountRemovalCleanup(encodeAndroidPendingAccountRemovalCleanup(pending))) assertNull( @@ -66,6 +69,11 @@ class AndroidAccountPreviewCleanupRecoveryTest { "${pending.accountStorageKey}:${pending.workIdentity}", )?.durableMutationIdentity, ) + assertNull( + decodeAndroidPendingAccountRemovalCleanup( + "${pending.accountStorageKey}:${pending.workIdentity}", + )?.legacyAccountScopeDigest, + ) val mismatchedIdentity = if (pending.workIdentity.first() == 'f') "e".repeat(64) else "f".repeat(64) assertFailsWith { pending.copy(previewCacheIdentity = mismatchedIdentity) } } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt index 39efbfa30..585998455 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemovalCleanupRecoveryWorkTest.kt @@ -104,6 +104,32 @@ class AndroidAccountRemovalCleanupRecoveryWorkTest { assertFalse(messages.single().contains("private/path/account-secret")) } + @Test + fun supportCleanupFailureKeepsRestartRecoveryPendingUntilRetrySucceeds() = runBlocking { + val pending = cleanup("a", "1") + var supportCleanupFails = true + var supportCleanupAttempts = 0 + var clears = 0 + + suspend fun recover(): Boolean = recoverPendingAndroidAccountRemovalCleanups( + pending = listOf(pending), + accountOwnedByRegistry = { false }, + removeAccountOwnedWork = { + supportCleanupAttempts += 1 + if (supportCleanupFails) error("synthetic support cleanup failure") + }, + clearCleanup = { clears += 1 }, + recordFailure = {}, + ) + + assertFalse(recover()) + assertEquals(0, clears) + supportCleanupFails = false + assertTrue(recover()) + assertEquals(2, supportCleanupAttempts) + assertEquals(1, clears) + } + @Test fun unreadableCleanupJournalDefersRecoveryWithABoundedMessage() { val messages = mutableListOf() diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 768d3b7c0..98847bdab 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|4238 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4237 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|1003 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1224 @@ -23,7 +23,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingDynamicUiCaptu ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MediaSearchDav.kt|1244 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckBoardSurface.kt|1114 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckRelationDialogs.kt|1234 -ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1892 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt|1883 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt|1331 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12348 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt|1693 diff --git a/ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.android.kt b/ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.android.kt index cc9217617..db7201520 100644 --- a/ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.android.kt +++ b/ui/src/androidMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.android.kt @@ -42,6 +42,23 @@ internal actual fun rememberHomeWorkspaceLayoutStorage(): HomeWorkspaceLayoutSto } } +fun removeAndroidHomeWorkspaceAccountPreferences( + context: Context, + accountScopeDigest: String, + legacyAccountScopeDigest: String?, +) { + val preferences = context.applicationContext.getSharedPreferences( + HOME_WORKSPACE_PREFERENCES, + Context.MODE_PRIVATE, + ) + val keys = homeWorkspaceAccountPersistenceKeys(accountScopeDigest, legacyAccountScopeDigest) + synchronized(ANDROID_HOME_WORKSPACE_STORAGE_LOCK) { + val editor = preferences.edit() + keys.forEach(editor::remove) + check(editor.commit()) { "The home workspace account settings could not be removed." } + } +} + @Composable internal actual fun rememberHomeFormFactor(): HomeFormFactor { val configuration = LocalConfiguration.current diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt index e5f33b44b..2c2d3ffe7 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt @@ -11,7 +11,7 @@ object AccountPrivateMemoryCleanup { sharedDynamicNativeMemoryCache.retireAccount(accountStorageKey) sharedDashboardStatusMemoryCache.purgeRetiredAccount(accountStorageKey) ContactsWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) - DeckWorkspaceMemoryCache.removeAccount(accountStorageKey) + DeckWorkspaceMemoryCache.purgeRetiredAccount(accountStorageKey) sharedDocumentEditingCapabilitiesCache.purgeRetiredAccount(accountStorageKey) SupportSettingsDraftRegistry.removeAccount(accountStorageKey) removeCalendarWorkspaceMemory(accountStorageKey) 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 3f9a44819..0cf769ad8 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt @@ -13,21 +13,32 @@ internal data class DeckWorkspaceMemorySnapshot( ) internal object DeckWorkspaceMemoryCache { + private val gate = sharedAccountPrivateMemoryGate private val entries = linkedMapOf() - fun get(session: NextcloudSession): DeckWorkspaceMemorySnapshot? { + fun producer(session: NextcloudSession): AccountPrivateMemoryProducer? = + gate.producer(session.accountId.storageKey) + + fun get(session: NextcloudSession): DeckWorkspaceMemorySnapshot? = + gate.read(session.accountId.storageKey, null) { val key = key(session) - return entries.remove(key)?.also { entries[key] = it } + entries.remove(key)?.also { entries[key] = it } } - fun store(session: NextcloudSession, value: DeckWorkspaceMemorySnapshot) { - val key = key(session) - entries.remove(key) - entries[key] = value - while (entries.size > MAXIMUM_RETAINED_DECK_ACCOUNTS) entries.remove(entries.keys.first()) + fun store( + session: NextcloudSession, + value: DeckWorkspaceMemorySnapshot, + producer: AccountPrivateMemoryProducer?, + ) { + gate.mutate(session.accountId.storageKey, producer) { + val key = key(session) + entries.remove(key) + entries[key] = value + while (entries.size > MAXIMUM_RETAINED_DECK_ACCOUNTS) entries.remove(entries.keys.first()) + } } - fun removeAccount(accountStorageKey: String) { + internal fun purgeRetiredAccount(accountStorageKey: String) { entries.remove(accountStorageKey) } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt index dfa532a84..908bab55e 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt @@ -21,6 +21,21 @@ internal interface HomeWorkspaceLayoutStorage { } } +internal fun homeWorkspaceAccountPersistenceKeys( + accountScopeDigest: String, + legacyAccountScopeDigest: String? = null, +): Set = buildSet { + setOfNotNull(accountScopeDigest, legacyAccountScopeDigest).forEach { digest -> + require(digest.isCanonicalSha256Digest()) { + "The home workspace account scope must be a canonical SHA-256 digest." + } + add("apps:pins:1:$digest") + HomeFormFactor.entries.forEach { formFactor -> + add(HomeWorkspaceScope(digest, formFactor).persistenceKey) + } + } +} + internal data class HomeWorkspaceLayoutLoad( val layout: HomeWorkspaceLayout, val storageAuthoritative: Boolean = true, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt index c8d7fbd77..01ecfc93d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt @@ -82,6 +82,7 @@ fun NativeDeckScreen( requestedBoardId, requestedCardId, ) { + val cacheProducer = DeckWorkspaceMemoryCache.producer(session) DeckWorkspaceMemoryCache.store( session, DeckWorkspaceMemorySnapshot( @@ -93,6 +94,7 @@ fun NativeDeckScreen( requestedBoardId = requestedBoardId, requestedCardId = requestedCardId, ), + cacheProducer, ) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt index 0106d8cbc..cb2430141 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanupTest.kt @@ -49,8 +49,8 @@ class AccountPrivateMemoryCleanupTest { ContactsWorkspaceMemoryCache.store( retained, "retained", ContactsLoadState.Ready(emptyList(), emptyList()), retainedProducer, ) - DeckWorkspaceMemoryCache.store(removed, deckSnapshot()) - DeckWorkspaceMemoryCache.store(retained, deckSnapshot()) + DeckWorkspaceMemoryCache.store(removed, deckSnapshot(), removedProducer) + DeckWorkspaceMemoryCache.store(retained, deckSnapshot(), retainedProducer) sharedDocumentEditingCapabilitiesCache.store( removed, NextcloudDocumentEditingCapabilities.Unavailable, null, removedProducer, ) @@ -141,6 +141,7 @@ class AccountPrivateMemoryCleanupTest { sharedDocumentEditingCapabilitiesCache.store( account, NextcloudDocumentEditingCapabilities.Unavailable, null, staleProducer, ) + DeckWorkspaceMemoryCache.store(account, deckSnapshot(), staleProducer) PreviewMemoryCache.put( PreviewCacheKey(accountKey, "core", 1L, "etag", 64, 64), byteArrayOf(1), staleProducer, ) @@ -149,6 +150,7 @@ class AccountPrivateMemoryCleanupTest { assertNull(ContactsWorkspaceMemoryCache.get(account, "user")) assertNull(UserStatusWorkspaceMemoryCache.get(account)) assertNull(sharedDocumentEditingCapabilitiesCache.get(account)) + assertNull(DeckWorkspaceMemoryCache.get(account)) assertNull(PreviewMemoryCache.get(PreviewCacheKey(accountKey, "core", 1L, "etag", 64, 64))) val currentProducer = requireNotNull(sharedAccountPrivateMemoryGate.producer(accountKey)) @@ -158,11 +160,13 @@ class AccountPrivateMemoryCleanupTest { sharedDocumentEditingCapabilitiesCache.store( account, NextcloudDocumentEditingCapabilities.Unavailable, null, currentProducer, ) + DeckWorkspaceMemoryCache.store(account, deckSnapshot(), currentProducer) assertNotNull(sharedDashboardStatusMemoryCache.get(account, 2L)) assertNotNull(ContactsWorkspaceMemoryCache.get(account, "user")) assertNotNull(UserStatusWorkspaceMemoryCache.get(account)) assertNotNull(sharedDocumentEditingCapabilitiesCache.get(account)) + assertNotNull(DeckWorkspaceMemoryCache.get(account)) AccountPrivateMemoryCleanup.removeAccount(accountKey) AccountPrivateMemoryLifecycle.activateAccount(accountKey) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt index 4b640519f..0276eb840 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt @@ -14,6 +14,26 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class HomeWorkspaceLayoutTest { + @Test + fun `account cleanup keys include canonical and legacy workspace state`() { + val current = "a".repeat(64) + val legacy = "b".repeat(64) + + assertEquals( + setOf( + "apps:pins:1:$current", + "home:1:p:$current", + "home:1:t:$current", + "home:1:d:$current", + "apps:pins:1:$legacy", + "home:1:p:$legacy", + "home:1:t:$legacy", + "home:1:d:$legacy", + ), + homeWorkspaceAccountPersistenceKeys(current, legacy), + ) + } + @Test fun `coordinator defers preference reads until its effect runs`() = runBlocking { val storage = RecordingHomeWorkspaceStorage() diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanup.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanup.kt index 6b00398e5..f0276f008 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanup.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportAccountStorageCleanup.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive /** Deletes only durable support artifacts whose descriptor proves ownership by one account. */ -internal class JvmSupportAccountStorageCleanup( +class JvmSupportAccountStorageCleanup( private val root: File, private val directorySync: (File) -> Unit, private val deleteFile: (File) -> Boolean = File::delete, From 32f97e9fa5e07412225a0e09106460a8b5ee83b6 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 10:58:15 +0200 Subject: [PATCH 107/119] fix(desktop): clear removed account state --- .../DesktopAccountCredentialPersistence.kt | 3 + .../app/DesktopAccountRemoval.kt | 50 +++++++- .../app/DesktopDynamicDiscoveryCache.kt | 117 ++++++++++++++++++ .../app/DesktopHomeWorkspaceLayoutStorage.kt | 9 ++ .../app/DesktopNextcloudServices.kt | 77 ++++++------ .../app/DesktopAccountMemoryRetirementTest.kt | 4 +- .../app/DesktopAccountOperationGuardTest.kt | 16 ++- ...ktopDynamicDiscoveryCacheRetirementTest.kt | 91 ++++++++++++++ .../DesktopHomeWorkspaceLayoutStorageTest.kt | 33 +++++ 9 files changed, 350 insertions(+), 50 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCache.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCacheRetirementTest.kt diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index a6bf1e449..49e108d08 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -768,6 +768,9 @@ internal fun desktopFileCacheAccountId(account: NextcloudAccountRecord): String internal fun desktopDurableMutationAccountScope(account: NextcloudAccountRecord): String = durableMutationAccountScope(account.toSession(appPassword = "")) +internal fun desktopAccountPersistenceScopeDigests(account: NextcloudAccountRecord): AccountPersistenceScopeDigests = + accountPersistenceScopeDigests(account.toSession(appPassword = "")) + internal class DesktopAccountSessionPublication( private val registerPrivateValue: (String) -> Unit, private val publishAccountIdentity: (String) -> Unit, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index b367ca065..bfc3c6665 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -39,6 +39,7 @@ internal data class DesktopAccountSyncPairCleanup( val phase: DesktopAccountSyncPairCleanupPhase, val durableMutationAccountScope: String? = null, val accountStorageKey: String? = null, + val legacyAccountScopeDigest: String? = null, ) internal fun DesktopAccountSyncPairCleanup.matchesAccountActivation( @@ -56,7 +57,20 @@ internal class DesktopAccountSyncPairCleanupJournal( accountId: String, durableMutationAccountScope: String? = null, accountStorageKey: String? = null, - ) = persist(accountId, DesktopAccountSyncPairCleanupPhase.Prepared, durableMutationAccountScope, accountStorageKey) + ) = prepare(accountId, durableMutationAccountScope, accountStorageKey, legacyAccountScopeDigest = null) + + fun prepare( + accountId: String, + durableMutationAccountScope: String?, + accountStorageKey: String?, + legacyAccountScopeDigest: String?, + ) = persist( + accountId, + DesktopAccountSyncPairCleanupPhase.Prepared, + durableMutationAccountScope, + accountStorageKey, + legacyAccountScopeDigest, + ) fun commit(accountId: String) { val current = decode(accountId, preferences.get(cleanupKey(accountId), null)) @@ -68,6 +82,7 @@ internal class DesktopAccountSyncPairCleanupJournal( DesktopAccountSyncPairCleanupPhase.Committed, current.durableMutationAccountScope, current.accountStorageKey, + current.legacyAccountScopeDigest, ) } @@ -122,6 +137,7 @@ internal class DesktopAccountSyncPairCleanupJournal( phase: DesktopAccountSyncPairCleanupPhase, durableMutationAccountScope: String?, accountStorageKey: String?, + legacyAccountScopeDigest: String?, ) { validateDesktopSyncPairCleanupAccountId(accountId) require( @@ -130,6 +146,9 @@ internal class DesktopAccountSyncPairCleanupJournal( require(accountStorageKey == null || accountStorageKey.matches(ACCOUNT_STORAGE_KEY_PATTERN)) { "The desktop account storage cleanup identity is invalid." } + require(legacyAccountScopeDigest == null || legacyAccountScopeDigest.matches(ACCOUNT_STORAGE_KEY_PATTERN)) { + "The desktop legacy workspace cleanup identity is invalid." + } val key = cleanupKey(accountId) val current = preferences.get(key, null)?.let { decode(accountId, it) } check(current == null || current.phase != DesktopAccountSyncPairCleanupPhase.Unknown) { @@ -141,7 +160,7 @@ internal class DesktopAccountSyncPairCleanupJournal( } preferences.put( key, - encode(phase, durableMutationAccountScope, accountStorageKey), + encode(phase, durableMutationAccountScope, accountStorageKey, legacyAccountScopeDigest), ) preferences.flush() } @@ -161,6 +180,7 @@ internal class DesktopAccountSyncPairCleanupJournal( } val scope = fields.getOrNull(2)?.takeIf(String::isCanonicalGroupwareMutationAccountScope) val accountStorageKey = fields.getOrNull(3)?.takeIf { it.matches(ACCOUNT_STORAGE_KEY_PATTERN) } + val legacyAccountScopeDigest = fields.getOrNull(4)?.takeIf { it.matches(ACCOUNT_STORAGE_KEY_PATTERN) } return if (fields.size == 3 && fields[0] == VALUE_VERSION && scope != null) { DesktopAccountSyncPairCleanup(accountId, phase, scope) } else if ( @@ -168,6 +188,11 @@ internal class DesktopAccountSyncPairCleanupJournal( scope != null && accountStorageKey != null ) { DesktopAccountSyncPairCleanup(accountId, phase, scope, accountStorageKey) + } else if ( + fields.size == 5 && fields[0] == VALUE_VERSION_WITH_LEGACY_ACCOUNT_SCOPE && + scope != null && accountStorageKey != null && legacyAccountScopeDigest != null + ) { + DesktopAccountSyncPairCleanup(accountId, phase, scope, accountStorageKey, legacyAccountScopeDigest) } else { DesktopAccountSyncPairCleanup(accountId, DesktopAccountSyncPairCleanupPhase.Unknown) } @@ -177,8 +202,20 @@ internal class DesktopAccountSyncPairCleanupJournal( phase: DesktopAccountSyncPairCleanupPhase, scope: String?, accountStorageKey: String?, + legacyAccountScopeDigest: String?, ): String { val encodedPhase = if (phase == DesktopAccountSyncPairCleanupPhase.Prepared) PREPARED else COMMITTED + if (legacyAccountScopeDigest != null) { + requireNotNull(scope) + requireNotNull(accountStorageKey) + return listOf( + VALUE_VERSION_WITH_LEGACY_ACCOUNT_SCOPE, + encodedPhase, + scope, + accountStorageKey, + legacyAccountScopeDigest, + ).joinToString(VALUE_SEPARATOR) + } if (accountStorageKey != null) { requireNotNull(scope) return listOf(VALUE_VERSION_WITH_ACCOUNT_STORAGE, encodedPhase, scope, accountStorageKey) @@ -201,6 +238,7 @@ internal class DesktopAccountSyncPairCleanupJournal( const val COMMITTED = "committed" const val VALUE_VERSION = "v2" const val VALUE_VERSION_WITH_ACCOUNT_STORAGE = "v3" + const val VALUE_VERSION_WITH_LEGACY_ACCOUNT_SCOPE = "v4" const val VALUE_SEPARATOR = "|" val ACCOUNT_STORAGE_KEY_PATTERN = Regex("[0-9a-f]{64}") } @@ -276,7 +314,8 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( accountId: String, durableMutationAccountScope: String? = null, accountStorageKey: String? = null, - prepareCleanup: suspend (String, String?, String?) -> Unit, + legacyAccountScopeDigest: String? = null, + prepareCleanup: suspend (String, String?, String?, String?) -> Unit, commitCleanup: suspend (String) -> Unit, clearCleanup: suspend (String) -> Unit, accountOwnership: (String) -> DesktopAccountOwnership, @@ -285,7 +324,7 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( retireCommittedAccount: () -> Unit = {}, recordCleanupFailure: suspend (Exception) -> Unit, ): Boolean { - prepareCleanup(accountId, durableMutationAccountScope, accountStorageKey) + prepareCleanup(accountId, durableMutationAccountScope, accountStorageKey, legacyAccountScopeDigest) val removed = try { removeCredential() } catch (failure: Throwable) { @@ -315,6 +354,7 @@ internal suspend fun removeDesktopAccountBeforeSyncPairCleanup( DesktopAccountSyncPairCleanupPhase.Committed, durableMutationAccountScope, accountStorageKey, + legacyAccountScopeDigest, ), ) clearCleanup(accountId) @@ -330,6 +370,7 @@ internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( accountId: String?, durableMutationAccountScope: String? = null, accountStorageKey: String? = null, + legacyAccountScopeDigest: String? = null, cleanupJournal: DesktopAccountSyncPairCleanupJournal, accountOwnership: (String) -> DesktopAccountOwnership, commitRemoval: suspend () -> Unit, @@ -345,6 +386,7 @@ internal suspend fun clearDesktopActiveAccountBeforeSyncPairCleanup( accountId = accountId, durableMutationAccountScope = durableMutationAccountScope, accountStorageKey = accountStorageKey, + legacyAccountScopeDigest = legacyAccountScopeDigest, prepareCleanup = cleanupJournal::prepare, commitCleanup = cleanupJournal::commit, clearCleanup = cleanupJournal::clear, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCache.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCache.kt new file mode 100644 index 000000000..71301d278 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCache.kt @@ -0,0 +1,117 @@ +package dev.obiente.nextcloudnative.app + +import java.io.File +import java.io.FileOutputStream +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** Serializes persisted dynamic discovery publications with desktop account retirement. */ +internal class DesktopDynamicDiscoveryCache(private val root: File) { + private val lock = Any() + private val retiredAccounts = mutableSetOf() + private val accountIncarnations = mutableMapOf() + + fun load(accountStorageKey: String, cacheAccountId: String, appId: String): String? = synchronized(lock) { + if (accountStorageKey in retiredAccounts) return@synchronized null + val target = cacheFile(cacheAccountId, appId) ?: return@synchronized null + if (!target.isFile || target.length() !in 1..MAX_PERSISTED_DYNAMIC_DISCOVERY_BYTES.toLong()) { + return@synchronized null + } + runCatching(target::readText).getOrNull() + } + + fun save( + accountStorageKey: String, + cacheAccountId: String, + appId: String, + encoded: String, + producer: DynamicNativeMemoryCacheProducer?, + ) = synchronized(lock) { + val current = producer ?: return@synchronized + require(current.accountStorageKey == accountStorageKey) { + "The dynamic discovery producer belongs to another account." + } + if ( + accountStorageKey in retiredAccounts || + current.incarnation != (accountIncarnations[accountStorageKey] ?: 0L) + ) { + return@synchronized + } + val target = cacheFile(cacheAccountId, appId) ?: return@synchronized + check(root.mkdirs() || root.isDirectory) { "Could not create the dynamic contract cache." } + val temporary = File(root, "${target.name}.part") + try { + FileOutputStream(temporary).use { output -> + output.write(encoded.encodeToByteArray()) + output.fd.sync() + } + try { + Files.move( + temporary.toPath(), + target.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move( + temporary.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + ) + } + } finally { + temporary.delete() + } + } + + fun fenceAccount(accountStorageKey: String) = synchronized(lock) { + fenceAccountLocked(accountStorageKey) + } + + fun retireAccount(accountStorageKey: String?, cacheAccountId: String) = synchronized(lock) { + accountStorageKey?.let(::fenceAccountLocked) + require(cacheAccountId.matches(ACCOUNT_CACHE_ID)) { "The dynamic discovery cache account is invalid." } + if (!root.exists()) return@synchronized + check(root.isDirectory) { "The dynamic contract cache is unavailable." } + val files = root.listFiles() ?: error("Could not inspect the dynamic contract cache.") + files.forEach { file -> + check(file.isFile && file.name.matches(ACCOUNT_CACHE_FILE)) { + "The dynamic contract cache contains an unexpected entry." + } + } + files.filter { file -> file.name.startsWith("$cacheAccountId-") } + .forEach { file -> + check(file.delete() || !file.exists()) { "Could not clear the dynamic contract cache." } + } + } + + fun activateAccount(accountStorageKey: String) = synchronized(lock) { + retiredAccounts -= accountStorageKey + } + + private fun fenceAccountLocked(accountStorageKey: String) { + if (retiredAccounts.add(accountStorageKey)) { + accountIncarnations[accountStorageKey] = (accountIncarnations[accountStorageKey] ?: 0L) + 1L + } + } + + private fun cacheFile(cacheAccountId: String, appId: String): File? { + if (!cacheAccountId.matches(ACCOUNT_CACHE_ID) || !appId.isSafeDynamicDiscoveryCacheAppId()) return null + return File(root, "$cacheAccountId-$appId.json") + } + + private companion object { + val ACCOUNT_CACHE_ID = Regex("[0-9a-f]{64}") + val ACCOUNT_CACHE_FILE = Regex("${ACCOUNT_CACHE_ID.pattern}-[A-Za-z0-9._-]{1,128}\\.json(?:\\.part)?") + } +} + +internal object DesktopDynamicDiscoveryCacheCoordinator { + private val instances = mutableMapOf() + + fun get(root: File): DesktopDynamicDiscoveryCache = synchronized(this) { + val key = root.absoluteFile.normalize().path + instances.getOrPut(key) { DesktopDynamicDiscoveryCache(root) } + } +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorage.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorage.kt index ddd73e932..cd4080bc9 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorage.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorage.kt @@ -36,6 +36,15 @@ internal class DesktopHomeWorkspaceLayoutStorage( } } + fun removeAccount(accountScopeDigest: String, legacyAccountScopeDigest: String? = null) { + val keys = homeWorkspaceAccountPersistenceKeys(accountScopeDigest, legacyAccountScopeDigest) + withExclusiveAccess { + preferences.sync() + keys.forEach(preferences::remove) + preferences.flush() + } + } + private fun withExclusiveAccess(operation: () -> T): T { val path = lockFile.toPath().toAbsolutePath().normalize() return desktopHomeWorkspaceProcessLocks.computeIfAbsent(path.toString()) { ReentrantLock() }.withLock { diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index df1edaa73..22d65262d 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -696,6 +696,10 @@ class DesktopNextcloudServices( supportIntakeRoot: File? = null, ) : NextcloudPlatformServices, AutoCloseable { private val preferences = Preferences.userRoot().node("dev/obiente/nextcloudnative") + private val homeWorkspaceLayoutStorage = DesktopHomeWorkspaceLayoutStorage( + preferences.node("home-workspace"), + desktopHomeWorkspaceLockFile(), + ) private val ownsTemporarySupportDiagnosticsRoot = providedSupportDiagnostics == null && supportDiagnosticsRoot == null private val resolvedSupportDiagnosticsRoot = supportDiagnosticsRoot ?: if (providedSupportDiagnostics == null) { Files.createTempDirectory("nextcloud-native-test-diagnostics").toFile() @@ -746,7 +750,9 @@ class DesktopNextcloudServices( catalogCache = FileAppStoreCatalogCache(desktopContractCacheDirectory("catalogs")), verifiedContractCache = FileVerifiedContractCache(desktopContractCacheDirectory("verified")), ) - private val dynamicDiscoveryCacheDirectory = desktopContractCacheDirectory("discoveries-v1") + private val dynamicDiscoveryCache = DesktopDynamicDiscoveryCacheCoordinator.get( + desktopContractCacheDirectory("discoveries-v1"), + ) private val pendingDynamicMutationDirectory = desktopPendingDynamicMutationDirectory() private val fileReadCache = defaultDesktopFileReadCache() private val virtualRangeCaches = mutableMapOf() @@ -3330,12 +3336,11 @@ class DesktopNextcloudServices( session: NextcloudSession, appId: String, ): DynamicDescriptorDiscovery? = withContext(Dispatchers.IO) { - val target = dynamicDiscoveryCacheFile(session, appId) ?: return@withContext null - if (!target.isFile || target.length() !in 1..MAX_PERSISTED_DYNAMIC_DISCOVERY_BYTES.toLong()) { - return@withContext null - } - runCatching { target.readText() } - .getOrNull() + dynamicDiscoveryCache.load( + session.accountId.storageKey, + desktopFileCacheAccountId(session), + appId, + ) ?.let { encoded -> decodePersistedDynamicDiscovery(encoded, appId, session.serverUrl) } } override suspend fun saveCachedDynamicAppDiscovery( @@ -3344,35 +3349,13 @@ class DesktopNextcloudServices( producer: DynamicNativeMemoryCacheProducer?, ) = withContext(Dispatchers.IO) { val encoded = encodePersistedDynamicDiscovery(discovery) ?: return@withContext - val target = dynamicDiscoveryCacheFile(session, discovery.descriptor.app.id) ?: return@withContext - check(dynamicDiscoveryCacheDirectory.mkdirs() || dynamicDiscoveryCacheDirectory.isDirectory) { - "Could not create the dynamic contract cache." - } - val temporary = File(dynamicDiscoveryCacheDirectory, "${target.name}.part") - temporary.outputStream().buffered().use { output -> - output.write(encoded.encodeToByteArray()) - output.flush() - } - try { - Files.move( - temporary.toPath(), - target.toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING, - ) - } catch (_: AtomicMoveNotSupportedException) { - Files.move( - temporary.toPath(), - target.toPath(), - StandardCopyOption.REPLACE_EXISTING, - ) - } - Unit - } - - private fun dynamicDiscoveryCacheFile(session: NextcloudSession, appId: String): File? { - if (!appId.isSafeDynamicDiscoveryCacheAppId()) return null - return File(dynamicDiscoveryCacheDirectory, "${desktopFileCacheAccountId(session)}-$appId.json") + dynamicDiscoveryCache.save( + session.accountId.storageKey, + desktopFileCacheAccountId(session), + discovery.descriptor.app.id, + encoded, + producer, + ) } override suspend fun loadPendingDynamicMutation( @@ -3493,6 +3476,7 @@ class DesktopNextcloudServices( }, activate = { AccountPrivateMemoryLifecycle.activateAccount(it.accountId.storageKey) + dynamicDiscoveryCache.activateAccount(it.accountId.storageKey) dynamicApiRequestCoalescer.activateAccount(desktopFileCacheAccountId(it)) synchronized(fileRangeSessionLock) { sessionClearing = false } startDesktopSyncLifecycle() @@ -3570,6 +3554,7 @@ class DesktopNextcloudServices( ?: return@serialize false val providerAccountId = desktopFileCacheAccountId(account) val durableMutationScope = desktopDurableMutationAccountScope(account) + val accountPersistenceScopes = desktopAccountPersistenceScopeDigests(account) requireDesktopAccountRemovalReady(providerAccountId, isLinuxDesktop()) accountOperationGuard.withSyncRunLock { fileSyncEngine.requireAccountRemovalReady(providerAccountId) @@ -3577,6 +3562,7 @@ class DesktopNextcloudServices( accountId = providerAccountId, durableMutationAccountScope = durableMutationScope, accountStorageKey = account.id.storageKey, + legacyAccountScopeDigest = accountPersistenceScopes.legacy, prepareCleanup = accountSyncPairCleanupJournal::prepare, commitCleanup = accountSyncPairCleanupJournal::commit, clearCleanup = accountSyncPairCleanupJournal::clear, @@ -3590,7 +3576,7 @@ class DesktopNextcloudServices( } }, removeSyncPairs = ::removeDesktopAccountOwnedState, retireCommittedAccount = { - AccountPrivateMemoryLifecycle.retireAccount(account.id.storageKey) + fenceDesktopAccountPrivateCaches(account.id.storageKey) }, ) { recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(providerAccountId, it)) @@ -3632,6 +3618,8 @@ class DesktopNextcloudServices( val durableMutationScope = activeSession?.let(::durableMutationAccountScope) ?: activeRecord?.let(::desktopDurableMutationAccountScope) val accountStorageKey = activeSession?.accountId?.storageKey ?: activeRecord?.id?.storageKey + val accountPersistenceScopes = activeSession?.let(::accountPersistenceScopeDigests) + ?: activeRecord?.let(::desktopAccountPersistenceScopeDigests) val syncJob = synchronized(this) { val active = backgroundFileSyncJob backgroundFileSyncJob = null @@ -3752,7 +3740,7 @@ class DesktopNextcloudServices( } try { clearDesktopActiveAccountBeforeSyncPairCleanup( - accountId, durableMutationScope, accountStorageKey, + accountId, durableMutationScope, accountStorageKey, accountPersistenceScopes?.legacy, accountSyncPairCleanupJournal, ::desktopAccountOwnership, { commitDesktopAccountRemovalBeforeVirtualFileTeardown( @@ -3783,7 +3771,9 @@ class DesktopNextcloudServices( ::removeDesktopAccountOwnedState, ::recordSupportDiagnostic, retireCommittedAccount = { - accountStorageKey?.let(AccountPrivateMemoryLifecycle::retireAccount) + if (accountStorageKey != null && accountId != null) { + fenceDesktopAccountPrivateCaches(accountStorageKey) + } }, ) } finally { @@ -3850,12 +3840,16 @@ class DesktopNextcloudServices( } private suspend fun removeDesktopAccountOwnedState(cleanup: DesktopAccountSyncPairCleanup) { val accountId = cleanup.accountId + dynamicDiscoveryCache.retireAccount(cleanup.accountStorageKey, accountId) clearDesktopDynamicApiState(accountId, dynamicApiRequestCoalescer, dynamicApiReadCache) supportIntake.removeAccount(accountId) removeDesktopPendingDynamicMutations(pendingDynamicMutationDirectory, accountId) cleanup.durableMutationAccountScope?.let(durableMutationRecovery::removeAccount) cleanup.accountStorageKey?.let { deckCardDrafts.removeAccount(it, accountId) } cleanup.accountStorageKey?.let(AccountPrivateMemoryLifecycle::retireAccount) + cleanup.accountStorageKey?.let { accountStorageKey -> + homeWorkspaceLayoutStorage.removeAccount(accountStorageKey, cleanup.legacyAccountScopeDigest) + } externalFileHandoff.removeAccount(accountId) removeDesktopAccountPrivateStorage( accountId, fileSyncEngine, fileReadCache, virtualRangeCache(accountId), preferences, @@ -3877,6 +3871,11 @@ class DesktopNextcloudServices( } } + private fun fenceDesktopAccountPrivateCaches(accountStorageKey: String) { + AccountPrivateMemoryLifecycle.retireAccount(accountStorageKey) + dynamicDiscoveryCache.fenceAccount(accountStorageKey) + } + private fun desktopAccountOwnership(accountId: String): DesktopAccountOwnership = sessionPublicationGuard.serialize { accountCredentials.accountOwnership(accountId) } override suspend fun prepareDeckCardDraftRecovery(session: NextcloudSession) = withContext(Dispatchers.IO) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt index 2c41e6f1d..821894150 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt @@ -15,7 +15,7 @@ class DesktopAccountMemoryRetirementTest { val removed = removeDesktopAccountBeforeSyncPairCleanup( accountId = "a".repeat(64), accountStorageKey = "b".repeat(64), - prepareCleanup = { _, _, _ -> events += "prepare" }, + prepareCleanup = { _, _, _, _ -> events += "prepare" }, commitCleanup = { events += "commit"; throw IOException("disk full") }, clearCleanup = { events += "clear" }, accountOwnership = { DesktopAccountOwnership.Absent }, @@ -36,7 +36,7 @@ class DesktopAccountMemoryRetirementTest { runCatching { removeDesktopAccountBeforeSyncPairCleanup( accountId = "c".repeat(64), - prepareCleanup = { _, _, _ -> }, + prepareCleanup = { _, _, _, _ -> }, commitCleanup = {}, clearCleanup = {}, accountOwnership = { ownership }, diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 74d38d49a..79e8e7af9 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -729,7 +729,7 @@ class DesktopAccountOperationGuardTest { val removed = removeDesktopAccountBeforeSyncPairCleanup( accountId = CLEANUP_ACCOUNT_ID, - prepareCleanup = { _, _, _ -> events += "prepare-cleanup" }, + prepareCleanup = { _, _, _, _ -> events += "prepare-cleanup" }, commitCleanup = { events += "commit-cleanup" }, clearCleanup = { events += "clear-cleanup" }, accountOwnership = { DesktopAccountOwnership.Absent }, @@ -764,7 +764,7 @@ class DesktopAccountOperationGuardTest { assertFailsWith { removeDesktopAccountBeforeSyncPairCleanup( accountId = CLEANUP_ACCOUNT_ID, - prepareCleanup = { _, _, _ -> events += "prepare-cleanup" }, + prepareCleanup = { _, _, _, _ -> events += "prepare-cleanup" }, commitCleanup = { events += "commit-cleanup" }, clearCleanup = { events += "clear-cleanup" }, accountOwnership = { DesktopAccountOwnership.Absent }, @@ -793,7 +793,7 @@ class DesktopAccountOperationGuardTest { assertFailsWith { removeDesktopAccountBeforeSyncPairCleanup( accountId = CLEANUP_ACCOUNT_ID, - prepareCleanup = { _, _, _ -> events += "prepare-cleanup" }, + prepareCleanup = { _, _, _, _ -> events += "prepare-cleanup" }, commitCleanup = { events += "commit-cleanup" }, clearCleanup = { events += "clear-cleanup" }, accountOwnership = { DesktopAccountOwnership.Absent }, @@ -941,6 +941,7 @@ class DesktopAccountOperationGuardTest { accountId = CLEANUP_ACCOUNT_ID, durableMutationAccountScope = MUTATION_SCOPE, accountStorageKey = ACCOUNT_STORAGE_KEY, + legacyAccountScopeDigest = LEGACY_ACCOUNT_SCOPE, prepareCleanup = firstJournal::prepare, commitCleanup = firstJournal::commit, clearCleanup = firstJournal::clear, @@ -960,12 +961,13 @@ class DesktopAccountOperationGuardTest { DesktopAccountSyncPairCleanupPhase.Committed, MUTATION_SCOPE, ACCOUNT_STORAGE_KEY, + LEGACY_ACCOUNT_SCOPE, ), ), restored.pending(), ) assertEquals( - "v3|committed|$MUTATION_SCOPE|$ACCOUNT_STORAGE_KEY", + "v4|committed|$MUTATION_SCOPE|$ACCOUNT_STORAGE_KEY|$LEGACY_ACCOUNT_SCOPE", preferences.get("fsac.$CLEANUP_ACCOUNT_ID", null), ) @@ -973,7 +975,10 @@ class DesktopAccountOperationGuardTest { retryDesktopAccountSyncPairCleanup( cleanup = restored.pending().single(), accountOwnership = { DesktopAccountOwnership.Present }, - removeSyncPairs = { retryEvents += "remove-pairs-${it.accountId}" }, + removeSyncPairs = { + assertEquals(LEGACY_ACCOUNT_SCOPE, it.legacyAccountScopeDigest) + retryEvents += "remove-pairs-${it.accountId}" + }, clearCleanup = { retryEvents += "clear-cleanup-$it" restored.clear(it) @@ -1055,5 +1060,6 @@ class DesktopAccountOperationGuardTest { const val CLEANUP_ACCOUNT_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" const val MUTATION_SCOPE = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" const val ACCOUNT_STORAGE_KEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + const val LEGACY_ACCOUNT_SCOPE = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCacheRetirementTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCacheRetirementTest.kt new file mode 100644 index 000000000..246bcd0c9 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDynamicDiscoveryCacheRetirementTest.kt @@ -0,0 +1,91 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopDynamicDiscoveryCacheRetirementTest { + @Test + fun `credential commit fence rejects publication before durable cleanup deletes the file`() { + val root = Files.createTempDirectory("desktop-dynamic-discovery-fence").toFile() + val cache = DesktopDynamicDiscoveryCache(root) + val accountStorageKey = "d".repeat(64) + val cacheAccountId = "5".repeat(64) + val staleProducer = DynamicNativeMemoryCacheProducer(accountStorageKey, 0L) + try { + cache.save(accountStorageKey, cacheAccountId, "deck", "before", staleProducer) + + cache.fenceAccount(accountStorageKey) + cache.save(accountStorageKey, cacheAccountId, "deck", "late", staleProducer) + + assertTrue(root.resolve("$cacheAccountId-deck.json").isFile) + assertNull(cache.load(accountStorageKey, cacheAccountId, "deck")) + + cache.retireAccount(accountStorageKey, cacheAccountId) + assertFalse(root.resolve("$cacheAccountId-deck.json").exists()) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `retirement deletes one account prefix and rejects stale publication until activation`() { + val root = Files.createTempDirectory("desktop-dynamic-discovery").toFile() + val first = DesktopDynamicDiscoveryCacheCoordinator.get(root) + val second = DesktopDynamicDiscoveryCacheCoordinator.get(root) + val removedStorageKey = "a".repeat(64) + val removedCacheId = "1".repeat(64) + val retainedStorageKey = "b".repeat(64) + val retainedCacheId = "2".repeat(64) + val removedProducer = DynamicNativeMemoryCacheProducer(removedStorageKey, 0L) + val retainedProducer = DynamicNativeMemoryCacheProducer(retainedStorageKey, 0L) + try { + first.save(removedStorageKey, removedCacheId, "deck", "removed", removedProducer) + first.save(retainedStorageKey, retainedCacheId, "deck", "retained", retainedProducer) + + second.retireAccount(removedStorageKey, removedCacheId) + first.activateAccount(removedStorageKey) + first.save(removedStorageKey, removedCacheId, "deck", "stale", removedProducer) + + assertNull(first.load(removedStorageKey, removedCacheId, "deck")) + assertEquals("retained", first.load(retainedStorageKey, retainedCacheId, "deck")) + assertFalse(root.resolve("$removedCacheId-deck.json").exists()) + assertTrue(root.resolve("$retainedCacheId-deck.json").isFile) + + first.save( + removedStorageKey, + removedCacheId, + "deck", + "current", + DynamicNativeMemoryCacheProducer(removedStorageKey, 1L), + ) + assertEquals("current", first.load(removedStorageKey, removedCacheId, "deck")) + } finally { + root.deleteRecursively() + } + } + + @Test + fun `restart cleanup without a storage identity still removes account files`() { + val root = Files.createTempDirectory("desktop-dynamic-discovery-restart").toFile() + val cache = DesktopDynamicDiscoveryCache(root) + val removedCacheId = "3".repeat(64) + val retainedCacheId = "4".repeat(64) + try { + root.resolve("$removedCacheId-deck.json").writeText("removed") + root.resolve("$removedCacheId-talk.json.part").writeText("partial") + root.resolve("$retainedCacheId-deck.json").writeText("retained") + + cache.retireAccount(accountStorageKey = null, cacheAccountId = removedCacheId) + + assertFalse(root.resolve("$removedCacheId-deck.json").exists()) + assertFalse(root.resolve("$removedCacheId-talk.json.part").exists()) + assertTrue(root.resolve("$retainedCacheId-deck.json").isFile) + } finally { + root.deleteRecursively() + } + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorageTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorageTest.kt index 7a86e3122..52d054b1a 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorageTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorageTest.kt @@ -9,9 +9,39 @@ import java.util.prefs.Preferences import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class DesktopHomeWorkspaceLayoutStorageTest { + @Test + fun `account removal clears canonical and legacy workspace preferences without touching a peer`() { + val directory = Files.createTempDirectory("nextcloud-native-home-workspace-removal-test").toFile() + val node = "dev/obiente/nextcloudnative/test-home-workspace-${UUID.randomUUID()}" + val preferences = Preferences.userRoot().node(node) + val cleanupPreferences = Preferences.userRoot().node(node) + val storage = DesktopHomeWorkspaceLayoutStorage(cleanupPreferences, directory.resolve("preferences.lock")) + val canonical = "a".repeat(64) + val legacy = "b".repeat(64) + val retained = "c".repeat(64) + val removedKeys = listOf(canonical, legacy).flatMap(::workspacePreferenceKeys) + val retainedKeys = workspacePreferenceKeys(retained) + try { + removedKeys.forEach { key -> preferences.put(key, "removed") } + retainedKeys.forEach { key -> preferences.put(key, "retained") } + preferences.flush() + + storage.removeAccount(canonical, legacy) + preferences.sync() + + removedKeys.forEach { key -> assertNull(preferences.get(key, null), key) } + retainedKeys.forEach { key -> assertEquals("retained", preferences.get(key, null), key) } + } finally { + preferences.removeNode() + Preferences.userRoot().flush() + directory.deleteRecursively() + } + } + @Test fun `concurrent storage instances admit only one conditional promotion`() { val directory = Files.createTempDirectory("nextcloud-native-home-workspace-lock-test").toFile() @@ -70,4 +100,7 @@ class DesktopHomeWorkspaceLayoutStorageTest { home.deleteRecursively() } } + + private fun workspacePreferenceKeys(accountScopeDigest: String): List = + homeWorkspaceAccountPersistenceKeys(accountScopeDigest).toList() } From fd101127772bfd5775979dc8132c1197848b8658 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 11:06:33 +0200 Subject: [PATCH 108/119] fix(ui): validate workspace cleanup identity --- .../dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt index 908bab55e..49eb8d140 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt @@ -26,7 +26,7 @@ internal fun homeWorkspaceAccountPersistenceKeys( legacyAccountScopeDigest: String? = null, ): Set = buildSet { setOfNotNull(accountScopeDigest, legacyAccountScopeDigest).forEach { digest -> - require(digest.isCanonicalSha256Digest()) { + require(digest.length == 64 && digest.all { character -> character in '0'..'9' || character in 'a'..'f' }) { "The home workspace account scope must be a canonical SHA-256 digest." } add("apps:pins:1:$digest") From a626adea00fd66d2e1e722bb2e721ea12fad2bf9 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:22:09 +0000 Subject: [PATCH 109/119] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 5b4d0fc92..8a44a7b25 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -434,7 +434,7 @@ "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": "697c1c248d79745ddb9ab7d96d8ae9a581800831d72fc17ac2d450b05f209773", + "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", @@ -481,7 +481,7 @@ "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": "234a843934cf27499c623c6022528cc003b4bfb42b70defb3f097ba1fc863d77", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DeckWorkspaceMemoryCache.kt": "275e63824e802c3df6bc749dfed9a6cd993ffe542ac0814919b186c707a29cac", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCalendarCanvas.kt": "4b87e50bb0133438c3e22c4b7ff39ec1fa0db0c7bb14ed227e82d8f18eeb2c18", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCalendarPanes.kt": "cade401c689544fb987d7bafd4f82ffdeb8fa51693f1c18f7382af2e3eabc252", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DesktopCalendarWorkspace.kt": "03c2b85a2a7c86b7506b956c7ab079a251ae862a9d68411fc2d3bb327b48bce5", @@ -571,7 +571,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceActions.kt": "211cc20d9e7f60cc9337591acfad3e63653bff3c97aa6a693b6f647b976f0dfb", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceHeader.kt": "0cacd1a4887bc8c830a1667e445bc11339a4c3b888feebaee8625ddc708965ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayout.kt": "f878809aef689f4f47311225e487efe8fb411be1180264b1e69be74bdf1ab5b6", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt": "3c89436210b7c93d5970cf65b97ad5893adf6b12d485ab160831c3946770bfc2", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt": "51a29eb6313c071412229f73a36c44f888c4f1fb7fdce0af1125d449ec45b26d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ImageDecodeBounds.kt": "6a218194682e175396d57b4d4a3fc8aea2c0b39a11212daa61a4d60bc8ed42a5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingFileShare.kt": "2ffac5d12aea372662848799769a9a4f2c887fa31bc2def8647a5773b89d2e35", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/IncomingShareUploadScreen.kt": "7730c0b937b79a85cd5b1d9b302783f561a314b7ca022d51bf836a51cefa583d", @@ -624,7 +624,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": "0f38a3f9873fcfd91ee304d6afbe6a875115eb747591e123388d5c622020a038", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeDeckScreen.kt": "d0200cfa7b9905b3554b71c7c4f982d9b5024246ee5ec08c9b3e7c74d42e0eea", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaCollectionActions.kt": "18f039c4e004fcd267c494c4f50cd467f445d4a9e74338da1f7d56d8e46d8b3d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaCollectionBrowser.kt": "4b2af88cd6ad9282fc7088e42988fc1c80237761cea3d3f5d76c6d10c47ea4a1", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NativeMediaCollections.kt": "0d57ec1eaa6802513aafb88153f23e603a64fd7d1028e586227986ed155c6b14", From f3c064797591b037e9ad17cdc06c3f3ff978591a Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 11:48:36 +0200 Subject: [PATCH 110/119] fix(accounts): close remaining retirement races --- .../AndroidAccountCredentialController.kt | 4 +- .../AndroidAccountOperationGuard.kt | 18 ------ .../AndroidNextcloudServices.kt | 16 ++--- .../AndroidAccountOperationGuardTest.kt | 31 ---------- .../AndroidAccountSelectionPostCommitTest.kt | 30 ++++++++++ tools/kotlin-file-size-baseline.txt | 2 +- .../app/DesktopAccountRemoval.kt | 20 ++++--- .../app/DesktopFileReadCache.kt | 58 +++++++++---------- .../app/DesktopFileReadCacheLifecycle.kt | 37 ++++++++++++ .../app/DesktopFileReadCacheModels.kt | 26 +++++++++ .../app/DesktopNextcloudServices.kt | 23 ++++---- .../app/LinuxVirtualFileSystem.kt | 6 +- .../app/DesktopAccountOperationGuardTest.kt | 39 ++++++++++++- .../app/DesktopFileReadCacheTest.kt | 38 ++++++++++++ 14 files changed, 232 insertions(+), 116 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheLifecycle.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheModels.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 9d3db4bdd..0207ea5c5 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -30,6 +30,7 @@ internal class AndroidAccountCredentialController( private val removeQueuedUploads: suspend (NextcloudSession) -> Unit, private val retryQueuedUploadsCleanup: suspend (NextcloudSession, String, String?, String?, String?) -> Unit, private val retryQueuedUploadsCleanupWithoutCredentials: suspend (String, String, String?, String?, String?) -> Unit, + private val activatePersistedAccount: suspend (NextcloudSession) -> Unit, ) { private val appContext = context.applicationContext private val handoffCleanup = AndroidExternalFileHandoffCleanup(appContext, preferences, ::commitPreferences) @@ -44,7 +45,6 @@ internal class AndroidAccountCredentialController( ) }, ) - fun loadSession(): NextcloudSession? = ANDROID_FILE_SYNC_SESSION_SCHEDULING_GUARD.restorePersistedSession( load = { val registry = readRegistryForCredentialLoad() @@ -62,7 +62,6 @@ internal class AndroidAccountCredentialController( ?: AndroidAccountRetentionSnapshot.Unavailable fun activeAccountId(): NextcloudAccountId? = readCredentialFreeRegistry()?.activeAccountId - fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = ANDROID_ACCOUNT_CREDENTIAL_STORE_GUARD.serialize { val registry = readRegistryForCredentialLoad() ?: return@serialize null @@ -489,6 +488,7 @@ internal class AndroidAccountCredentialController( ) }, finishMaintenance = { + activatePersistedAccount(session) clearAndroidPreviousPreviewAfterCommittedSelection( previousSession = previousSession, selectedSession = session, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt index 2cadce801..a6e0b70f6 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuard.kt @@ -172,21 +172,3 @@ internal suspend fun withAndroidAccountPrivateStatePublication( ): Result = credentialMutationMutex.withLock { guard.withExactAccountSession(expectedSession, resolveSession, unavailable) { publish() } } - -internal suspend fun activateAndroidDynamicReadsAfterCredentialSave( - persistedSession: dev.obiente.nextcloudnative.app.NextcloudSession, - credentialMutationMutex: Mutex, - guard: AndroidAccountOperationGuard, - resolveSession: suspend () -> dev.obiente.nextcloudnative.app.NextcloudSession?, - activate: suspend (String) -> Unit, -) { - withAndroidAccountPrivateStatePublication( - expectedSession = persistedSession, - credentialMutationMutex = credentialMutationMutex, - guard = guard, - resolveSession = resolveSession, - unavailable = {}, - ) { - activate(NextcloudDocumentIds.cacheAccountId(persistedSession)) - } -} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 2e388b0a4..de2145b69 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -478,6 +478,11 @@ internal class AndroidNextcloudServices( removeQueuedUploads = accountOwnedStateCleanup::remove, retryQueuedUploadsCleanup = accountOwnedStateCleanup::retry, retryQueuedUploadsCleanupWithoutCredentials = accountOwnedStateCleanup::retryWithoutCredentials, + activatePersistedAccount = { session -> + dynamicApiRequestCoalescer.activateAccount(NextcloudDocumentIds.cacheAccountId(session)) + AccountPrivateMemoryLifecycle.activateAccount(session.accountId.storageKey) + dynamicDiscoveryCache.activateAccount(session.accountId.storageKey) + }, ) init { @@ -928,16 +933,7 @@ internal class AndroidNextcloudServices( withAndroidDeckCardDraftSession(session, accountCredentials) { deckCardDrafts.migrateLegacyEntries(session) } } - override suspend fun saveSession(session: NextcloudSession): NextcloudSession { - val persisted = accountCredentials.saveSession(session) - activateAndroidDynamicReadsAfterCredentialSave( - persisted, ANDROID_ACCOUNT_CREDENTIAL_MUTATION_MUTEX, ANDROID_ACCOUNT_OPERATION_GUARD, - { accountCredentials.loadSession(persisted.accountId) }, dynamicApiRequestCoalescer::activateAccount, - ) - AccountPrivateMemoryLifecycle.activateAccount(persisted.accountId.storageKey) - dynamicDiscoveryCache.activateAccount(persisted.accountId.storageKey) - return persisted - } + override suspend fun saveSession(session: NextcloudSession): NextcloudSession = accountCredentials.saveSession(session) internal fun accountRetentionSnapshot() = accountCredentials.accountRetentionSnapshot() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index bc60db02b..352e81f17 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -59,37 +59,6 @@ class AndroidAccountOperationGuardTest { assertFalse(draftExists) } - @Test - fun removalInThePostSaveGapCannotReopenDynamicReads() = runBlocking { - val guard = AndroidAccountOperationGuard() - val credentialMutations = Mutex() - val persisted = NextcloudSession("https://cloud.example.test", "alice", "saved-password") - val saveReturned = CompletableDeferred() - val continueAfterSave = CompletableDeferred() - var current: NextcloudSession? = persisted - var activatedAccountId: String? = null - val save = async { - saveReturned.complete(Unit) - continueAfterSave.await() - activateAndroidDynamicReadsAfterCredentialSave( - persistedSession = persisted, - credentialMutationMutex = credentialMutations, - guard = guard, - resolveSession = { current }, - activate = { activatedAccountId = it }, - ) - } - saveReturned.await() - - credentialMutations.withLock { - guard.withAccount(NextcloudDocumentIds.accountKey(persisted)) { current = null } - } - continueAfterSave.complete(Unit) - save.await() - - assertEquals(null, activatedAccountId) - } - @Test fun lateDurableWriterCannotPublishAfterRemovalAndCredentialReplacement() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionPostCommitTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionPostCommitTest.kt index 5ef7bed3b..388669181 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionPostCommitTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountSelectionPostCommitTest.kt @@ -39,6 +39,36 @@ class AndroidAccountSelectionPostCommitTest { assertEquals(listOf("commit", "maintain"), events) } + @Test + fun cancellationAfterCredentialCommitActivatesThePersistedAccountBeforePropagating() = runBlocking { + val events = mutableListOf() + val cancellation = CancellationException("login owner stopped after credential commit") + val save = async { + val owner = currentCoroutineContext() + completeAndroidAccountSelectionTransition( + transitionDispatcher = Dispatchers.Default, + commitTransition = { markCommitted -> + events += "persist" + markCommitted() + owner.cancel(cancellation) + }, + finishMaintenance = { + assertTrue(currentCoroutineContext().isActive) + events += "activate-dynamic" + yield() + events += "activate-private" + events += "activate-discovery" + }, + ) + } + + assertFailsWith { save.await() } + assertEquals( + listOf("persist", "activate-dynamic", "activate-private", "activate-discovery"), + events, + ) + } + @Test fun cancellationBeforeCommitDoesNotRunTransitionOrMaintenance() = runBlocking { val events = mutableListOf() diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 98847bdab..308375ff0 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|4237 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4233 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|1003 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1224 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index bfc3c6665..a86fc9332 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -459,16 +459,20 @@ internal suspend fun retryDesktopAccountSyncPairCleanup( clearCleanup: suspend (String) -> Unit, reactivatePresentAccount: (DesktopAccountSyncPairCleanup) -> Unit = {}, ) { - if (cleanup.phase != DesktopAccountSyncPairCleanupPhase.Committed) { - when (accountOwnership(cleanup.accountId)) { - DesktopAccountOwnership.Present -> { - clearCleanup(cleanup.accountId) - reactivatePresentAccount(cleanup) - return + when (cleanup.phase) { + DesktopAccountSyncPairCleanupPhase.Unknown -> return + DesktopAccountSyncPairCleanupPhase.Prepared -> { + when (accountOwnership(cleanup.accountId)) { + DesktopAccountOwnership.Present -> { + clearCleanup(cleanup.accountId) + reactivatePresentAccount(cleanup) + return + } + DesktopAccountOwnership.Unknown -> return + DesktopAccountOwnership.Absent -> Unit } - DesktopAccountOwnership.Unknown -> return - DesktopAccountOwnership.Absent -> Unit } + DesktopAccountSyncPairCleanupPhase.Committed -> Unit } removeSyncPairs(cleanup) clearCleanup(cleanup.accountId) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt index eacd32b42..829af2791 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt @@ -10,31 +10,6 @@ import java.util.prefs.Preferences import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -internal data class DesktopCachedFileContent( - val bytes: ByteArray, - val mimeType: String?, - val etag: String, -) - -internal data class DesktopCachedFileListing( - val files: List, - val fetchedAtEpochMillis: Long, -) - -internal data class DesktopCachedVirtualListing( - val nodes: List, - val fetchedAtEpochMillis: Long, - val freshAtEpochMillis: Long = fetchedAtEpochMillis, -) - -internal data class DesktopVirtualFileCacheSummary( - val policy: VirtualFileCachePolicy, - val cachedBytes: Long, - val reclaimableBytes: Long, - val entryCount: Int, - val availableFreeBytes: Long, -) - /** * Disposable, account-private Files read cache for desktop. * @@ -56,6 +31,7 @@ internal class DesktopFileReadCache( ) { private val loadedIndexes = LinkedHashMap(16, 0.75f, true) private val failedVirtualListingInvalidations = mutableMapOf>() + private val lifecycle = DesktopFileReadCacheLifecycle() private val virtualListingInvalidationPreferences = preferences.node( "linux-virtual-metadata-invalidations-v1", ) @@ -74,7 +50,7 @@ internal class DesktopFileReadCache( @Synchronized fun cachedListingPaths(accountId: String): Set = - load(accountId).let { index -> + if (!lifecycle.isActive(accountId)) emptySet() else load(accountId).let { index -> buildSet { index.listings.mapTo(this, CachedListingV1::path) index.listingShards.mapTo(this, CachedListingShardReferenceV1::path) @@ -83,6 +59,7 @@ internal class DesktopFileReadCache( @Synchronized fun cachedListingSnapshot(accountId: String, path: String): DesktopCachedFileListing? { + if (!lifecycle.isActive(accountId)) return null val normalized = path.cachePath() val index = load(accountId) val listing = index.listings.firstOrNull { it.path == normalized } @@ -95,7 +72,7 @@ internal class DesktopFileReadCache( @Synchronized fun cachedVirtualListingPaths(accountId: String): Set = - load(accountId).let { index -> + if (!lifecycle.isActive(accountId)) emptySet() else load(accountId).let { index -> buildSet { index.virtualListings.mapTo(this, CachedVirtualListingV1::path) index.virtualListingShards.mapTo(this, CachedVirtualListingShardReferenceV1::path) @@ -104,6 +81,7 @@ internal class DesktopFileReadCache( @Synchronized fun cachedVirtualListingSnapshot(accountId: String, path: String): DesktopCachedVirtualListing? { + if (!lifecycle.isActive(accountId)) return null val normalized = path.cachePath() val index = load(accountId) val listing = index.virtualListings.firstOrNull { it.path == normalized } @@ -120,6 +98,7 @@ internal class DesktopFileReadCache( @Synchronized fun failedVirtualListingInvalidations(accountId: String): Set { + if (!lifecycle.isActive(accountId)) return emptySet() failedVirtualListingInvalidations[accountId]?.let { return it.toSet() } return if (virtualListingInvalidationPreferences.getBoolean(accountId, false)) { setOf("") @@ -129,7 +108,12 @@ internal class DesktopFileReadCache( } @Synchronized - fun replaceFailedVirtualListingInvalidations(accountId: String, paths: Set) { + fun replaceFailedVirtualListingInvalidations( + accountId: String, + paths: Set, + cacheProducer: DesktopFileReadCacheProducer? = producer(accountId), + ) { + if (!lifecycle.accepts(accountId, cacheProducer)) return if (paths.isEmpty()) { failedVirtualListingInvalidations.remove(accountId) virtualListingInvalidationPreferences.remove(accountId) @@ -144,6 +128,7 @@ internal class DesktopFileReadCache( @Synchronized fun removeAccount(accountId: String) { + lifecycle.retire(accountId) try { purgeDesktopAccountCacheDirectory(root, accountId) virtualListingInvalidationPreferences.remove(accountId) @@ -154,13 +139,21 @@ internal class DesktopFileReadCache( } } + @Synchronized + fun producer(accountId: String): DesktopFileReadCacheProducer? = lifecycle.producer(accountId) + + @Synchronized + fun activateAccount(accountId: String) = lifecycle.activate(accountId) + @Synchronized fun storeListing( accountId: String, path: String, files: List, nowEpochMillis: Long = System.currentTimeMillis(), + cacheProducer: DesktopFileReadCacheProducer? = producer(accountId), ) { + if (!lifecycle.accepts(accountId, cacheProducer)) return require(nowEpochMillis >= 0L) require(files.size <= MAX_FILES_PER_LISTING) { "The folder contains too many cacheable entries." } val normalized = path.cachePath() @@ -193,7 +186,9 @@ internal class DesktopFileReadCache( files: List, fetchedAtEpochMillis: Long, nowEpochMillis: Long = System.currentTimeMillis(), + cacheProducer: DesktopFileReadCacheProducer? = producer(accountId), ): Boolean { + if (!lifecycle.accepts(accountId, cacheProducer)) return false require(fetchedAtEpochMillis >= 0L) require(nowEpochMillis >= 0L) val normalized = path.cachePath() @@ -209,7 +204,7 @@ internal class DesktopFileReadCache( ) { return false } - storeListing(accountId, normalized, files, fetchedAtEpochMillis) + storeListing(accountId, normalized, files, fetchedAtEpochMillis, cacheProducer) return true } @@ -221,7 +216,9 @@ internal class DesktopFileReadCache( fetchedAtEpochMillis: Long, freshAtEpochMillis: Long = fetchedAtEpochMillis, nowEpochMillis: Long = System.currentTimeMillis(), + cacheProducer: DesktopFileReadCacheProducer? = producer(accountId), ): Boolean { + if (!lifecycle.accepts(accountId, cacheProducer)) return false require(fetchedAtEpochMillis >= 0L) require(freshAtEpochMillis >= fetchedAtEpochMillis) require(nowEpochMillis >= 0L) @@ -264,6 +261,7 @@ internal class DesktopFileReadCache( path: String, maximumBytes: Long, ): DesktopCachedFileContent? { + if (!lifecycle.isActive(accountId)) return null require(maximumBytes > 0L) val normalized = path.cachePath() val record = load(accountId).content.firstOrNull { it.path == normalized } ?: return null @@ -295,7 +293,9 @@ internal class DesktopFileReadCache( path: String, content: NextcloudFileContent, nowEpochMillis: Long = System.currentTimeMillis(), + cacheProducer: DesktopFileReadCacheProducer? = producer(accountId), ): Boolean { + if (!lifecycle.accepts(accountId, cacheProducer)) return false require(nowEpochMillis >= 0L) val normalized = path.cachePath() val etag = content.etag?.takeIf(String::isNotBlank) ?: return false diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheLifecycle.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheLifecycle.kt new file mode 100644 index 000000000..feda98b42 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheLifecycle.kt @@ -0,0 +1,37 @@ +package dev.obiente.nextcloudnative.app + +internal class DesktopFileReadCacheProducer internal constructor( + internal val accountId: String, + internal val incarnation: Long, +) + +/** Account incarnations for file-cache operations, called while the cache monitor is held. */ +internal class DesktopFileReadCacheLifecycle { + private val retiredAccounts = mutableSetOf() + private val incarnations = mutableMapOf() + + fun producer(accountId: String): DesktopFileReadCacheProducer? = + if (accountId in retiredAccounts) null else DesktopFileReadCacheProducer( + accountId, + incarnations[accountId] ?: 0L, + ) + + fun accepts(accountId: String, producer: DesktopFileReadCacheProducer?): Boolean { + val current = producer ?: return false + return current.accountId == accountId && accountId !in retiredAccounts && + current.incarnation == (incarnations[accountId] ?: 0L) + } + + fun retire(accountId: String) { + if (retiredAccounts.add(accountId)) incarnations[accountId] = (incarnations[accountId] ?: 0L) + 1L + } + + fun activate(accountId: String) { + retiredAccounts.remove(accountId) + } + + fun isActive(accountId: String): Boolean = accountId !in retiredAccounts +} + +internal fun DesktopFileReadCache.producerFor(session: NextcloudSession): Pair = + desktopFileCacheAccountId(session).let { accountId -> accountId to producer(accountId) } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheModels.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheModels.kt new file mode 100644 index 000000000..9ee1a95e3 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheModels.kt @@ -0,0 +1,26 @@ +package dev.obiente.nextcloudnative.app + +internal data class DesktopCachedFileContent( + val bytes: ByteArray, + val mimeType: String?, + val etag: String, +) + +internal data class DesktopCachedFileListing( + val files: List, + val fetchedAtEpochMillis: Long, +) + +internal data class DesktopCachedVirtualListing( + val nodes: List, + val fetchedAtEpochMillis: Long, + val freshAtEpochMillis: Long = fetchedAtEpochMillis, +) + +internal data class DesktopVirtualFileCacheSummary( + val policy: VirtualFileCachePolicy, + val cachedBytes: Long, + val reclaimableBytes: Long, + val entryCount: Int, + val availableFreeBytes: Long, +) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index 22d65262d..d81b121af 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3477,7 +3477,7 @@ class DesktopNextcloudServices( activate = { AccountPrivateMemoryLifecycle.activateAccount(it.accountId.storageKey) dynamicDiscoveryCache.activateAccount(it.accountId.storageKey) - dynamicApiRequestCoalescer.activateAccount(desktopFileCacheAccountId(it)) + dynamicApiRequestCoalescer.activateAccount(desktopFileCacheAccountId(it).also(fileReadCache::activateAccount)) synchronized(fileRangeSessionLock) { sessionClearing = false } startDesktopSyncLifecycle() }, @@ -3823,6 +3823,7 @@ class DesktopNextcloudServices( private fun reactivateDesktopMemoryAfterAbortedRemoval(cleanup: DesktopAccountSyncPairCleanup) { cleanup.accountStorageKey?.let(AccountPrivateMemoryLifecycle::activateAccount) + fileReadCache.activateAccount(cleanup.accountId) } private fun schedulePendingAccountSyncPairCleanupRetry() = serviceScope.launch { @@ -4115,7 +4116,7 @@ class DesktopNextcloudServices( userId: String, path: String, ): NextcloudFileListing = withContext(Dispatchers.IO) { - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) val requestStartedAtEpochMillis = System.currentTimeMillis().coerceAtLeast(0L) try { val response = request( @@ -4130,7 +4131,7 @@ class DesktopNextcloudServices( accountId = accountId, path = path, files = files, - fetchedAtEpochMillis = requestStartedAtEpochMillis, + fetchedAtEpochMillis = requestStartedAtEpochMillis, cacheProducer = cacheProducer, ) } NextcloudFileListing(files, NextcloudFileListingSource.Network) @@ -4400,7 +4401,7 @@ class DesktopNextcloudServices( maxBytes: Long, ): NextcloudFileContent = withContext(Dispatchers.IO) { require(maxBytes > 0) { "The download size limit must be greater than zero." } - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) val cached = fileReadCache.cachedContent(accountId, path, maxBytes) try { var response = request( @@ -4434,7 +4435,7 @@ class DesktopNextcloudServices( response.status !in 200..299 -> error("Downloading the file failed (HTTP ${response.status}).") else -> NextcloudFileContent(response.body, response.contentType, response.etag).also { content -> - runCatching { fileReadCache.storeContent(accountId, path, content) } + runCatching { fileReadCache.storeContent(accountId, path, content, cacheProducer = cacheProducer) } } } } catch (failure: IOException) { @@ -4752,7 +4753,7 @@ class DesktopNextcloudServices( expectedEtag: String, ): SavedTextFile = withContext(Dispatchers.IO) { val specification = textFileDavSaveRequest(text, expectedEtag) - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun queueAffectedMetadataRefresh() = refreshRetainedFoldersAfterMutation(session, userId, accountId, path) val response = request( @@ -4772,9 +4773,9 @@ class DesktopNextcloudServices( refreshRetainedFoldersAfterMutation(session, userId, accountId, path) etag?.let { fileReadCache.storeContent( - accountId, - path, + accountId, path, NextcloudFileContent(specification.body, specification.contentType, it), + cacheProducer = cacheProducer, ) } } @@ -4791,7 +4792,7 @@ class DesktopNextcloudServices( require(utf8.size.toLong() <= MAX_EDITABLE_TEXT_BYTES) { "Text files larger than ${MAX_EDITABLE_TEXT_BYTES / (1024 * 1024)} MiB cannot be created in the app." } - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun queueAffectedMetadataRefresh() = refreshRetainedFoldersAfterMutation(session, userId, accountId, path) val response = request( @@ -4811,9 +4812,9 @@ class DesktopNextcloudServices( refreshRetainedFoldersAfterMutation(session, userId, accountId, path) response.etag?.let { fileReadCache.storeContent( - accountId, - path, + accountId, path, NextcloudFileContent(utf8, "text/plain; charset=utf-8", it), + cacheProducer = cacheProducer, ) } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt index 31cebcd8b..d1e394acc 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt @@ -125,6 +125,7 @@ internal interface LinuxVirtualMetadataStore { internal class DesktopLinuxVirtualMetadataStore( private val cache: DesktopFileReadCache, private val accountId: String, + private val cacheProducer: DesktopFileReadCacheProducer? = cache.producer(accountId), ) : LinuxVirtualMetadataStore { override fun load(path: String): LinuxVirtualDirectorySnapshot? { val listing = cache.cachedVirtualListingSnapshot(accountId, path) ?: return null @@ -134,7 +135,6 @@ internal class DesktopLinuxVirtualMetadataStore( freshAtEpochMillis = listing.freshAtEpochMillis, ) } - override fun store(path: String, snapshot: LinuxVirtualDirectorySnapshot): Boolean = cache.storeVirtualListingUnlessNewer( accountId = accountId, @@ -142,8 +142,8 @@ internal class DesktopLinuxVirtualMetadataStore( nodes = snapshot.nodes, fetchedAtEpochMillis = snapshot.fetchedAtEpochMillis, freshAtEpochMillis = snapshot.freshAtEpochMillis, + cacheProducer = cacheProducer, ) - override fun invalidate(path: String) = cache.invalidate(accountId, path) override fun retainedPaths(): Set = cache.cachedVirtualListingPaths(accountId) @@ -151,7 +151,7 @@ internal class DesktopLinuxVirtualMetadataStore( override fun failedInvalidations(): Set = cache.failedVirtualListingInvalidations(accountId) override fun replaceFailedInvalidations(paths: Set) = - cache.replaceFailedVirtualListingInvalidations(accountId, paths) + cache.replaceFailedVirtualListingInvalidations(accountId, paths, cacheProducer) } internal class RetainedLinuxVirtualMetadataStore( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 79e8e7af9..7dba4a570 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -1030,12 +1030,45 @@ class DesktopAccountOperationGuardTest { } @Test - fun malformedCleanupUsesCredentialFreeOwnershipToRecover() = runBlocking { + fun futureCleanupFormatRemainsBlockedAndUntouchedWhenCredentialsAreAbsent() = runBlocking { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val futureValue = "v99|committed|future-private-state" + preferences.put("fsac.$CLEANUP_ACCOUNT_ID", futureValue) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + var ownershipChecks = 0 + val events = mutableListOf() + + try { + val cleanup = journal.pending().single() + assertEquals(DesktopAccountSyncPairCleanupPhase.Unknown, cleanup.phase) + assertTrue(journal.blocksAccountActivation(CLEANUP_ACCOUNT_ID)) + + retryDesktopAccountSyncPairCleanup( + cleanup = cleanup, + accountOwnership = { + ownershipChecks += 1 + DesktopAccountOwnership.Absent + }, + removeSyncPairs = { events += "remove-pairs" }, + clearCleanup = { events += "clear-cleanup" }, + ) + + assertEquals(0, ownershipChecks) + assertTrue(events.isEmpty()) + assertEquals(futureValue, preferences.get("fsac.$CLEANUP_ACCOUNT_ID", null)) + assertTrue(journal.blocksAccountActivation(CLEANUP_ACCOUNT_ID)) + } finally { + preferences.removeNode() + } + } + + @Test + fun preparedCleanupUsesCredentialFreeOwnershipToRecover() = runBlocking { val absentEvents = mutableListOf() retryDesktopAccountSyncPairCleanup( cleanup = DesktopAccountSyncPairCleanup( CLEANUP_ACCOUNT_ID, - DesktopAccountSyncPairCleanupPhase.Unknown, + DesktopAccountSyncPairCleanupPhase.Prepared, ), accountOwnership = { DesktopAccountOwnership.Absent }, removeSyncPairs = { absentEvents += "remove-pairs" }, @@ -1047,7 +1080,7 @@ class DesktopAccountOperationGuardTest { retryDesktopAccountSyncPairCleanup( cleanup = DesktopAccountSyncPairCleanup( CLEANUP_ACCOUNT_ID, - DesktopAccountSyncPairCleanupPhase.Unknown, + DesktopAccountSyncPairCleanupPhase.Prepared, ), accountOwnership = { DesktopAccountOwnership.Present }, removeSyncPairs = { presentEvents += "remove-pairs" }, diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheTest.kt index 9cff3cc85..52a48ce70 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheTest.kt @@ -17,6 +17,44 @@ import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject class DesktopFileReadCacheTest { + @Test + fun `stale producers cannot recreate cache files across retirement and reactivation`() = withCache { root, cache -> + val accountId = desktopFileCacheAccountId(session()) + val staleProducer = checkNotNull(cache.producer(accountId)) + val content = NextcloudFileContent("private".encodeToByteArray(), "text/plain", "\"etag-stale\"") + val listing = listOf(file("Notes/private.txt", "\"etag-stale\"")) + + assertTrue(cache.storeContent(accountId, "Notes/private.txt", content, cacheProducer = staleProducer)) + cache.removeAccount(accountId) + + assertFalse(cache.storeContent(accountId, "Notes/private.txt", content, cacheProducer = staleProducer)) + assertFalse( + cache.storeListingUnlessNewer( + accountId, + "Notes", + listing, + fetchedAtEpochMillis = 10, + cacheProducer = staleProducer, + ), + ) + assertFalse(root.resolve(accountId).exists()) + + cache.activateAccount(accountId) + assertFalse(cache.storeContent(accountId, "Notes/private.txt", content, cacheProducer = staleProducer)) + val currentProducer = checkNotNull(cache.producer(accountId)) + assertTrue(cache.storeContent(accountId, "Notes/private.txt", content, cacheProducer = currentProducer)) + assertTrue( + cache.storeListingUnlessNewer( + accountId, + "Notes", + listing, + fetchedAtEpochMillis = 20, + cacheProducer = currentProducer, + ), + ) + assertContentEquals(content.bytes, cache.cachedContent(accountId, "Notes/private.txt", 64)?.bytes) + } + @Test fun `metadata and content survive a new cache instance without storing credentials`() = withCache { root, cache -> val session = session(password = "first-secret") From 412f36b39dd5165e055bbadc1db3fea2873891ca Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 12:45:49 +0200 Subject: [PATCH 111/119] fix(accounts): close desktop cleanup gaps --- .../DesktopAccountCredentialPersistence.kt | 7 +- .../app/DesktopAccountRemoval.kt | 58 ++++++++++- .../app/DesktopFileReadCache.kt | 14 +-- .../app/DesktopNextcloudServices.kt | 98 +++++++++---------- .../app/LinuxVirtualFileSystem.kt | 2 +- ...DesktopAccountCredentialPersistenceTest.kt | 25 +++-- .../app/DesktopAccountMemoryRetirementTest.kt | 57 +++++++++++ .../app/DesktopAccountOperationGuardTest.kt | 83 ++++++++++++++++ .../app/DesktopFileReadCacheTest.kt | 2 + 9 files changed, 277 insertions(+), 69 deletions(-) diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 49e108d08..962e2aab7 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -93,7 +93,7 @@ internal class DesktopAccountCredentialPersistence( ?: when { read.encoded == null -> NextcloudAccountRegistry.Empty read.unsupportedVersion -> throw unsupportedRegistryForMutation() - else -> NextcloudAccountRegistry.Empty + else -> throw malformedRegistryForMutation() } val previousRecord = registry.accounts.firstOrNull { account -> account.id == session.accountId } val persistedSession = previousRecord @@ -680,6 +680,11 @@ internal class DesktopAccountCredentialPersistence( return IllegalStateException("The local account registry was written by a newer app version.") } + private fun malformedRegistryForMutation(): IllegalStateException { + recordCredentialDiagnostic("ACCOUNT_REGISTRY_MALFORMED", "account-registry.persist") + return IllegalStateException("The local account registry is malformed and cannot be replaced safely.") + } + private fun recordCredentialDiagnostic( code: String, operation: String, diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt index a86fc9332..9de08d2fe 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRemoval.kt @@ -20,7 +20,20 @@ internal fun requireDesktopAccountActivationAllowed(blockedByUnknownCleanup: Boo } internal fun DesktopAccountSyncPairCleanupJournal.requireAccountActivationAllowed(record: NextcloudAccountRecord) = - requireDesktopAccountActivationAllowed(blocksAccountActivation(desktopFileCacheAccountId(record))) + requireDesktopAccountActivationAllowed( + blocksAccountActivation(desktopFileCacheAccountId(record), record.id.storageKey), + ) + +internal fun loadDesktopSessionAfterCleanupGate( + record: NextcloudAccountRecord?, + cleanupJournal: DesktopAccountSyncPairCleanupJournal, + load: () -> NextcloudSession?, + publish: (NextcloudSession) -> Unit, +): NextcloudSession? { + requireDesktopAccountActivationAllowed(cleanupJournal.blocksAllAccountActivation()) + record?.let(cleanupJournal::requireAccountActivationAllowed) + return load()?.also(publish) +} internal enum class DesktopAccountSyncPairCleanupPhase { Prepared, @@ -92,10 +105,36 @@ internal class DesktopAccountSyncPairCleanupJournal( preferences.flush() } - fun blocksAccountActivation(accountId: String): Boolean { + fun blocksAccountActivation(accountId: String, accountStorageKey: String? = null): Boolean { validateDesktopSyncPairCleanupAccountId(accountId) - val encoded = preferences.get(cleanupKey(accountId), null) - val blocked = encoded != null && decode(accountId, encoded).phase == DesktopAccountSyncPairCleanupPhase.Unknown + require(accountStorageKey == null || accountStorageKey.matches(ACCOUNT_STORAGE_KEY_PATTERN)) { + "The desktop account storage cleanup identity is invalid." + } + val blocked = if (accountStorageKey == null) { + val encoded = preferences.get(cleanupKey(accountId), null) + encoded != null && decode(accountId, encoded).phase == DesktopAccountSyncPairCleanupPhase.Unknown + } else { + pending().any { cleanup -> + cleanup.phase == DesktopAccountSyncPairCleanupPhase.Unknown && + (cleanup.accountStorageKey == null || cleanup.matchesAccountActivation(accountId, accountStorageKey)) + } + } + if (blocked) recordMalformedOnce() + return blocked + } + + fun blocksAllAccountActivation(): Boolean { + val blocked = preferences.keys().asSequence() + .filter { key -> key.startsWith(KEY_PREFIX) } + .any { key -> + val accountId = key.removePrefix(KEY_PREFIX) + val cleanup = runCatching { + validateDesktopSyncPairCleanupAccountId(accountId) + decode(accountId, preferences.get(key, null)) + }.getOrNull() + cleanup == null || + cleanup.phase == DesktopAccountSyncPairCleanupPhase.Unknown && cleanup.accountStorageKey == null + } if (blocked) recordMalformedOnce() return blocked } @@ -193,6 +232,17 @@ internal class DesktopAccountSyncPairCleanupJournal( scope != null && accountStorageKey != null && legacyAccountScopeDigest != null ) { DesktopAccountSyncPairCleanup(accountId, phase, scope, accountStorageKey, legacyAccountScopeDigest) + } else if ( + fields.size > 5 && fields[0] == VALUE_VERSION_WITH_LEGACY_ACCOUNT_SCOPE && + scope != null && accountStorageKey != null && legacyAccountScopeDigest != null + ) { + DesktopAccountSyncPairCleanup( + accountId, + DesktopAccountSyncPairCleanupPhase.Unknown, + scope, + accountStorageKey, + legacyAccountScopeDigest, + ) } else { DesktopAccountSyncPairCleanup(accountId, DesktopAccountSyncPairCleanupPhase.Unknown) } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt index 829af2791..94a2abbae 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCache.kt @@ -128,7 +128,7 @@ internal class DesktopFileReadCache( @Synchronized fun removeAccount(accountId: String) { - lifecycle.retire(accountId) + retireAccount(accountId) try { purgeDesktopAccountCacheDirectory(root, accountId) virtualListingInvalidationPreferences.remove(accountId) @@ -139,11 +139,9 @@ internal class DesktopFileReadCache( } } - @Synchronized - fun producer(accountId: String): DesktopFileReadCacheProducer? = lifecycle.producer(accountId) - - @Synchronized - fun activateAccount(accountId: String) = lifecycle.activate(accountId) + @Synchronized fun retireAccount(accountId: String) = lifecycle.retire(accountId) + @Synchronized fun producer(accountId: String): DesktopFileReadCacheProducer? = lifecycle.producer(accountId) + @Synchronized fun activateAccount(accountId: String) = lifecycle.activate(accountId) @Synchronized fun storeListing( @@ -409,7 +407,8 @@ internal class DesktopFileReadCache( ): VirtualFileEvictionPlan = applyEviction(accountId, requestedBytesToFree, nowEpochMillis) @Synchronized - fun invalidate(accountId: String, path: String) { + fun invalidate(accountId: String, path: String, cacheProducer: DesktopFileReadCacheProducer? = producer(accountId)): Boolean { + if (!lifecycle.accepts(accountId, cacheProducer)) return false val normalized = path.cachePath() val parent = normalized.parentCachePath() val accountDirectory = accountDirectory(accountId) @@ -456,6 +455,7 @@ internal class DesktopFileReadCache( content = index.content.filterNot { it in removed }, ), ) + return true } private fun CacheIndexV1.bounded(): CacheIndexV1 { 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 d81b121af..b32a2cddc 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -1274,9 +1274,10 @@ class DesktopNextcloudServices( userId: String, accountId: String, path: String, + cacheProducer: DesktopFileReadCacheProducer?, ) { synchronized(virtualFileProviderLock) { - runCatching { invalidateDesktopFileMetadata(accountId, path) } + if (!runCatching { invalidateDesktopFileMetadata(accountId, path, cacheProducer) }.getOrDefault(false)) return val cache = runCatching { virtualRangeCache(accountId) }.getOrNull() ?: return val roots = runCatching { cache.retainedFoldersAffectedByListingChanges(accountId, listOf(path)) @@ -1358,12 +1359,8 @@ class DesktopNextcloudServices( private val fileSyncEngine = DesktopFileSyncEngine( minimumFreeSpaceBytes = { fileReadCache.loadPolicy().minimumFreeSpaceBytes }, onRemoteMutationCommitted = { session, userId, path -> - refreshRetainedFoldersAfterMutation( - session, - userId, - desktopFileCacheAccountId(session), - path, - ) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) }, ) private val accountSyncPairCleanupJournal = DesktopAccountSyncPairCleanupJournal( @@ -1683,6 +1680,7 @@ class DesktopNextcloudServices( ) } val accountId = desktopFileCacheAccountId(session) + val cacheProducer = fileReadCache.producer(accountId) if (accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) return unknownCleanupStateRejection() runCatching(linuxProviderCleanup::retry).exceptionOrNull()?.let { return VirtualFileStorageActionResult.Rejected(it.message ?: "The earlier Linux mount is still active.") @@ -1863,7 +1861,7 @@ class DesktopNextcloudServices( tree = DesktopFileSyncRemoteTree(session, userId, ""), onCommitted = { path -> runCatching { virtualRangeCache(accountId).invalidate(accountId, path) } - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) recoveredWritebackPaths += path }, ) @@ -1892,7 +1890,7 @@ class DesktopNextcloudServices( }, ), afterMutationInvalidated = { path -> - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) }, ) metadataBackendReference = metadataBackend @@ -2381,16 +2379,13 @@ class DesktopNextcloudServices( if (ownsTemporarySupportDiagnosticsRoot) requireNotNull(resolvedSupportDiagnosticsRoot).deleteRecursively() } - private fun invalidateDesktopFileMetadata(accountId: String, path: String) { - synchronized(virtualFileProviderLock) { - val mountedBackend = linuxVirtualMetadataBackend - ?.takeIf { linuxVirtualFileMountIdentity == accountId } - if (mountedBackend != null) { - mountedBackend.invalidateAfterExternalMutation(path) - } else { - fileReadCache.invalidate(accountId, path) - } - } + private fun invalidateDesktopFileMetadata( + accountId: String, path: String, cacheProducer: DesktopFileReadCacheProducer?, + ): Boolean = synchronized(virtualFileProviderLock) { + if (!fileReadCache.invalidate(accountId, path, cacheProducer)) return@synchronized false + linuxVirtualMetadataBackend?.takeIf { linuxVirtualFileMountIdentity == accountId } + ?.invalidateAfterExternalMutation(path) + true } override suspend fun chooseFileSyncLocalRoot(initialRootHint: String?): FileSyncLocalRoot? = @@ -3447,12 +3442,15 @@ class DesktopNextcloudServices( ) } override fun loadSession(): NextcloudSession? = sessionPublicationGuard.serialize { - val session = accountCredentials.loadActiveSession() + val activeId = accountCredentials.activeAccountId() + val record = accountCredentials.listAccounts().firstOrNull { account -> account.id == activeId } + val session = loadDesktopSessionAfterCleanupGate( + record, accountSyncPairCleanupJournal, accountCredentials::loadActiveSession, + accountSessionPublication::publish, + ) if (session == null) { supportDiagnostics.setActiveAccountIdentity(null) supportIntake.setActiveAccountIdentity(null) - } else { - accountSessionPublication.publish(session) } session } @@ -3488,9 +3486,11 @@ class DesktopNextcloudServices( override fun activeAccountId() = sessionPublicationGuard.serialize(accountCredentials::activeAccountId) override fun loadSession(accountId: NextcloudAccountId): NextcloudSession? = sessionPublicationGuard.serialize { - accountCredentials.loadSession(accountId)?.also { session -> - accountSessionPublication.register(session) - } + val record = accountCredentials.listAccounts().firstOrNull { account -> account.id == accountId } + loadDesktopSessionAfterCleanupGate( + record, accountSyncPairCleanupJournal, { accountCredentials.loadSession(accountId) }, + accountSessionPublication::register, + ) } override suspend fun selectAccount(accountId: NextcloudAccountId): NextcloudSession? = withContext(Dispatchers.IO) { @@ -3576,7 +3576,7 @@ class DesktopNextcloudServices( } }, removeSyncPairs = ::removeDesktopAccountOwnedState, retireCommittedAccount = { - fenceDesktopAccountPrivateCaches(account.id.storageKey) + fenceDesktopAccountPrivateCaches(account.id.storageKey, providerAccountId) }, ) { recordSupportDiagnostic(desktopAccountSyncPairCleanupFailureDiagnostic(providerAccountId, it)) @@ -3772,7 +3772,7 @@ class DesktopNextcloudServices( ::recordSupportDiagnostic, retireCommittedAccount = { if (accountStorageKey != null && accountId != null) { - fenceDesktopAccountPrivateCaches(accountStorageKey) + fenceDesktopAccountPrivateCaches(accountStorageKey, accountId) } }, ) @@ -3810,7 +3810,9 @@ class DesktopNextcloudServices( accountSyncPairCleanupJournal::clear, ::reactivateDesktopMemoryAfterAbortedRemoval, ) } - requireDesktopAccountActivationAllowed(accountSyncPairCleanupJournal.blocksAccountActivation(accountId)) + requireDesktopAccountActivationAllowed( + accountSyncPairCleanupJournal.blocksAccountActivation(accountId, accountStorageKey), + ) } private suspend fun retryPendingAccountSyncPairCleanups() = retryPendingDesktopAccountSyncPairCleanups( @@ -3872,7 +3874,8 @@ class DesktopNextcloudServices( } } - private fun fenceDesktopAccountPrivateCaches(accountStorageKey: String) { + private fun fenceDesktopAccountPrivateCaches(accountStorageKey: String, fileCacheAccountId: String) { + fileReadCache.retireAccount(fileCacheAccountId) AccountPrivateMemoryLifecycle.retireAccount(accountStorageKey) dynamicDiscoveryCache.fenceAccount(accountStorageKey) } @@ -4221,9 +4224,9 @@ class DesktopNextcloudServices( ).conflictConditionHeaders(), ) } - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun refreshMetadata() { - runCatching { refreshRetainedFoldersAfterMutation(session, userId, accountId, safePath) } + runCatching { refreshRetainedFoldersAfterMutation(session, userId, accountId, safePath, cacheProducer) } } val response = request( method = "PROPPATCH", @@ -4427,7 +4430,7 @@ class DesktopNextcloudServices( response.status == 304 && cached != null -> NextcloudFileContent(cached.bytes, cached.mimeType, cached.etag) response.status == 404 -> { - runCatching { refreshRetainedFoldersAfterMutation(session, userId, accountId, path) } + runCatching { refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) } error("The file no longer exists on the server.") } response.status >= 500 && cached != null -> @@ -4673,9 +4676,9 @@ class DesktopNextcloudServices( version: NextcloudFileVersion, ): Unit = withContext(Dispatchers.IO) { val specification = fileVersionRestoreRequest(userId, file, version) - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun queueAffectedMetadataRefresh() = - refreshRetainedFoldersAfterMutation(session, userId, accountId, file.path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, file.path, cacheProducer) val response = request( method = specification.method, url = session.serverUrl + specification.relativePath, @@ -4692,12 +4695,7 @@ class DesktopNextcloudServices( when (val result = classifyFileVersionRestoreHttpResponse(response.status)) { FileVersionRestoreHttpResult.Restored -> { runCatching { - refreshRetainedFoldersAfterMutation( - session, - userId, - accountId, - file.path, - ) + refreshRetainedFoldersAfterMutation(session, userId, accountId, file.path, cacheProducer) } } is FileVersionRestoreHttpResult.Rejected -> error(result.message) @@ -4755,7 +4753,7 @@ class DesktopNextcloudServices( val specification = textFileDavSaveRequest(text, expectedEtag) val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun queueAffectedMetadataRefresh() = - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) val response = request( "PUT", buildNextcloudFileUrl(session.serverUrl, userId, path), @@ -4770,7 +4768,7 @@ class DesktopNextcloudServices( val etag = response.etag ?: runCatchingPreservingCancellation { loadFileEtag(session, userId, path) }.getOrNull() runCatching { - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) etag?.let { fileReadCache.storeContent( accountId, path, @@ -4794,7 +4792,7 @@ class DesktopNextcloudServices( } val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun queueAffectedMetadataRefresh() = - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) val response = request( "PUT", buildNextcloudFileUrl(session.serverUrl, userId, path), @@ -4809,7 +4807,7 @@ class DesktopNextcloudServices( check(response.status in 200..299) { "Creating the text file failed (HTTP ${response.status})." } check(response.status == 201) { "The server did not confirm that a new text file was created." } runCatching { - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) response.etag?.let { fileReadCache.storeContent( accountId, path, @@ -4826,9 +4824,9 @@ class DesktopNextcloudServices( userId: String, path: String, ): Boolean = withContext(Dispatchers.IO) { - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun queueAffectedMetadataRefresh() = - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) val response = request( method = "MKCOL", url = buildNextcloudFileUrl(session.serverUrl, userId, path), @@ -4842,7 +4840,7 @@ class DesktopNextcloudServices( if (response.status !in 200..299) throw fileOperationException(response.status) check(response.status == 201) { "The server did not confirm that a new folder was created." } runCatching { - refreshRetainedFoldersAfterMutation(session, userId, accountId, path) + refreshRetainedFoldersAfterMutation(session, userId, accountId, path, cacheProducer) } true } @@ -4861,12 +4859,12 @@ class DesktopNextcloudServices( put("Overwrite", if (spec.overwrite) "T" else "F") } } - val accountId = desktopFileCacheAccountId(session) + val (accountId, cacheProducer) = fileReadCache.producerFor(session) fun invalidateAffectedMetadata() { runCatching { - refreshRetainedFoldersAfterMutation(session, userId, accountId, spec.sourcePath) + refreshRetainedFoldersAfterMutation(session, userId, accountId, spec.sourcePath, cacheProducer) spec.destinationPath?.let { destination -> - refreshRetainedFoldersAfterMutation(session, userId, accountId, destination) + refreshRetainedFoldersAfterMutation(session, userId, accountId, destination, cacheProducer) } } } diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt index d1e394acc..67e67d7db 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/LinuxVirtualFileSystem.kt @@ -144,7 +144,7 @@ internal class DesktopLinuxVirtualMetadataStore( freshAtEpochMillis = snapshot.freshAtEpochMillis, cacheProducer = cacheProducer, ) - override fun invalidate(path: String) = cache.invalidate(accountId, path) + override fun invalidate(path: String) { cache.invalidate(accountId, path, cacheProducer) } override fun retainedPaths(): Set = cache.cachedVirtualListingPaths(accountId) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index 648700347..de7483de0 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -365,19 +365,32 @@ class DesktopAccountCredentialPersistenceTest { } @Test - fun malformedRegistryWithoutLegacyCredentialIsReplacedByFreshSignIn() = withStore { preferences, secrets -> + fun malformedRegistryWithoutLegacyCredentialRejectsFreshSignInWithoutOrphaningState() = + withStore { preferences, secrets -> val session = firstSession() - preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, "{not-json") + val malformedRegistry = "{not-json" + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, malformedRegistry) + secrets.save( + desktopAccountSecretReference(secondSession().accountId), "bob", "existing-secret".encodeToByteArray(), + ) + val savesBefore = secrets.saveCount + val clearsBefore = secrets.clearCount val diagnostics = mutableListOf() val persistence = persistence(preferences, secrets, diagnostics) assertEquals(DesktopAccountOwnership.Unknown, persistence.accountOwnership(desktopFileCacheAccountId(session))) - assertEquals(session, persistence.saveSession(session)) + assertFailsWith { persistence.saveSession(session) } - assertEquals(session.accountId, decodeRegistry(preferences).activeAccountId) - assertEquals(session, persistence(preferences, secrets).loadActiveSession()) + assertEquals(malformedRegistry, preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertNull(secrets.load(desktopAccountSecretReference(session.accountId))) + assertEquals( + "existing-secret", + secrets.load(desktopAccountSecretReference(secondSession().accountId))?.decodeToString(), + ) + assertEquals(savesBefore, secrets.saveCount) + assertEquals(clearsBefore, secrets.clearCount) assertTrue(diagnostics.any { it.code == "ACCOUNT_REGISTRY_MALFORMED" }) - } + } @Test fun malformedRegistryFallsBackWithoutDiscardingTheLegacyCredential() = withStore { preferences, secrets -> diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt index 821894150..8c74e0738 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountMemoryRetirementTest.kt @@ -1,6 +1,9 @@ package dev.obiente.nextcloudnative.app import java.io.IOException +import java.nio.file.Files +import java.util.UUID +import java.util.prefs.Preferences import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -52,4 +55,58 @@ class DesktopAccountMemoryRetirementTest { assertTrue(attempt(DesktopAccountOwnership.Absent)) assertFalse(attempt(DesktopAccountOwnership.Present)) } + + @Test + fun `committed removal fences file cache before physical cleanup can fail`() = runBlocking { + val root = Files.createTempDirectory("desktop-file-cache-commit-fence-").toFile() + val preferences = Preferences.userRoot().node("desktop-file-cache-commit-fence-${UUID.randomUUID()}") + val accountId = "d".repeat(64) + val cache = DesktopFileReadCache(root, preferences = preferences) + val staleProducer = checkNotNull(cache.producer(accountId)) + val privateContent = NextcloudFileContent( + "private".encodeToByteArray(), + "text/plain", + "etag-private", + ) + try { + assertTrue( + cache.storeContent( + accountId, + "Notes/private.txt", + privateContent, + cacheProducer = staleProducer, + ), + ) + + val removed = removeDesktopAccountBeforeSyncPairCleanup( + accountId = accountId, + prepareCleanup = { _, _, _, _ -> }, + commitCleanup = {}, + clearCleanup = {}, + accountOwnership = { DesktopAccountOwnership.Absent }, + removeCredential = { true }, + removeSyncPairs = { + assertTrue(root.resolve(accountId).isDirectory) + assertFalse( + cache.storeContent( + accountId, + "Notes/late.txt", + privateContent, + cacheProducer = staleProducer, + ), + ) + error("synthetic cleanup failure before physical cache removal") + }, + retireCommittedAccount = { cache.retireAccount(accountId) }, + recordCleanupFailure = {}, + ) + + assertTrue(removed) + assertFalse(root.resolve(accountId).resolve("index-v1.json").readText().contains("late.txt")) + } finally { + runCatching { cache.removeAccount(accountId) } + preferences.removeNode() + root.deleteRecursively() + } + } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt index 7dba4a570..35409227c 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountOperationGuardTest.kt @@ -930,6 +930,88 @@ class DesktopAccountOperationGuardTest { } } + @Test + fun futureCleanupPreservesCanonicalIdentityAndBlocksEquivalentReactivation() = runBlocking { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val oldCacheIdentity = "1".repeat(64) + val canonicalCacheIdentity = "2".repeat(64) + val futureValue = "v4|committed|$MUTATION_SCOPE|$ACCOUNT_STORAGE_KEY|$LEGACY_ACCOUNT_SCOPE|future" + preferences.put("fsac.$oldCacheIdentity", futureValue) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + + try { + val cleanup = journal.pendingForAccountActivation(canonicalCacheIdentity, ACCOUNT_STORAGE_KEY).single() + assertEquals(DesktopAccountSyncPairCleanupPhase.Unknown, cleanup.phase) + assertEquals(ACCOUNT_STORAGE_KEY, cleanup.accountStorageKey) + assertTrue(journal.blocksAccountActivation(canonicalCacheIdentity, ACCOUNT_STORAGE_KEY)) + + retryDesktopAccountSyncPairCleanup( + cleanup = cleanup, + accountOwnership = { error("future cleanup ownership must not be queried") }, + removeSyncPairs = { error("future cleanup must not remove state") }, + clearCleanup = { error("future cleanup must not be cleared") }, + ) + + assertEquals(futureValue, preferences.get("fsac.$oldCacheIdentity", null)) + assertTrue(journal.blocksAccountActivation(canonicalCacheIdentity, ACCOUNT_STORAGE_KEY)) + } finally { + preferences.removeNode() + } + } + + @Test + fun futureCleanupBlocksCredentialLoadAndPrivateSessionPublication() { + val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") + val session = NextcloudSession("https://cloud.example.test", "alice", "private-password") + val record = session.accountRecord() + val cacheIdentity = desktopFileCacheAccountId(record) + val journal = DesktopAccountSyncPairCleanupJournal(preferences) + var loads = 0 + var publications = 0 + + try { + listOf( + "v4|committed|$MUTATION_SCOPE|${record.id.storageKey}|$LEGACY_ACCOUNT_SCOPE|future", + "v99|committed|${"a".repeat(64)}|${"b".repeat(64)}", + ).forEach { futureValue -> + preferences.put("fsac.$cacheIdentity", futureValue) + assertFailsWith { + loadDesktopSessionAfterCleanupGate( + record, + journal, + load = { loads += 1; session }, + publish = { publications += 1 }, + ) + } + } + preferences.put("fsac.$cacheIdentity", "v99|committed|unknown-layout") + assertFailsWith { + loadDesktopSessionAfterCleanupGate( + record = null, + cleanupJournal = journal, + load = { loads += 1; session }, + publish = { publications += 1 }, + ) + } + preferences.remove("fsac.$cacheIdentity") + preferences.put("fsac.not-a-valid-account-id", "committed") + assertFailsWith { + loadDesktopSessionAfterCleanupGate( + record = null, + cleanupJournal = journal, + load = { loads += 1; session }, + publish = { publications += 1 }, + ) + } + assertEquals("committed", preferences.get("fsac.not-a-valid-account-id", null)) + + assertEquals(0, loads) + assertEquals(0, publications) + } finally { + preferences.removeNode() + } + } + @Test fun committedPairCleanupFailureSurvivesRestartAndBlocksReactivationUntilRetry() = runBlocking { val preferences = Preferences.userRoot().node("desktop-account-cleanup-test-${UUID.randomUUID()}") @@ -1057,6 +1139,7 @@ class DesktopAccountOperationGuardTest { assertTrue(events.isEmpty()) assertEquals(futureValue, preferences.get("fsac.$CLEANUP_ACCOUNT_ID", null)) assertTrue(journal.blocksAccountActivation(CLEANUP_ACCOUNT_ID)) + assertTrue(journal.blocksAccountActivation("9".repeat(64), ACCOUNT_STORAGE_KEY)) } finally { preferences.removeNode() } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheTest.kt index 52a48ce70..04fb06db3 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopFileReadCacheTest.kt @@ -52,7 +52,9 @@ class DesktopFileReadCacheTest { cacheProducer = currentProducer, ), ) + assertFalse(cache.invalidate(accountId, "Notes", staleProducer)) assertContentEquals(content.bytes, cache.cachedContent(accountId, "Notes/private.txt", 64)?.bytes) + assertEquals(listing, cache.cachedListing(accountId, "Notes")) } @Test From 5d63343d56b0c5f1f5b8d3b65c7be5e535b8b5be Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 12:45:57 +0200 Subject: [PATCH 112/119] fix(accounts): quiesce android private activity --- .../AndroidAccountCredentialController.kt | 48 ++-- .../AndroidAccountCredentialRecovery.kt | 43 ++++ .../AndroidAccountCredentialTransitions.kt | 18 ++ .../nextcloudnative/AndroidAccountFileRead.kt | 143 ++++++++++++ .../nextcloudnative/AndroidAccountRemoval.kt | 1 + .../AndroidMalformedCredentialReset.kt | 85 +++++++ .../AndroidNextcloudServices.kt | 27 ++- .../AndroidPersistedSession.kt | 2 +- .../AndroidVirtualFileProxyCallback.kt | 10 + .../AndroidAccountOperationGuardTest.kt | 173 ++++++++++++++ ...droidIndependentCredentialSlotResetTest.kt | 213 ++++++++++++++++++ .../nextcloudnative/app/NextcloudFileRange.kt | 3 + 12 files changed, 727 insertions(+), 39 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt index 0207ea5c5..ba538af3c 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialController.kt @@ -107,27 +107,32 @@ internal class AndroidAccountCredentialController( when (val read = readStore()) { is AndroidAccountCredentialStoreRead.Available -> { requireSupportedCredentialSlots(read.state.registry) - replaceActiveState(read.state.upsertAndSelect(session), read.state.activeSession) + replaceActiveState( + read.state.upsertAndSelect(session), read.state.activeSession, + read.state.sessions[session.accountId], + ) } is AndroidAccountCredentialStoreRead.Invalid -> { val retained = readIndependentCredentialSlotState() - check(retained != null || !hasIndependentCredentialState()) { + check(retained != null || !hasAndroidIndependentCredentialState(preferences)) { "The aggregate account credential store is invalid; reset it before signing in again." } replaceActiveState( replacement = (retained ?: AndroidAccountCredentialState.Empty).upsertAndSelect(session), previousSession = retained?.activeSession, + replacedSession = retained?.sessions?.get(session.accountId), suspectEncrypted = read.encrypted, ) } AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> { val retained = readIndependentCredentialSlotState() - check(retained != null || !hasIndependentCredentialState()) { + check(retained != null || !hasAndroidIndependentCredentialState(preferences)) { "The independent account credential slots could not be recovered." } replaceActiveState( replacement = (retained ?: AndroidAccountCredentialState.Empty).upsertAndSelect(session), previousSession = retained?.activeSession, + replacedSession = retained?.sessions?.get(session.accountId), ) } is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) @@ -144,7 +149,7 @@ internal class AndroidAccountCredentialController( val selected = current.select(accountId) ?: return@withLock null selectAndroidAccountAfterRemovalCleanup( requireNotNull(selected.activeSession), ::retryPendingAccountRemovalCleanup, registerSessionPrivateValues, - ) { replaceActiveState(selected, current.activeSession, suspectEncrypted) } + ) { replaceActiveState(selected, current.activeSession, suspectEncrypted = suspectEncrypted) } } suspend fun removeAccount(accountId: NextcloudAccountId): Boolean = @@ -290,16 +295,13 @@ internal class AndroidAccountCredentialController( val retained = readIndependentCredentialSlotState() when { retained != null -> clearRecoveredInvalidStore(retained, read.encrypted) - hasIndependentCredentialState() -> - error("The independent account credential slots could not be recovered.") + hasAndroidIndependentCredentialState(preferences) -> + clearUnregisteredIndependentCredentialSlots(read.encrypted) else -> clearInvalidStore(read.encrypted) } } AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable -> { - requireAndroidIndependentCredentialStateCanBeExplicitlyReset( - preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null), - ) - clearInvalidStore(null) + clearUnregisteredIndependentCredentialSlots(null) } is AndroidAccountCredentialStoreRead.Unsupported -> unsupportedCredentialStoreMutation(read.version) } @@ -326,6 +328,11 @@ internal class AndroidAccountCredentialController( ) notifyDocumentRootsChanged() } + private suspend fun clearUnregisteredIndependentCredentialSlots(suspectEncrypted: String?) = + clearUnregisteredAndroidAccountCredentialSlots( + preferences, sessionCipher, accountRemovalCleanupJournal, suspectEncrypted, + prepareAccountRemoval, removeQueuedUploads, ::commitPreferences, ::recordAccountRemovalCleanupFailure, + ::clearInvalidStore) private suspend fun clearRecoveredInvalidStore( current: AndroidAccountCredentialState, @@ -426,20 +433,16 @@ internal class AndroidAccountCredentialController( private suspend fun replaceActiveState( replacement: AndroidAccountCredentialState, previousSession: NextcloudSession?, + replacedSession: NextcloudSession? = null, suspectEncrypted: String? = null, - ) { - val replacementSession = requireNotNull(replacement.activeSession) - val affectedAccountIds = listOfNotNull(previousSession, replacementSession) - .map(NextcloudDocumentIds::accountKey) - ANDROID_ACCOUNT_OPERATION_GUARD.withAccounts(affectedAccountIds) { - replaceActiveStateWhileOperationsIdle(replacement, previousSession, suspectEncrypted) - } - } - + ) = replaceAndroidActiveStateWithAccountLeases( + replacement, previousSession, replacedSession, suspectEncrypted, + replace = ::replaceActiveStateWhileOperationsIdle) private suspend fun replaceActiveStateWhileOperationsIdle( replacement: AndroidAccountCredentialState, previousSession: NextcloudSession?, suspectEncrypted: String?, + replacedSession: NextcloudSession? = null, ) { val session = requireNotNull(replacement.activeSession) val encrypted = encryptState(replacement) @@ -568,7 +571,8 @@ internal class AndroidAccountCredentialController( val retained = readIndependentCredentialSlotState() when { retained != null -> availableCredentialStore(retained) - hasIndependentCredentialState() -> AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable + hasAndroidIndependentCredentialState(preferences) -> + AndroidAccountCredentialStoreRead.IndependentRecoveryUnavailable else -> availableCredentialStore(AndroidAccountCredentialState.Empty) } } @@ -634,10 +638,6 @@ internal class AndroidAccountCredentialController( } } - private fun hasIndependentCredentialState(): Boolean = - preferences.contains(ANDROID_ACCOUNT_REGISTRY_KEY) || - preferences.all.keys.any { key -> key.startsWith(ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX) } - private fun readCredentialSlot(accountId: NextcloudAccountId): AndroidAccountCredentialSlotRead = try { readAndroidAccountCredentialSlot( accountId = accountId, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt index b5b84084c..48c923432 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialRecovery.kt @@ -88,12 +88,55 @@ internal fun unsupportedCredentialStoreMutation(version: Int): Nothing = internal fun androidAccountCredentialSlotKey(accountId: NextcloudAccountId): String = "$ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX${accountId.storageKey}" +internal data class AndroidIndependentCredentialSlotReset( + val preferenceKey: String, + val encrypted: String, + val session: NextcloudSession, +) + +internal fun recoverAndroidIndependentCredentialSlotsForReset( + preferenceKeys: Collection, + readEncrypted: (String) -> String?, + decrypt: (String) -> String, +): List { + val slotKeys = preferenceKeys + .filter { key -> key.startsWith(ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX) } + .sorted() + check(slotKeys.size <= MAX_ANDROID_ACCOUNT_CREDENTIALS) { + "The independent account credential slot set is too large to reset safely." + } + return slotKeys.map { key -> + val encrypted = checkNotNull(readEncrypted(key)) { + "An independent account credential slot disappeared during reset." + } + val restored = decodeAndroidAccountCredentialState(decrypt(encrypted)) + restored.unsupportedVersion?.let(::unsupportedCredentialStoreMutation) + val state = checkNotNull(restored.state) { + "An independent account credential slot is invalid and cannot be reset safely." + } + check(state.registry.accounts.size == 1 && state.sessions.size == 1) { + "An independent account credential slot has an invalid account count." + } + val session = checkNotNull(state.activeSession) { + "An independent account credential slot does not select its account." + } + check(key == androidAccountCredentialSlotKey(session.accountId)) { + "An independent account credential slot has a mismatched identity." + } + AndroidIndependentCredentialSlotReset(key, encrypted, session) + } +} + internal fun retainedAndroidAccountCredentialSlotKeys( state: AndroidAccountCredentialState, ): Set = state.registry.accounts.mapTo(hashSetOf()) { account -> androidAccountCredentialSlotKey(account.id) } +internal fun hasAndroidIndependentCredentialState(preferences: SharedPreferences): Boolean = + preferences.contains(ANDROID_ACCOUNT_REGISTRY_KEY) || + preferences.all.keys.any { key -> key.startsWith(ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX) } + internal fun readAndroidAccountCredentialSlot( accountId: NextcloudAccountId, readEncrypted: (String) -> String?, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt index dc3796a2a..c5713fd0e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountCredentialTransitions.kt @@ -9,6 +9,24 @@ internal fun removeActiveAndroidAccountCredentialState( state: AndroidAccountCredentialState, ): AndroidAccountCredentialState = state.registry.activeAccountId?.let(state::remove) ?: state +internal suspend fun replaceAndroidActiveStateWithAccountLeases( + replacement: AndroidAccountCredentialState, + previousSession: NextcloudSession?, + replacedSession: NextcloudSession?, + suspectEncrypted: String?, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + coordinator: AndroidFileRangeSessionCoordinator = ANDROID_FILE_RANGE_SESSION_COORDINATOR, + replace: suspend (AndroidAccountCredentialState, NextcloudSession?, String?, NextcloudSession?) -> Unit, +) { + val replacementSession = requireNotNull(replacement.activeSession) + val accountIdentities = listOfNotNull(previousSession, replacementSession, replacedSession) + .map(NextcloudDocumentIds::accountKey) + guard.withAccounts(accountIdentities) { + quiesceAndroidFileRangesBeforeCredentialReplacement(replacedSession, replacementSession, coordinator) + replace(replacement, previousSession, suspectEncrypted, replacedSession) + } +} + internal suspend fun rollbackUnavailableAndroidAccountRemoval( active: Boolean = false, recovered: AndroidAccountCredentialState, diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt index 44d7eb88f..48efa09f2 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileRead.kt @@ -1,6 +1,12 @@ package dev.obiente.nextcloudnative +import android.util.Base64 +import dev.obiente.nextcloudnative.app.NextcloudFileRangeSession import dev.obiente.nextcloudnative.app.NextcloudSession +import java.io.FileNotFoundException +import java.nio.charset.StandardCharsets +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -16,3 +22,140 @@ internal suspend fun withRetainedAndroidAccountFileRead( unavailable = { error("The account changed before the file read could finish.") }, ) { read() } } + +internal class AndroidFileRangeSessionActivity { + private val monitor = Any() + private val active = linkedSetOf() + private val drained = CompletableDeferred() + private var closed = false + + fun start(cancel: () -> Unit = {}): (() -> Unit)? = synchronized(monitor) { + if (closed) return@synchronized null + val operation = Operation(cancel) + active += operation + { finish(operation) } + } + + private fun finish(operation: Operation) { + val complete = synchronized(monitor) { + active.remove(operation) + closed && active.isEmpty() + } + if (complete) drained.complete(Unit) + } + + fun close() { + val operations = synchronized(monitor) { + closed = true + active.toList().also { if (it.isEmpty()) drained.complete(Unit) } + } + operations.forEach { operation -> operation.cancel() } + } + + suspend fun awaitDrained() = drained.await() + + fun whenDrained(action: () -> Unit) { + drained.invokeOnCompletion { action() } + } + + private class Operation(val cancel: () -> Unit) +} + +internal class AndroidFileRangeSessionCoordinator { + private val monitor = Any() + private val registrations = mutableMapOf>() + + fun register( + accountIdentity: String, + activity: AndroidFileRangeSessionActivity, + closeSource: () -> Unit, + ): AutoCloseable { + lateinit var registration: Registration + registration = Registration( + closeSource = closeSource, + awaitDrained = activity::awaitDrained, + whenDrained = activity::whenDrained, + unregister = { unregister(accountIdentity, registration) }, + ) + synchronized(monitor) { registrations.getOrPut(accountIdentity, ::linkedSetOf) += registration } + return registration + } + + suspend fun quiesce(accountIdentity: String) { + val current = synchronized(monitor) { registrations[accountIdentity]?.toList().orEmpty() } + current.forEach(Registration::cancel) + current.forEach { registration -> registration.awaitDrained() } + synchronized(monitor) { registrations.remove(accountIdentity) } + } + + private fun unregister(accountIdentity: String, registration: Registration) = synchronized(monitor) { + registrations[accountIdentity]?.let { current -> + current -= registration + if (current.isEmpty()) registrations.remove(accountIdentity) + } + } + + private class Registration( + private val closeSource: () -> Unit, + val awaitDrained: suspend () -> Unit, + private val whenDrained: ((() -> Unit) -> Unit), + private val unregister: () -> Unit, + ) : AutoCloseable { + private val cancelled = AtomicBoolean(false) + + fun cancel() { + if (cancelled.compareAndSet(false, true)) closeSource() + } + + override fun close() { + cancel() + whenDrained(unregister) + } + } +} + +internal val ANDROID_FILE_RANGE_SESSION_COORDINATOR = AndroidFileRangeSessionCoordinator() + +internal suspend fun quiesceAndroidFileRangesBeforeCredentialReplacement( + previousSession: NextcloudSession?, + replacementSession: NextcloudSession, + coordinator: AndroidFileRangeSessionCoordinator = ANDROID_FILE_RANGE_SESSION_COORDINATOR, +) { + if ( + previousSession != null && previousSession.accountId == replacementSession.accountId && + previousSession != replacementSession + ) { + coordinator.quiesce(NextcloudDocumentIds.accountKey(previousSession)) + } +} + +internal fun openTrackedAndroidFileRangeSession( + expectedSession: NextcloudSession, + resolveSession: () -> NextcloudSession?, + activity: AndroidFileRangeSessionActivity, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + coordinator: AndroidFileRangeSessionCoordinator = ANDROID_FILE_RANGE_SESSION_COORDINATOR, + openSource: () -> NextcloudFileRangeSession, +): NextcloudFileRangeSession { + val lease = guard.acquireBlocking(NextcloudDocumentIds.accountKey(expectedSession)) + return try { + if (resolveSession() != expectedSession) { + throw FileNotFoundException("The account changed before the file range session could start.") + } + val source = openSource() + val registration = coordinator.register( + NextcloudDocumentIds.accountKey(expectedSession), activity, source::close, + ) + NextcloudFileRangeSession(source.size, source::read, registration::close, activity::start) + } catch (failure: Throwable) { + activity.close() + throw failure + } finally { + lease.close() + } +} + +internal fun androidFileRangeAuthorization(session: NextcloudSession): String = Base64.encodeToString( + "${session.loginName}:${session.appPassword}".toByteArray(StandardCharsets.UTF_8), + Base64.NO_WRAP, +) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt index 6d55c4a83..6cbb56f40 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountRemoval.kt @@ -88,6 +88,7 @@ internal suspend fun preflightAndroidAccountRemoval(context: Context, session: N internal suspend fun prepareAndroidAccountRemoval(context: Context, session: NextcloudSession) { preflightAndroidAccountRemoval(context, session) + ANDROID_FILE_RANGE_SESSION_COORDINATOR.quiesce(NextcloudDocumentIds.accountKey(session)) } internal fun revokeAndroidAccountDocumentGrants(context: Context, accountIdentity: String) { diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt new file mode 100644 index 000000000..f7f44e284 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidMalformedCredentialReset.kt @@ -0,0 +1,85 @@ +package dev.obiente.nextcloudnative + +import android.content.SharedPreferences +import dev.obiente.nextcloudnative.app.NextcloudSession + +internal suspend fun clearUnregisteredAndroidAccountCredentialSlots( + preferences: SharedPreferences, + sessionCipher: SessionCipher, + cleanupJournal: AndroidAccountRemovalCleanupJournal, + suspectEncrypted: String?, + prepareAccountRemoval: suspend (NextcloudSession) -> Unit, + removeAccountOwnedState: suspend (NextcloudSession) -> Unit, + commitPreferences: (SharedPreferences.Editor) -> Unit, + recordCleanupFailure: (Exception) -> Unit, + clearInvalidStore: suspend (String?) -> Unit, +) { + requireAndroidIndependentCredentialStateCanBeExplicitlyReset( + preferences.getString(ANDROID_ACCOUNT_REGISTRY_KEY, null), + ) + val slots = recoverAndroidIndependentCredentialSlotsForReset( + preferenceKeys = preferences.all.keys, + readEncrypted = { key -> preferences.getString(key, null) }, + decrypt = sessionCipher::decrypt, + ) + val cleanupSnapshot = cleanupJournal.snapshot() + requireAndroidAccountRemovalCleanupJournalAllowsActivation(cleanupSnapshot) + retireUnregisteredAndroidAccountCredentialSlots( + slots = slots, + preexistingCleanupAccountStorageKeys = cleanupSnapshot.cleanups.mapTo(hashSetOf()) { it.accountStorageKey }, + retryPreexistingCleanup = { slot -> + retryAndroidAccountRemovalCleanup( + accountOwnedByRegistry = false, + removeAccountOwnedWork = { removeAccountOwnedState(slot.session) }, + clearCleanup = { cleanupJournal.clear(slot.session.accountId.storageKey) }, + ) + }, + prepareAccountRemoval = prepareAccountRemoval, + commitSlotRemoval = { slot, cleanup -> + commitPreferences( + cleanupJournal.prepareEdit(preferences.edit().remove(slot.preferenceKey), cleanup), + ) + }, + rollbackSlotRemoval = { slot -> + commitPreferences(preferences.edit().putString(slot.preferenceKey, slot.encrypted)) + }, + removeAccountOwnedState = removeAccountOwnedState, + clearCleanup = cleanupJournal::clear, + recordCleanupFailure = recordCleanupFailure, + ) + clearInvalidStore(suspectEncrypted) +} + +internal suspend fun retireUnregisteredAndroidAccountCredentialSlots( + slots: List, + preexistingCleanupAccountStorageKeys: Set = emptySet(), + retryPreexistingCleanup: suspend (AndroidIndependentCredentialSlotReset) -> Unit = {}, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + prepareAccountRemoval: suspend (NextcloudSession) -> Unit, + commitSlotRemoval: suspend (AndroidIndependentCredentialSlotReset, AndroidPendingAccountRemovalCleanup) -> Unit, + rollbackSlotRemoval: suspend (AndroidIndependentCredentialSlotReset) -> Unit, + removeAccountOwnedState: suspend (NextcloudSession) -> Unit, + clearCleanup: suspend (String) -> Unit, + recordCleanupFailure: (Exception) -> Unit, +) { + slots.forEach { slot -> + val session = slot.session + if (session.accountId.storageKey in preexistingCleanupAccountStorageKeys) { + retryPreexistingCleanup(slot) + } + val pendingCleanup = pendingAndroidAccountRemovalCleanup(session) + withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session), guard) { + removeRecoveredAndroidAccountCredentialData( + prepareAccountRemoval = { prepareAccountRemoval(session) }, + removeQueuedUploads = { removeAccountOwnedState(session) }, + clearRecoveredAccount = { commitSlotRemoval(slot, pendingCleanup) }, + rollbackRecoveredAccount = { + rollbackSlotRemoval(slot) + clearCleanup(session.accountId.storageKey) + }, + completeCommittedCleanup = { clearCleanup(session.accountId.storageKey) }, + recordCommittedCleanupFailure = recordCleanupFailure, + ) + } + } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index de2145b69..8bcb3891f 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -423,8 +423,9 @@ internal class AndroidNextcloudServices( private val fileOfflineRepository = AndroidFileOfflineRepository(appContext) private val fileReadCache = AndroidFileReadCache(File(appContext.cacheDir, "files-read-v1")) private val virtualFileCache = AndroidVirtualFileCache(appContext) - private val nativeMediaPreviewCache = - AndroidNativeMediaPreviewCache(File(appContext.cacheDir, "native-media-previews-v1")) + private val nativeMediaPreviewCache = AndroidNativeMediaPreviewCache( + File(appContext.cacheDir, "native-media-previews-v1"), + ) private val dynamicApiState = androidDynamicApiProcessState(File(appContext.cacheDir, "dynamic-api-v1")) private val dynamicApiReadCache = dynamicApiState.cache private val dynamicApiRequestCoalescer = dynamicApiState.coalescer @@ -2370,13 +2371,11 @@ internal class AndroidNextcloudServices( require(size > 0L) { "The file range session size must be positive." } val safeEtag = requireSafeFileRangeEtag(expectedEtag) val url = buildNextcloudFileUrl(session.serverUrl, userId, path) - val authorization = Base64.encodeToString( - "${session.loginName}:${session.appPassword}".toByteArray(StandardCharsets.UTF_8), - Base64.NO_WRAP, - ) + val authorization = androidFileRangeAuthorization(session) val closed = AtomicBoolean(false) - val activeCalls = ConcurrentHashMap.newKeySet() - return NextcloudFileRangeSession( + val activity = AndroidFileRangeSessionActivity() + return openTrackedAndroidFileRangeSession(session, { loadSession(session.accountId) }, activity) { + NextcloudFileRangeSession( size = size, readBlock = { offset, length -> withContext(Dispatchers.IO) { @@ -2398,8 +2397,8 @@ internal class AndroidNextcloudServices( .header("If-Match", safeEtag) .build() val call = noRedirectHttpClient.newCall(request) - activeCalls += call - if (closed.get()) { + val finishCall = activity.start(call::cancel) + if (finishCall == null) { call.cancel() } try { @@ -2446,17 +2445,17 @@ internal class AndroidNextcloudServices( ) throw failure } finally { - activeCalls -= call + finishCall?.invoke() } } }, closeBlock = { if (closed.compareAndSet(false, true)) { - activeCalls.forEach { call -> call.cancel() } - activeCalls.clear() + activity.close() } }, - ) + ) + } } override suspend fun downloadMemoriesFileRange( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index 263602739..c2de11a79 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -300,7 +300,7 @@ private fun recordAccountCredentialDiagnostic( private class AndroidCredentialMismatchException : IllegalArgumentException() private const val ANDROID_ACCOUNT_CREDENTIAL_STORE_VERSION = 2 -private const val MAX_ANDROID_ACCOUNT_CREDENTIALS = 64 +internal const val MAX_ANDROID_ACCOUNT_CREDENTIALS = 64 private const val MAX_ANDROID_ACCOUNT_CREDENTIAL_STORE_BYTES = 512 * 1024 private const val KEY_VERSION = "version" private const val KEY_ACCOUNT_REGISTRY = "account_registry_v1" diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt index eaad3c181..93722fc4e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidVirtualFileProxyCallback.kt @@ -60,6 +60,16 @@ internal class AndroidVirtualFileProxyCallback( @Synchronized override fun onRead(offset: Long, requestedSize: Int, data: ByteArray): Int { + val finishSourceUse = source.beginUse() + ?: throw OperationCanceledException("Virtual file read cancelled") + return try { + readWhileSourceIsRetained(offset, requestedSize, data) + } finally { + finishSourceUse() + } + } + + private fun readWhileSourceIsRetained(offset: Long, requestedSize: Int, data: ByteArray): Int { if (released || cancelled.get() || !accessAllowed()) { throw OperationCanceledException("Virtual file read cancelled") } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt index 352e81f17..3f2fc3801 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountOperationGuardTest.kt @@ -1,11 +1,13 @@ package dev.obiente.nextcloudnative +import dev.obiente.nextcloudnative.app.NextcloudFileRangeSession import dev.obiente.nextcloudnative.app.NextcloudSession import java.io.FileNotFoundException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async @@ -363,6 +365,177 @@ class AndroidAccountOperationGuardTest { assertTrue(removalEntered) } + @Test + fun removalCancelsAndDrainsOpenRangeSessionBeforeCredentialCommit() = runBlocking { + val guard = AndroidAccountOperationGuard() + val coordinator = AndroidFileRangeSessionCoordinator() + val session = NextcloudSession("https://cloud.example.test", "alice", "password") + val readStarted = CompletableDeferred() + val cancelObserved = CompletableDeferred() + val releaseRead = CompletableDeferred() + val activity = AndroidFileRangeSessionActivity() + val rangeSession = openTrackedAndroidFileRangeSession( + expectedSession = session, + resolveSession = { session }, + activity = activity, + guard = guard, + coordinator = coordinator, + openSource = { + NextcloudFileRangeSession( + size = 8L, + readBlock = { _, length -> + val finishCall = requireNotNull(activity.start { cancelObserved.complete(Unit) }) + try { + readStarted.complete(Unit) + releaseRead.await() + ByteArray(length) + } finally { + finishCall() + } + }, + closeBlock = activity::close, + ) + }, + ) + val read = async { + val finishUse = requireNotNull(rangeSession.beginUse()) + try { + rangeSession.read(0L, 1) + } finally { + finishUse() + } + } + readStarted.await() + var committed = false + val removal = async { + withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session), guard) { + coordinator.quiesce(NextcloudDocumentIds.accountKey(session)) + committed = true + } + } + + cancelObserved.await() + yield() + assertFalse(committed) + releaseRead.complete(Unit) + read.await() + removal.await() + assertTrue(committed) + rangeSession.close() + rangeSession.close() + } + + @Test + fun staleRangeSessionCannotStartAfterCredentialRetirement() = runBlocking { + val guard = AndroidAccountOperationGuard() + val session = NextcloudSession("https://cloud.example.test", "alice", "old-password") + + assertFailsWith { + openTrackedAndroidFileRangeSession( + expectedSession = session, + resolveSession = { session.copy(appPassword = "new-password") }, + activity = AndroidFileRangeSessionActivity(), + guard = guard, + openSource = { error("stale range source must not open") }, + ) + } + + withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session), guard) { } + + assertFailsWith { + openTrackedAndroidFileRangeSession( + expectedSession = session, + resolveSession = { session }, + activity = AndroidFileRangeSessionActivity(), + guard = guard, + openSource = { error("synthetic range construction failure") }, + ) + } + withAndroidAccountRemovalLease(NextcloudDocumentIds.accountKey(session), guard) { } + } + + @Test + fun sameAccountReauthenticationDrainsOldPasswordRangeBeforeCredentialCommit() = runBlocking { + val coordinator = AndroidFileRangeSessionCoordinator() + val old = NextcloudSession("https://cloud.example.test", "alice", "old-password") + val replacement = old.copy(appPassword = "new-password") + val activity = AndroidFileRangeSessionActivity() + val cancelObserved = CompletableDeferred() + val finishRead = requireNotNull(activity.start { cancelObserved.complete(Unit) }) + coordinator.register(NextcloudDocumentIds.accountKey(old), activity, activity::close) + var committed = false + + val reauthenticate = async { + quiesceAndroidFileRangesBeforeCredentialReplacement(old, replacement, coordinator) + committed = true + } + + cancelObserved.await() + assertFalse(committed) + finishRead() + reauthenticate.await() + assertTrue(committed) + assertNull(activity.start()) + } + + @Test + fun selectingAnotherRetainedAccountLeavesPreviousAccountRangeOpen() = runBlocking { + val coordinator = AndroidFileRangeSessionCoordinator() + val previous = NextcloudSession("https://one.example.test", "alice", "first-password") + val selected = NextcloudSession("https://two.example.test", "bob", "second-password") + val activity = AndroidFileRangeSessionActivity() + var cancelled = false + coordinator.register(NextcloudDocumentIds.accountKey(previous), activity, activity::close) + val finishRead = requireNotNull(activity.start { cancelled = true }) + + quiesceAndroidFileRangesBeforeCredentialReplacement(previous, selected, coordinator) + + assertFalse(cancelled) + finishRead() + activity.close() + } + + @Test + fun inactiveReauthenticationLocksOldRangeIdentityAgainstLateRegistration() = runBlocking { + val guard = AndroidAccountOperationGuard() + val coordinator = AndroidFileRangeSessionCoordinator() + val old = NextcloudSession("https://CLOUD.example.test:443/", "alice", "old-password") + val replacement = NextcloudSession("https://cloud.example.test", "alice", "new-password") + val active = NextcloudSession("https://two.example.test", "bob", "second-password") + val replacementState = AndroidAccountCredentialState.Empty.upsertAndSelect(replacement) + val activity = AndroidFileRangeSessionActivity() + val cancelObserved = CompletableDeferred() + val finishOldRead = requireNotNull(activity.start { cancelObserved.complete(Unit) }) + coordinator.register(NextcloudDocumentIds.accountKey(old), activity, activity::close) + var current: NextcloudSession? = old + + val transition = async { + replaceAndroidActiveStateWithAccountLeases( + replacement = replacementState, + previousSession = active, + replacedSession = old, + suspectEncrypted = null, + guard = guard, + coordinator = coordinator, + ) { _, _, _, _ -> current = replacement } + } + cancelObserved.await() + val lateOpen = async { + runCatching { + openTrackedAndroidFileRangeSession( + old, { current }, AndroidFileRangeSessionActivity(), guard, coordinator, + ) { NextcloudFileRangeSession(8L, { _, length -> ByteArray(length) }) } + } + } + yield() + assertFalse(lateOpen.isCompleted) + + finishOldRead() + transition.await() + assertTrue(lateOpen.await().exceptionOrNull() is FileNotFoundException) + assertEquals(replacement, current) + } + @Test fun directDocumentMutationLeaseRejectsReauthenticatedSessionAndReleasesTheGuard() = runBlocking { val guard = AndroidAccountOperationGuard() diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt new file mode 100644 index 000000000..40ee694de --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidIndependentCredentialSlotResetTest.kt @@ -0,0 +1,213 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudSession +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking + +class AndroidIndependentCredentialSlotResetTest { + @Test + fun malformedRegistryResetRecoversEveryBoundedSlotIdentityBeforeDeletion() { + val first = NextcloudSession("https://one.example.test", "alice", "first-secret") + val second = NextcloudSession("https://two.example.test", "bob", "second-secret") + val sessionsByKey = listOf(first, second).associateBy { session -> + androidAccountCredentialSlotKey(session.accountId) + } + val ciphertexts = sessionsByKey.mapValues { (_, session) -> "encrypted-${session.accountId.storageKey}" } + val payloads = sessionsByKey.mapValues { (_, session) -> + encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(session)) + } + + val recovered = recoverAndroidIndependentCredentialSlotsForReset( + preferenceKeys = ciphertexts.keys, + readEncrypted = ciphertexts::get, + decrypt = { encrypted -> + payloads.getValue(ciphertexts.entries.single { entry -> entry.value == encrypted }.key) + }, + ) + + assertEquals( + setOf(first.accountId, second.accountId), + recovered.mapTo(linkedSetOf()) { slot -> slot.session.accountId }, + ) + assertEquals(ciphertexts.keys, recovered.mapTo(linkedSetOf()) { slot -> slot.preferenceKey }) + } + + @Test + fun malformedRegistryResetRejectsMismatchedOrUnreadableSlotIdentity() { + val session = NextcloudSession("https://cloud.example.test", "alice", "secret") + val wrongKey = "$ANDROID_ACCOUNT_CREDENTIAL_SLOT_KEY_PREFIX${"f".repeat(64)}" + val payload = encodeAndroidAccountCredentialState(AndroidAccountCredentialState.Empty.upsertAndSelect(session)) + + assertFailsWith { + recoverAndroidIndependentCredentialSlotsForReset( + preferenceKeys = listOf(wrongKey), + readEncrypted = { "encrypted" }, + decrypt = { payload }, + ) + } + assertFailsWith { + recoverAndroidIndependentCredentialSlotsForReset( + preferenceKeys = listOf(androidAccountCredentialSlotKey(session.accountId)), + readEncrypted = { "encrypted" }, + decrypt = { "{malformed" }, + ) + } + } + + @Test + fun recoveredSlotsArePreparedAndJournaledBeforeDeletionAndCleanup() = runBlocking { + val first = resetSlot(NextcloudSession("https://one.example.test", "alice", "first-secret")) + val second = resetSlot(NextcloudSession("https://two.example.test", "bob", "second-secret")) + val events = mutableListOf() + val presentSlots = mutableSetOf(first.preferenceKey, second.preferenceKey) + val tombstones = mutableSetOf() + + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(first, second), + guard = AndroidAccountOperationGuard(), + prepareAccountRemoval = { session -> events += "prepare-${session.loginName}" }, + commitSlotRemoval = { slot, cleanup -> + events += "commit-${slot.session.loginName}" + presentSlots -= slot.preferenceKey + tombstones += cleanup.accountStorageKey + }, + rollbackSlotRemoval = { slot -> presentSlots += slot.preferenceKey }, + removeAccountOwnedState = { session -> + assertFalse(androidAccountCredentialSlotKey(session.accountId) in presentSlots) + assertTrue(session.accountId.storageKey in tombstones) + events += "cleanup-${session.loginName}" + }, + clearCleanup = { accountStorageKey -> tombstones -= accountStorageKey }, + recordCleanupFailure = { error("cleanup must succeed") }, + ) + + assertEquals( + listOf("prepare-alice", "commit-alice", "cleanup-alice", "prepare-bob", "commit-bob", "cleanup-bob"), + events, + ) + assertTrue(presentSlots.isEmpty()) + assertTrue(tombstones.isEmpty()) + } + + @Test + fun cleanupFailureKeepsOnlyItsTombstoneAndDoesNotDropLaterHealthyCleanup() = runBlocking { + val first = resetSlot(NextcloudSession("https://one.example.test", "alice", "first-secret")) + val second = resetSlot(NextcloudSession("https://two.example.test", "bob", "second-secret")) + val tombstones = mutableSetOf() + val removed = mutableSetOf() + val failures = mutableListOf() + + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(first, second), + guard = AndroidAccountOperationGuard(), + prepareAccountRemoval = {}, + commitSlotRemoval = { slot, cleanup -> + removed += slot.preferenceKey + tombstones += cleanup.accountStorageKey + }, + rollbackSlotRemoval = { error("committed slots must not be restored") }, + removeAccountOwnedState = { session -> + if (session == first.session) error("synthetic cleanup failure") + }, + clearCleanup = { accountStorageKey -> tombstones -= accountStorageKey }, + recordCleanupFailure = failures::add, + ) + + assertEquals(setOf(first.preferenceKey, second.preferenceKey), removed) + assertEquals(setOf(first.session.accountId.storageKey), tombstones) + assertEquals(1, failures.size) + } + + @Test + fun cancellationAfterCommittedSlotLeavesRetryTombstoneWithoutResurrection() = runBlocking { + val first = resetSlot(NextcloudSession("https://one.example.test", "alice", "first-secret")) + val second = resetSlot(NextcloudSession("https://two.example.test", "bob", "second-secret")) + val removed = mutableSetOf() + val restored = mutableSetOf() + val tombstones = mutableSetOf() + + assertFailsWith { + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(first, second), + guard = AndroidAccountOperationGuard(), + prepareAccountRemoval = {}, + commitSlotRemoval = { slot, cleanup -> + removed += slot.preferenceKey + tombstones += cleanup.accountStorageKey + }, + rollbackSlotRemoval = { slot -> restored += slot.preferenceKey }, + removeAccountOwnedState = { session -> + if (session == second.session) throw CancellationException("synthetic cancellation") + }, + clearCleanup = { accountStorageKey -> tombstones -= accountStorageKey }, + recordCleanupFailure = { error("cancellation must propagate") }, + ) + } + + assertEquals(setOf(first.preferenceKey, second.preferenceKey), removed) + assertTrue(restored.isEmpty()) + assertEquals(setOf(second.session.accountId.storageKey), tombstones) + } + + @Test + fun rollbackRestoredSlotRetriesPreexistingTombstoneBeforeResettingIt() = runBlocking { + val slot = resetSlot(NextcloudSession("https://one.example.test", "alice", "first-secret")) + val tombstones = mutableSetOf(slot.session.accountId.storageKey) + var retryFails = true + var commitAttempted = false + + assertFailsWith { + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(slot), + preexistingCleanupAccountStorageKeys = tombstones.toSet(), + retryPreexistingCleanup = { + if (retryFails) error("synthetic persisted cleanup failure") + tombstones -= it.session.accountId.storageKey + }, + guard = AndroidAccountOperationGuard(), + prepareAccountRemoval = {}, + commitSlotRemoval = { _, _ -> commitAttempted = true; error("synthetic commit failure") }, + rollbackSlotRemoval = { error("slot must remain untouched") }, + removeAccountOwnedState = { error("cleanup must not start") }, + clearCleanup = { tombstones -= it }, + recordCleanupFailure = { error("cleanup must not start") }, + ) + } + + assertFalse(commitAttempted) + assertEquals(setOf(slot.session.accountId.storageKey), tombstones) + + retryFails = false + assertFailsWith { + retireUnregisteredAndroidAccountCredentialSlots( + slots = listOf(slot), + preexistingCleanupAccountStorageKeys = tombstones.toSet(), + retryPreexistingCleanup = { tombstones -= it.session.accountId.storageKey }, + guard = AndroidAccountOperationGuard(), + prepareAccountRemoval = {}, + commitSlotRemoval = { _, cleanup -> + commitAttempted = true + tombstones += cleanup.accountStorageKey + error("synthetic slot-removal commit failure") + }, + rollbackSlotRemoval = {}, + removeAccountOwnedState = { error("cleanup must not start") }, + clearCleanup = { tombstones -= it }, + recordCleanupFailure = { error("cleanup must not start") }, + ) + } + assertTrue(commitAttempted) + assertTrue(tombstones.isEmpty()) + } + + private fun resetSlot(session: NextcloudSession) = AndroidIndependentCredentialSlotReset( + preferenceKey = androidAccountCredentialSlotKey(session.accountId), + encrypted = "encrypted-${session.accountId.storageKey}", + session = session, + ) +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt index 89ed2a0f4..bcadd1f39 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt @@ -10,6 +10,7 @@ class NextcloudFileRangeSession( val size: Long, private val readBlock: suspend (offset: Long, length: Int) -> ByteArray, private val closeBlock: () -> Unit = {}, + private val beginUseBlock: () -> (() -> Unit)? = { {} }, ) : AutoCloseable { init { require(size > 0L) { "A file range session must have a positive size." } @@ -17,6 +18,8 @@ class NextcloudFileRangeSession( suspend fun read(offset: Long, length: Int): ByteArray = readBlock(offset, length) + fun beginUse(): (() -> Unit)? = beginUseBlock() + override fun close() = closeBlock() } From 31c7c7e2458c3aaf0f78568e122cb951742d7642 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:52:25 +0000 Subject: [PATCH 113/119] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 8a44a7b25..8100381e8 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -636,7 +636,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudChunkUploadPolicy.kt": "2635374193979991fa6b0e4d244a248b27d9ff4fcca148b47412530cb3031d87", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt": "b9b73c66436a686381072162c9656c5158af7ca40c38311ec15193d8a652f145", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileListing.kt": "1f3de4f31d44d3ead139686d3fbcfbd27ccb520a9dab06ccc1d3e582ee25fb71", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt": "0704f87e909bbb9950e02b9ec21e3cab58e8a5438f2b900c94e296527f35c157", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileRange.kt": "e2d786bc7e80199aad9507edd2a0d93d508ca621e093d7b87a131993ab90ed14", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudLinkRouting.kt": "5b29a90b69bb32aba118ef6c8b3f9d6eb26c03835823119b4a0f5bb1c1f4cb17", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt": "fb424bf8979ac292ef30daba64e905e6f5123066cda793aa0244907e11b465c9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewerActions.kt": "48aaed6948d1423113d300cc3ab86d244ab76e8225e24988e9855edf74553944", From eadef3dc989977bcb507865ed2bfb6743f9e9136 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 07:37:26 +0200 Subject: [PATCH 114/119] fix(android): reuse account lease for provider mutation lookups --- .../AndroidAccountFileListing.kt | 57 +++++++ .../AndroidNextcloudServices.kt | 56 ++++--- .../NextcloudDocumentsProvider.kt | 30 ++-- .../AndroidAccountFileListingTest.kt | 149 ++++++++++++++++++ tools/kotlin-file-size-baseline.txt | 4 +- 5 files changed, 248 insertions(+), 48 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListing.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListingTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListing.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListing.kt new file mode 100644 index 000000000..604584105 --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListing.kt @@ -0,0 +1,57 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudFile +import dev.obiente.nextcloudnative.app.NextcloudFileListing +import dev.obiente.nextcloudnative.app.NextcloudFileListingHttpException +import dev.obiente.nextcloudnative.app.NextcloudFileListingSource +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.io.IOException + +internal data class AndroidDavFileListingResponse(val status: Int, val files: List) + +/** The caller may reuse a lease only while its enclosing account operation still owns it. */ +internal suspend fun loadAndroidAccountFileListing( + session: NextcloudSession, + resolveSession: suspend () -> NextcloudSession?, + cache: AndroidFileReadCache, + path: String, + accountLeaseHeld: Boolean = false, + guard: AndroidAccountOperationGuard = ANDROID_ACCOUNT_OPERATION_GUARD, + request: suspend () -> AndroidDavFileListingResponse, +): NextcloudFileListing { + val read: suspend () -> NextcloudFileListing = { + readAndroidAccountFileListing(cache, NextcloudDocumentIds.accountKey(session), path, request) + } + return if (accountLeaseHeld) read() else withRetainedAndroidAccountFileRead(session, resolveSession, guard, read) +} + +private suspend fun readAndroidAccountFileListing( + cache: AndroidFileReadCache, + accountId: String, + path: String, + request: suspend () -> AndroidDavFileListingResponse, +): NextcloudFileListing = try { + val response = request() + if (response.status == 207) { + val files = response.files.drop(1) + .sortedWith(compareByDescending { it.isDirectory }.thenBy { it.name.lowercase() }) + runCatching { cache.storeListing(accountId, path, files) } + NextcloudFileListing(files, NextcloudFileListingSource.Network) + } else { + val cached = if (response.status >= 500) cache.cachedListing(accountId, path) else null + cached?.let { NextcloudFileListing(it.files, NextcloudFileListingSource.Cache) } + ?: throw NextcloudFileListingHttpException(response.status) + } +} catch (failure: IOException) { + cache.cachedListing(accountId, path)?.files + ?.let { NextcloudFileListing(it, NextcloudFileListingSource.Cache) } + ?: throw failure +} + +internal fun requireAndroidDocumentDirectory( + reference: NextcloudDocumentReference, + findDocument: (String) -> NextcloudFile, +) { + if (reference.isRoot) return + require(findDocument(reference.path).isDirectory) { "The selected parent is not a folder." } +} diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 8bcb3891f..2d1344f27 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -1347,35 +1347,33 @@ internal class AndroidNextcloudServices( session: NextcloudSession, userId: String, path: String, - ): NextcloudFileListing = withRetainedAndroidAccountFileRead(session, { loadSession(session.accountId) }) read@{ - val accountId = NextcloudDocumentIds.accountKey(session) - try { - val response = request( - method = "PROPFIND", - url = buildNextcloudFileUrl(session.serverUrl, userId, path), - session = session, - body = DAV_PROPERTIES, - contentType = "application/xml; charset=utf-8", - headers = mapOf("Depth" to "1", "Accept" to "application/xml"), - ) - if (response.status == 207) { - val files = parseDavFiles(response.body, userId).drop(1) - .sortedWith(compareByDescending { it.isDirectory }.thenBy { it.name.lowercase() }) - runCatching { fileReadCache.storeListing(accountId, path, files) } - NextcloudFileListing(files, NextcloudFileListingSource.Network) - } else { - if (response.status >= 500) { - fileReadCache.cachedListing(accountId, path)?.files?.let { - return@read NextcloudFileListing(it, NextcloudFileListingSource.Cache) - } - } - throw NextcloudFileListingHttpException(response.status) - } - } catch (failure: IOException) { - fileReadCache.cachedListing(accountId, path)?.files - ?.let { NextcloudFileListing(it, NextcloudFileListingSource.Cache) } - ?: throw failure - } + ): NextcloudFileListing = listFilesWithSource(session, userId, path, accountLeaseHeld = false) + + internal suspend fun listFilesWhileAccountLeaseHeld( + session: NextcloudSession, + userId: String, + path: String, + ): List = listFilesWithSource(session, userId, path, accountLeaseHeld = true).files + + private suspend fun listFilesWithSource( + session: NextcloudSession, + userId: String, + path: String, + accountLeaseHeld: Boolean, + ): NextcloudFileListing = loadAndroidAccountFileListing( + session, { loadSession(session.accountId) }, fileReadCache, path, accountLeaseHeld, + ) { + val response = request( + method = "PROPFIND", + url = buildNextcloudFileUrl(session.serverUrl, userId, path), + session = session, + body = DAV_PROPERTIES, + contentType = "application/xml; charset=utf-8", + headers = mapOf("Depth" to "1", "Accept" to "application/xml"), + ) + AndroidDavFileListingResponse( + response.status, if (response.status == 207) parseDavFiles(response.body, userId) else emptyList(), + ) } override suspend fun listFilesCachedWithSource( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt index a6ed66330..7cbb908a3 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt @@ -466,7 +466,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { withAndroidDocumentMutation(requireSession(), services::loadSession) { session -> val parent = requireReference(parentDocumentId, session) val account = resolveAccount(session) - requireDirectory(session, account, parent) + requireAndroidDocumentDirectory(parent) { findDocument(session, account, it, accountLeaseHeld = true) } val path = childPath(parent.path, requireSafeDisplayName(displayName)) withNoBlockingAndroidDocumentWriteback(context, session, path) { mutationCall { @@ -487,7 +487,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val reference = requireReference(documentId, session) if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be renamed.") val account = resolveAccount(session) - val file = findDocument(session, account, reference.path) + val file = findDocument(session, account, reference.path, accountLeaseHeld = true) val destination = childPath(NextcloudDocumentIds.parentPath(reference.path), requireSafeDisplayName(displayName)) if (destination == reference.path) return@withAndroidDocumentMutation documentId val etag = requireMutationEtag(file) @@ -503,7 +503,7 @@ class NextcloudDocumentsProvider : DocumentsProvider() { val reference = requireReference(documentId, session) if (reference.isRoot) throw SecurityException("The Nextcloud root cannot be deleted.") val account = resolveAccount(session) - val file = findDocument(session, account, reference.path) + val file = findDocument(session, account, reference.path, accountLeaseHeld = true) withNoBlockingAndroidDocumentWriteback(context, session, reference.path) { mutationCall { webDav.delete( @@ -532,8 +532,8 @@ class NextcloudDocumentsProvider : DocumentsProvider() { "The supplied source parent does not contain this document." } val account = resolveAccount(session) - requireDirectory(session, account, targetParent) - val file = findDocument(session, account, source.path) + requireAndroidDocumentDirectory(targetParent) { findDocument(session, account, it, accountLeaseHeld = true) } + val file = findDocument(session, account, source.path, accountLeaseHeld = true) val destination = childPath(targetParent.path, file.name) if (destination == source.path) return@withAndroidDocumentMutation sourceDocumentId withNoBlockingAndroidDocumentWriteback(context, session, source.path, destination) { @@ -727,16 +727,6 @@ class NextcloudDocumentsProvider : DocumentsProvider() { ) } - private fun requireDirectory( - session: NextcloudSession, - account: ResolvedAccount, - reference: NextcloudDocumentReference, - ) { - if (reference.isRoot) return - val parent = findDocument(session, account, reference.path) - require(parent.isDirectory) { "The selected parent is not a folder." } - } - private fun requireMutationEtag(file: NextcloudFile): String = file.etag?.takeIf(String::isNotBlank) ?: throw IllegalStateException("Nextcloud did not provide an ETag, so this document cannot be changed safely.") @@ -869,14 +859,20 @@ class NextcloudDocumentsProvider : DocumentsProvider() { } } - private fun findDocument(session: NextcloudSession, account: ResolvedAccount, path: String): NextcloudFile = + private fun findDocument( + session: NextcloudSession, + account: ResolvedAccount, + path: String, + accountLeaseHeld: Boolean = false, + ): NextcloudFile = providerCall( message = "The requested Nextcloud document was not found.", accountIdentity = account.accountKey, ) { val parent = NextcloudDocumentIds.parentPath(path) runBlocking(Dispatchers.IO) { - services.listFiles(session, account.userId, parent) + if (accountLeaseHeld) services.listFilesWhileAccountLeaseHeld(session, account.userId, parent) + else services.listFiles(session, account.userId, parent) }.firstOrNull { it.path == path } ?: throw FileNotFoundException("The requested Nextcloud document was not found.") } diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListingTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListingTest.kt new file mode 100644 index 000000000..af6bf991e --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidAccountFileListingTest.kt @@ -0,0 +1,149 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudFile +import dev.obiente.nextcloudnative.app.NextcloudFileListingHttpException +import dev.obiente.nextcloudnative.app.NextcloudFileListingSource +import dev.obiente.nextcloudnative.app.NextcloudSession +import java.io.IOException +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +class AndroidAccountFileListingTest { + @Test + fun mutationMetadataReusesLeaseForRenameDeleteMoveAndNestedCreate() = withCache { cache -> + val guard = AndroidAccountOperationGuard() + val session = session() + val key = NextcloudDocumentIds.accountKey(session) + val lookups = listOf("rename", "delete", "move source", "move destination", "nested create") + lookups.forEach { operation -> + val lease = acquireAndroidDocumentMutationAccountLease(session, { session }, guard) + try { + fun findDocument(path: String): NextcloudFile = runBlocking { + withTimeout(1_000) { + loadAndroidAccountFileListing( + session, { error("The mutation already validated its session") }, cache, + NextcloudDocumentIds.parentPath(path), accountLeaseHeld = true, guard = guard, + ) { + assertFalse(guard.tryWithAccount(key, unavailable = { false }, action = { true }), operation) + AndroidDavFileListingResponse(207, listOf(file("", true), file(path, true))) + }.files.single() + } + } + if (operation in listOf("move destination", "nested create")) { + requireAndroidDocumentDirectory(NextcloudDocumentReference(key, "Notes/Child"), ::findDocument) + } else { + assertEquals("Notes/Child", findDocument("Notes/Child").path, operation) + } + runBlocking { + assertFalse(guard.tryWithAccount(key, unavailable = { false }, action = { true }), operation) + } + } finally { + lease.close() + } + runBlocking { assertTrue(guard.tryWithAccount(key, unavailable = { false }, action = { true }), operation) } + } + } + + @Test + fun ordinaryListingWaitsForLeaseAndRejectsRemovedSessionBeforeRequest() = withCache { cache -> + runBlocking { + val guard = AndroidAccountOperationGuard() + val session = session() + var current: NextcloudSession? = session + val lease = guard.acquireBlocking(NextcloudDocumentIds.accountKey(session)) + var requested = false + val listing = async(start = CoroutineStart.UNDISPATCHED) { + runCatching { + loadAndroidAccountFileListing(session, { current }, cache, "", guard = guard) { + requested = true + AndroidDavFileListingResponse(207, emptyList()) + } + } + } + try { + assertFalse(listing.isCompleted) + assertFalse(requested) + current = null + } finally { + lease.close() + } + val result = withTimeout(1_000) { listing.await() } + assertTrue(result.exceptionOrNull() is IllegalStateException) + assertFalse(requested) + } + } + + @Test + fun listingExtractionPreservesNetworkSortingAndOfflineFallbackBoundaries() = withCache { cache -> + runBlocking { + val session = session() + val guard = AndroidAccountOperationGuard() + val root = file("", true) + val directory = file("Notes", true) + val alpha = file("a.txt") + val zulu = file("Z.txt") + val fresh = loadAndroidAccountFileListing(session, { session }, cache, "", guard = guard) { + assertFalse(guard.tryWithAccount( + NextcloudDocumentIds.accountKey(session), unavailable = { false }, action = { true }, + )) + AndroidDavFileListingResponse(207, listOf(root, zulu, alpha, directory)) + } + assertEquals(NextcloudFileListingSource.Network, fresh.source) + assertEquals(listOf(directory, alpha, zulu), fresh.files) + for (status in listOf(500, 503)) { + val cached = loadAndroidAccountFileListing(session, { session }, cache, "", guard = guard) { + AndroidDavFileListingResponse(status, emptyList()) + } + assertEquals(NextcloudFileListingSource.Cache, cached.source) + assertEquals(fresh.files, cached.files) + } + val offline = loadAndroidAccountFileListing(session, { session }, cache, "", guard = guard) { + throw IOException("offline") + } + assertEquals(fresh.files, offline.files) + assertEquals(NextcloudFileListingSource.Cache, offline.source) + for (status in listOf(401, 403, 404)) { + assertEquals(status, assertFailsWith { + loadAndroidAccountFileListing(session, { session }, cache, "", guard = guard) { + AndroidDavFileListingResponse(status, emptyList()) + } + }.status) + } + assertFailsWith { + loadAndroidAccountFileListing(session, { session }, cache, "", guard = guard) { + throw CancellationException("cancelled") + } + } + } + } + + @Test + fun rootCreateSkipsLookupAndNonDirectoryParentIsRejected() { + val key = NextcloudDocumentIds.accountKey(session()) + requireAndroidDocumentDirectory(NextcloudDocumentReference(key, "")) { error("Root has no parent listing") } + assertFailsWith { + requireAndroidDocumentDirectory(NextcloudDocumentReference(key, "Notes.txt")) { file(it) } + } + } + + private fun session() = NextcloudSession("https://cloud.example.test", "alice", "fixture-password") + + private fun file(path: String, directory: Boolean = false) = NextcloudFile( + path = path, name = path.substringAfterLast('/'), isDirectory = directory, + mimeType = null, size = null, lastModified = null, fileId = null, hasPreview = false, etag = "\"etag\"", + ) + + private fun withCache(block: (AndroidFileReadCache) -> Unit) { + val root = Files.createTempDirectory("ncn-account-listing-test-").toFile() + try { block(AndroidFileReadCache(root)) } finally { root.deleteRecursively() } + } +} diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index 308375ff0..7d0074d62 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -1,7 +1,7 @@ androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt|851 -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4233 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4230 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidProjectContentClient.kt|985 -androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|1003 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/NextcloudDocumentsProvider.kt|995 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirer.kt|1224 contractAcquisition/src/main/kotlin/dev/obiente/nextcloudnative/contracts/StaticRouteContract.kt|1883 contractAcquisition/src/test/kotlin/dev/obiente/nextcloudnative/contracts/SignedAppStoreContractAcquirerTest.kt|1798 From fc550cfbd9971e552a4c2bba13469968283fad71 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 08:47:26 +0200 Subject: [PATCH 115/119] fix(accounts): fence complete dynamic read lifetimes --- .../436-displaced-read-account-fence.md | 7 + .../app/DynamicApiRequestCoalescer.kt | 130 +++++++------- .../app/DynamicApiRequestCoalescerTest.kt | 170 ++++++++++++++++++ 3 files changed, 245 insertions(+), 62 deletions(-) create mode 100644 changes/unreleased/436-displaced-read-account-fence.md diff --git a/changes/unreleased/436-displaced-read-account-fence.md b/changes/unreleased/436-displaced-read-account-fence.md new file mode 100644 index 000000000..2294d537a --- /dev/null +++ b/changes/unreleased/436-displaced-read-account-fence.md @@ -0,0 +1,7 @@ +category: security +issue: none +pull: 436 +platforms: android, desktop +user-facing: yes + +Account removal now stops older dynamic reads even when a newer request replaced them during refresh, preventing retries with retired credentials after sign-in. diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt index 627b8ab19..d6f2fa219 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt @@ -18,17 +18,21 @@ import kotlinx.coroutines.withContext class DynamicApiRequestCoalescer { private data class Key(val accountId: String, val requestIdentity: String) - private data class InFlight( + private class ReadLifetime { + var fenced = false + } + + private class InFlight( val accountGeneration: Long, val requestGeneration: Long, val result: CompletableDeferred, + val lifetime: ReadLifetime, ) private val mutex = Mutex() private val accountGenerations = mutableMapOf() private val closedAccounts = mutableSetOf() - private val fencedAccountGenerations = mutableMapOf() - private val fencedInFlight = mutableMapOf>>() + private val activeReads = mutableMapOf>() private val requestGenerations = mutableMapOf() private val inFlight = mutableMapOf>() @@ -37,17 +41,42 @@ class DynamicApiRequestCoalescer { requestIdentity: String, load: suspend () -> T, commit: (T) -> Unit = {}, + ): T { + val lifetime = mutex.withLock { + if (accountId in closedAccounts) throw DynamicReadAccountFencedException() + ReadLifetime().also { activeReads.getOrPut(accountId, ::mutableSetOf).add(it) } + } + try { + return executeReads(accountId, requestIdentity, load, commit, lifetime) + } finally { + withContext(NonCancellable) { + mutex.withLock { + val reads = activeReads[accountId] + reads?.remove(lifetime) + if (reads?.isEmpty() == true) activeReads.remove(accountId) + } + } + } + } + + private suspend fun executeReads( + accountId: String, + requestIdentity: String, + load: suspend () -> T, + commit: (T) -> Unit, + lifetime: ReadLifetime, ): T { while (true) { val key = Key(accountId, requestIdentity) var owner = false val entry = mutex.withLock { - if (accountId in closedAccounts) throw DynamicReadAccountFencedException() + if (lifetime.fenced || accountId in closedAccounts) throw DynamicReadAccountFencedException() val accountGeneration = accountGenerations[accountId] ?: 0L inFlight[key]?.takeIf { current -> current.accountGeneration == accountGeneration } ?: InFlight( accountGeneration = accountGeneration, requestGeneration = requestGenerations[key] ?: 0L, result = CompletableDeferred(), + lifetime = lifetime, ).also { inFlight[key] = it owner = true @@ -55,66 +84,57 @@ class DynamicApiRequestCoalescer { } if (!owner) { try { - return entry.result.await() + val loaded = entry.result.await() + return mutex.withLock { + if (lifetime.fenced) throw DynamicReadAccountFencedException() + loaded + } } catch (_: DynamicReadInvalidatedException) { continue } } - val loaded = try { - load() - } catch (failure: Throwable) { - if (failure is CancellationException) { - withContext(NonCancellable) { - mutex.withLock { - inFlight.remove(key, entry) - entry.result.completeExceptionally(failure) - retireRequestGenerationIfIdle(key, entry.requestGeneration) - retireAccountFenceEntry(accountId, entry) - } + try { + val loaded = try { + load() + } catch (failure: Throwable) { + if (failure is CancellationException) throw failure + val invalidation = mutex.withLock { + val cause = invalidationCause(accountId, key, entry) + inFlight.remove(key, entry) + entry.result.completeExceptionally( + if (cause == InvalidationCause.None) failure else cause.exception(), + ) + cause } + if (invalidation == InvalidationCause.Invalidated) continue + if (invalidation == InvalidationCause.Fenced) throw DynamicReadAccountFencedException() throw failure } val invalidation = mutex.withLock { val cause = invalidationCause(accountId, key, entry) - inFlight.remove(key, entry) if (cause != InvalidationCause.None) { entry.result.completeExceptionally(cause.exception()) } else { - entry.result.completeExceptionally(failure) - retireRequestGenerationIfIdle(key, entry.requestGeneration) + commit(loaded) + entry.result.complete(loaded) } - retireAccountFenceEntry(accountId, entry) + inFlight.remove(key, entry) cause } - if (invalidation == InvalidationCause.Invalidated) continue + if (invalidation == InvalidationCause.None) return loaded if (invalidation == InvalidationCause.Fenced) throw DynamicReadAccountFencedException() + } catch (failure: Throwable) { + entry.result.completeExceptionally(failure) throw failure - } - val invalidation = mutex.withLock { - val cause = invalidationCause(accountId, key, entry) - if (cause != InvalidationCause.None) { - inFlight.remove(key, entry) - entry.result.completeExceptionally(cause.exception()) - retireAccountFenceEntry(accountId, entry) - cause - } else { - try { - commit(loaded) + } finally { + withContext(NonCancellable) { + mutex.withLock { inFlight.remove(key, entry) - entry.result.complete(loaded) retireRequestGenerationIfIdle(key, entry.requestGeneration) - InvalidationCause.None - } catch (failure: Throwable) { - inFlight.remove(key, entry) - entry.result.completeExceptionally(failure) - retireRequestGenerationIfIdle(key, entry.requestGeneration) - throw failure } } } - if (invalidation == InvalidationCause.None) return loaded - if (invalidation == InvalidationCause.Fenced) throw DynamicReadAccountFencedException() } } @@ -135,13 +155,8 @@ class DynamicApiRequestCoalescer { val generation = (accountGenerations[accountId] ?: 0L) + 1L accountGenerations[accountId] = generation closedAccounts += accountId - val fenced = inFlight.filter { (key, entry) -> - key.accountId == accountId && entry.accountGeneration < generation - }.values - if (fenced.isNotEmpty()) { - fencedAccountGenerations[accountId] = generation - fencedInFlight.getOrPut(accountId, ::mutableSetOf).addAll(fenced) - } + // Keep waiters and retry gaps fenced even after their old deduplication slot retires. + activeReads[accountId]?.forEach { it.fenced = true } requestGenerations.keys.removeAll { it.accountId == accountId } invalidate() } @@ -171,6 +186,9 @@ class DynamicApiRequestCoalescer { internal suspend fun retainedRequestGenerationCount(): Int = mutex.withLock { requestGenerations.size } + internal suspend fun activeReadCount(): Int = + mutex.withLock { activeReads.values.sumOf { it.size } } + private fun retireRequestGenerationIfIdle(key: Key, generation: Long) { if (key !in inFlight && requestGenerations[key] == generation) { requestGenerations.remove(key) @@ -178,13 +196,10 @@ class DynamicApiRequestCoalescer { } private fun invalidationCause(accountId: String, key: Key, entry: InFlight): InvalidationCause { + if (entry.lifetime.fenced) return InvalidationCause.Fenced val accountGeneration = accountGenerations[accountId] ?: 0L if (accountGeneration != entry.accountGeneration) { - return if ((fencedAccountGenerations[accountId] ?: Long.MIN_VALUE) > entry.accountGeneration) { - InvalidationCause.Fenced - } else { - InvalidationCause.Invalidated - } + return InvalidationCause.Invalidated } return if ((requestGenerations[key] ?: 0L) != entry.requestGeneration) { InvalidationCause.Invalidated @@ -193,15 +208,6 @@ class DynamicApiRequestCoalescer { } } - private fun retireAccountFenceEntry(accountId: String, entry: InFlight) { - val entries = fencedInFlight[accountId] ?: return - entries.remove(entry) - if (entries.isEmpty()) { - fencedInFlight.remove(accountId) - fencedAccountGenerations.remove(accountId) - } - } - private enum class InvalidationCause { None, Invalidated, diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt index 6f19b0083..9be834860 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescerTest.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative.app import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.awaitCancellation @@ -9,6 +10,7 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.supervisorScope +import kotlin.coroutines.CoroutineContext import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -153,6 +155,161 @@ class DynamicApiRequestCoalescerTest { } } + @Test + fun `displaced owner and waiter remain fenced after replacement finishes and account reopens`() = runBlocking { + assertDisplacedOwnerRemainsFenced(failLoad = false) + } + + @Test + fun `displaced failed owner cannot retry old credentials after account reopens`() = runBlocking { + assertDisplacedOwnerRemainsFenced(failLoad = true) + } + + private suspend fun assertDisplacedOwnerRemainsFenced(failLoad: Boolean) = supervisorScope { + val coalescer = DynamicApiRequestCoalescer() + val finishOldRead = CompletableDeferred() + val committed = mutableListOf() + var oldCredentialLoads = 0 + val owner = async(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { + oldCredentialLoads += 1 + finishOldRead.await() + if (failLoad) error("retired credential transport failed") + "retired account data" + }, commit = committed::add) + } + val waiter = async(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { fail("must not retry retired waiter") }) + } + coalescer.invalidateAccount("account-a") { committed.clear() } + assertEquals("replacement", coalescer.execute("account-a", "GET items", load = { "replacement" })) + assertEquals(2, coalescer.activeReadCount()) + + coalescer.fenceAccount("account-a") { committed.clear() } + coalescer.activateAccount("account-a") + assertEquals("new credentials", coalescer.execute("account-a", "GET items", load = { "new credentials" })) + finishOldRead.complete(Unit) + + assertFailsWith { owner.await() } + assertFailsWith { waiter.await() } + assertEquals(1, oldCredentialLoads) + assertEquals(emptyList(), committed) + assertEquals(0, coalescer.activeReadCount()) + } + + @Test + fun `queued invalidated waiter stays fenced after its owner finishes and account reopens`() = runBlocking { + supervisorScope { + val coalescer = DynamicApiRequestCoalescer() + val dispatcher = QueuedReadDispatcher() + val finishFirstLoad = CompletableDeferred() + var ownerLoads = 0 + var retiredWaiterLoads = 0 + val owner = async(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { + ownerLoads += 1 + if (ownerLoads == 1) finishFirstLoad.await() + "owner-$ownerLoads" + }) + } + val waiter = async(dispatcher, start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { + retiredWaiterLoads += 1 + "retired waiter credentials" + }) + } + try { + assertEquals(2, coalescer.activeReadCount()) + coalescer.invalidateRequest("account-a", "GET items") {} + finishFirstLoad.complete(Unit) + assertEquals("owner-2", owner.await()) + assertEquals(1, coalescer.activeReadCount()) + assertEquals(1, dispatcher.pendingCount) + assertEquals(0, coalescer.retainedRequestGenerationCount()) + + coalescer.fenceAccount("account-a") {} + coalescer.activateAccount("account-a") + dispatcher.runAll() + + assertFailsWith { waiter.await() } + assertEquals(0, retiredWaiterLoads) + assertEquals(0, coalescer.activeReadCount()) + assertEquals("new credentials", coalescer.execute("account-a", "GET items", load = { "new credentials" })) + } finally { + owner.cancel() + waiter.cancel() + dispatcher.runAll() + } + } + } + + @Test + fun `queued successful waiter cannot deliver retired data after account reopens`() = runBlocking { + supervisorScope { + val coalescer = DynamicApiRequestCoalescer() + val dispatcher = QueuedReadDispatcher() + val finishLoad = CompletableDeferred() + val owner = async(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { + finishLoad.await() + "retired account data" + }) + } + val waiter = async(dispatcher, start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { fail("must not reload retired waiter") }) + } + try { + finishLoad.complete(Unit) + assertEquals("retired account data", owner.await()) + assertEquals(1, coalescer.activeReadCount()) + assertEquals(1, dispatcher.pendingCount) + + coalescer.fenceAccount("account-a") {} + coalescer.activateAccount("account-a") + dispatcher.runAll() + + assertFailsWith { waiter.await() } + assertEquals(0, coalescer.activeReadCount()) + assertEquals("new credentials", coalescer.execute("account-a", "GET items", load = { "new credentials" })) + } finally { + owner.cancel() + waiter.cancel() + dispatcher.runAll() + } + } + } + + @Test + fun `displaced cancellation retires only its own owner and preserves another account`() = runBlocking { + val coalescer = DynamicApiRequestCoalescer() + val otherRelease = CompletableDeferred() + val cancelled = launch(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-a", "GET items", load = { awaitCancellation() }) + } + coalescer.invalidateAccount("account-a") {} + assertEquals("replacement", coalescer.execute("account-a", "GET items", load = { "replacement" })) + val other = async(start = CoroutineStart.UNDISPATCHED) { + coalescer.execute("account-b", "GET items", load = { otherRelease.await(); "other" }) + } + coalescer.fenceAccount("account-a") {} + cancelled.cancelAndJoin() + assertEquals(1, coalescer.activeReadCount()) + otherRelease.complete(Unit) + assertEquals("other", other.await()) + assertEquals(0, coalescer.activeReadCount()) + } + + @Test + fun `failed commit retires its active owner`() = runBlocking { + val coalescer = DynamicApiRequestCoalescer() + assertFailsWith { + coalescer.execute("account-a", "GET items", load = { "loaded" }, commit = { error("cache failed") }) + } + assertEquals(0, coalescer.activeReadCount()) + assertEquals("fresh", coalescer.execute("account-a", "GET items", load = { "fresh" })) + assertEquals(0, coalescer.activeReadCount()) + } + @Test fun `cancelled owner remains cancelled and releases its in flight entry`() = runBlocking { val coalescer = DynamicApiRequestCoalescer() @@ -275,4 +432,17 @@ class DynamicApiRequestCoalescerTest { assertEquals(0, coalescer.retainedRequestGenerationCount()) } + + private class QueuedReadDispatcher : CoroutineDispatcher() { + private val pending = ArrayDeque() + val pendingCount: Int get() = pending.size + + override fun dispatch(context: CoroutineContext, block: Runnable) { + pending.addLast(block) + } + + fun runAll() { + while (pending.isNotEmpty()) pending.removeFirst().run() + } + } } From ef0f432d97f8190b9fa119f2e3aef259e48b29d7 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 08:47:26 +0200 Subject: [PATCH 116/119] fix(accounts): persist credential rollback completion --- .../desktop-credential-rollback-completion.md | 7 + .../DesktopAccountCredentialPersistence.kt | 18 +- ...DesktopAccountCredentialPersistenceTest.kt | 2 +- ...DesktopCredentialRollbackCompletionTest.kt | 183 ++++++++++++++++++ 4 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 changes/unreleased/desktop-credential-rollback-completion.md create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopCredentialRollbackCompletionTest.kt diff --git a/changes/unreleased/desktop-credential-rollback-completion.md b/changes/unreleased/desktop-credential-rollback-completion.md new file mode 100644 index 000000000..f06037c76 --- /dev/null +++ b/changes/unreleased/desktop-credential-rollback-completion.md @@ -0,0 +1,7 @@ +category: fix +issue: 172 +pull: 436 +platforms: desktop +user-facing: yes + +Record successful desktop credential rollback before deleting its recovery secret, so interrupted cleanup can retry without locking accounts out. diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt index 962e2aab7..a366aadef 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistence.kt @@ -119,7 +119,7 @@ internal class DesktopAccountCredentialPersistence( } catch (failure: Exception) { var credentialRollbackCompleted = false try { - markPendingCredentialSaveRollback() + persistPendingCredentialSavePhase(CREDENTIAL_SAVE_ROLLBACK) if (previousSecret == null) { secretStore.clear(secretReference) } else { @@ -129,6 +129,7 @@ internal class DesktopAccountCredentialPersistence( previousSecret, ) } + persistPendingCredentialSavePhase(CREDENTIAL_SAVE_ROLLBACK_COMPLETED) secretStore.clear(rollbackReference) credentialRollbackCompleted = true } catch (rollbackFailure: Exception) { @@ -204,7 +205,7 @@ internal class DesktopAccountCredentialPersistence( ) return legacy } - clearLegacyCredentialAfterMigration(legacy) + retryPendingLegacyCredentialCleanup(legacy) return legacy } @@ -224,10 +225,6 @@ internal class DesktopAccountCredentialPersistence( private fun migrateLegacyCredential(session: NextcloudSession) { persistPendingLegacyCredentialCleanup(session) saveSecret(session) - clearLegacyCredentialAfterMigration(session) - } - - private fun clearLegacyCredentialAfterMigration(session: NextcloudSession) { retryPendingLegacyCredentialCleanup(session) } @@ -254,6 +251,7 @@ internal class DesktopAccountCredentialPersistence( CREDENTIAL_SAVE_SECRET_WRITING, CREDENTIAL_SAVE_SECRET_WRITTEN, CREDENTIAL_SAVE_ROLLBACK, + CREDENTIAL_SAVE_ROLLBACK_COMPLETED, ) if (phase !in knownPhases) { credentialRollbackRecoveryUnavailable() @@ -284,6 +282,7 @@ internal class DesktopAccountCredentialPersistence( } try { secretStore.save(secretReference, registry.accounts.first { it.id == accountId }.loginName, rollbackSecret) + persistPendingCredentialSavePhase(CREDENTIAL_SAVE_ROLLBACK_COMPLETED) secretStore.clear(rollbackReference) } catch (cancelled: CancellationException) { throw cancelled @@ -301,7 +300,7 @@ internal class DesktopAccountCredentialPersistence( credentialRollbackRecoveryUnavailable(failure) } } - if (phase == CREDENTIAL_SAVE_PREPARED) { + if (phase == CREDENTIAL_SAVE_PREPARED || phase == CREDENTIAL_SAVE_ROLLBACK_COMPLETED) { try { secretStore.clear(rollbackReference) } catch (cancelled: CancellationException) { @@ -458,10 +457,10 @@ internal class DesktopAccountCredentialPersistence( throw DesktopCredentialRollbackRecoveryUnavailableException(failure) } - private fun markPendingCredentialSaveRollback() { + private fun persistPendingCredentialSavePhase(phase: String) { val previousPhase = preferences.get(KEY_PENDING_CREDENTIAL_SAVE_PHASE, null) try { - preferences.put(KEY_PENDING_CREDENTIAL_SAVE_PHASE, CREDENTIAL_SAVE_ROLLBACK) + preferences.put(KEY_PENDING_CREDENTIAL_SAVE_PHASE, phase) flushPreferences() } catch (cancelled: CancellationException) { throw cancelled @@ -754,6 +753,7 @@ internal class DesktopAccountCredentialPersistence( const val CREDENTIAL_SAVE_SECRET_WRITING = "secret-writing" const val CREDENTIAL_SAVE_SECRET_WRITTEN = "secret-written" const val CREDENTIAL_SAVE_ROLLBACK = "rollback" + const val CREDENTIAL_SAVE_ROLLBACK_COMPLETED = "rollback-completed" } } diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt index de7483de0..4327c5465 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountCredentialPersistenceTest.kt @@ -890,7 +890,7 @@ class DesktopAccountCredentialPersistenceTest { persistence.saveSession(original) } assertEquals(original.appPassword, secrets.load(desktopAccountSecretReference(original.accountId))?.decodeToString()) - assertEquals("rollback", preferences.get("accountCredentialSavePhase", null)) + assertEquals("rollback-completed", preferences.get("accountCredentialSavePhase", null)) } @Test diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopCredentialRollbackCompletionTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopCredentialRollbackCompletionTest.kt new file mode 100644 index 000000000..47312076b --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopCredentialRollbackCompletionTest.kt @@ -0,0 +1,183 @@ +package dev.obiente.nextcloudnative.app + +import java.util.UUID +import java.util.prefs.Preferences +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class DesktopCredentialRollbackCompletionTest { + @Test + fun journalClearFailureAfterRestorationCanRetryWithoutTheBackupSecret() = withStore { fixture -> + listOf("rollback", "secret-writing").forEach { phase -> + fixture.preparePendingRollback(phase) + var failClear = true + val persistence = fixture.persistence { + if (failClear && fixture.preferences.get(PHASE_KEY, null) == null) { + failClear = false + error("synthetic journal-clear flush failure") + } + fixture.preferences.flush() + } + + assertFailsWith { + persistence.loadActiveSession() + } + + assertEquals("rollback-completed", fixture.preferences.get(PHASE_KEY, null)) + assertEquals(fixture.original.appPassword, fixture.primarySecret()) + assertNull(fixture.secrets.load(fixture.rollbackReference)) + fixture.assertRestartRecovered() + } + } + + @Test + fun completionMarkerFlushFailureRetainsTheBackupForAnotherRollback() = withStore { fixture -> + fixture.preparePendingRollback("rollback") + var failCompletion = true + val persistence = fixture.persistence { + if (failCompletion && fixture.preferences.get(PHASE_KEY, null) == "rollback-completed") { + failCompletion = false + error("synthetic rollback-completion flush failure") + } + fixture.preferences.flush() + } + + assertFailsWith { + persistence.loadActiveSession() + } + + assertEquals("rollback", fixture.preferences.get(PHASE_KEY, null)) + assertNotNull(fixture.secrets.load(fixture.rollbackReference)) + fixture.assertRestartRecovered() + } + + @Test + fun restartAfterRecoveryDeletesItsBackupKeepsTheOriginalActiveAccount() = withStore { fixture -> + fixture.preparePendingRollback("secret-writing") + fixture.crashAfterBackupDeletion() + + assertFailsWith { fixture.persistence().loadActiveSession() } + + assertEquals("rollback-completed", fixture.preferences.get(PHASE_KEY, null)) + assertNull(fixture.secrets.load(fixture.rollbackReference)) + fixture.assertRestartRecovered() + } + + @Test + fun restartAfterImmediateRollbackDeletesItsBackupKeepsTheOriginalActiveAccount() = withStore { fixture -> + fixture.secrets.failNextSaveTarget = fixture.primaryReference.targetName + fixture.crashAfterBackupDeletion() + + assertFailsWith { + fixture.persistence().saveSession(fixture.original.copy(appPassword = "replacement-password")) + } + + assertEquals("rollback-completed", fixture.preferences.get(PHASE_KEY, null)) + assertEquals(fixture.original.appPassword, fixture.primarySecret()) + assertNull(fixture.secrets.load(fixture.rollbackReference)) + fixture.assertRestartRecovered() + } + + @Test + fun immediateRollbackJournalClearFailureCanRetryWithoutTheBackupSecret() = withStore { fixture -> + fixture.secrets.failNextSaveTarget = fixture.primaryReference.targetName + var failClear = true + val persistence = fixture.persistence { + if (failClear && fixture.preferences.get(PHASE_KEY, null) == null) { + failClear = false + error("synthetic immediate rollback journal-clear failure") + } + fixture.preferences.flush() + } + + assertFailsWith { + persistence.saveSession(fixture.original.copy(appPassword = "replacement-password")) + } + + assertEquals("rollback-completed", fixture.preferences.get(PHASE_KEY, null)) + assertNull(fixture.secrets.load(fixture.rollbackReference)) + fixture.assertRestartRecovered() + } + + private fun withStore(test: (RollbackFixture) -> Unit) { + val preferences = Preferences.userRoot().node("desktop-rollback-completion-test-${UUID.randomUUID()}") + try { + test(RollbackFixture(preferences)) + } finally { + preferences.removeNode() + } + } +} + +private class RollbackFixture(val preferences: Preferences) { + val secrets = RollbackSecretStore() + val original = NextcloudSession("https://cloud.example.test", "alice", "original-password") + private val active = NextcloudSession("https://other.example.test", "bob", "other-password") + val primaryReference = desktopAccountSecretReference(original.accountId) + val rollbackReference = desktopAccountCredentialRollbackReference(original.accountId) + + init { + persistence().saveSession(original) + persistence().saveSession(active) + } + + fun persistence(flush: () -> Unit = preferences::flush) = + DesktopAccountCredentialPersistence(preferences, secrets, recordDiagnostic = {}, flushPreferences = flush) + + fun preparePendingRollback(phase: String) { + secrets.save(primaryReference, original.loginName, "replacement-password".encodeToByteArray()) + secrets.save(rollbackReference, original.loginName, original.appPassword.encodeToByteArray()) + preferences.put("accountCredentialSaveServer", original.serverUrl) + preferences.put("accountCredentialSaveLogin", original.loginName) + preferences.put(PHASE_KEY, phase) + preferences.flush() + } + + fun crashAfterBackupDeletion() { + secrets.afterClear = { reference -> + if (reference == rollbackReference) { + secrets.afterClear = {} + throw SimulatedRollbackProcessExit() + } + } + } + + fun primarySecret(): String? = secrets.load(primaryReference)?.decodeToString() + + fun assertRestartRecovered() { + val restarted = persistence() + assertEquals(active, restarted.loadActiveSession()) + assertEquals(original, restarted.loadSession(original.accountId)) + assertNull(secrets.load(rollbackReference)) + assertNull(preferences.get(PHASE_KEY, null)) + assertNull(preferences.get("accountCredentialSaveServer", null)) + assertNull(preferences.get("accountCredentialSaveLogin", null)) + } +} + +private class RollbackSecretStore : DesktopSecretStore { + private val values = mutableMapOf() + var failNextSaveTarget: String? = null + var afterClear: (DesktopSecretReference) -> Unit = {} + + override fun load(reference: DesktopSecretReference): ByteArray? = values[reference.targetName]?.copyOf() + + override fun save(reference: DesktopSecretReference, username: String?, secret: ByteArray) { + if (reference.targetName == failNextSaveTarget) { + failNextSaveTarget = null + error("synthetic primary credential save failure") + } + values[reference.targetName] = secret.copyOf() + } + + override fun clear(reference: DesktopSecretReference) { + values.remove(reference.targetName) + afterClear(reference) + } +} + +private class SimulatedRollbackProcessExit : Error() +private const val PHASE_KEY = "accountCredentialSavePhase" From 6fe80d061d47e7b258313c2cee94d55756dbf111 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 08:47:26 +0200 Subject: [PATCH 117/119] fix(deck): retire legacy drafts before replacement --- .../AndroidDeckCardDraftStore.kt | 32 +++++-- .../AndroidDeckCardDraftStoreTest.kt | 77 +++++++++++++++- .../436-deck-legacy-draft-recovery.md | 7 ++ .../app/DesktopDeckCardDraftStore.kt | 26 ++++-- .../app/DesktopDeckCardDraftStoreTest.kt | 90 ++++++++++++++++++- 5 files changed, 212 insertions(+), 20 deletions(-) create mode 100644 changes/unreleased/436-deck-legacy-draft-recovery.md diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt index 8826ab134..fc659ac59 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt @@ -44,6 +44,9 @@ internal class AndroidDeckCardDraftStore( fun save(session: NextcloudSession, persisted: PersistedDeckCardDraft): Unit = synchronized(STORAGE_LOCK) { + check( + migrateLegacyEntry(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session), persisted.key), + ) { "The previous Deck card draft could not be retired before saving its replacement." } migrateLegacyEntries(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session)) val storedKey = storageKey(session, persisted.key) clearQuarantineBeforeSave(storedKey) @@ -105,16 +108,16 @@ internal class AndroidDeckCardDraftStore( legacyAccountIdentity: String, key: DeckCardDraftKey, decodedLegacy: StoredDeckCardDraft? = null, - ) { + ): Boolean { val legacyKey = legacyStorageKey(legacyAccountIdentity, key) val legacyMarker = quarantineKey(legacyKey, LEGACY_KEY_PREFIX, LEGACY_QUARANTINE_PREFIX) val targetKey = storageKey(accountStorageKey, key) val targetMarker = quarantineKey(targetKey) val legacyEncrypted = storage.getString(legacyKey) if (legacyEncrypted == null) { - val markerValue = storage.entries()[legacyMarker] as? String ?: return - if (storage.putString(targetMarker, markerValue)) storage.remove(setOf(legacyMarker)) - return + val markerValue = storage.entries()[legacyMarker] as? String ?: return true + if (!storage.putString(targetMarker, markerValue)) return false + return storage.remove(setOf(legacyMarker)) } val legacy = decodedLegacy ?: decode(legacyEncrypted) if ( @@ -128,17 +131,17 @@ internal class AndroidDeckCardDraftStore( } val migrated = encode(accountStorageKey, targetKey, legacy.draft, legacy.updatedAtEpochMillis) val markerValue = storage.entries()[legacyMarker] as? String - if (markerValue != null && !storage.putString(targetMarker, markerValue)) return + if (markerValue != null && !storage.putString(targetMarker, markerValue)) return false val existingTarget = storage.getString(targetKey) if (existingTarget == null) { - if (!storage.putString(targetKey, migrated)) return + if (!storage.putString(targetKey, migrated)) return false } else { val existing = decode(existingTarget) requireStorageSlot(existing, targetKey) requireStorageOwner(existing, accountStorageKey) requireResource(existing, key) } - storage.remove(setOf(legacyKey, legacyMarker)) + return storage.remove(setOf(legacyKey, legacyMarker)) } private fun encode( @@ -178,6 +181,21 @@ internal class AndroidDeckCardDraftStore( key: DeckCardDraftKey, discardUnreadable: Boolean = false, ): Unit = synchronized(STORAGE_LOCK) { + if (discardUnreadable) { + val storedKey = storageKey(session, key) + val legacyKey = legacyStorageKey(NextcloudDocumentIds.accountKey(session), key) + check( + storage.remove( + setOf( + storedKey, + quarantineKey(storedKey), + legacyKey, + quarantineKey(legacyKey, LEGACY_KEY_PREFIX, LEGACY_QUARANTINE_PREFIX), + ), + ), + ) { "The Deck card draft could not be cleared." } + return@synchronized + } migrateLegacyEntry(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session), key) val storedKey = storageKey(session, key) if (!discardUnreadable) { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt index e4061e727..2aa5b724b 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt @@ -297,10 +297,81 @@ class AndroidDeckCardDraftStoreTest { store.migrateLegacyEntries(session) val updated = original.copy(draft = original.draft.copy(title = "Newer")) - store.save(session, updated) - - assertEquals(updated, store.load(session, original.key)) + assertFailsWith { store.save(session, updated) } + assertEquals(original, store.load(session, original.key)) assertTrue(legacyKey in storage.values) + + storage.removeSucceeds = true + val restarted = store(storage, IdentityDeckDraftCipher) + restarted.save(session, updated) + + assertEquals(updated, restarted.load(session, original.key)) + assertTrue(legacyKey !in storage.values) + } + + @Test + fun `legacy submitted markers must retire before replacement survives restart`() { + listOf(false, true).forEach { hasLegacyDraft -> + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val original = persisted(title = "Submitted") + val replacement = persisted(title = "Fresh replacement") + val legacyKey = store.legacyStorageKey(session, original.key) + val marker = legacyKey.replaceFirst("draft_", "submitted_") + if (hasLegacyDraft) storage.values[legacyKey] = legacyCiphertextFor(original, legacyKey) + storage.values[marker] = AndroidDeckCardDraftStore.QUARANTINE_MARKER + storage.removeSucceeds = false + + assertNull(store.load(session, original.key)) + assertFailsWith { store.save(session, replacement) } + assertTrue(marker in storage.values) + + storage.removeSucceeds = true + val restarted = store(storage, IdentityDeckDraftCipher) + restarted.save(session, replacement) + + assertTrue(legacyKey !in storage.values) + assertTrue(marker !in storage.values) + repeat(2) { + assertEquals(replacement, store(storage, IdentityDeckDraftCipher).load(session, original.key)) + } + } + } + + @Test + fun `explicit legacy discard bypasses decryption and preserves unrelated recovery`() { + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val key = persisted().key + val legacyKey = store.legacyStorageKey(session, key) + val targetKey = store.storageKey(session, key) + val marker = legacyKey.replaceFirst("draft_", "submitted_") + val targetMarker = targetKey.replaceFirst("draft_v2_", "submitted_v2_") + val unrelated = setOf( + store.legacyStorageKey(session.copy(loginName = "bob"), key), + store.legacyStorageKey(session, persisted(cardId = 91L).key), + store.storageKey(session.copy(loginName = "bob"), key), + ).associateWith { "unrelated-unreadable" } + storage.values.putAll(unrelated) + setOf(legacyKey, targetKey, marker, targetMarker).forEach { storage.values[it] = "unreadable" } + val unavailable = store(storage, object : AndroidDeckDraftCipher { + override fun encrypt(value: String): String = error("No cipher access during explicit discard") + override fun decrypt(value: String): String = error("No cipher access during explicit discard") + }) + + assertFailsWith { store.load(session, key) } + assertFailsWith { store.clear(session, key) } + storage.removeSucceeds = false + assertFailsWith { unavailable.clear(session, key, discardUnreadable = true) } + assertEquals("unreadable", storage.values[legacyKey]) + storage.removeSucceeds = true + + unavailable.clear(session, key, discardUnreadable = true) + + assertEquals(unrelated, storage.values) + val replacement = persisted(title = "Replacement") + store.save(session, replacement) + assertEquals(replacement, store(storage, IdentityDeckDraftCipher).load(session, key)) } @Test diff --git a/changes/unreleased/436-deck-legacy-draft-recovery.md b/changes/unreleased/436-deck-legacy-draft-recovery.md new file mode 100644 index 000000000..2cf4940ac --- /dev/null +++ b/changes/unreleased/436-deck-legacy-draft-recovery.md @@ -0,0 +1,7 @@ +category: fix +issue: none +pull: 436 +platforms: android, desktop +user-facing: yes + +Keep replacement Deck drafts from being cleared by an older submitted-draft marker after migration. Explicitly discarding an unreadable legacy draft now clears only that account's selected draft without requiring its encryption key. diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt index 508f9c462..09b7bb919 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt @@ -50,8 +50,10 @@ internal class DesktopDeckCardDraftStore( @Synchronized fun save(session: NextcloudSession, persisted: PersistedDeckCardDraft) { + check(migrateLegacyEntry(session.accountId.storageKey, desktopFileCacheAccountId(session), persisted.key)) { + "The previous Deck card draft could not be retired before saving its replacement." + } migrateLegacyEntries(session) - migrateLegacyEntry(session.accountId.storageKey, desktopFileCacheAccountId(session), persisted.key) val updatedAtEpochMillis = nowEpochMillis() require(updatedAtEpochMillis >= 0L) { "The Deck draft timestamp is invalid." } val file = draftFile(session, persisted.key) @@ -87,6 +89,18 @@ internal class DesktopDeckCardDraftStore( key: DeckCardDraftKey, discardUnreadable: Boolean = false, ) { + if (discardUnreadable) { + check(!Files.isSymbolicLink(root.toPath())) { + "Desktop Deck draft storage must not be a symbolic link." + } + val legacy = File(root, legacyStorageFileName(desktopFileCacheAccountId(session), key)) + val file = draftFile(session, key) + check( + deleteDurably(legacy) && deleteDurably(legacyQuarantineFile(legacy)) && + deleteDurably(file) && deleteDurably(quarantineFile(file)), + ) { "The Deck card draft could not be cleared." } + return + } migrateLegacyEntry(session.accountId.storageKey, desktopFileCacheAccountId(session), key) val file = draftFile(session, key) if (file.exists() && !discardUnreadable) { @@ -241,17 +255,16 @@ internal class DesktopDeckCardDraftStore( key: DeckCardDraftKey, decodedLegacy: StoredDeckCardDraft? = null, providedEncryptionKey: ByteArray? = null, - ) { + ): Boolean { val legacyFile = File(root, legacyStorageFileName(legacyAccountIdentity, key)) val legacyMarker = legacyQuarantineFile(legacyFile) val target = File(root, storageFileName(accountStorageKey, key)) val targetMarker = quarantineFile(target) if (!legacyFile.exists()) { - if (!legacyMarker.exists()) return + if (!legacyMarker.exists()) return true ensurePrivateDirectory() publish(targetMarker, SUBMITTED_MARKER_BYTES) - deleteDurably(legacyMarker) - return + return deleteDurably(legacyMarker) } val encryptionKey = providedEncryptionKey ?: keyProvider.encryptionKey() val legacy = decodedLegacy ?: readAuthenticated(legacyFile, encryptionKey, key) @@ -277,8 +290,7 @@ internal class DesktopDeckCardDraftStore( } else { publish(target, envelope) } - deleteDurably(legacyFile) - deleteDurably(legacyMarker) + return deleteDurably(legacyFile) && deleteDurably(legacyMarker) } private fun clearQuarantineBeforeSave(draftFile: File) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt index 0e0510e0c..acd04aa56 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt @@ -241,15 +241,99 @@ class DesktopDeckCardDraftStoreTest { failing.migrateLegacyEntries(session) val updated = original.copy(draft = original.draft.copy(title = "Newer")) - failing.save(session, updated) - - assertEquals(updated, failing.load(session, original.key)) + assertFailsWith { failing.save(session, updated) } + assertEquals(original, failing.load(session, original.key)) assertTrue(legacy.exists()) + + val restarted = DesktopDeckCardDraftStore(root, fixedKey(key)) + restarted.save(session, updated) + + assertEquals(updated, restarted.load(session, original.key)) + assertTrue(!legacy.exists()) } finally { root.deleteRecursively() } } + @Test + fun `legacy submitted markers must retire before replacement survives restart`() { + listOf("marker-only", "draft-deletion", "marker-deletion").forEach { failureMode -> + withStore { root, key, store -> + val session = session() + val original = persisted(title = "Submitted") + val replacement = persisted(title = "Fresh replacement") + val legacy = root.resolve(store.legacyStorageFileName(desktopFileCacheAccountId(session), original.key)) + val marker = root.resolve( + legacy.name.replaceFirst("draft_", "submitted_").removeSuffix(".json.enc") + ".marker", + ) + if (failureMode != "marker-only") writeLegacyDraft(legacy, key, original) + marker.writeBytes(DesktopDeckCardDraftStore.SUBMITTED_MARKER_BYTES) + val failedTarget = if (failureMode == "draft-deletion") legacy else marker + val failing = DesktopDeckCardDraftStore( + root = root, + keyProvider = fixedKey(key), + deleteFile = { file -> + if (file == failedTarget) false else Files.deleteIfExists(file.toPath()) || !file.exists() + }, + ) + + assertNull(failing.load(session, original.key)) + assertTrue(marker.exists()) + assertFailsWith { failing.save(session, replacement) } + assertTrue(marker.exists()) + + val restarted = DesktopDeckCardDraftStore(root, fixedKey(key)) + restarted.save(session, replacement) + + assertTrue(!legacy.exists()) + assertTrue(!marker.exists()) + repeat(2) { + assertEquals(replacement, DesktopDeckCardDraftStore(root, fixedKey(key)).load(session, original.key)) + } + } + } + } + + @Test + fun `explicit legacy discard bypasses keyring and preserves unrelated recovery`() = withStore { root, key, store -> + val session = session() + val draftKey = persisted().key + val legacy = root.resolve(store.legacyStorageFileName(desktopFileCacheAccountId(session), draftKey)) + val target = root.resolve(store.storageFileName(session, draftKey)) + val marker = root.resolve(legacy.name.replaceFirst("draft_", "submitted_").removeSuffix(".json.enc") + ".marker") + val targetMarker = root.resolve( + target.name.replaceFirst("draft_v2_", "submitted_v2_").removeSuffix(".json.enc") + ".marker", + ) + val unrelated = listOf( + store.legacyStorageFileName(desktopFileCacheAccountId(session(login = "bob")), draftKey), + store.legacyStorageFileName(desktopFileCacheAccountId(session), persisted(cardId = 91L).key), + store.storageFileName(session(login = "bob"), draftKey), + ).associateWith { "unrelated-unreadable" } + unrelated.forEach { (name, content) -> root.resolve(name).writeText(content) } + listOf(legacy, target, marker, targetMarker).forEach { it.writeText("unreadable") } + var failDeletion = true + val unavailable = DesktopDeckCardDraftStore( + root = root, + keyProvider = DesktopDeckDraftKeyProvider { error("No keyring access during explicit discard") }, + deleteFile = { file -> + if (file == legacy && failDeletion) false else Files.deleteIfExists(file.toPath()) || !file.exists() + }, + ) + + assertFailsWith { store.load(session, draftKey) } + assertFailsWith { store.clear(session, draftKey) } + assertFailsWith { unavailable.clear(session, draftKey, discardUnreadable = true) } + assertEquals("unreadable", legacy.readText()) + failDeletion = false + + unavailable.clear(session, draftKey, discardUnreadable = true) + + assertEquals(unrelated, root.listFiles().orEmpty().associate { it.name to it.readText() }) + val replacement = persisted(title = "Replacement") + store.save(session, replacement) + assertEquals(replacement, DesktopDeckCardDraftStore(root, fixedKey(key)).load(session, draftKey)) + } + @Test fun `account removal preserves unreadable and other account legacy drafts`() = withStore { root, key, store -> From a64f83b249f2f3d5aafba9f4d101e6010ffeb0a5 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 9 Sep 2026 08:51:57 +0200 Subject: [PATCH 118/119] fix(deck): retain legacy submission tombstones during cleanup --- .../AndroidDeckCardDraftStore.kt | 10 +++++-- .../AndroidDeckCardDraftStoreTest.kt | 27 ++++++++++++++++++- .../436-deck-legacy-draft-recovery.md | 2 +- .../app/DesktopDeckCardDraftStore.kt | 8 ++++-- .../app/DesktopDeckCardDraftStoreTest.kt | 26 ++++++++++++++++++ 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt index fc659ac59..9a346c8f0 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStore.kt @@ -213,12 +213,18 @@ internal class AndroidDeckCardDraftStore( fun quarantineAfterSubmit(session: NextcloudSession, key: DeckCardDraftKey): Unit = synchronized(STORAGE_LOCK) { - migrateLegacyEntry(session.accountId.storageKey, NextcloudDocumentIds.accountKey(session), key) val storedKey = storageKey(session, key) + val legacyKey = legacyStorageKey(NextcloudDocumentIds.accountKey(session), key) + val legacyMarker = quarantineKey(legacyKey, LEGACY_KEY_PREFIX, LEGACY_QUARANTINE_PREFIX) + check(storage.putString(legacyMarker, QUARANTINE_MARKER)) { + "The submitted legacy Deck card draft could not be quarantined." + } check(storage.putString(quarantineKey(storedKey), QUARANTINE_MARKER)) { "The submitted Deck card draft could not be quarantined." } - if (!storage.remove(setOf(storedKey, quarantineKey(storedKey)))) return@synchronized + if (!storage.remove(setOf(legacyKey, legacyMarker, storedKey, quarantineKey(storedKey)))) { + return@synchronized + } } fun discardAll(): Unit = synchronized(STORAGE_LOCK) { diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt index 2aa5b724b..cbe90ab94 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidDeckCardDraftStoreTest.kt @@ -338,6 +338,30 @@ class AndroidDeckCardDraftStoreTest { } } + @Test + fun `submitted legacy draft cannot return after migration deletion fails`() { + val storage = MemoryDeckDraftStorage() + val store = store(storage, IdentityDeckDraftCipher) + val original = persisted(title = "Submitted legacy draft") + val legacyKey = store.legacyStorageKey(session, original.key) + val legacyCiphertext = legacyCiphertextFor(original, legacyKey) + storage.values[legacyKey] = legacyCiphertext + storage.removeSucceeds = false + + assertEquals(original, store.load(session, original.key)) + store.quarantineAfterSubmit(session, original.key) + + assertEquals(legacyCiphertext, storage.values[legacyKey]) + assertEquals( + AndroidDeckCardDraftStore.QUARANTINE_MARKER, + storage.values[legacyKey.replaceFirst("draft_", "submitted_")], + ) + assertNull(store(storage, IdentityDeckDraftCipher).load(session, original.key)) + storage.removeSucceeds = true + assertNull(store(storage, IdentityDeckDraftCipher).load(session, original.key)) + assertTrue(storage.values.isEmpty()) + } + @Test fun `explicit legacy discard bypasses decryption and preserves unrelated recovery`() { val storage = MemoryDeckDraftStorage() @@ -368,7 +392,8 @@ class AndroidDeckCardDraftStoreTest { unavailable.clear(session, key, discardUnreadable = true) - assertEquals(unrelated, storage.values) + assertEquals(unrelated.keys, storage.values.keys) + unrelated.forEach { (storedKey, value) -> assertEquals(value, storage.getString(storedKey)) } val replacement = persisted(title = "Replacement") store.save(session, replacement) assertEquals(replacement, store(storage, IdentityDeckDraftCipher).load(session, key)) diff --git a/changes/unreleased/436-deck-legacy-draft-recovery.md b/changes/unreleased/436-deck-legacy-draft-recovery.md index 2cf4940ac..36d2e851d 100644 --- a/changes/unreleased/436-deck-legacy-draft-recovery.md +++ b/changes/unreleased/436-deck-legacy-draft-recovery.md @@ -4,4 +4,4 @@ pull: 436 platforms: android, desktop user-facing: yes -Keep replacement Deck drafts from being cleared by an older submitted-draft marker after migration. Explicitly discarding an unreadable legacy draft now clears only that account's selected draft without requiring its encryption key. +Preserve replacement Deck drafts and prevent submitted drafts from reappearing after failed legacy cleanup. Explicit discard clears only the selected account's draft without requiring its encryption key. diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt index 09b7bb919..dc97cef09 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStore.kt @@ -117,12 +117,16 @@ internal class DesktopDeckCardDraftStore( @Synchronized fun quarantineAfterSubmit(session: NextcloudSession, key: DeckCardDraftKey) { - migrateLegacyEntry(session.accountId.storageKey, desktopFileCacheAccountId(session), key) + val legacy = File(root, legacyStorageFileName(desktopFileCacheAccountId(session), key)) + val legacyMarker = legacyQuarantineFile(legacy) val file = draftFile(session, key) val quarantine = quarantineFile(file) ensurePrivateDirectory() + publish(legacyMarker, SUBMITTED_MARKER_BYTES) publish(quarantine, SUBMITTED_MARKER_BYTES) - if (deleteDurably(file)) deleteDurably(quarantine) + if (deleteDurably(legacy) && deleteDurably(file) && deleteDurably(legacyMarker)) { + deleteDurably(quarantine) + } } @Synchronized diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt index acd04aa56..b277b382d 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopDeckCardDraftStoreTest.kt @@ -294,6 +294,32 @@ class DesktopDeckCardDraftStoreTest { } } + @Test + fun `submitted legacy draft cannot return after migration deletion fails`() = withStore { root, key, store -> + val session = session() + val original = persisted(title = "Submitted legacy draft") + val legacy = root.resolve(store.legacyStorageFileName(desktopFileCacheAccountId(session), original.key)) + val marker = root.resolve(legacy.name.replaceFirst("draft_", "submitted_").removeSuffix(".json.enc") + ".marker") + writeLegacyDraft(legacy, key, original) + val originalEnvelope = legacy.readText() + val failing = DesktopDeckCardDraftStore( + root = root, + keyProvider = fixedKey(key), + deleteFile = { file -> + if (file == legacy) false else Files.deleteIfExists(file.toPath()) || !file.exists() + }, + ) + + assertEquals(original, failing.load(session, original.key)) + failing.quarantineAfterSubmit(session, original.key) + + assertEquals(originalEnvelope, legacy.readText()) + assertTrue(marker.exists()) + assertNull(failing.load(session, original.key)) + assertNull(DesktopDeckCardDraftStore(root, fixedKey(key)).load(session, original.key)) + assertTrue(root.listFiles().orEmpty().isEmpty()) + } + @Test fun `explicit legacy discard bypasses keyring and preserves unrelated recovery`() = withStore { root, key, store -> val session = session() From d3a3482e76ac9747f2419eb97ca366960f4db496 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:59:48 +0000 Subject: [PATCH 119/119] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 8100381e8..e3aefd977 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -488,7 +488,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DocumentPreview.kt": "a9a8743dd7a381504282cc6ddc68034425024ccc1ae51667da9734bbfc7b1a79", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DurableMutationRecoveryDialog.kt": "e720eadb477a347762cd1894285788ac9f6820972431fe0a1953d01955667bfe", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicActionUi.kt": "2b7ef2d18b4a23615686ced0b7c9c621c58dc5edd0202104d0ca55b1ebf61d81", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "ddf80ca67d954f6e063c9e88c75794fcb81cbd6d42887c45d1a04b1cefe4f2fd", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicApiRequestCoalescer.kt": "9aeb3dce3a1bd11651a7c84b5ab0e77e2d905055c68bc8f8cd02d412670c5ed5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicArtworkMemoryCache.kt": "c313daea9465087ab1862814bc5a772bdcc1f087bc673eb80f417db73668ea1c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicCollectionHeaderActions.kt": "d352d0a0fc28bdf5cfd3cf24b04dc7b23aa15de5ec29dbcf6e5c49f25599d1ab", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicContractInfo.kt": "cff6ba11283705120375452d6d539c20581f4dd0115dd3d07049eb965242b019",