From de92b09f0a88e9af34128443d2cdbb9b9951d01c Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 20:07:48 +0200 Subject: [PATCH 01/24] feat(accounts): add account identity registry foundation --- .../AndroidNextcloudServices.kt | 18 +- .../AndroidPersistedSession.kt | 67 ++++++ .../AndroidPersistedSessionTest.kt | 96 +++++++++ .../172-account-identity-foundation.md | 7 + .../nextcloudnative/app/DashboardStatus.kt | 13 +- .../app/DashboardStatusScreens.kt | 5 +- .../app/DynamicNativeMemoryCache.kt | 3 +- .../app/GroupwareCalendarScreen.kt | 6 +- .../app/GroupwareContactsState.kt | 6 +- .../app/NextcloudAccountIdentity.kt | 26 +++ .../app/NextcloudAccountRegistry.kt | 197 ++++++++++++++++++ .../nextcloudnative/app/NextcloudNativeApp.kt | 25 +-- .../nextcloudnative/app/NextcloudNotes.kt | 4 +- .../app/NextcloudNotesCache.kt | 21 +- .../nextcloudnative/app/NextcloudPlatform.kt | 9 +- .../app/RemoteFolderPickerOperations.kt | 2 +- .../app/NextcloudAccountIdentityTest.kt | 104 +++++++++ .../app/NextcloudAccountRegistryTest.kt | 139 ++++++++++++ .../app/DesktopAccountRegistryPersistence.kt | 50 +++++ .../app/DesktopNextcloudServices.kt | 5 +- .../DesktopAccountRegistryPersistenceTest.kt | 78 +++++++ .../app/PreviewMemoryCache.jvm.kt | 18 +- 22 files changed, 834 insertions(+), 65 deletions(-) create mode 100644 androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt create mode 100644 androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt create mode 100644 changes/unreleased/172-account-identity-foundation.md create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt create mode 100644 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt create mode 100644 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 54a3b4d5e..21e30d03e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -924,11 +924,12 @@ internal class AndroidNextcloudServices( val encrypted = preferences.getString(KEY_SESSION, null) ?: return@restorePersistedSession null runCatching { - val json = JSONObject(sessionCipher.decrypt(encrypted)) - NextcloudSession( - serverUrl = json.getString("serverUrl"), - loginName = json.getString("loginName"), - appPassword = json.getString("appPassword"), + restoreAndroidPersistedSession( + encoded = sessionCipher.decrypt(encrypted), + persistMigrated = { migrated -> + preferences.edit().putString(KEY_SESSION, sessionCipher.encrypt(migrated)).apply() + }, + recordDiagnostic = ::recordSupportDiagnostic, ) }.onFailure { failure -> recordSupportDiagnostic( @@ -960,12 +961,7 @@ internal class AndroidNextcloudServices( registerSessionPrivateValues(session) val previousAccountId = loadSession()?.let(NextcloudDocumentIds::cacheAccountId) val replacementAccountId = NextcloudDocumentIds.cacheAccountId(session) - val json = JSONObject() - .put("serverUrl", session.serverUrl) - .put("loginName", session.loginName) - .put("appPassword", session.appPassword) - .toString() - val encrypted = runCatching { sessionCipher.encrypt(json) } + val encrypted = runCatching { sessionCipher.encrypt(encodeAndroidPersistedSession(session)) } .onFailure { failure -> recordSupportDiagnostic( SupportDiagnosticEventDraft( diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt new file mode 100644 index 000000000..e063e5ffc --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -0,0 +1,67 @@ +package dev.obiente.nextcloudnative + +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.encodeNextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.singleAccountRegistry +import org.json.JSONObject + +internal fun restoreAndroidPersistedSession( + encoded: String, + persistMigrated: (String) -> Unit, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, +): NextcloudSession { + val json = JSONObject(encoded) + val session = NextcloudSession( + serverUrl = json.getString("serverUrl"), + loginName = json.getString("loginName"), + appPassword = json.getString("appPassword"), + ) + 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 { + persistMigrated( + json.put(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(restored.registry)).toString(), + ) + }.onFailure { + recordDiagnostic( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Authentication, + operation = "account-registry.migrate", + outcome = "failed", + code = "ACCOUNT_REGISTRY_MIGRATION_FAILED", + ), + ) + } + } + return session +} + +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 const val KEY_ACCOUNT_REGISTRY = "account_registry_v1" diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt new file mode 100644 index 000000000..a6466c563 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -0,0 +1,96 @@ +package dev.obiente.nextcloudnative + +import dev.obiente.nextcloudnative.app.NextcloudAccountRegistrySource +import dev.obiente.nextcloudnative.app.SupportDiagnosticEventDraft +import dev.obiente.nextcloudnative.app.decodeNextcloudAccountRegistry +import dev.obiente.nextcloudnative.app.restoreNextcloudAccountRegistry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import org.json.JSONObject + +class AndroidPersistedSessionTest { + @Test + fun legacyPayloadMigratesOnceAndRestartsWithTheSameActiveAccount() { + val diagnostics = mutableListOf() + var migrated: String? = null + + val first = restoreAndroidPersistedSession( + encoded = legacyPayload(), + 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) + assertTrue(diagnostics.isEmpty()) + + var unexpectedSecondMigration = false + val restarted = restoreAndroidPersistedSession( + encoded = migratedPayload, + persistMigrated = { unexpectedSecondMigration = true }, + recordDiagnostic = diagnostics::add, + ) + assertEquals(first, restarted) + assertFalse(unexpectedSecondMigration) + assertTrue(diagnostics.isEmpty()) + } + + @Test + fun malformedRegistryFallsBackWithoutDiscardingTheLegacySession() { + val diagnostics = mutableListOf() + var migrated: String? = null + val malformed = JSONObject(legacyPayload()) + .put(ACCOUNT_REGISTRY_KEY, "{not-json") + .toString() + + val session = restoreAndroidPersistedSession( + encoded = malformed, + persistMigrated = { encoded -> migrated = encoded }, + 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(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")) + } + + @Test + fun savedPayloadKeepsCredentialsOutsideTheRegistry() { + val session = restoreAndroidPersistedSession( + encoded = legacyPayload(), + persistMigrated = {}, + 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")) + } + + private fun legacyPayload(): String = JSONObject() + .put("serverUrl", "https://cloud.example.test") + .put("loginName", "alice") + .put("appPassword", "private-app-password") + .toString() + + private companion object { + const val ACCOUNT_REGISTRY_KEY = "account_registry_v1" + } +} diff --git a/changes/unreleased/172-account-identity-foundation.md b/changes/unreleased/172-account-identity-foundation.md new file mode 100644 index 000000000..b2d5f8d65 --- /dev/null +++ b/changes/unreleased/172-account-identity-foundation.md @@ -0,0 +1,7 @@ +category: internal +issue: 172 +pull: none +platforms: android, desktop +user-facing: no + +Introduce one credential-free account identity for process-local caches, persist a versioned active-account registry with safe legacy-session migration, and redact session values from diagnostic string rendering. 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 ce1859d4c..d9168ff01 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt @@ -675,15 +675,15 @@ data class CachedDashboardStatus( internal class DashboardStatusMemoryCache( private val ttlSeconds: Long = DASHBOARD_STATUS_CACHE_TTL_SECONDS, ) { - private val entries = mutableMapOf() + private val entries = mutableMapOf() fun get(session: NextcloudSession, nowEpochSeconds: Long): CachedDashboardStatus? { - val entry = entries[session.dashboardCacheKey()] ?: return null + val entry = entries[session.accountId] ?: return null return entry.takeIf { nowEpochSeconds >= it.storedAtEpochSeconds && nowEpochSeconds - it.storedAtEpochSeconds <= ttlSeconds } ?: run { - entries.remove(session.dashboardCacheKey()) + entries.remove(session.accountId) null } } @@ -695,11 +695,11 @@ internal class DashboardStatusMemoryCache( nowEpochSeconds: Long, ) { require(nowEpochSeconds >= 0L) { "The dashboard cache timestamp is invalid." } - entries[session.dashboardCacheKey()] = CachedDashboardStatus(dashboard, status, nowEpochSeconds) + entries[session.accountId] = CachedDashboardStatus(dashboard, status, nowEpochSeconds) } fun invalidate(session: NextcloudSession) { - entries.remove(session.dashboardCacheKey()) + entries.remove(session.accountId) } } @@ -886,9 +886,6 @@ private fun String.encodeStatusFormComponent(): String = buildString { } } -private fun NextcloudSession.dashboardCacheKey(): String = - serverUrl.trim().trimEnd('/').lowercase() + '\u0000' + loginName - private val dashboardJson = Json { ignoreUnknownKeys = true } private const val USER_STATUS_BASE_PATH = "/ocs/v2.php/apps/user_status/api/v1/user_status" 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 726b55af5..a4465491a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt @@ -1410,7 +1410,7 @@ private sealed interface UserStatusSurfaceState { } private object UserStatusWorkspaceMemoryCache { - private val entries = linkedMapOf() + private val entries = linkedMapOf() fun get(session: NextcloudSession): UserStatusSurfaceState.Available? { val key = key(session) @@ -1424,8 +1424,7 @@ private object UserStatusWorkspaceMemoryCache { while (entries.size > MAXIMUM_RETAINED_STATUS_ACCOUNTS) entries.remove(entries.keys.first()) } - private fun key(session: NextcloudSession): String = - "${session.serverUrl.trimEnd('/')}\n${session.loginName}" + private fun key(session: NextcloudSession): NextcloudAccountId = session.accountId } private enum class StatusExpiryChoice(val label: String, val seconds: Long?) { 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 d1c43ccef..d4ca8722d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt @@ -296,7 +296,6 @@ private val DYNAMIC_SCREEN_SCOPE_RELATIONS = setOf( "threadid", ) -private fun NextcloudSession.dynamicAccountKey(): String = - serverUrl.trim().trimEnd('/').lowercase() + '\u0000' + loginName +private fun NextcloudSession.dynamicAccountKey(): String = accountId.storageKey internal val sharedDynamicNativeMemoryCache = DynamicNativeMemoryCache() 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 3e7ed6a7a..235389d6d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt @@ -92,7 +92,7 @@ private sealed interface CalendarLoadState { } private object CalendarWorkspaceMemoryCache { - private val entries = linkedMapOf() + private val entries = linkedMapOf, CalendarLoadState.Ready>() fun get( session: NextcloudSession, @@ -116,8 +116,8 @@ private object CalendarWorkspaceMemoryCache { userId: String, month: CalendarMonth, timeWindow: GroupwareDavTimeWindow, - ): String = "${session.serverUrl.trimEnd('/')}\n${session.loginName}\n$userId\n" + - "${month.year}-${month.month}\n${timeWindow.startUtc}-${timeWindow.endUtc}" + ): Pair = session.accountId to + "$userId\n${month.year}-${month.month}\n${timeWindow.startUtc}-${timeWindow.endUtc}" } @OptIn(ExperimentalMaterial3Api::class) 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 154bb204a..09be3cb67 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt @@ -10,15 +10,15 @@ internal sealed interface ContactsLoadState { } internal object ContactsWorkspaceMemoryCache { - private val entries = linkedMapOf() + private val entries = linkedMapOf, ContactsLoadState.Ready>() fun get(session: NextcloudSession, userId: String): ContactsLoadState.Ready? { - val key = "${session.serverUrl.trimEnd('/')}\n${session.loginName}\n$userId" + val key = session.accountId to userId return entries.remove(key)?.also { entries[key] = it } } fun store(session: NextcloudSession, userId: String, value: ContactsLoadState.Ready) { - val key = "${session.serverUrl.trimEnd('/')}\n${session.loginName}\n$userId" + val key = session.accountId to userId entries.remove(key) entries[key] = value while (entries.size > MAXIMUM_RETAINED_CONTACT_ACCOUNTS) entries.remove(entries.keys.first()) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt new file mode 100644 index 000000000..fbf26100a --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt @@ -0,0 +1,26 @@ +package dev.obiente.nextcloudnative.app + +/** + * Credential-free process identity for one Nextcloud account. + * + * The value is a one-way digest of the normalized server address and login name. Callers keep the + * type intact so account-scoped state cannot accidentally use a path, username, or password as its + * owner. A future persistent account registry can replace the derivation without changing cache + * owners again. + */ +@JvmInline +value class NextcloudAccountId internal constructor(val storageKey: String) { + init { + require(storageKey.length == SHA_256_HEX_LENGTH && storageKey.all(Char::isLowerHexDigit)) { + "The local account identity must be a canonical SHA-256 digest." + } + } + + override fun toString(): String = "NextcloudAccountId()" +} + +internal expect fun deriveNextcloudAccountId(serverUrl: String, loginName: String): NextcloudAccountId + +private fun Char.isLowerHexDigit(): Boolean = this in '0'..'9' || this in 'a'..'f' + +private const val SHA_256_HEX_LENGTH = 64 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt new file mode 100644 index 000000000..a40d77df2 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt @@ -0,0 +1,197 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** Credential-free metadata for one locally known account. */ +data class NextcloudAccountRecord( + val id: NextcloudAccountId, + val serverUrl: String, + val loginName: String, +) { + init { + require(serverUrl.isNotBlank() && serverUrl.length <= MAX_ACCOUNT_SERVER_URL_LENGTH) + require(loginName.isNotBlank() && loginName.length <= MAX_ACCOUNT_LOGIN_NAME_LENGTH) + require(id == deriveNextcloudAccountId(serverUrl, loginName)) { + "The local account identity does not match its canonical server and login." + } + } +} + +/** + * Version-independent account registry state. + * + * Credentials remain in the platform secret store. An active account is always one of the stored + * records, and removing it leaves selection empty rather than silently choosing another account. + */ +data class NextcloudAccountRegistry( + val accounts: List, + val activeAccountId: NextcloudAccountId?, +) { + init { + require(accounts.size <= MAX_LOCAL_ACCOUNTS) { "The local account registry is too large." } + require(accounts.map(NextcloudAccountRecord::id).distinct().size == accounts.size) { + "The local account registry contains duplicate identities." + } + require(activeAccountId == null || accounts.any { account -> account.id == activeAccountId }) { + "The active account is not present in the local registry." + } + } + + val activeAccount: NextcloudAccountRecord? + get() = accounts.firstOrNull { account -> account.id == activeAccountId } + + fun upsertAndSelect(record: NextcloudAccountRecord): NextcloudAccountRegistry { + val replaced = accounts.map { account -> if (account.id == record.id) record else account } + return copy( + accounts = if (replaced.any { account -> account.id == record.id }) replaced else replaced + record, + activeAccountId = record.id, + ) + } + + fun select(id: NextcloudAccountId): NextcloudAccountRegistry? = + takeIf { registry -> registry.accounts.any { account -> account.id == id } } + ?.copy(activeAccountId = id) + + fun remove(id: NextcloudAccountId): NextcloudAccountRegistry = copy( + accounts = accounts.filterNot { account -> account.id == id }, + activeAccountId = activeAccountId?.takeUnless { active -> active == id }, + ) + + companion object { + val Empty = NextcloudAccountRegistry(emptyList(), null) + } +} + +enum class NextcloudAccountRegistrySource { + Empty, + Persisted, + LegacySession, +} + +enum class NextcloudAccountRegistryRecoveryReason(val diagnosticCode: String) { + MalformedRegistry("ACCOUNT_REGISTRY_MALFORMED"), + ActiveSessionMismatch("ACCOUNT_REGISTRY_ACTIVE_SESSION_MISMATCH"), +} + +data class RestoredNextcloudAccountRegistry( + val registry: NextcloudAccountRegistry, + val source: NextcloudAccountRegistrySource, + val recoveryReason: NextcloudAccountRegistryRecoveryReason? = null, +) { + val needsPersistence: Boolean + get() = source == NextcloudAccountRegistrySource.LegacySession +} + +fun NextcloudSession.accountRecord(): NextcloudAccountRecord = NextcloudAccountRecord( + id = accountId, + serverUrl = serverUrl, + loginName = loginName, +) + +fun singleAccountRegistry(session: NextcloudSession): NextcloudAccountRegistry = + NextcloudAccountRegistry.Empty.upsertAndSelect(session.accountRecord()) + +fun restoreNextcloudAccountRegistry( + encoded: String?, + legacySession: NextcloudSession?, +): RestoredNextcloudAccountRegistry { + val persisted = encoded?.let(::decodeNextcloudAccountRegistry) + if (persisted != null) { + val legacyAccount = legacySession?.accountRecord() + if (legacyAccount == null) { + return RestoredNextcloudAccountRegistry(persisted, NextcloudAccountRegistrySource.Persisted) + } + if (persisted.activeAccountId == legacyAccount.id) { + val refreshed = persisted.upsertAndSelect(legacyAccount) + return RestoredNextcloudAccountRegistry( + registry = refreshed, + source = if (refreshed == persisted) { + NextcloudAccountRegistrySource.Persisted + } else { + NextcloudAccountRegistrySource.LegacySession + }, + ) + } + return RestoredNextcloudAccountRegistry( + registry = persisted.upsertAndSelect(legacyAccount), + source = NextcloudAccountRegistrySource.LegacySession, + recoveryReason = NextcloudAccountRegistryRecoveryReason.ActiveSessionMismatch, + ) + } + if (legacySession != null) { + return RestoredNextcloudAccountRegistry( + registry = singleAccountRegistry(legacySession), + source = NextcloudAccountRegistrySource.LegacySession, + recoveryReason = encoded?.let { NextcloudAccountRegistryRecoveryReason.MalformedRegistry }, + ) + } + return RestoredNextcloudAccountRegistry( + registry = NextcloudAccountRegistry.Empty, + source = NextcloudAccountRegistrySource.Empty, + recoveryReason = encoded?.let { NextcloudAccountRegistryRecoveryReason.MalformedRegistry }, + ) +} + +fun encodeNextcloudAccountRegistry(registry: NextcloudAccountRegistry): String = + accountRegistryJson.encodeToString( + PersistedNextcloudAccountRegistry( + version = ACCOUNT_REGISTRY_VERSION, + activeAccountId = registry.activeAccountId?.storageKey, + accounts = registry.accounts.sortedBy { account -> account.id.storageKey }.map { account -> + PersistedNextcloudAccountRecord( + id = account.id.storageKey, + serverUrl = account.serverUrl, + loginName = account.loginName, + ) + }, + ), + ).also { encoded -> + require(encoded.encodeToByteArray().size <= MAX_ACCOUNT_REGISTRY_BYTES) { + "The local account registry is too large to persist." + } + } + +fun decodeNextcloudAccountRegistry(encoded: String): NextcloudAccountRegistry? { + if (encoded.encodeToByteArray().size > MAX_ACCOUNT_REGISTRY_BYTES) return null + return runCatching { + val persisted = accountRegistryJson.decodeFromString(encoded) + require(persisted.version == ACCOUNT_REGISTRY_VERSION) + NextcloudAccountRegistry( + accounts = persisted.accounts.map { account -> + NextcloudAccountRecord( + id = NextcloudAccountId(account.id), + serverUrl = account.serverUrl, + loginName = account.loginName, + ) + }, + activeAccountId = persisted.activeAccountId?.let(::NextcloudAccountId), + ) + }.getOrNull() +} + +@Serializable +private data class PersistedNextcloudAccountRegistry( + val version: Int, + val activeAccountId: String?, + val accounts: List, +) + +@Serializable +private data class PersistedNextcloudAccountRecord( + val id: String, + val serverUrl: String, + val loginName: String, +) + +private val accountRegistryJson = Json { + ignoreUnknownKeys = true + explicitNulls = false +} + +private const val ACCOUNT_REGISTRY_VERSION = 1 +private const val MAX_LOCAL_ACCOUNTS = 64 +private const val MAX_ACCOUNT_REGISTRY_BYTES = 256 * 1024 +private const val MAX_ACCOUNT_SERVER_URL_LENGTH = 8 * 1024 +private const val MAX_ACCOUNT_LOGIN_NAME_LENGTH = 1024 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 be3910e7f..cb4f94a58 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -7118,7 +7118,7 @@ internal fun shouldShowDynamicRecordFallbackDetail( selectedRecordResourceId?.sameDynamicResourceAs(viewResourceId) == true private object ActivityWorkspaceMemoryCache { - private val entries = linkedMapOf() + private val entries = linkedMapOf, ActivityTimelineState>() fun get(session: NextcloudSession, filterId: String): ActivityTimelineState? { val key = key(session, filterId) @@ -7132,8 +7132,8 @@ private object ActivityWorkspaceMemoryCache { while (entries.size > MAXIMUM_RETAINED_ACTIVITY_ACCOUNTS) entries.remove(entries.keys.first()) } - private fun key(session: NextcloudSession, filterId: String): String = - "${session.serverUrl.trimEnd('/')}\n${session.loginName}\n$filterId" + private fun key(session: NextcloudSession, filterId: String): Pair = + session.accountId to filterId } @Composable @@ -11889,34 +11889,31 @@ private enum class MarkdownFileViewMode { } internal object TalkWorkspaceMemoryCache { - private val rooms = linkedMapOf>() - private val messages = linkedMapOf>() + private val rooms = linkedMapOf>() + private val messages = linkedMapOf, List>() - fun rooms(session: NextcloudSession): List? = touch(rooms, accountKey(session)) + fun rooms(session: NextcloudSession): List? = touch(rooms, session.accountId) fun storeRooms(session: NextcloudSession, value: List) { - store(rooms, accountKey(session), value, MAXIMUM_RETAINED_TALK_ACCOUNTS) + store(rooms, session.accountId, value, MAXIMUM_RETAINED_TALK_ACCOUNTS) } fun messages(session: NextcloudSession, roomToken: String): List? = - touch(messages, "${accountKey(session)}\n$roomToken") + touch(messages, session.accountId to roomToken) fun storeMessages(session: NextcloudSession, roomToken: String, value: List) { store( messages, - "${accountKey(session)}\n$roomToken", + session.accountId to roomToken, value, MAXIMUM_RETAINED_TALK_ROOMS, ) } - private fun accountKey(session: NextcloudSession): String = - "${session.serverUrl.trimEnd('/')}\n${session.loginName}" - - private fun touch(entries: LinkedHashMap, key: String): T? = + private fun touch(entries: LinkedHashMap, key: Key): T? = entries.remove(key)?.also { entries[key] = it } - private fun store(entries: LinkedHashMap, key: String, value: T, maximum: Int) { + 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()) 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 4ed71e6fb..48250ac91 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt @@ -870,9 +870,7 @@ internal fun NextcloudNoteEditor( navigationCommitInProgress: Boolean = false, onMutationInProgressChanged: (Boolean) -> Unit = {}, ) { - val accountKey = remember(session.serverUrl, session.loginName) { - session.serverUrl.trimEnd('/').lowercase() + '\u0000' + session.loginName - } + val accountKey = remember(session.serverUrl, session.loginName) { session.accountId } val accountScope = remember(session.serverUrl, session.loginName) { durableMutationAccountScope(session) } 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 f191eab01..cab3dd22a 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt @@ -2,19 +2,19 @@ package dev.obiente.nextcloudnative.app /** Small process-local cache used for stale-while-revalidate Notes screens. */ internal class NextcloudNotesCache { - private val noteLists = mutableMapOf>() - private val noteListEtags = mutableMapOf() - private val noteDetails = mutableMapOf, NextcloudNote>() + private val noteLists = mutableMapOf>() + private val noteListEtags = mutableMapOf() + private val noteDetails = mutableMapOf, NextcloudNote>() - fun list(session: NextcloudSession): List? = noteLists[session.cacheKey()] + fun list(session: NextcloudSession): List? = noteLists[session.accountId] - fun listEtag(session: NextcloudSession): String? = noteListEtags[session.cacheKey()] + fun listEtag(session: NextcloudSession): String? = noteListEtags[session.accountId] fun detail(session: NextcloudSession, noteId: Long): NextcloudNote? = - noteDetails[session.cacheKey() to noteId] + noteDetails[session.accountId to noteId] fun storeList(session: NextcloudSession, notes: List, etag: String? = null) { - val account = session.cacheKey() + val account = session.accountId noteLists[account] = notes etag?.takeIf(String::isNotBlank)?.let { noteListEtags[account] = it } ?: noteListEtags.remove(account) @@ -22,7 +22,7 @@ internal class NextcloudNotesCache { } fun storeDetail(session: NextcloudSession, note: NextcloudNote) { - val account = session.cacheKey() + 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 @@ -30,14 +30,11 @@ internal class NextcloudNotesCache { } fun remove(session: NextcloudSession, noteId: Long) { - val account = session.cacheKey() + val account = session.accountId noteDetails.remove(account to noteId) noteLists[account] = noteLists[account]?.filterNot { note -> note.id == noteId } ?: return noteListEtags.remove(account) } - - private fun NextcloudSession.cacheKey(): String = - serverUrl.trim().trimEnd('/').lowercase() + '\u0000' + loginName } internal val sharedNextcloudNotesCache = NextcloudNotesCache() 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 19324fd90..3442d297b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -50,7 +50,14 @@ data class NextcloudSession( val serverUrl: String, val loginName: String, val appPassword: String, -) +) { + /** Opaque, credential-free identity for account-scoped process state. */ + val accountId: NextcloudAccountId + get() = deriveNextcloudAccountId(serverUrl, loginName) + + override fun toString(): String = + "NextcloudSession(serverUrl=, loginName=, appPassword=)" +} data class LoginChallenge( val enteredServerUrl: String, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt index b9976edf8..0d7bb8b4c 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt @@ -62,7 +62,7 @@ fun remoteFolderPickerOperations( ): RemoteFolderPickerOperations { require(userId.isNotBlank()) return RemoteFolderPickerOperations( - identity = "${session.serverUrl}|${session.loginName}|$userId", + identity = "${session.accountId.storageKey}|$userId", listCached = { path -> services.listFilesCachedWithSource(session, userId, path) }, listNetwork = { path -> services.listFilesWithSource(session, userId, path) }, createDirectoryIfAbsent = { path -> services.createDirectoryIfAbsent(session, userId, path) }, diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt new file mode 100644 index 000000000..a15652d97 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt @@ -0,0 +1,104 @@ +package dev.obiente.nextcloudnative.app + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class NextcloudAccountIdentityTest { + @Test + fun credentialRotationKeepsTheSameAccountIdentity() { + val session = session() + + assertEquals(session.accountId, session.copy(appPassword = "rotated-secret").accountId) + } + + @Test + fun trailingServerSlashKeepsTheSameAccountIdentity() { + val session = session() + + assertEquals(session.accountId, session.copy(serverUrl = "https://cloud.example.test/Cloud/").accountId) + } + + @Test + fun caseSensitiveServerPathsRemainDifferentAccounts() { + val session = session() + + assertNotEquals(session.accountId, session.copy(serverUrl = "https://cloud.example.test/cloud").accountId) + } + + @Test + fun schemeHostAndDefaultPortUseCanonicalUrlIdentity() { + val canonical = session() + val equivalent = canonical.copy(serverUrl = "HTTPS://CLOUD.EXAMPLE.TEST:443/Cloud///") + + assertEquals(canonical.accountId, equivalent.accountId) + } + + @Test + fun nonDefaultPortsRemainDifferentAccounts() { + val canonical = session() + + assertNotEquals( + canonical.accountId, + canonical.copy(serverUrl = "https://cloud.example.test:8443/Cloud").accountId, + ) + } + + @Test + fun accountIdentityRejectsUrlCredentialsQueriesAndFragments() { + listOf( + "https://alice:secret@cloud.example.test/Cloud", + "https://cloud.example.test/Cloud?account=alice", + "https://cloud.example.test/Cloud#account", + ).forEach { serverUrl -> + assertFailsWith { + session().copy(serverUrl = serverUrl).accountId + } + } + } + + @Test + fun sessionAndAccountIdentityDoNotRenderPrivateValues() { + val session = session() + + assertEquals("NextcloudAccountId()", session.accountId.toString()) + assertEquals( + "NextcloudSession(serverUrl=, loginName=, appPassword=)", + session.toString(), + ) + assertNotEquals(session.serverUrl, session.accountId.storageKey) + assertNotEquals(session.loginName, session.accountId.storageKey) + assertNotEquals(session.appPassword, session.accountId.storageKey) + } + + @Test + fun notesCacheDoesNotCollideForCaseSensitiveServerPaths() { + 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"))) + + assertNotNull(cache.list(upper)) + assertNull(cache.list(lower)) + } + + private fun session() = NextcloudSession( + serverUrl = "https://cloud.example.test/Cloud", + loginName = "alice", + appPassword = "private-app-password", + ) + + private fun note(id: Long, title: String) = NextcloudNote( + id = id, + title = title, + modified = 1L, + category = "Work", + favorite = false, + readOnly = false, + content = null, + etag = null, + ) +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt new file mode 100644 index 000000000..586a268cb --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt @@ -0,0 +1,139 @@ +package dev.obiente.nextcloudnative.app + +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 NextcloudAccountRegistryTest { + @Test + fun restartPreservesTheExactActiveAccount() { + val first = session("https://one.example.test/cloud", "alice", "first-secret") + val second = session("https://two.example.test/cloud", "alice", "second-secret") + val beforeRestart = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + .select(first.accountId) + val encoded = encodeNextcloudAccountRegistry(requireNotNull(beforeRestart)) + + val restored = restoreNextcloudAccountRegistry(encoded, first) + + assertEquals(NextcloudAccountRegistrySource.Persisted, restored.source) + assertEquals(first.accountId, restored.registry.activeAccountId) + assertEquals(setOf(first.accountId, second.accountId), restored.registry.accounts.map { it.id }.toSet()) + assertFalse(restored.needsPersistence) + } + + @Test + fun encodingIsDeterministicAcrossInsertionOrder() { + val first = session("https://one.example.test", "alice", "first-secret").accountRecord() + val second = session("https://two.example.test", "alice", "second-secret").accountRecord() + val firstOrder = NextcloudAccountRegistry(listOf(first, second), second.id) + val secondOrder = NextcloudAccountRegistry(listOf(second, first), second.id) + + assertEquals( + encodeNextcloudAccountRegistry(firstOrder), + encodeNextcloudAccountRegistry(secondOrder), + ) + } + + @Test + fun legacySessionMigratesToOneSelectedCredentialFreeRecord() { + val session = session("https://cloud.example.test", "alice", "private-app-password") + + val restored = restoreNextcloudAccountRegistry(encoded = null, legacySession = session) + val encoded = encodeNextcloudAccountRegistry(restored.registry) + + assertEquals(NextcloudAccountRegistrySource.LegacySession, restored.source) + assertEquals(session.accountId, restored.registry.activeAccountId) + assertTrue(restored.needsPersistence) + assertFalse(encoded.contains(session.appPassword)) + assertFalse(encoded.contains("appPassword")) + } + + @Test + fun duplicateCanonicalIdentitiesFallBackToTheValidLegacySession() { + val session = session("https://cloud.example.test/Cloud", "alice", "private-app-password") + val id = session.accountId.storageKey + val duplicateRegistry = """ + { + "version": 1, + "activeAccountId": "$id", + "accounts": [ + {"id": "$id", "serverUrl": "https://cloud.example.test/Cloud", "loginName": "alice"}, + {"id": "$id", "serverUrl": "HTTPS://CLOUD.EXAMPLE.TEST:443/Cloud/", "loginName": "alice"} + ] + } + """.trimIndent() + + val restored = restoreNextcloudAccountRegistry(duplicateRegistry, session) + + assertEquals(NextcloudAccountRegistrySource.LegacySession, restored.source) + assertEquals(NextcloudAccountRegistryRecoveryReason.MalformedRegistry, restored.recoveryReason) + assertEquals(listOf(session.accountRecord()), restored.registry.accounts) + assertEquals(session.accountId, restored.registry.activeAccountId) + } + + @Test + fun malformedRegistryNeverDiscardsAValidLegacySession() { + val session = session("https://cloud.example.test", "alice", "private-app-password") + + val restored = restoreNextcloudAccountRegistry("{not-json", session) + + assertEquals(NextcloudAccountRegistrySource.LegacySession, restored.source) + assertEquals(NextcloudAccountRegistryRecoveryReason.MalformedRegistry, restored.recoveryReason) + assertEquals(session.accountRecord(), restored.registry.activeAccount) + } + + @Test + fun staleActiveSelectionPreservesRecordsWithoutRebindingLegacyCredentials() { + val legacy = session("https://one.example.test", "alice", "private-app-password") + val other = session("https://two.example.test", "alice", "other-private-app-password") + val encoded = encodeNextcloudAccountRegistry(singleAccountRegistry(other)) + + val restored = restoreNextcloudAccountRegistry(encoded, legacy) + + assertEquals(NextcloudAccountRegistrySource.LegacySession, restored.source) + assertEquals(NextcloudAccountRegistryRecoveryReason.ActiveSessionMismatch, restored.recoveryReason) + assertEquals(legacy.accountId, restored.registry.activeAccountId) + assertEquals( + setOf(legacy.accountRecord(), other.accountRecord()), + restored.registry.accounts.toSet(), + ) + } + + @Test + fun removalDoesNotSilentlySelectAnotherAccount() { + val first = session("https://one.example.test", "alice", "first-secret") + val second = session("https://two.example.test", "alice", "second-secret") + val registry = NextcloudAccountRegistry.Empty + .upsertAndSelect(first.accountRecord()) + .upsertAndSelect(second.accountRecord()) + + val removed = registry.remove(second.accountId) + + assertNull(removed.activeAccountId) + assertEquals(listOf(first.accountRecord()), removed.accounts) + assertNull(removed.select(second.accountId)) + } + + @Test + fun recoveryDiagnosticsContainNoAccountOrCredentialValues() { + val session = session("https://private.example.test", "private-user", "private-app-password") + val restored = restoreNextcloudAccountRegistry("invalid", session) + val diagnostic = requireNotNull(restored.recoveryReason).diagnosticCode + + assertEquals("ACCOUNT_REGISTRY_MALFORMED", diagnostic) + assertFalse(diagnostic.contains(session.serverUrl)) + assertFalse(diagnostic.contains(session.loginName)) + assertFalse(diagnostic.contains(session.appPassword)) + } + + private fun session(serverUrl: String, loginName: String, appPassword: String) = NextcloudSession( + serverUrl = serverUrl, + loginName = loginName, + appPassword = appPassword, + ) +} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt new file mode 100644 index 000000000..6a10370ad --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt @@ -0,0 +1,50 @@ +package dev.obiente.nextcloudnative.app + +import java.util.prefs.Preferences + +internal fun restoreDesktopAccountRegistry( + preferences: Preferences, + session: NextcloudSession, + recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, +) { + val restored = restoreNextcloudAccountRegistry(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null), 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 { + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodeNextcloudAccountRegistry(restored.registry)) + }.onFailure { + recordDiagnostic( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Authentication, + operation = "account-registry.migrate", + outcome = "failed", + code = "ACCOUNT_REGISTRY_MIGRATION_FAILED", + ), + ) + } + } +} + +internal fun persistDesktopAccountRegistry(preferences: Preferences, session: NextcloudSession) { + preferences.put( + DESKTOP_ACCOUNT_REGISTRY_KEY, + encodeNextcloudAccountRegistry(singleAccountRegistry(session)), + ) +} + +internal fun clearDesktopAccountRegistry(preferences: Preferences) { + preferences.remove(DESKTOP_ACCOUNT_REGISTRY_KEY) +} + +internal const val DESKTOP_ACCOUNT_REGISTRY_KEY = "account_registry_v1" 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 c3fec8ece..e1a2e6f0c 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3678,6 +3678,7 @@ class DesktopNextcloudServices( } 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) @@ -3714,6 +3715,7 @@ class DesktopNextcloudServices( } preferences.put(KEY_SERVER, session.serverUrl) preferences.put(KEY_LOGIN, session.loginName) + persistDesktopAccountRegistry(preferences, session) val accountIdentity = desktopFileCacheAccountId(session) supportDiagnostics.setActiveAccountIdentity(accountIdentity) supportIntake.setActiveAccountIdentity(accountIdentity) @@ -3721,7 +3723,6 @@ class DesktopNextcloudServices( synchronized(fileRangeSessionLock) { sessionClearing = false } startDesktopSyncLifecycle() } - override suspend fun clearSession() = withContext(Dispatchers.IO) { val userHome = File(System.getProperty("user.home")) val rangeSessions = synchronized(fileRangeSessionLock) { @@ -3854,6 +3855,7 @@ class DesktopNextcloudServices( sessionPublicationGuard.serialize { preferences.remove(KEY_SERVER) preferences.remove(KEY_LOGIN) + clearDesktopAccountRegistry(preferences) supportDiagnostics.setActiveAccountIdentity(null) supportIntake.setActiveAccountIdentity(null) } @@ -3865,7 +3867,6 @@ class DesktopNextcloudServices( } } } - override suspend fun loadDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt new file mode 100644 index 000000000..e97a6319a --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt @@ -0,0 +1,78 @@ +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.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DesktopAccountRegistryPersistenceTest { + @Test + fun legacyPreferencesMigrateAndRestartWithTheSameActiveAccount() = withPreferences { preferences -> + val session = session() + val diagnostics = mutableListOf() + + restoreDesktopAccountRegistry(preferences, session, diagnostics::add) + val migrated = preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null) + val registry = decodeNextcloudAccountRegistry(requireNotNull(migrated)) + + assertEquals(session.accountId, requireNotNull(registry).activeAccountId) + assertTrue(diagnostics.isEmpty()) + + restoreDesktopAccountRegistry(preferences, session, diagnostics::add) + assertEquals(migrated, preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertTrue(diagnostics.isEmpty()) + } + + @Test + fun malformedRegistryIsReplacedWithoutLeakingPrivateSessionValues() = withPreferences { preferences -> + val session = session() + val diagnostics = mutableListOf() + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, "{not-json") + + restoreDesktopAccountRegistry(preferences, session, diagnostics::add) + + val restored = decodeNextcloudAccountRegistry( + requireNotNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)), + ) + assertEquals(session.accountId, requireNotNull(restored).activeAccountId) + assertEquals(listOf("ACCOUNT_REGISTRY_MALFORMED"), diagnostics.mapNotNull { it.code }) + val renderedDiagnostics = diagnostics.joinToString() + assertFalse(renderedDiagnostics.contains(session.serverUrl)) + assertFalse(renderedDiagnostics.contains(session.loginName)) + assertFalse(renderedDiagnostics.contains(session.appPassword)) + } + + @Test + fun explicitSaveAndRemovalOwnOnlyCredentialFreeMetadata() = withPreferences { preferences -> + val session = session() + + persistDesktopAccountRegistry(preferences, session) + val encoded = requireNotNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + + assertFalse(encoded.contains(session.appPassword)) + assertNotNull(decodeNextcloudAccountRegistry(encoded)) + clearDesktopAccountRegistry(preferences) + assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + } + + private fun session() = NextcloudSession( + serverUrl = "https://cloud.example.test", + loginName = "alice", + appPassword = "private-app-password", + ) + + private fun withPreferences(block: (Preferences) -> Unit) { + val preferences = Preferences.userRoot().node( + "dev/obiente/nextcloudnative/tests/account-registry/${UUID.randomUUID()}", + ) + try { + block(preferences) + } finally { + preferences.removeNode() + } + } +} diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.jvm.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.jvm.kt index 49447cfd9..9d7fc9202 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.jvm.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.jvm.kt @@ -1,9 +1,23 @@ package dev.obiente.nextcloudnative.app import java.security.MessageDigest +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -internal actual fun previewCacheDigest(session: NextcloudSession): String { - val identity = session.serverUrl.trimEnd('/') + "\u0000" + session.loginName +internal actual fun deriveNextcloudAccountId(serverUrl: String, loginName: String): NextcloudAccountId { + val accountServer = canonicalAccountServerUrl(serverUrl) + val identity = accountServer + "\u0000" + loginName return MessageDigest.getInstance("SHA-256").digest(identity.encodeToByteArray()) .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } + .let(::NextcloudAccountId) +} + +internal actual fun previewCacheDigest(session: NextcloudSession): String = session.accountId.storageKey + +private fun canonicalAccountServerUrl(value: String): String { + val url = value.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." + } + return url.toString().trimEnd('/') } From c72ccc06f05e1cf035ff14bab6197e2ca3ad5214 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 20:10:16 +0200 Subject: [PATCH 02/24] chore(changelog): link pull request --- changes/unreleased/172-account-identity-foundation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/unreleased/172-account-identity-foundation.md b/changes/unreleased/172-account-identity-foundation.md index b2d5f8d65..652c75e3e 100644 --- a/changes/unreleased/172-account-identity-foundation.md +++ b/changes/unreleased/172-account-identity-foundation.md @@ -1,6 +1,6 @@ category: internal issue: 172 -pull: none +pull: 429 platforms: android, desktop user-facing: no From f8cce0baca03a541e7c39184879c4ef7a7f241f1 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 21:00:08 +0200 Subject: [PATCH 03/24] fix(accounts): harden registry persistence and migration --- .../AndroidPersistedSession.kt | 4 +- .../AndroidPersistedSessionTest.kt | 42 +++++++++++++++ .../172-account-identity-foundation.md | 2 +- .../nextcloudnative/app/AppWorkspacePins.kt | 25 ++++++--- .../app/DashboardStatusScreens.kt | 9 ++-- .../app/HomeWorkspacePersistence.kt | 12 ++++- .../app/NextcloudAccountRegistry.kt | 49 +++++++++++++++--- .../nextcloudnative/app/NextcloudNativeApp.kt | 7 +-- .../nextcloudnative/app/PreviewMemoryCache.kt | 13 +++++ .../app/AppWorkspacePinsTest.kt | 16 ++++++ .../app/HomeWorkspaceLayoutTest.kt | 19 +++++++ .../app/NextcloudAccountIdentityTest.kt | 19 +++++++ .../app/NextcloudAccountRegistryTest.kt | 47 +++++++++++++++++ .../app/DesktopAccountRegistryPersistence.kt | 25 ++++++--- .../app/DesktopNextcloudServices.kt | 4 +- .../DesktopAccountRegistryPersistenceTest.kt | 51 +++++++++++++++++++ .../app/JvmSupportDiagnostics.kt | 9 ++++ .../app/PreviewMemoryCache.jvm.kt | 6 +++ 18 files changed, 326 insertions(+), 33 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index e063e5ffc..1dfd04ae4 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -7,6 +7,7 @@ import dev.obiente.nextcloudnative.app.SupportDiagnosticSeverity 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.JSONObject internal fun restoreAndroidPersistedSession( @@ -42,7 +43,7 @@ internal fun restoreAndroidPersistedSession( persistMigrated( json.put(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(restored.registry)).toString(), ) - }.onFailure { + }.onFailure { failure -> recordDiagnostic( SupportDiagnosticEventDraft( severity = SupportDiagnosticSeverity.Warning, @@ -50,6 +51,7 @@ internal fun restoreAndroidPersistedSession( operation = "account-registry.migrate", outcome = "failed", code = "ACCOUNT_REGISTRY_MIGRATION_FAILED", + 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 a6466c563..c23ae8034 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -8,6 +8,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue import org.json.JSONObject @@ -68,6 +69,26 @@ class AndroidPersistedSessionTest { assertFalse(renderedDiagnostics.contains("cloud.example.test")) } + @Test + fun unsupportedFutureRegistryIsNotPersistedOver() { + val diagnostics = mutableListOf() + var migrated = false + val futureRegistry = """{"version":2,"futureAccounts":[]}""" + val payload = JSONObject(legacyPayload()) + .put(ACCOUNT_REGISTRY_KEY, futureRegistry) + .toString() + + val session = restoreAndroidPersistedSession( + encoded = payload, + persistMigrated = { migrated = true }, + recordDiagnostic = diagnostics::add, + ) + + assertEquals("alice", session.loginName) + assertFalse(migrated) + assertEquals(listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }) + } + @Test fun savedPayloadKeepsCredentialsOutsideTheRegistry() { val session = restoreAndroidPersistedSession( @@ -84,6 +105,27 @@ class AndroidPersistedSessionTest { assertFalse(encodedRegistry.contains("appPassword")) } + @Test + fun migrationFailureAttachesABoundedCauseWithoutPrivateValues() { + val diagnostics = mutableListOf() + + val session = restoreAndroidPersistedSession( + encoded = legacyPayload(), + persistMigrated = { error("private-app-password at cloud.example.test for alice") }, + recordDiagnostic = diagnostics::add, + ) + + assertEquals("alice", session.loginName) + val diagnostic = diagnostics.single() + assertEquals("ACCOUNT_REGISTRY_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")) + } + private fun legacyPayload(): String = JSONObject() .put("serverUrl", "https://cloud.example.test") .put("loginName", "alice") diff --git a/changes/unreleased/172-account-identity-foundation.md b/changes/unreleased/172-account-identity-foundation.md index 652c75e3e..da40afafd 100644 --- a/changes/unreleased/172-account-identity-foundation.md +++ b/changes/unreleased/172-account-identity-foundation.md @@ -4,4 +4,4 @@ pull: 429 platforms: android, desktop user-facing: no -Introduce one credential-free account identity for process-local caches, persist a versioned active-account registry with safe legacy-session migration, and redact session values from diagnostic string rendering. +Add credential-free account identity and a bounded versioned registry, preserve unsupported future registries, migrate legacy session and UI keys, scope caches by account, and redact session diagnostics. diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt index 1753943e1..792c618e5 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt @@ -19,16 +19,26 @@ internal data class AppWorkspacePinsLoad( internal class AppWorkspacePinsRepository( private val storage: HomeWorkspaceLayoutStorage, ) { - fun load(accountScopeDigest: String): List { - return loadWithProvenance(accountScopeDigest).appIds + fun load(accountScopeDigest: String, legacyAccountScopeDigest: String? = null): List { + return loadWithProvenance(accountScopeDigest, legacyAccountScopeDigest).appIds } - fun loadWithProvenance(accountScopeDigest: String): AppWorkspacePinsLoad { + fun loadWithProvenance( + accountScopeDigest: String, + legacyAccountScopeDigest: String? = null, + ): AppWorkspacePinsLoad { val read = runCatching { storage.read(persistenceKey(accountScopeDigest)) } if (read.isFailure) { return AppWorkspacePinsLoad(defaultAppWorkspacePinnedIds(), storageAuthoritative = false) } - val encoded = read.getOrNull() + var migratedFromLegacy = false + val encoded = read.getOrNull() ?: legacyAccountScopeDigest?.let { legacyScope -> + val legacyRead = runCatching { storage.read(persistenceKey(legacyScope)) } + if (legacyRead.isFailure) { + return AppWorkspacePinsLoad(defaultAppWorkspacePinnedIds(), storageAuthoritative = false) + } + legacyRead.getOrNull()?.also { migratedFromLegacy = true } + } ?: return AppWorkspacePinsLoad(defaultAppWorkspacePinnedIds(), storageAuthoritative = true) if (encoded.length !in 1..MAX_APP_WORKSPACE_PINS_CHARACTERS) { return AppWorkspacePinsLoad(defaultAppWorkspacePinnedIds(), storageAuthoritative = true) @@ -39,10 +49,9 @@ internal class AppWorkspacePinsRepository( if (snapshot.schemaVersion != APP_WORKSPACE_PINS_SCHEMA_VERSION) { return AppWorkspacePinsLoad(defaultAppWorkspacePinnedIds(), storageAuthoritative = true) } - return AppWorkspacePinsLoad( - validatedAppWorkspacePinnedIds(snapshot.appIds) ?: defaultAppWorkspacePinnedIds(), - storageAuthoritative = true, - ) + val appIds = validatedAppWorkspacePinnedIds(snapshot.appIds) ?: defaultAppWorkspacePinnedIds() + val migrated = !migratedFromLegacy || save(accountScopeDigest, appIds) + return AppWorkspacePinsLoad(appIds, storageAuthoritative = migrated) } fun save(accountScopeDigest: String, appIds: List): Boolean { 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 a4465491a..b8db29789 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt @@ -233,14 +233,15 @@ internal fun NativeDashboardScreen( val workspaceRepository = remember(workspaceStorage) { HomeWorkspaceLayoutRepository(workspaceStorage) } - val workspaceScope = remember(session.serverUrl, session.loginName, formFactor) { + val workspacePersistenceScopes = remember(session) { accountPersistenceScopeDigests(session) } + val workspaceScope = remember(workspacePersistenceScopes.current, formFactor) { HomeWorkspaceScope( - accountScopeDigest = previewCacheDigest(session), + accountScopeDigest = workspacePersistenceScopes.current, formFactor = formFactor, ) } - var workspaceLayout by remember(workspaceScope) { - mutableStateOf(workspaceRepository.load(workspaceScope)) + var workspaceLayout by remember(workspaceScope, workspacePersistenceScopes.legacy) { + mutableStateOf(workspaceRepository.load(workspaceScope, workspacePersistenceScopes.legacy)) } NativeDashboardPresentation( 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 bac3b5cf9..ce683381b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt @@ -16,10 +16,18 @@ internal class HomeWorkspaceLayoutRepository( private val encodeSnapshot: (HomeWorkspaceLayout) -> String = ::encodeHomeWorkspaceLayoutSnapshot, ) { - fun load(scope: HomeWorkspaceScope): HomeWorkspaceLayout { + fun load(scope: HomeWorkspaceScope, legacyAccountScopeDigest: String? = null): HomeWorkspaceLayout { val encoded = runCatching { storage.read(scope.persistenceKey) }.getOrNull() + if (encoded != null) return decodeHomeWorkspaceLayoutSnapshot(scope, encoded) + val legacyScope = legacyAccountScopeDigest?.let { digest -> + HomeWorkspaceScope(digest, scope.formFactor) + } ?: return defaultHomeWorkspaceLayout(scope) + val legacyEncoded = runCatching { storage.read(legacyScope.persistenceKey) }.getOrNull() ?: return defaultHomeWorkspaceLayout(scope) - return decodeHomeWorkspaceLayoutSnapshot(scope, encoded) + val legacyLayout = decodeHomeWorkspaceLayoutSnapshot(legacyScope, legacyEncoded) + val migrated = HomeWorkspaceLayout(scope, legacyLayout.sections) + save(migrated) + return migrated } /** 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 a40d77df2..d652df83f 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt @@ -3,6 +3,9 @@ package dev.obiente.nextcloudnative.app import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive /** Credential-free metadata for one locally known account. */ data class NextcloudAccountRecord( @@ -72,6 +75,7 @@ enum class NextcloudAccountRegistrySource { enum class NextcloudAccountRegistryRecoveryReason(val diagnosticCode: String) { MalformedRegistry("ACCOUNT_REGISTRY_MALFORMED"), + UnsupportedRegistryVersion("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), ActiveSessionMismatch("ACCOUNT_REGISTRY_ACTIVE_SESSION_MISMATCH"), } @@ -81,7 +85,8 @@ data class RestoredNextcloudAccountRegistry( val recoveryReason: NextcloudAccountRegistryRecoveryReason? = null, ) { val needsPersistence: Boolean - get() = source == NextcloudAccountRegistrySource.LegacySession + get() = source == NextcloudAccountRegistrySource.LegacySession && + recoveryReason != NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion } fun NextcloudSession.accountRecord(): NextcloudAccountRecord = NextcloudAccountRecord( @@ -97,7 +102,8 @@ fun restoreNextcloudAccountRegistry( encoded: String?, legacySession: NextcloudSession?, ): RestoredNextcloudAccountRegistry { - val persisted = encoded?.let(::decodeNextcloudAccountRegistry) + val decoded = encoded?.let(::decodeNextcloudAccountRegistryResult) + val persisted = (decoded as? NextcloudAccountRegistryDecodeResult.Valid)?.registry if (persisted != null) { val legacyAccount = legacySession?.accountRecord() if (legacyAccount == null) { @@ -120,6 +126,17 @@ fun restoreNextcloudAccountRegistry( recoveryReason = NextcloudAccountRegistryRecoveryReason.ActiveSessionMismatch, ) } + if (decoded == NextcloudAccountRegistryDecodeResult.UnsupportedVersion) { + return RestoredNextcloudAccountRegistry( + registry = legacySession?.let(::singleAccountRegistry) ?: NextcloudAccountRegistry.Empty, + source = if (legacySession == null) { + NextcloudAccountRegistrySource.Empty + } else { + NextcloudAccountRegistrySource.LegacySession + }, + recoveryReason = NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion, + ) + } if (legacySession != null) { return RestoredNextcloudAccountRegistry( registry = singleAccountRegistry(legacySession), @@ -153,11 +170,20 @@ fun encodeNextcloudAccountRegistry(registry: NextcloudAccountRegistry): String = } } -fun decodeNextcloudAccountRegistry(encoded: String): NextcloudAccountRegistry? { - if (encoded.encodeToByteArray().size > MAX_ACCOUNT_REGISTRY_BYTES) return null +fun decodeNextcloudAccountRegistry(encoded: String): NextcloudAccountRegistry? = + (decodeNextcloudAccountRegistryResult(encoded) as? NextcloudAccountRegistryDecodeResult.Valid)?.registry + +private fun decodeNextcloudAccountRegistryResult(encoded: String): NextcloudAccountRegistryDecodeResult { + if (encoded.encodeToByteArray().size > MAX_ACCOUNT_REGISTRY_BYTES) { + return NextcloudAccountRegistryDecodeResult.Malformed + } + val version = runCatching { + accountRegistryJson.parseToJsonElement(encoded).jsonObject["version"]?.jsonPrimitive?.intOrNull + }.getOrNull() ?: return NextcloudAccountRegistryDecodeResult.Malformed + if (version > ACCOUNT_REGISTRY_VERSION) return NextcloudAccountRegistryDecodeResult.UnsupportedVersion + if (version != ACCOUNT_REGISTRY_VERSION) return NextcloudAccountRegistryDecodeResult.Malformed return runCatching { val persisted = accountRegistryJson.decodeFromString(encoded) - require(persisted.version == ACCOUNT_REGISTRY_VERSION) NextcloudAccountRegistry( accounts = persisted.accounts.map { account -> NextcloudAccountRecord( @@ -168,7 +194,18 @@ fun decodeNextcloudAccountRegistry(encoded: String): NextcloudAccountRegistry? { }, activeAccountId = persisted.activeAccountId?.let(::NextcloudAccountId), ) - }.getOrNull() + }.fold( + onSuccess = NextcloudAccountRegistryDecodeResult::Valid, + onFailure = { NextcloudAccountRegistryDecodeResult.Malformed }, + ) +} + +private sealed interface NextcloudAccountRegistryDecodeResult { + data class Valid(val registry: NextcloudAccountRegistry) : NextcloudAccountRegistryDecodeResult + + data object Malformed : NextcloudAccountRegistryDecodeResult + + data object UnsupportedVersion : NextcloudAccountRegistryDecodeResult } @Serializable 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 cb4f94a58..a14a6f35d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -1258,9 +1258,10 @@ private fun AuthenticatedApp( var lastOpenedAppId by remember(session) { mutableStateOf(services.loadLastOpenedAppId()) } val appPinsStorage = rememberHomeWorkspaceLayoutStorage() val appPinsRepository = remember(appPinsStorage) { AppWorkspacePinsRepository(appPinsStorage) } - val appPinsAccountScope = remember(session) { previewCacheDigest(session) } - val loadedAppPins = remember(appPinsAccountScope) { - appPinsRepository.loadWithProvenance(appPinsAccountScope) + val appPinsPersistenceScopes = remember(session) { accountPersistenceScopeDigests(session) } + val appPinsAccountScope = appPinsPersistenceScopes.current + val loadedAppPins = remember(appPinsPersistenceScopes) { + appPinsRepository.loadWithProvenance(appPinsAccountScope, appPinsPersistenceScopes.legacy) } var pinnedAppIds by remember(appPinsAccountScope) { mutableStateOf(loadedAppPins.appIds) } var appPinsStorageAuthoritative by remember(appPinsAccountScope) { 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 64032e4e4..ddaa23722 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt @@ -192,3 +192,16 @@ private fun previewCacheAccount(session: NextcloudSession): String { } internal expect fun previewCacheDigest(session: NextcloudSession): String + +internal data class AccountPersistenceScopeDigests( + val current: String, + val legacy: String?, +) + +internal fun accountPersistenceScopeDigests(session: NextcloudSession): AccountPersistenceScopeDigests { + val current = previewCacheDigest(session) + val legacy = legacyPreviewCacheDigest(session).takeUnless { digest -> digest == current } + return AccountPersistenceScopeDigests(current, legacy) +} + +internal expect fun legacyPreviewCacheDigest(session: NextcloudSession): String diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt index bd2db7470..63a28f8f6 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt @@ -21,6 +21,22 @@ class AppWorkspacePinsTest { assertTrue(storage.values.keys.single().endsWith(firstAccount)) } + @Test + fun `legacy account key is copied to the canonical key on first load`() { + val storage = MemoryStorage() + val repository = AppWorkspacePinsRepository(storage) + val current = "a".repeat(64) + val legacy = "b".repeat(64) + assertTrue(repository.save(legacy, listOf("files", "deck"))) + + val loaded = repository.loadWithProvenance(current, legacy) + + assertEquals(listOf("files", "deck"), loaded.appIds) + assertTrue(loaded.storageAuthoritative) + assertEquals(listOf("files", "deck"), repository.load(current)) + assertTrue(storage.values.keys.any { key -> key.endsWith(current) }) + } + @Test fun `pin toggles canonical aliases without duplicates`() { assertEquals(listOf("files", "spreed"), toggleAppWorkspacePin(listOf("files"), "talk")) 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 34774c2ba..3613ff25c 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt @@ -288,6 +288,25 @@ class HomeWorkspaceLayoutTest { ) } + @Test + fun `legacy account layout is rebound and copied to the canonical key`() { + val storage = RecordingHomeWorkspaceStorage() + val repository = HomeWorkspaceLayoutRepository(storage) + val legacyScope = scope(HomeFormFactor.Phone, digit = 'b') + val currentScope = scope(HomeFormFactor.Phone, digit = 'a') + val legacyLayout = defaultHomeWorkspaceLayout(legacyScope) + .hide(HomeSectionIds.PhotoBackup) + .resize(HomeSectionIds.Activity, HomeSectionSize.Dense) + assertTrue(repository.save(legacyLayout)) + + val loaded = repository.load(currentScope, legacyScope.accountScopeDigest) + + assertEquals(currentScope, loaded.scope) + assertEquals(legacyLayout.sections, loaded.sections) + assertEquals(loaded, repository.load(currentScope)) + assertEquals(currentScope.persistenceKey, storage.lastKey) + } + @Test fun `repository reports snapshot encoding failures without touching storage`() { val storage = RecordingHomeWorkspaceStorage() 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 a15652d97..ab5a1ceda 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt @@ -37,6 +37,25 @@ class NextcloudAccountIdentityTest { assertEquals(canonical.accountId, equivalent.accountId) } + @Test + fun canonicalIdentityRetainsThePreviousPersistenceDigestForMigration() { + val canonical = session() + val equivalent = canonical.copy(serverUrl = "HTTPS://CLOUD.EXAMPLE.TEST:443/Cloud/") + + assertEquals( + accountPersistenceScopeDigests(canonical).current, + accountPersistenceScopeDigests(equivalent).current, + ) + assertNotEquals( + legacyPreviewCacheDigest(canonical), + legacyPreviewCacheDigest(equivalent), + ) + assertEquals( + legacyPreviewCacheDigest(equivalent), + accountPersistenceScopeDigests(equivalent).legacy, + ) + } + @Test fun nonDefaultPortsRemainDifferentAccounts() { val canonical = session() diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt index 586a268cb..ce6d94ba4 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt @@ -87,6 +87,53 @@ class NextcloudAccountRegistryTest { assertEquals(session.accountRecord(), restored.registry.activeAccount) } + @Test + fun unsupportedRegistryVersionUsesLegacySessionWithoutOverwritingFutureData() { + val session = session("https://cloud.example.test", "alice", "private-app-password") + val futureRegistry = """ + { + "version": 2, + "activeAccount": "future-account", + "records": [{"future": true}] + } + """.trimIndent() + + val restored = restoreNextcloudAccountRegistry(futureRegistry, session) + + assertEquals(NextcloudAccountRegistrySource.LegacySession, restored.source) + assertEquals(NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion, restored.recoveryReason) + assertEquals(session.accountRecord(), restored.registry.activeAccount) + assertFalse(restored.needsPersistence) + } + + @Test + fun unsupportedRegistryVersionWithoutLegacyCredentialsRemainsUntouched() { + val futureRegistry = """{"version":99,"accounts":[]}""" + + val restored = restoreNextcloudAccountRegistry(futureRegistry, legacySession = null) + + assertEquals(NextcloudAccountRegistry.Empty, restored.registry) + assertEquals(NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion, restored.recoveryReason) + assertFalse(restored.needsPersistence) + } + + @Test + fun zeroAndNegativeRegistryVersionsRemainMalformedAndRepairable() { + val session = session("https://cloud.example.test", "alice", "private-app-password") + + listOf(0, -1).forEach { version -> + val restored = restoreNextcloudAccountRegistry( + encoded = """{"version":$version,"accounts":[]}""", + legacySession = session, + ) + + assertEquals(NextcloudAccountRegistrySource.LegacySession, restored.source) + assertEquals(NextcloudAccountRegistryRecoveryReason.MalformedRegistry, restored.recoveryReason) + assertEquals(session.accountRecord(), restored.registry.activeAccount) + assertTrue(restored.needsPersistence) + } + } + @Test fun staleActiveSelectionPreservesRecordsWithoutRebindingLegacyCredentials() { val legacy = session("https://one.example.test", "alice", "private-app-password") 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 6a10370ad..24d20eb4c 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt @@ -21,8 +21,8 @@ internal fun restoreDesktopAccountRegistry( } if (restored.needsPersistence) { runCatching { - preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodeNextcloudAccountRegistry(restored.registry)) - }.onFailure { + persistDesktopAccountRegistry(preferences, prepareDesktopAccountRegistry(restored.registry)) + }.onFailure { failure -> recordDiagnostic( SupportDiagnosticEventDraft( severity = SupportDiagnosticSeverity.Warning, @@ -30,6 +30,7 @@ internal fun restoreDesktopAccountRegistry( operation = "account-registry.migrate", outcome = "failed", code = "ACCOUNT_REGISTRY_MIGRATION_FAILED", + exception = failure.toNonSecretSupportDiagnosticExceptionDraft(), ), ) } @@ -37,10 +38,22 @@ internal fun restoreDesktopAccountRegistry( } internal fun persistDesktopAccountRegistry(preferences: Preferences, session: NextcloudSession) { - preferences.put( - DESKTOP_ACCOUNT_REGISTRY_KEY, - encodeNextcloudAccountRegistry(singleAccountRegistry(session)), - ) + persistDesktopAccountRegistry(preferences, prepareDesktopAccountRegistry(singleAccountRegistry(session))) +} + +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." + } + } + +internal fun persistDesktopAccountRegistry(preferences: Preferences, encodedRegistry: String) { + require(encodedRegistry.length <= Preferences.MAX_VALUE_LENGTH) + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, encodedRegistry) } internal fun clearDesktopAccountRegistry(preferences: Preferences) { 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 e1a2e6f0c..576f13409 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3685,9 +3685,9 @@ class DesktopNextcloudServices( } } } - 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 { @@ -3713,9 +3713,9 @@ class DesktopNextcloudServices( ) throw failure } + persistDesktopAccountRegistry(preferences, encodedRegistry) preferences.put(KEY_SERVER, session.serverUrl) preferences.put(KEY_LOGIN, session.loginName) - persistDesktopAccountRegistry(preferences, session) val accountIdentity = desktopFileCacheAccountId(session) supportDiagnostics.setActiveAccountIdentity(accountIdentity) supportIntake.setActiveAccountIdentity(accountIdentity) 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 e97a6319a..ff547f349 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt @@ -7,6 +7,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class DesktopAccountRegistryPersistenceTest { @@ -46,6 +47,56 @@ class DesktopAccountRegistryPersistenceTest { assertFalse(renderedDiagnostics.contains(session.appPassword)) } + @Test + fun unsupportedFutureRegistryIsReportedWithoutBeingOverwritten() = withPreferences { preferences -> + val session = session() + val diagnostics = mutableListOf() + val futureRegistry = """{"version":2,"futureAccounts":[{"id":"future"}]}""" + preferences.put(DESKTOP_ACCOUNT_REGISTRY_KEY, futureRegistry) + + restoreDesktopAccountRegistry(preferences, session, diagnostics::add) + + assertEquals(futureRegistry, preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + assertEquals(listOf("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), diagnostics.mapNotNull { it.code }) + } + + @Test + fun oversizedMigrationReportsABoundedCauseWithoutChangingPreferences() = withPreferences { preferences -> + val session = NextcloudSession( + serverUrl = "https://cloud.example.test/" + "a".repeat(8_050), + loginName = "alice", + appPassword = "private-app-password", + ) + val diagnostics = mutableListOf() + + restoreDesktopAccountRegistry(preferences, session, diagnostics::add) + + 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)) + } + + @Test + fun desktopValueLimitIsValidatedBeforeAnyMetadataWrite() = withPreferences { preferences -> + val session = NextcloudSession( + serverUrl = "https://cloud.example.test/" + "a".repeat(8_050), + loginName = "alice", + appPassword = "private-app-password", + ) + preferences.put("server", "existing-server") + preferences.put("login", "existing-login") + + assertFailsWith { prepareDesktopAccountRegistry(session) } + + assertEquals("existing-server", preferences.get("server", null)) + assertEquals("existing-login", preferences.get("login", null)) + assertNull(preferences.get(DESKTOP_ACCOUNT_REGISTRY_KEY, null)) + } + @Test fun explicitSaveAndRemovalOwnOnlyCredentialFreeMetadata() = withPreferences { preferences -> val session = session() diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt index 33651ef63..51413e711 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmSupportDiagnostics.kt @@ -522,6 +522,15 @@ fun Throwable.toSupportDiagnosticExceptionDraft( ?.toSupportDiagnosticExceptionDraft(depth + 1), ) +/** Bounded failure shape for persistence paths which may fail before secrets are registered. */ +fun Throwable.toNonSecretSupportDiagnosticExceptionDraft(): SupportDiagnosticExceptionDraft = + toSupportDiagnosticExceptionDraft().withoutMessages() + +private fun SupportDiagnosticExceptionDraft.withoutMessages(): SupportDiagnosticExceptionDraft = copy( + message = null, + cause = cause?.withoutMessages(), +) + internal fun SupportDiagnosticsEnvironment.safeForReport(): SupportDiagnosticsEnvironment = SupportDiagnosticsEnvironment( appVersion = appVersion.safeEnvironmentValue(), diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.jvm.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.jvm.kt index 9d7fc9202..be5261c49 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.jvm.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.jvm.kt @@ -13,6 +13,12 @@ internal actual fun deriveNextcloudAccountId(serverUrl: String, loginName: Strin internal actual fun previewCacheDigest(session: NextcloudSession): String = session.accountId.storageKey +internal actual fun legacyPreviewCacheDigest(session: NextcloudSession): String { + val identity = session.serverUrl.trimEnd('/') + "\u0000" + session.loginName + return MessageDigest.getInstance("SHA-256").digest(identity.encodeToByteArray()) + .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } +} + private fun canonicalAccountServerUrl(value: String): String { val url = value.trim().toHttpUrlOrNull() requireNotNull(url) { "The account server address is invalid." } From c732b2d785b9b18dac358890eefe100cd1445e33 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 21:49:36 +0200 Subject: [PATCH 04/24] fix(accounts): bound registry and layout recovery --- .../app/HomeWorkspacePersistence.kt | 9 +++- .../app/NextcloudAccountRegistry.kt | 12 ++++- .../app/HomeWorkspaceLayoutTest.kt | 46 ++++++++++++++++++- .../app/NextcloudAccountRegistryTest.kt | 45 ++++++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) 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 ce683381b..0bfb099b4 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt @@ -1,6 +1,7 @@ package dev.obiente.nextcloudnative.app import androidx.compose.runtime.Composable +import kotlinx.coroutines.CancellationException import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -17,7 +18,13 @@ internal class HomeWorkspaceLayoutRepository( ::encodeHomeWorkspaceLayoutSnapshot, ) { fun load(scope: HomeWorkspaceScope, legacyAccountScopeDigest: String? = null): HomeWorkspaceLayout { - val encoded = runCatching { storage.read(scope.persistenceKey) }.getOrNull() + val encoded = try { + storage.read(scope.persistenceKey) + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + return defaultHomeWorkspaceLayout(scope) + } if (encoded != null) return decodeHomeWorkspaceLayoutSnapshot(scope, encoded) val legacyScope = legacyAccountScopeDigest?.let { digest -> HomeWorkspaceScope(digest, scope.formFactor) 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 d652df83f..8ee82fe36 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt @@ -121,7 +121,7 @@ fun restoreNextcloudAccountRegistry( ) } return RestoredNextcloudAccountRegistry( - registry = persisted.upsertAndSelect(legacyAccount), + registry = persisted.reconcileLegacyActiveAccount(legacyAccount), source = NextcloudAccountRegistrySource.LegacySession, recoveryReason = NextcloudAccountRegistryRecoveryReason.ActiveSessionMismatch, ) @@ -151,6 +151,16 @@ fun restoreNextcloudAccountRegistry( ) } +private fun NextcloudAccountRegistry.reconcileLegacyActiveAccount( + legacyAccount: NextcloudAccountRecord, +): NextcloudAccountRegistry { + if (accounts.any { account -> account.id == legacyAccount.id } || accounts.size < MAX_LOCAL_ACCOUNTS) { + return upsertAndSelect(legacyAccount) + } + val displacedId = activeAccountId ?: accounts.maxBy { account -> account.id.storageKey }.id + return remove(displacedId).upsertAndSelect(legacyAccount) +} + fun encodeNextcloudAccountRegistry(registry: NextcloudAccountRegistry): String = accountRegistryJson.encodeToString( PersistedNextcloudAccountRegistry( 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 3613ff25c..cd7addf53 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt @@ -2,6 +2,7 @@ package dev.obiente.nextcloudnative.app import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import kotlinx.coroutines.CancellationException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -307,6 +308,42 @@ class HomeWorkspaceLayoutTest { assertEquals(currentScope.persistenceKey, storage.lastKey) } + @Test + fun `failed canonical read never overwrites it from a stale legacy layout`() { + val storage = RecordingHomeWorkspaceStorage() + val repository = HomeWorkspaceLayoutRepository(storage) + val legacyScope = scope(HomeFormFactor.Phone, digit = 'b') + val currentScope = scope(HomeFormFactor.Phone, digit = 'a') + val legacyLayout = defaultHomeWorkspaceLayout(legacyScope).hide(HomeSectionIds.PhotoBackup) + val canonicalLayout = defaultHomeWorkspaceLayout(currentScope).hide(HomeSectionIds.Activity) + assertTrue(repository.save(canonicalLayout)) + val canonicalValue = storage.value(currentScope.persistenceKey) + assertTrue(repository.save(legacyLayout)) + storage.failedReadKey = currentScope.persistenceKey + + val loaded = repository.load(currentScope, legacyScope.accountScopeDigest) + + assertEquals(defaultHomeWorkspaceLayout(currentScope), loaded) + assertEquals(legacyScope.persistenceKey, storage.lastKey) + assertEquals(canonicalValue, storage.value(currentScope.persistenceKey)) + storage.failedReadKey = null + assertEquals(canonicalLayout, repository.load(currentScope)) + } + + @Test + fun `canonical read cancellation remains control flow`() { + val storage = RecordingHomeWorkspaceStorage() + val repository = HomeWorkspaceLayoutRepository(storage) + val currentScope = scope(HomeFormFactor.Phone, digit = 'a') + storage.failedReadKey = currentScope.persistenceKey + storage.readFailure = CancellationException("synthetic cancellation") + + assertFailsWith { + repository.load(currentScope, legacyAccountScopeDigest = "b".repeat(64)) + } + assertEquals(null, storage.lastKey) + } + @Test fun `repository reports snapshot encoding failures without touching storage`() { val storage = RecordingHomeWorkspaceStorage() @@ -361,13 +398,20 @@ class HomeWorkspaceLayoutTest { private val values = mutableMapOf() var lastKey: String? = null var lastValue: String? = null + var failedReadKey: String? = null + var readFailure: Throwable = IllegalStateException("synthetic canonical read failure") - override fun read(persistenceKey: String): String? = values[persistenceKey] + override fun read(persistenceKey: String): String? { + if (persistenceKey == failedReadKey) throw readFailure + return values[persistenceKey] + } override fun write(persistenceKey: String, encodedSnapshot: String) { lastKey = persistenceKey lastValue = encodedSnapshot values[persistenceKey] = encodedSnapshot } + + fun value(persistenceKey: String): String? = values[persistenceKey] } } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt index ce6d94ba4..a66ec211b 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt @@ -151,6 +151,51 @@ class NextcloudAccountRegistryTest { ) } + @Test + fun fullRegistryReplacesTheStaleActiveRecordWithTheLegacySession() { + val legacy = session("https://legacy.example.test", "alice", "private-app-password") + val persistedAccounts = (0 until MAX_LOCAL_ACCOUNTS).map { index -> + session("https://account-$index.example.test", "user-$index", "secret-$index").accountRecord() + } + val staleActive = persistedAccounts[17] + val encoded = encodeNextcloudAccountRegistry( + NextcloudAccountRegistry(persistedAccounts, staleActive.id), + ) + + val restored = restoreNextcloudAccountRegistry(encoded, legacy) + + assertEquals(NextcloudAccountRegistryRecoveryReason.ActiveSessionMismatch, restored.recoveryReason) + assertEquals(legacy.accountId, restored.registry.activeAccountId) + assertEquals(MAX_LOCAL_ACCOUNTS, restored.registry.accounts.size) + assertTrue(legacy.accountRecord() in restored.registry.accounts) + assertFalse(staleActive in restored.registry.accounts) + assertTrue(restored.needsPersistence) + } + + @Test + fun fullUnselectedRegistryUsesADeterministicBoundedReplacement() { + val legacy = session("https://legacy.example.test", "alice", "private-app-password") + val persistedAccounts = (0 until MAX_LOCAL_ACCOUNTS).map { index -> + session("https://account-$index.example.test", "user-$index", "secret-$index").accountRecord() + } + val displaced = persistedAccounts.maxBy { account -> account.id.storageKey } + val forward = encodeNextcloudAccountRegistry( + NextcloudAccountRegistry(persistedAccounts, activeAccountId = null), + ) + val reverse = encodeNextcloudAccountRegistry( + NextcloudAccountRegistry(persistedAccounts.reversed(), activeAccountId = null), + ) + + val restoredForward = restoreNextcloudAccountRegistry(forward, legacy) + val restoredReverse = restoreNextcloudAccountRegistry(reverse, legacy) + + assertEquals(restoredForward, restoredReverse) + assertEquals(MAX_LOCAL_ACCOUNTS, restoredForward.registry.accounts.size) + assertEquals(legacy.accountId, restoredForward.registry.activeAccountId) + assertTrue(legacy.accountRecord() in restoredForward.registry.accounts) + assertFalse(displaced in restoredForward.registry.accounts) + } + @Test fun removalDoesNotSilentlySelectAnotherAccount() { val first = session("https://one.example.test", "alice", "first-secret") From 8d8ab82a93af7eb20bfcf1706c5a94811f571db7 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Tue, 1 Sep 2026 22:21:43 +0200 Subject: [PATCH 05/24] test(accounts): expose registry capacity to tests --- .../dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8ee82fe36..f4d7727c2 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt @@ -238,7 +238,7 @@ private val accountRegistryJson = Json { } private const val ACCOUNT_REGISTRY_VERSION = 1 -private const val MAX_LOCAL_ACCOUNTS = 64 +internal const val MAX_LOCAL_ACCOUNTS = 64 private const val MAX_ACCOUNT_REGISTRY_BYTES = 256 * 1024 private const val MAX_ACCOUNT_SERVER_URL_LENGTH = 8 * 1024 private const val MAX_ACCOUNT_LOGIN_NAME_LENGTH = 1024 From c1ddf79a206ac572e6b22511f635d91344520ca4 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 02:49:44 +0200 Subject: [PATCH 06/24] fix(accounts): preserve registry recovery state --- .../app/NextcloudAccountRegistry.kt | 22 +++++++++++-- .../app/NextcloudAccountRegistryTest.kt | 32 +++++++++++++++++-- 2 files changed, 50 insertions(+), 4 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 f4d7727c2..09f63fdf2 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt @@ -157,7 +157,10 @@ private fun NextcloudAccountRegistry.reconcileLegacyActiveAccount( if (accounts.any { account -> account.id == legacyAccount.id } || accounts.size < MAX_LOCAL_ACCOUNTS) { return upsertAndSelect(legacyAccount) } - val displacedId = activeAccountId ?: accounts.maxBy { account -> account.id.storageKey }.id + val displacedId = accounts + .filterNot { account -> account.id == activeAccountId } + .maxBy { account -> account.id.storageKey } + .id return remove(displacedId).upsertAndSelect(legacyAccount) } @@ -184,8 +187,20 @@ fun decodeNextcloudAccountRegistry(encoded: String): NextcloudAccountRegistry? = (decodeNextcloudAccountRegistryResult(encoded) as? NextcloudAccountRegistryDecodeResult.Valid)?.registry private fun decodeNextcloudAccountRegistryResult(encoded: String): NextcloudAccountRegistryDecodeResult { + val envelopeVersion = accountRegistryVersionEnvelope + .find(encoded.take(MAX_ACCOUNT_REGISTRY_VERSION_ENVELOPE_CHARACTERS)) + ?.groupValues + ?.get(1) + ?.toIntOrNull() + if (envelopeVersion != null && envelopeVersion > ACCOUNT_REGISTRY_VERSION) { + return NextcloudAccountRegistryDecodeResult.UnsupportedVersion + } if (encoded.encodeToByteArray().size > MAX_ACCOUNT_REGISTRY_BYTES) { - return NextcloudAccountRegistryDecodeResult.Malformed + return if (envelopeVersion != null) { + NextcloudAccountRegistryDecodeResult.Malformed + } else { + NextcloudAccountRegistryDecodeResult.UnsupportedVersion + } } val version = runCatching { accountRegistryJson.parseToJsonElement(encoded).jsonObject["version"]?.jsonPrimitive?.intOrNull @@ -237,8 +252,11 @@ private val accountRegistryJson = Json { explicitNulls = false } +private val accountRegistryVersionEnvelope = Regex("""\A\s*\{\s*"version"\s*:\s*(-?\d+)""") + private const val ACCOUNT_REGISTRY_VERSION = 1 internal const val MAX_LOCAL_ACCOUNTS = 64 private const val MAX_ACCOUNT_REGISTRY_BYTES = 256 * 1024 +private const val MAX_ACCOUNT_REGISTRY_VERSION_ENVELOPE_CHARACTERS = 512 private const val MAX_ACCOUNT_SERVER_URL_LENGTH = 8 * 1024 private const val MAX_ACCOUNT_LOGIN_NAME_LENGTH = 1024 diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt index a66ec211b..b87f993a3 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt @@ -117,6 +117,30 @@ class NextcloudAccountRegistryTest { assertFalse(restored.needsPersistence) } + @Test + fun oversizedFutureRegistryRemainsUntouchedAfterBoundedVersionInspection() { + val session = session("https://cloud.example.test", "alice", "private-app-password") + val futureRegistry = """{"version":2,"future":"${"x".repeat(300 * 1024)}"}""" + + val restored = restoreNextcloudAccountRegistry(futureRegistry, session) + + assertEquals(NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion, restored.recoveryReason) + assertEquals(session.accountRecord(), restored.registry.activeAccount) + assertFalse(restored.needsPersistence) + } + + @Test + fun oversizedCurrentRegistryStillUsesTheVersionSpecificSizeLimit() { + val session = session("https://cloud.example.test", "alice", "private-app-password") + val oversizedRegistry = """{"version":1,"padding":"${"x".repeat(300 * 1024)}"}""" + + val restored = restoreNextcloudAccountRegistry(oversizedRegistry, session) + + assertEquals(NextcloudAccountRegistryRecoveryReason.MalformedRegistry, restored.recoveryReason) + assertEquals(session.accountRecord(), restored.registry.activeAccount) + assertTrue(restored.needsPersistence) + } + @Test fun zeroAndNegativeRegistryVersionsRemainMalformedAndRepairable() { val session = session("https://cloud.example.test", "alice", "private-app-password") @@ -152,12 +176,15 @@ class NextcloudAccountRegistryTest { } @Test - fun fullRegistryReplacesTheStaleActiveRecordWithTheLegacySession() { + fun fullRegistryPreservesTheStaleActiveRecordWhileSelectingTheLegacySession() { val legacy = session("https://legacy.example.test", "alice", "private-app-password") val persistedAccounts = (0 until MAX_LOCAL_ACCOUNTS).map { index -> session("https://account-$index.example.test", "user-$index", "secret-$index").accountRecord() } val staleActive = persistedAccounts[17] + val displaced = persistedAccounts + .filterNot { account -> account.id == staleActive.id } + .maxBy { account -> account.id.storageKey } val encoded = encodeNextcloudAccountRegistry( NextcloudAccountRegistry(persistedAccounts, staleActive.id), ) @@ -168,7 +195,8 @@ class NextcloudAccountRegistryTest { assertEquals(legacy.accountId, restored.registry.activeAccountId) assertEquals(MAX_LOCAL_ACCOUNTS, restored.registry.accounts.size) assertTrue(legacy.accountRecord() in restored.registry.accounts) - assertFalse(staleActive in restored.registry.accounts) + assertTrue(staleActive in restored.registry.accounts) + assertFalse(displaced in restored.registry.accounts) assertTrue(restored.needsPersistence) } From 799f5c13c0076ff363afec8f42d9e3a248279bc9 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 05:24:33 +0200 Subject: [PATCH 07/24] fix(accounts): validate persisted identity boundaries --- .../app/HomeWorkspacePersistence.kt | 9 +++++++-- .../app/NextcloudAccountRegistry.kt | 2 +- .../app/HomeWorkspaceLayoutTest.kt | 15 +++++++++++++++ .../app/JvmLoginFlowHttpPolicyTest.kt | 17 +++++++++++++++++ .../app/JvmLoginFlowHttpPolicy.kt | 1 + 5 files changed, 41 insertions(+), 3 deletions(-) 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 0bfb099b4..5a9e354f1 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt @@ -29,8 +29,13 @@ internal class HomeWorkspaceLayoutRepository( val legacyScope = legacyAccountScopeDigest?.let { digest -> HomeWorkspaceScope(digest, scope.formFactor) } ?: return defaultHomeWorkspaceLayout(scope) - val legacyEncoded = runCatching { storage.read(legacyScope.persistenceKey) }.getOrNull() - ?: return defaultHomeWorkspaceLayout(scope) + val legacyEncoded = try { + storage.read(legacyScope.persistenceKey) + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + null + } ?: return defaultHomeWorkspaceLayout(scope) val legacyLayout = decodeHomeWorkspaceLayoutSnapshot(legacyScope, legacyEncoded) val migrated = HomeWorkspaceLayout(scope, legacyLayout.sections) save(migrated) 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 09f63fdf2..6ec98548e 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt @@ -259,4 +259,4 @@ internal const val MAX_LOCAL_ACCOUNTS = 64 private const val MAX_ACCOUNT_REGISTRY_BYTES = 256 * 1024 private const val MAX_ACCOUNT_REGISTRY_VERSION_ENVELOPE_CHARACTERS = 512 private const val MAX_ACCOUNT_SERVER_URL_LENGTH = 8 * 1024 -private const val MAX_ACCOUNT_LOGIN_NAME_LENGTH = 1024 +internal const val MAX_ACCOUNT_LOGIN_NAME_LENGTH = 1024 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 cd7addf53..f2263ac98 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt @@ -344,6 +344,21 @@ class HomeWorkspaceLayoutTest { assertEquals(null, storage.lastKey) } + @Test + fun `legacy read cancellation remains control flow`() { + val storage = RecordingHomeWorkspaceStorage() + val repository = HomeWorkspaceLayoutRepository(storage) + val currentScope = scope(HomeFormFactor.Phone, digit = 'a') + val legacyScope = scope(HomeFormFactor.Phone, digit = 'b') + storage.failedReadKey = legacyScope.persistenceKey + storage.readFailure = CancellationException("synthetic legacy cancellation") + + assertFailsWith { + repository.load(currentScope, legacyAccountScopeDigest = legacyScope.accountScopeDigest) + } + assertEquals(null, storage.lastKey) + } + @Test fun `repository reports snapshot encoding failures without touching storage`() { val storage = RecordingHomeWorkspaceStorage() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicyTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicyTest.kt index 8620d9f58..efc804a91 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicyTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicyTest.kt @@ -106,6 +106,23 @@ class JvmLoginFlowHttpPolicyTest { assertNull(interpretation.approvedLoginName) } + @Test + fun `oversized login name is rejected before account registry construction`() { + val interpretation = interpretLoginPollHttpResponse( + status = 200, + body = """{ + "server": "https://cloud.example.test", + "loginName": "${"x".repeat(MAX_ACCOUNT_LOGIN_NAME_LENGTH + 1)}", + "appPassword": "private-app-password" + }""".trimIndent(), + challenge = challenge(), + ) + + val failure = assertIs(interpretation.result) + assertEquals("LOGIN_POLL_RESPONSE_INVALID", failure.code) + assertNull(interpretation.approvedLoginName) + } + @Test fun `fallback diagnostic distinguishes a routed 404 from a pre exchange failure`() { val routed = loginPollEndpointFallbackDiagnostic( diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt index 0ddcc74af..e14a5120d 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt @@ -83,6 +83,7 @@ fun interpretLoginPollHttpResponse( val loginName = json.getString("loginName") val appPassword = json.getString("appPassword") require(loginName.isNotEmpty()) { "The login name is empty." } + require(loginName.length <= MAX_ACCOUNT_LOGIN_NAME_LENGTH) { "The login name is too long." } require(appPassword.isNotEmpty()) { "The app password is empty." } LoginPollHttpInterpretation( result = LoginPollResult.Approved(NextcloudSession(resultServerUrl, loginName, appPassword)), From 1a2f05fe9127da9d55df68d77d85d5bfece26bb3 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 07:26:09 +0200 Subject: [PATCH 08/24] fix(workspaces): defer legacy preference migration --- .../nextcloudnative/app/AppWorkspacePins.kt | 43 ++++++++++++------ .../app/DashboardStatusScreens.kt | 8 ++-- .../app/HomeWorkspacePersistence.kt | 32 ++++++++++---- .../nextcloudnative/app/NextcloudNativeApp.kt | 6 +-- .../app/WorkspaceLegacyMigrationEffects.kt | 44 +++++++++++++++++++ .../app/AppWorkspacePinsTest.kt | 25 +++++++++-- .../app/HomeWorkspaceLayoutTest.kt | 13 +++--- 7 files changed, 134 insertions(+), 37 deletions(-) create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt index 792c618e5..c852c9572 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative.app +import kotlinx.coroutines.CancellationException import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -14,6 +15,7 @@ internal data class AppWorkspacePinsSnapshot( internal data class AppWorkspacePinsLoad( val appIds: List, val storageAuthoritative: Boolean, + val legacyMigrationRequired: Boolean = false, ) internal class AppWorkspacePinsRepository( @@ -27,40 +29,53 @@ internal class AppWorkspacePinsRepository( accountScopeDigest: String, legacyAccountScopeDigest: String? = null, ): AppWorkspacePinsLoad { - val read = runCatching { storage.read(persistenceKey(accountScopeDigest)) } - if (read.isFailure) { + val encoded = try { + storage.read(persistenceKey(accountScopeDigest)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { return AppWorkspacePinsLoad(defaultAppWorkspacePinnedIds(), storageAuthoritative = false) } - var migratedFromLegacy = false - val encoded = read.getOrNull() ?: legacyAccountScopeDigest?.let { legacyScope -> - val legacyRead = runCatching { storage.read(persistenceKey(legacyScope)) } - if (legacyRead.isFailure) { + var legacyMigrationRequired = false + val persisted = encoded ?: legacyAccountScopeDigest?.let { legacyScope -> + try { + storage.read(persistenceKey(legacyScope)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { return AppWorkspacePinsLoad(defaultAppWorkspacePinnedIds(), storageAuthoritative = false) - } - legacyRead.getOrNull()?.also { migratedFromLegacy = true } + }?.also { legacyMigrationRequired = true } } ?: return AppWorkspacePinsLoad(defaultAppWorkspacePinnedIds(), storageAuthoritative = true) - if (encoded.length !in 1..MAX_APP_WORKSPACE_PINS_CHARACTERS) { + if (persisted.length !in 1..MAX_APP_WORKSPACE_PINS_CHARACTERS) { return AppWorkspacePinsLoad(defaultAppWorkspacePinnedIds(), storageAuthoritative = true) } val snapshot = runCatching { - appWorkspacePinsJson.decodeFromString(encoded) + appWorkspacePinsJson.decodeFromString(persisted) }.getOrNull() ?: return AppWorkspacePinsLoad(defaultAppWorkspacePinnedIds(), storageAuthoritative = true) if (snapshot.schemaVersion != APP_WORKSPACE_PINS_SCHEMA_VERSION) { return AppWorkspacePinsLoad(defaultAppWorkspacePinnedIds(), storageAuthoritative = true) } val appIds = validatedAppWorkspacePinnedIds(snapshot.appIds) ?: defaultAppWorkspacePinnedIds() - val migrated = !migratedFromLegacy || save(accountScopeDigest, appIds) - return AppWorkspacePinsLoad(appIds, storageAuthoritative = migrated) + return AppWorkspacePinsLoad( + appIds = appIds, + storageAuthoritative = !legacyMigrationRequired, + legacyMigrationRequired = legacyMigrationRequired, + ) } fun save(accountScopeDigest: String, appIds: List): Boolean { val validated = validatedAppWorkspacePinnedIds(appIds) ?: return false - return runCatching { + return try { val encoded = appWorkspacePinsJson.encodeToString(AppWorkspacePinsSnapshot(appIds = validated)) check(encoded.length <= MAX_APP_WORKSPACE_PINS_CHARACTERS) storage.write(persistenceKey(accountScopeDigest), encoded) - }.isSuccess + true + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + false + } } private fun persistenceKey(accountScopeDigest: String): String { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt index b8db29789..5474e1368 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt @@ -74,11 +74,11 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext import kotlin.time.Clock internal sealed interface DashboardSurfaceState { @@ -240,9 +240,9 @@ internal fun NativeDashboardScreen( formFactor = formFactor, ) } - var workspaceLayout by remember(workspaceScope, workspacePersistenceScopes.legacy) { - mutableStateOf(workspaceRepository.load(workspaceScope, workspacePersistenceScopes.legacy)) - } + var workspaceLayout by rememberMigratedHomeWorkspaceLayout( + workspaceRepository, workspaceScope, workspacePersistenceScopes.legacy, + ) NativeDashboardPresentation( state = state, 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 5a9e354f1..21bcd5df4 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt @@ -12,34 +12,45 @@ internal interface HomeWorkspaceLayoutStorage { fun write(persistenceKey: String, encodedSnapshot: String) } +internal data class HomeWorkspaceLayoutLoad( + val layout: HomeWorkspaceLayout, + val legacyMigrationRequired: Boolean = false, +) + internal class HomeWorkspaceLayoutRepository( private val storage: HomeWorkspaceLayoutStorage, private val encodeSnapshot: (HomeWorkspaceLayout) -> String = ::encodeHomeWorkspaceLayoutSnapshot, ) { fun load(scope: HomeWorkspaceScope, legacyAccountScopeDigest: String? = null): HomeWorkspaceLayout { + return loadWithMigration(scope, legacyAccountScopeDigest).layout + } + + fun loadWithMigration( + scope: HomeWorkspaceScope, + legacyAccountScopeDigest: String? = null, + ): HomeWorkspaceLayoutLoad { val encoded = try { storage.read(scope.persistenceKey) } catch (failure: CancellationException) { throw failure } catch (_: Exception) { - return defaultHomeWorkspaceLayout(scope) + return HomeWorkspaceLayoutLoad(defaultHomeWorkspaceLayout(scope)) } - if (encoded != null) return decodeHomeWorkspaceLayoutSnapshot(scope, encoded) + if (encoded != null) return HomeWorkspaceLayoutLoad(decodeHomeWorkspaceLayoutSnapshot(scope, encoded)) val legacyScope = legacyAccountScopeDigest?.let { digest -> HomeWorkspaceScope(digest, scope.formFactor) - } ?: return defaultHomeWorkspaceLayout(scope) + } ?: return HomeWorkspaceLayoutLoad(defaultHomeWorkspaceLayout(scope)) val legacyEncoded = try { storage.read(legacyScope.persistenceKey) } catch (failure: CancellationException) { throw failure } catch (_: Exception) { null - } ?: return defaultHomeWorkspaceLayout(scope) + } ?: return HomeWorkspaceLayoutLoad(defaultHomeWorkspaceLayout(scope)) val legacyLayout = decodeHomeWorkspaceLayoutSnapshot(legacyScope, legacyEncoded) val migrated = HomeWorkspaceLayout(scope, legacyLayout.sections) - save(migrated) - return migrated + return HomeWorkspaceLayoutLoad(migrated, legacyMigrationRequired = true) } /** @@ -47,10 +58,15 @@ internal class HomeWorkspaceLayoutRepository( * without crashing or pretending it was durably saved. */ fun save(layout: HomeWorkspaceLayout): Boolean { - return runCatching { + return try { val encoded = encodeSnapshot(layout) storage.write(layout.scope.persistenceKey, encoded) - }.isSuccess + true + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + false + } } } 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 a14a6f35d..390a65f93 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -1264,9 +1264,9 @@ private fun AuthenticatedApp( appPinsRepository.loadWithProvenance(appPinsAccountScope, appPinsPersistenceScopes.legacy) } var pinnedAppIds by remember(appPinsAccountScope) { mutableStateOf(loadedAppPins.appIds) } - var appPinsStorageAuthoritative by remember(appPinsAccountScope) { - mutableStateOf(loadedAppPins.storageAuthoritative) - } + var appPinsStorageAuthoritative by rememberMigratedAppPinsAuthority( + appPinsRepository, appPinsAccountScope, loadedAppPins, + ) var appPinsPersistenceError by remember(appPinsAccountScope) { mutableStateOf(null) } val togglePinnedApp: (String) -> String? = togglePinnedApp@{ appId -> if (!appPinsStorageAuthoritative) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt new file mode 100644 index 000000000..246a7b064 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt @@ -0,0 +1,44 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +internal fun rememberMigratedAppPinsAuthority( + repository: AppWorkspacePinsRepository, + accountScopeDigest: String, + loaded: AppWorkspacePinsLoad, +): MutableState { + val authoritative = remember(accountScopeDigest) { mutableStateOf(loaded.storageAuthoritative) } + LaunchedEffect(accountScopeDigest, loaded.legacyMigrationRequired) { + if (loaded.legacyMigrationRequired) { + authoritative.value = withContext(Dispatchers.Default) { + repository.save(accountScopeDigest, loaded.appIds) + } + } + } + return authoritative +} + +@Composable +internal fun rememberMigratedHomeWorkspaceLayout( + repository: HomeWorkspaceLayoutRepository, + scope: HomeWorkspaceScope, + legacyAccountScopeDigest: String?, +): MutableState { + val loaded = remember(scope, legacyAccountScopeDigest) { + repository.loadWithMigration(scope, legacyAccountScopeDigest) + } + val layout = remember(scope, legacyAccountScopeDigest) { mutableStateOf(loaded.layout) } + LaunchedEffect(scope, loaded.legacyMigrationRequired) { + if (loaded.legacyMigrationRequired) { + withContext(Dispatchers.Default) { repository.save(loaded.layout) } + } + } + return layout +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt index 63a28f8f6..0b77a7d55 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt @@ -1,5 +1,6 @@ package dev.obiente.nextcloudnative.app +import kotlinx.coroutines.CancellationException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -22,7 +23,7 @@ class AppWorkspacePinsTest { } @Test - fun `legacy account key is copied to the canonical key on first load`() { + fun `legacy account key returns a migration plan without writing during load`() { val storage = MemoryStorage() val repository = AppWorkspacePinsRepository(storage) val current = "a".repeat(64) @@ -32,11 +33,29 @@ class AppWorkspacePinsTest { val loaded = repository.loadWithProvenance(current, legacy) assertEquals(listOf("files", "deck"), loaded.appIds) - assertTrue(loaded.storageAuthoritative) - assertEquals(listOf("files", "deck"), repository.load(current)) + assertFalse(loaded.storageAuthoritative) + assertTrue(loaded.legacyMigrationRequired) + assertEquals(defaultAppWorkspacePinnedIds(), repository.load(current)) + assertTrue(repository.save(current, loaded.appIds)) assertTrue(storage.values.keys.any { key -> key.endsWith(current) }) } + @Test + fun `legacy pin read cancellation remains control flow`() { + val current = "e".repeat(64) + val legacy = "f".repeat(64) + val repository = AppWorkspacePinsRepository(object : HomeWorkspaceLayoutStorage { + override fun read(persistenceKey: String): String? = + if (persistenceKey.endsWith(current)) null else throw CancellationException("synthetic cancellation") + + override fun write(persistenceKey: String, encodedSnapshot: String) = Unit + }) + + assertFailsWith { + repository.loadWithProvenance(current, legacy) + } + } + @Test fun `pin toggles canonical aliases without duplicates`() { assertEquals(listOf("files", "spreed"), toggleAppWorkspacePin(listOf("files"), "talk")) 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 f2263ac98..002ac034e 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt @@ -290,7 +290,7 @@ class HomeWorkspaceLayoutTest { } @Test - fun `legacy account layout is rebound and copied to the canonical key`() { + fun `legacy account layout returns a migration plan without writing during load`() { val storage = RecordingHomeWorkspaceStorage() val repository = HomeWorkspaceLayoutRepository(storage) val legacyScope = scope(HomeFormFactor.Phone, digit = 'b') @@ -300,11 +300,14 @@ class HomeWorkspaceLayoutTest { .resize(HomeSectionIds.Activity, HomeSectionSize.Dense) assertTrue(repository.save(legacyLayout)) - val loaded = repository.load(currentScope, legacyScope.accountScopeDigest) + val loaded = repository.loadWithMigration(currentScope, legacyScope.accountScopeDigest) - assertEquals(currentScope, loaded.scope) - assertEquals(legacyLayout.sections, loaded.sections) - assertEquals(loaded, repository.load(currentScope)) + assertEquals(currentScope, loaded.layout.scope) + assertEquals(legacyLayout.sections, loaded.layout.sections) + assertTrue(loaded.legacyMigrationRequired) + assertEquals(defaultHomeWorkspaceLayout(currentScope), repository.load(currentScope)) + assertTrue(repository.save(loaded.layout)) + assertEquals(loaded.layout, repository.load(currentScope)) assertEquals(currentScope.persistenceKey, storage.lastKey) } From bf1ab52b34ed92df4c9721886afbf347ead54d72 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 08:32:57 +0200 Subject: [PATCH 09/24] fix(auth): defer and bound account migrations --- .../app/HomeWorkspacePersistence.android.kt | 19 +++++++- .../app/HomeWorkspacePersistence.kt | 18 ++++++++ .../app/NextcloudAccountRegistry.kt | 2 +- .../nextcloudnative/app/NextcloudNativeApp.kt | 13 +++--- .../app/NextcloudSessionLoading.kt | 44 +++++++++++++++++++ .../app/WorkspaceLegacyMigrationEffects.kt | 2 +- .../app/HomeWorkspaceLayoutTest.kt | 22 ++++++++++ .../app/NextcloudSessionLoadingTest.kt | 22 ++++++++++ .../app/HomeWorkspacePersistence.desktop.kt | 19 +++++++- .../app/JvmLoginFlowHttpPolicyTest.kt | 17 +++++++ .../app/JvmLoginFlowHttpPolicy.kt | 1 + 11 files changed, 166 insertions(+), 13 deletions(-) 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 e2ba86824..cc9217617 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 @@ -20,10 +20,24 @@ internal actual fun rememberHomeWorkspaceLayoutStorage(): HomeWorkspaceLayoutSto preferences.getString(persistenceKey, null) override fun write(persistenceKey: String, encodedSnapshot: String) { - check(preferences.edit().putString(persistenceKey, encodedSnapshot).commit()) { - "The home workspace could not be persisted." + synchronized(ANDROID_HOME_WORKSPACE_STORAGE_LOCK) { + check(preferences.edit().putString(persistenceKey, encodedSnapshot).commit()) { + "The home workspace could not be persisted." + } } } + + override fun writeIfAbsent(persistenceKey: String, encodedSnapshot: String): Boolean = + synchronized(ANDROID_HOME_WORKSPACE_STORAGE_LOCK) { + if (preferences.contains(persistenceKey)) { + false + } else { + check(preferences.edit().putString(persistenceKey, encodedSnapshot).commit()) { + "The home workspace could not be persisted." + } + true + } + } } } } @@ -45,3 +59,4 @@ internal actual fun rememberHomeFormFactor(): HomeFormFactor { private const val HOME_WORKSPACE_PREFERENCES = "nextcloud_native_home_workspace" private const val TABLET_SMALLEST_WIDTH_DP = 600 +private val ANDROID_HOME_WORKSPACE_STORAGE_LOCK = Any() 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 21bcd5df4..8c1f0387b 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt @@ -10,6 +10,12 @@ internal interface HomeWorkspaceLayoutStorage { fun read(persistenceKey: String): String? fun write(persistenceKey: String, encodedSnapshot: String) + + fun writeIfAbsent(persistenceKey: String, encodedSnapshot: String): Boolean { + if (read(persistenceKey) != null) return false + write(persistenceKey, encodedSnapshot) + return true + } } internal data class HomeWorkspaceLayoutLoad( @@ -68,6 +74,18 @@ internal class HomeWorkspaceLayoutRepository( false } } + + /** Promotes a legacy layout only while the canonical key is still absent. */ + fun saveIfAbsent(layout: HomeWorkspaceLayout): Boolean { + return try { + val encoded = encodeSnapshot(layout) + storage.writeIfAbsent(layout.scope.persistenceKey, encoded) + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + false + } + } } internal fun encodeHomeWorkspaceLayoutSnapshot(layout: HomeWorkspaceLayout): String = 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 6ec98548e..e479d0402 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt @@ -258,5 +258,5 @@ private const val ACCOUNT_REGISTRY_VERSION = 1 internal const val MAX_LOCAL_ACCOUNTS = 64 private const val MAX_ACCOUNT_REGISTRY_BYTES = 256 * 1024 private const val MAX_ACCOUNT_REGISTRY_VERSION_ENVELOPE_CHARACTERS = 512 -private const val MAX_ACCOUNT_SERVER_URL_LENGTH = 8 * 1024 +internal const val MAX_ACCOUNT_SERVER_URL_LENGTH = 8 * 1024 internal const val MAX_ACCOUNT_LOGIN_NAME_LENGTH = 1024 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 390a65f93..5f4c3d38f 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -535,12 +535,9 @@ fun NextcloudNativeApp( NextcloudNativeTheme(darkTheme = darkTheme) { NextcloudAppBackground { var sessionLoadAttempt by remember { mutableStateOf(0) } - val sessionLoad = remember(services, sessionLoadAttempt) { - loadNextcloudSessionSafely(services::loadSession) - } - var session by remember(services, sessionLoadAttempt) { - mutableStateOf((sessionLoad as? NextcloudSessionLoadState.Loaded)?.session) - } + val sessionComposition = rememberNextcloudSessionCompositionState(services, sessionLoadAttempt) + val sessionLoad = sessionComposition.loadState + var session by sessionComposition.session val signInAgain = { scope.launch { try { @@ -554,7 +551,9 @@ fun NextcloudNativeApp( } Unit } - if (sessionLoad == NextcloudSessionLoadState.SecureStorageUnavailable) { + if (sessionLoad == null) { + LoadingMessage("Loading account") + } else if (sessionLoad == NextcloudSessionLoadState.SecureStorageUnavailable) { SecureSessionStorageUnavailable( onRetry = { sessionLoadAttempt += 1 }, onSignInAgain = signInAgain, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt index 189cd6118..9c90dfe02 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt @@ -1,6 +1,14 @@ package dev.obiente.nextcloudnative.app +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext internal open class NextcloudSessionStorageUnavailableException( message: String, @@ -22,6 +30,42 @@ internal sealed interface NextcloudSessionLoadState { data object LegacyMigrationUnavailable : NextcloudSessionLoadState } +internal class NextcloudSessionLoadCoordinator( + private val loadSession: () -> NextcloudSession?, +) { + var state: NextcloudSessionLoadState? = null + private set + + suspend fun load(dispatcher: CoroutineDispatcher = Dispatchers.Default): NextcloudSessionLoadState { + val loaded = withContext(dispatcher) { loadNextcloudSessionSafely(loadSession) } + state = loaded + return loaded + } +} + +internal data class NextcloudSessionCompositionState( + val loadState: NextcloudSessionLoadState?, + val session: MutableState, +) + +@Composable +internal fun rememberNextcloudSessionCompositionState( + services: NextcloudPlatformServices, + loadAttempt: Int, +): NextcloudSessionCompositionState { + val coordinator = remember(services, loadAttempt) { NextcloudSessionLoadCoordinator(services::loadSession) } + val loadState = remember(services, loadAttempt) { + mutableStateOf(null) + } + val session = remember(services, loadAttempt) { mutableStateOf(null) } + LaunchedEffect(coordinator) { + val loaded = coordinator.load() + loadState.value = loaded + session.value = (loaded as? NextcloudSessionLoadState.Loaded)?.session + } + return NextcloudSessionCompositionState(loadState.value, session) +} + internal fun loadNextcloudSessionSafely( loadSession: () -> NextcloudSession?, ): NextcloudSessionLoadState = try { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt index 246a7b064..ca4f04c64 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt @@ -37,7 +37,7 @@ internal fun rememberMigratedHomeWorkspaceLayout( val layout = remember(scope, legacyAccountScopeDigest) { mutableStateOf(loaded.layout) } LaunchedEffect(scope, loaded.legacyMigrationRequired) { if (loaded.legacyMigrationRequired) { - withContext(Dispatchers.Default) { repository.save(loaded.layout) } + withContext(Dispatchers.Default) { repository.saveIfAbsent(loaded.layout) } } } return layout 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 002ac034e..9f465ce83 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt @@ -311,6 +311,22 @@ class HomeWorkspaceLayoutTest { assertEquals(currentScope.persistenceKey, storage.lastKey) } + @Test + fun `legacy promotion cannot overwrite a newer canonical layout`() { + val storage = RecordingHomeWorkspaceStorage() + val repository = HomeWorkspaceLayoutRepository(storage) + val legacyScope = scope(HomeFormFactor.Phone, digit = 'b') + val currentScope = scope(HomeFormFactor.Phone, digit = 'a') + val legacyLayout = defaultHomeWorkspaceLayout(legacyScope).hide(HomeSectionIds.PhotoBackup) + assertTrue(repository.save(legacyLayout)) + val loaded = repository.loadWithMigration(currentScope, legacyScope.accountScopeDigest) + val newer = defaultHomeWorkspaceLayout(currentScope).hide(HomeSectionIds.Activity) + assertTrue(repository.save(newer)) + + assertFalse(repository.saveIfAbsent(loaded.layout)) + assertEquals(newer, repository.load(currentScope)) + } + @Test fun `failed canonical read never overwrites it from a stale legacy layout`() { val storage = RecordingHomeWorkspaceStorage() @@ -430,6 +446,12 @@ class HomeWorkspaceLayoutTest { values[persistenceKey] = encodedSnapshot } + override fun writeIfAbsent(persistenceKey: String, encodedSnapshot: String): Boolean { + if (persistenceKey in values) return false + write(persistenceKey, encodedSnapshot) + return true + } + fun value(persistenceKey: String): String? = values[persistenceKey] } } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoadingTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoadingTest.kt index d1f011789..cc9154bdf 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoadingTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoadingTest.kt @@ -4,9 +4,31 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs +import kotlin.test.assertNull import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking class NextcloudSessionLoadingTest { + @Test + fun coordinatorDefersSessionMigrationUntilItsEffectRuns() = runBlocking { + var loads = 0 + val expected = NextcloudSession("https://cloud.invalid", "alice", "synthetic-secret") + val coordinator = NextcloudSessionLoadCoordinator { + loads += 1 + expected + } + + assertNull(coordinator.state) + assertEquals(0, loads) + + val loaded = assertIs(coordinator.load(Dispatchers.Unconfined)) + + assertEquals(expected, loaded.session) + assertEquals(loaded, coordinator.state) + assertEquals(1, loads) + } + @Test fun secureStorageFailureBecomesRetryableWithoutExposingItsMessage() { var attempts = 0 diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.desktop.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.desktop.kt index 078e7ba22..77b101aaf 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.desktop.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.desktop.kt @@ -12,11 +12,26 @@ internal actual fun rememberHomeWorkspaceLayoutStorage(): HomeWorkspaceLayoutSto preferences.get(persistenceKey, null) override fun write(persistenceKey: String, encodedSnapshot: String) { - preferences.put(persistenceKey, encodedSnapshot) - preferences.flush() + synchronized(DESKTOP_HOME_WORKSPACE_STORAGE_LOCK) { + preferences.put(persistenceKey, encodedSnapshot) + preferences.flush() + } } + + override fun writeIfAbsent(persistenceKey: String, encodedSnapshot: String): Boolean = + synchronized(DESKTOP_HOME_WORKSPACE_STORAGE_LOCK) { + if (preferences.get(persistenceKey, null) != null) { + false + } else { + preferences.put(persistenceKey, encodedSnapshot) + preferences.flush() + true + } + } } } @Composable internal actual fun rememberHomeFormFactor(): HomeFormFactor = HomeFormFactor.Desktop + +private val DESKTOP_HOME_WORKSPACE_STORAGE_LOCK = Any() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicyTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicyTest.kt index efc804a91..c64a42779 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicyTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicyTest.kt @@ -123,6 +123,23 @@ class JvmLoginFlowHttpPolicyTest { assertNull(interpretation.approvedLoginName) } + @Test + fun `oversized approved server URL is rejected before account registry construction`() { + val interpretation = interpretLoginPollHttpResponse( + status = 200, + body = """{ + "server": "https://${"x".repeat(MAX_ACCOUNT_SERVER_URL_LENGTH)}.example.test", + "loginName": "person", + "appPassword": "private-app-password" + }""".trimIndent(), + challenge = challenge(), + ) + + val failure = assertIs(interpretation.result) + assertEquals("LOGIN_POLL_RESPONSE_INVALID", failure.code) + assertNull(interpretation.approvedLoginName) + } + @Test fun `fallback diagnostic distinguishes a routed 404 from a pre exchange failure`() { val routed = loginPollEndpointFallbackDiagnostic( diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt index e14a5120d..c314eed99 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt @@ -82,6 +82,7 @@ fun interpretLoginPollHttpResponse( val resultServerUrl = normalizeServerUrl(json.getString("server"), challenge.transportSecurity) val loginName = json.getString("loginName") val appPassword = json.getString("appPassword") + require(resultServerUrl.length <= MAX_ACCOUNT_SERVER_URL_LENGTH) { "The server URL is too long." } require(loginName.isNotEmpty()) { "The login name is empty." } require(loginName.length <= MAX_ACCOUNT_LOGIN_NAME_LENGTH) { "The login name is too long." } require(appPassword.isNotEmpty()) { "The app password is empty." } From 953ed3261a41c07853f64fd0cc35529afe7ed6d3 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 10:02:32 +0200 Subject: [PATCH 10/24] fix(auth): reject blank login approvals --- .../app/JvmLoginFlowHttpPolicyTest.kt | 17 +++++++++++++++++ .../app/JvmLoginFlowHttpPolicy.kt | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicyTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicyTest.kt index c64a42779..15a941a81 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicyTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicyTest.kt @@ -106,6 +106,23 @@ class JvmLoginFlowHttpPolicyTest { assertNull(interpretation.approvedLoginName) } + @Test + fun `blank login name is rejected before account registry construction`() { + val interpretation = interpretLoginPollHttpResponse( + status = 200, + body = """{ + "server": "https://cloud.example.test", + "loginName": " ", + "appPassword": "private-app-password" + }""".trimIndent(), + challenge = challenge(), + ) + + val failure = assertIs(interpretation.result) + assertEquals("LOGIN_POLL_RESPONSE_INVALID", failure.code) + assertNull(interpretation.approvedLoginName) + } + @Test fun `oversized login name is rejected before account registry construction`() { val interpretation = interpretLoginPollHttpResponse( diff --git a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt index c314eed99..4d689f70f 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt @@ -83,7 +83,7 @@ fun interpretLoginPollHttpResponse( val loginName = json.getString("loginName") val appPassword = json.getString("appPassword") require(resultServerUrl.length <= MAX_ACCOUNT_SERVER_URL_LENGTH) { "The server URL is too long." } - require(loginName.isNotEmpty()) { "The login name is empty." } + require(loginName.isNotBlank()) { "The login name is blank." } require(loginName.length <= MAX_ACCOUNT_LOGIN_NAME_LENGTH) { "The login name is too long." } require(appPassword.isNotEmpty()) { "The app password is empty." } LoginPollHttpInterpretation( From ef2f9a65e068dad972c72290cb83601eae771771 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 10:05:29 +0200 Subject: [PATCH 11/24] refactor(auth): split session model from platform contracts --- tools/kotlin-file-size-baseline.txt | 2 +- .../nextcloudnative/app/NextcloudPlatform.kt | 13 ------------- .../nextcloudnative/app/NextcloudSession.kt | 14 ++++++++++++++ 3 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index a1f954c3b..eb446783d 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -28,7 +28,7 @@ ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudMediaViewer.kt ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt|12432 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|1730 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt|1724 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoEditing.kt|847 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoFolderBrowsing.kt|895 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PhotoTimelinePaging.kt|860 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 3442d297b..7ba660ad3 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt @@ -46,19 +46,6 @@ data class PlatformCapabilityStatus( val state: PlatformCapabilityState, ) -data class NextcloudSession( - val serverUrl: String, - val loginName: String, - val appPassword: String, -) { - /** Opaque, credential-free identity for account-scoped process state. */ - val accountId: NextcloudAccountId - get() = deriveNextcloudAccountId(serverUrl, loginName) - - override fun toString(): String = - "NextcloudSession(serverUrl=, loginName=, appPassword=)" -} - data class LoginChallenge( val enteredServerUrl: String, val pollEndpoint: String, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt new file mode 100644 index 000000000..7f54ab610 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt @@ -0,0 +1,14 @@ +package dev.obiente.nextcloudnative.app + +data class NextcloudSession( + val serverUrl: String, + val loginName: String, + val appPassword: String, +) { + /** Opaque, credential-free identity for account-scoped process state. */ + val accountId: NextcloudAccountId + get() = deriveNextcloudAccountId(serverUrl, loginName) + + override fun toString(): String = + "NextcloudSession(serverUrl=, loginName=, appPassword=)" +} From a6ca356a2363b5dddd9b29ba9d986d8d9edacb01 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 10:34:48 +0200 Subject: [PATCH 12/24] fix(workspaces): defer pinned app preference reads --- .../nextcloudnative/app/AppWorkspacePins.kt | 16 +++++++++ .../nextcloudnative/app/NextcloudNativeApp.kt | 13 ++++--- .../app/WorkspaceLegacyMigrationEffects.kt | 36 +++++++++++++------ .../app/AppWorkspacePinsTest.kt | 26 +++++++++++++- 4 files changed, 73 insertions(+), 18 deletions(-) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt index c852c9572..d903a4404 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt @@ -1,6 +1,9 @@ package dev.obiente.nextcloudnative.app import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -18,6 +21,19 @@ internal data class AppWorkspacePinsLoad( val legacyMigrationRequired: Boolean = false, ) +internal class AppWorkspacePinsLoadCoordinator( + private val loadPins: () -> AppWorkspacePinsLoad, +) { + var state: AppWorkspacePinsLoad? = null + private set + + suspend fun load(dispatcher: CoroutineDispatcher = Dispatchers.Default): AppWorkspacePinsLoad { + val loaded = withContext(dispatcher) { loadPins() } + state = loaded + return loaded + } +} + internal class AppWorkspacePinsRepository( private val storage: HomeWorkspaceLayoutStorage, ) { 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 5f4c3d38f..31c7202dc 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -1259,13 +1259,11 @@ private fun AuthenticatedApp( val appPinsRepository = remember(appPinsStorage) { AppWorkspacePinsRepository(appPinsStorage) } val appPinsPersistenceScopes = remember(session) { accountPersistenceScopeDigests(session) } val appPinsAccountScope = appPinsPersistenceScopes.current - val loadedAppPins = remember(appPinsPersistenceScopes) { - appPinsRepository.loadWithProvenance(appPinsAccountScope, appPinsPersistenceScopes.legacy) - } - var pinnedAppIds by remember(appPinsAccountScope) { mutableStateOf(loadedAppPins.appIds) } - var appPinsStorageAuthoritative by rememberMigratedAppPinsAuthority( - appPinsRepository, appPinsAccountScope, loadedAppPins, + val appPinsState = rememberAppWorkspacePinsCompositionState( + appPinsRepository, appPinsAccountScope, appPinsPersistenceScopes.legacy, ) + var pinnedAppIds by appPinsState.appIds + var appPinsStorageAuthoritative by appPinsState.storageAuthoritative var appPinsPersistenceError by remember(appPinsAccountScope) { mutableStateOf(null) } val togglePinnedApp: (String) -> String? = togglePinnedApp@{ appId -> if (!appPinsStorageAuthoritative) { @@ -1462,7 +1460,8 @@ private fun AuthenticatedApp( } } - LaunchedEffect(session, discoveryAttempt) { + LaunchedEffect(session, discoveryAttempt, appPinsState.loadComplete) { + if (!appPinsState.loadComplete) return@LaunchedEffect discoveryError = null val discoveryResult = runCatching { services.loadServerInfo(session) } val discovered = discoveryResult.getOrNull() diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt index ca4f04c64..984319b17 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt @@ -8,21 +8,37 @@ import androidx.compose.runtime.remember import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +internal data class AppWorkspacePinsCompositionState( + val appIds: MutableState>, + val storageAuthoritative: MutableState, + val loadComplete: Boolean, +) + @Composable -internal fun rememberMigratedAppPinsAuthority( +internal fun rememberAppWorkspacePinsCompositionState( repository: AppWorkspacePinsRepository, accountScopeDigest: String, - loaded: AppWorkspacePinsLoad, -): MutableState { - val authoritative = remember(accountScopeDigest) { mutableStateOf(loaded.storageAuthoritative) } - LaunchedEffect(accountScopeDigest, loaded.legacyMigrationRequired) { - if (loaded.legacyMigrationRequired) { - authoritative.value = withContext(Dispatchers.Default) { - repository.save(accountScopeDigest, loaded.appIds) - } + legacyAccountScopeDigest: String?, +): AppWorkspacePinsCompositionState { + val coordinator = remember(repository, accountScopeDigest, legacyAccountScopeDigest) { + AppWorkspacePinsLoadCoordinator { + repository.loadWithProvenance(accountScopeDigest, legacyAccountScopeDigest) + } + } + val appIds = remember(accountScopeDigest) { mutableStateOf(defaultAppWorkspacePinnedIds()) } + val authoritative = remember(accountScopeDigest) { mutableStateOf(false) } + val loaded = remember(accountScopeDigest) { mutableStateOf(null) } + LaunchedEffect(coordinator) { + val result = coordinator.load() + appIds.value = result.appIds + authoritative.value = if (result.legacyMigrationRequired) { + withContext(Dispatchers.Default) { repository.save(accountScopeDigest, result.appIds) } + } else { + result.storageAuthoritative } + loaded.value = result } - return authoritative + return AppWorkspacePinsCompositionState(appIds, authoritative, loaded.value != null) } @Composable diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt index 0b77a7d55..43b7c1cec 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt @@ -6,8 +6,28 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertFailsWith import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking class AppWorkspacePinsTest { + @Test + fun `coordinator defers preference reads until its effect runs`() = runBlocking { + val storage = MemoryStorage() + val repository = AppWorkspacePinsRepository(storage) + val coordinator = AppWorkspacePinsLoadCoordinator { + repository.loadWithProvenance("a".repeat(64), "b".repeat(64)) + } + + assertEquals(0, storage.readCount) + assertEquals(null, coordinator.state) + + val loaded = coordinator.load(Dispatchers.Unconfined) + + assertEquals(defaultAppWorkspacePinnedIds(), loaded.appIds) + assertEquals(2, storage.readCount) + assertEquals(loaded, coordinator.state) + } + @Test fun `pins persist per opaque account scope and preserve order`() { val storage = MemoryStorage() @@ -128,8 +148,12 @@ class AppWorkspacePinsTest { private class MemoryStorage : HomeWorkspaceLayoutStorage { val values = mutableMapOf() + var readCount = 0 - override fun read(persistenceKey: String): String? = values[persistenceKey] + override fun read(persistenceKey: String): String? { + readCount += 1 + return values[persistenceKey] + } override fun write(persistenceKey: String, encodedSnapshot: String) { values[persistenceKey] = encodedSnapshot From 5a866540e5a51341c703b30526899297e5f984e5 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 11:43:01 +0200 Subject: [PATCH 13/24] fix(workspaces): make legacy pin promotion conditional --- .../nextcloudnative/app/AppWorkspacePins.kt | 14 ++++++++++++++ .../app/WorkspaceLegacyMigrationEffects.kt | 2 +- .../nextcloudnative/app/AppWorkspacePinsTest.kt | 17 ++++++++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt index d903a4404..a27b2d449 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt @@ -94,6 +94,20 @@ internal class AppWorkspacePinsRepository( } } + /** Promotes legacy pins only while the canonical account key is still absent. */ + fun saveIfAbsent(accountScopeDigest: String, appIds: List): Boolean { + val validated = validatedAppWorkspacePinnedIds(appIds) ?: return false + return try { + val encoded = appWorkspacePinsJson.encodeToString(AppWorkspacePinsSnapshot(appIds = validated)) + check(encoded.length <= MAX_APP_WORKSPACE_PINS_CHARACTERS) + storage.writeIfAbsent(persistenceKey(accountScopeDigest), encoded) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + false + } + } + private fun persistenceKey(accountScopeDigest: String): String { require(accountScopeDigest.length == 64 && accountScopeDigest.all { it in '0'..'9' || it in 'a'..'f' }) return "apps:pins:$APP_WORKSPACE_PINS_SCHEMA_VERSION:$accountScopeDigest" diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt index 984319b17..9f2b6d086 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt @@ -32,7 +32,7 @@ internal fun rememberAppWorkspacePinsCompositionState( val result = coordinator.load() appIds.value = result.appIds authoritative.value = if (result.legacyMigrationRequired) { - withContext(Dispatchers.Default) { repository.save(accountScopeDigest, result.appIds) } + withContext(Dispatchers.Default) { repository.saveIfAbsent(accountScopeDigest, result.appIds) } } else { result.storageAuthoritative } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt index 43b7c1cec..fb7d0d38b 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt @@ -56,10 +56,25 @@ class AppWorkspacePinsTest { assertFalse(loaded.storageAuthoritative) assertTrue(loaded.legacyMigrationRequired) assertEquals(defaultAppWorkspacePinnedIds(), repository.load(current)) - assertTrue(repository.save(current, loaded.appIds)) + assertTrue(repository.saveIfAbsent(current, loaded.appIds)) assertTrue(storage.values.keys.any { key -> key.endsWith(current) }) } + @Test + fun `delayed legacy promotion does not overwrite newer canonical pins`() { + val storage = MemoryStorage() + val repository = AppWorkspacePinsRepository(storage) + val current = "c".repeat(64) + val legacy = "d".repeat(64) + assertTrue(repository.save(legacy, listOf("files", "deck"))) + val staleLegacy = repository.loadWithProvenance(current, legacy) + + assertTrue(repository.save(current, listOf("files", "calendar"))) + assertFalse(repository.saveIfAbsent(current, staleLegacy.appIds)) + + assertEquals(listOf("files", "calendar"), repository.load(current)) + } + @Test fun `legacy pin read cancellation remains control flow`() { val current = "e".repeat(64) From 2921cb142c0ef56d2407b8da6cca50d929b2f935 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 12:38:22 +0200 Subject: [PATCH 14/24] fix(accounts): preserve arbitrary future registry versions --- .../app/NextcloudAccountRegistry.kt | 52 +++++++++++++++---- .../app/NextcloudAccountRegistryTest.kt | 36 +++++++++++++ 2 files changed, 78 insertions(+), 10 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 e479d0402..e9c6a28ed 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt @@ -3,7 +3,6 @@ package dev.obiente.nextcloudnative.app import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -187,26 +186,36 @@ fun decodeNextcloudAccountRegistry(encoded: String): NextcloudAccountRegistry? = (decodeNextcloudAccountRegistryResult(encoded) as? NextcloudAccountRegistryDecodeResult.Valid)?.registry private fun decodeNextcloudAccountRegistryResult(encoded: String): NextcloudAccountRegistryDecodeResult { - val envelopeVersion = accountRegistryVersionEnvelope + val envelopeVersionToken = accountRegistryVersionEnvelope .find(encoded.take(MAX_ACCOUNT_REGISTRY_VERSION_ENVELOPE_CHARACTERS)) ?.groupValues ?.get(1) - ?.toIntOrNull() - if (envelopeVersion != null && envelopeVersion > ACCOUNT_REGISTRY_VERSION) { + val envelopeVersion = envelopeVersionToken?.let(::classifyAccountRegistryVersion) + if (envelopeVersion == AccountRegistryVersionClassification.Unsupported) { return NextcloudAccountRegistryDecodeResult.UnsupportedVersion } if (encoded.encodeToByteArray().size > MAX_ACCOUNT_REGISTRY_BYTES) { - return if (envelopeVersion != null) { + return if (envelopeVersionToken != null) { NextcloudAccountRegistryDecodeResult.Malformed } else { NextcloudAccountRegistryDecodeResult.UnsupportedVersion } } - val version = runCatching { - accountRegistryJson.parseToJsonElement(encoded).jsonObject["version"]?.jsonPrimitive?.intOrNull + val versionToken = runCatching { + accountRegistryJson.parseToJsonElement(encoded).jsonObject["version"] + ?.jsonPrimitive + ?.takeUnless { version -> version.isString } + ?.content }.getOrNull() ?: return NextcloudAccountRegistryDecodeResult.Malformed - if (version > ACCOUNT_REGISTRY_VERSION) return NextcloudAccountRegistryDecodeResult.UnsupportedVersion - if (version != ACCOUNT_REGISTRY_VERSION) return NextcloudAccountRegistryDecodeResult.Malformed + when (classifyAccountRegistryVersion(versionToken)) { + AccountRegistryVersionClassification.Current -> Unit + AccountRegistryVersionClassification.Unsupported -> { + return NextcloudAccountRegistryDecodeResult.UnsupportedVersion + } + AccountRegistryVersionClassification.Malformed -> { + return NextcloudAccountRegistryDecodeResult.Malformed + } + } return runCatching { val persisted = accountRegistryJson.decodeFromString(encoded) NextcloudAccountRegistry( @@ -225,6 +234,27 @@ private fun decodeNextcloudAccountRegistryResult(encoded: String): NextcloudAcco ) } +private fun classifyAccountRegistryVersion(value: String): AccountRegistryVersionClassification { + if (value.isEmpty() || value.any { character -> character !in '0'..'9' }) { + return AccountRegistryVersionClassification.Malformed + } + val current = ACCOUNT_REGISTRY_VERSION.toString() + if (value.length > current.length || value.length == current.length && value > current) { + return AccountRegistryVersionClassification.Unsupported + } + return if (value == current) { + AccountRegistryVersionClassification.Current + } else { + AccountRegistryVersionClassification.Malformed + } +} + +private enum class AccountRegistryVersionClassification { + Current, + Unsupported, + Malformed, +} + private sealed interface NextcloudAccountRegistryDecodeResult { data class Valid(val registry: NextcloudAccountRegistry) : NextcloudAccountRegistryDecodeResult @@ -252,7 +282,9 @@ private val accountRegistryJson = Json { explicitNulls = false } -private val accountRegistryVersionEnvelope = Regex("""\A\s*\{\s*"version"\s*:\s*(-?\d+)""") +private val accountRegistryVersionEnvelope = Regex( + """\A\s*\{\s*"version"\s*:\s*(-?(?:0|[1-9]\d*))(?=\s*[,}])""", +) private const val ACCOUNT_REGISTRY_VERSION = 1 internal const val MAX_LOCAL_ACCOUNTS = 64 diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt index b87f993a3..cd79c165a 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt @@ -117,6 +117,30 @@ class NextcloudAccountRegistryTest { assertFalse(restored.needsPersistence) } + @Test + fun futureRegistryVersionBeyondIntRangeRemainsUntouched() { + val session = session("https://cloud.example.test", "alice", "private-app-password") + val futureRegistry = """{"version":2147483648,"accounts":[]}""" + + val restored = restoreNextcloudAccountRegistry(futureRegistry, session) + + assertEquals(NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion, restored.recoveryReason) + assertEquals(session.accountRecord(), restored.registry.activeAccount) + assertFalse(restored.needsPersistence) + } + + @Test + fun extremelyLongBoundedFutureRegistryVersionRemainsUntouched() { + val session = session("https://cloud.example.test", "alice", "private-app-password") + val futureRegistry = """{"version":${"9".repeat(16 * 1024)},"accounts":[]}""" + + val restored = restoreNextcloudAccountRegistry(futureRegistry, session) + + assertEquals(NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion, restored.recoveryReason) + assertEquals(session.accountRecord(), restored.registry.activeAccount) + assertFalse(restored.needsPersistence) + } + @Test fun oversizedFutureRegistryRemainsUntouchedAfterBoundedVersionInspection() { val session = session("https://cloud.example.test", "alice", "private-app-password") @@ -129,6 +153,18 @@ class NextcloudAccountRegistryTest { assertFalse(restored.needsPersistence) } + @Test + fun oversizedFutureRegistryBeyondIntRangeRemainsUntouched() { + val session = session("https://cloud.example.test", "alice", "private-app-password") + val futureRegistry = """{"version":2147483648,"future":"${"x".repeat(300 * 1024)}"}""" + + val restored = restoreNextcloudAccountRegistry(futureRegistry, session) + + assertEquals(NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion, restored.recoveryReason) + assertEquals(session.accountRecord(), restored.registry.activeAccount) + assertFalse(restored.needsPersistence) + } + @Test fun oversizedCurrentRegistryStillUsesTheVersionSpecificSizeLimit() { val session = session("https://cloud.example.test", "alice", "private-app-password") From b7cbf4426b4d251c8f07827031fc336ddef86e9d Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 13:18:48 +0200 Subject: [PATCH 15/24] fix(workspaces): serialize desktop preference promotion --- .../app/DesktopHomeWorkspaceLayoutStorage.kt | 71 ++++++++++++++++++ .../app/HomeWorkspacePersistence.desktop.kt | 25 +------ .../DesktopHomeWorkspaceLayoutStorageTest.kt | 73 +++++++++++++++++++ 3 files changed, 145 insertions(+), 24 deletions(-) create mode 100644 ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorage.kt create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorageTest.kt diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorage.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorage.kt new file mode 100644 index 000000000..ddd73e932 --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorage.kt @@ -0,0 +1,71 @@ +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.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantLock +import java.util.prefs.Preferences +import kotlin.concurrent.withLock + +internal class DesktopHomeWorkspaceLayoutStorage( + private val preferences: Preferences, + private val lockFile: File, +) : HomeWorkspaceLayoutStorage { + override fun read(persistenceKey: String): String? = preferences.get(persistenceKey, null) + + override fun write(persistenceKey: String, encodedSnapshot: String) { + withExclusiveAccess { + preferences.sync() + preferences.put(persistenceKey, encodedSnapshot) + preferences.flush() + } + } + + override fun writeIfAbsent(persistenceKey: String, encodedSnapshot: String): Boolean = + withExclusiveAccess { + preferences.sync() + if (preferences.get(persistenceKey, null) != null) { + false + } else { + preferences.put(persistenceKey, encodedSnapshot) + preferences.flush() + true + } + } + + private fun withExclusiveAccess(operation: () -> T): T { + val path = lockFile.toPath().toAbsolutePath().normalize() + return desktopHomeWorkspaceProcessLocks.computeIfAbsent(path.toString()) { ReentrantLock() }.withLock { + val parent = requireNotNull(path.parent) { "Home workspace storage needs a parent directory." } + Files.createDirectories(parent) + check(Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(parent)) { + "Home workspace storage must use a real directory." + } + check(!Files.isSymbolicLink(path)) { "The home workspace lock cannot be a symbolic link." } + FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE).use { channel -> + channel.lock().use { operation() } + } + } + } +} + +internal fun desktopHomeWorkspaceLockFile( + osName: String = System.getProperty("os.name").lowercase(), + userHome: File = File(System.getProperty("user.home")), + environment: Map = System.getenv(), +): File { + val normalizedOsName = osName.lowercase() + val stateRoot = when { + normalizedOsName.contains("win") -> environment["LOCALAPPDATA"]?.takeIf(String::isNotBlank)?.let(::File) + ?: File(userHome, "AppData/Local") + normalizedOsName.contains("mac") -> File(userHome, "Library/Application Support") + else -> environment["XDG_STATE_HOME"]?.takeIf(String::isNotBlank)?.let(::File) + ?: File(userHome, ".local/state") + } + return File(stateRoot, "Nextcloud Native/Home Workspace/preferences.lock") +} + +private val desktopHomeWorkspaceProcessLocks = ConcurrentHashMap() diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.desktop.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.desktop.kt index 77b101aaf..eab120d6b 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.desktop.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.desktop.kt @@ -7,31 +7,8 @@ import java.util.prefs.Preferences @Composable internal actual fun rememberHomeWorkspaceLayoutStorage(): HomeWorkspaceLayoutStorage = remember { val preferences = Preferences.userRoot().node("dev/obiente/nextcloudnative/home-workspace") - object : HomeWorkspaceLayoutStorage { - override fun read(persistenceKey: String): String? = - preferences.get(persistenceKey, null) - - override fun write(persistenceKey: String, encodedSnapshot: String) { - synchronized(DESKTOP_HOME_WORKSPACE_STORAGE_LOCK) { - preferences.put(persistenceKey, encodedSnapshot) - preferences.flush() - } - } - - override fun writeIfAbsent(persistenceKey: String, encodedSnapshot: String): Boolean = - synchronized(DESKTOP_HOME_WORKSPACE_STORAGE_LOCK) { - if (preferences.get(persistenceKey, null) != null) { - false - } else { - preferences.put(persistenceKey, encodedSnapshot) - preferences.flush() - true - } - } - } + DesktopHomeWorkspaceLayoutStorage(preferences, desktopHomeWorkspaceLockFile()) } @Composable internal actual fun rememberHomeFormFactor(): HomeFormFactor = HomeFormFactor.Desktop - -private val DESKTOP_HOME_WORKSPACE_STORAGE_LOCK = Any() diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorageTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorageTest.kt new file mode 100644 index 000000000..7a86e3122 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopHomeWorkspaceLayoutStorageTest.kt @@ -0,0 +1,73 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.prefs.Preferences +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopHomeWorkspaceLayoutStorageTest { + @Test + fun `concurrent storage instances admit only one conditional promotion`() { + val directory = Files.createTempDirectory("nextcloud-native-home-workspace-lock-test").toFile() + val node = "dev/obiente/nextcloudnative/test-home-workspace-${UUID.randomUUID()}" + val firstPreferences = Preferences.userRoot().node(node) + val secondPreferences = Preferences.userRoot().node(node) + val first = DesktopHomeWorkspaceLayoutStorage(firstPreferences, directory.resolve("preferences.lock")) + val second = DesktopHomeWorkspaceLayoutStorage(secondPreferences, directory.resolve("preferences.lock")) + val start = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(2) + try { + val firstResult = executor.submit { + start.await() + first.writeIfAbsent("pins", "first") + } + val secondResult = executor.submit { + start.await() + second.writeIfAbsent("pins", "second") + } + + start.countDown() + val results = listOf( + firstResult.get(5, TimeUnit.SECONDS), + secondResult.get(5, TimeUnit.SECONDS), + ) + + assertEquals(1, results.count { it }) + assertEquals(1, results.count { !it }) + assertTrue(first.read("pins") in setOf("first", "second")) + assertFalse(Files.isSymbolicLink(directory.resolve("preferences.lock").toPath())) + } finally { + executor.shutdownNow() + firstPreferences.removeNode() + Preferences.userRoot().flush() + directory.deleteRecursively() + } + } + + @Test + fun `lock path follows each desktop platform state directory`() { + val home = Files.createTempDirectory("nextcloud-native-home-workspace-path-test").toFile() + try { + assertEquals( + home.resolve("state/Nextcloud Native/Home Workspace/preferences.lock"), + desktopHomeWorkspaceLockFile("Linux", home, mapOf("XDG_STATE_HOME" to home.resolve("state").path)), + ) + assertEquals( + home.resolve("local/Nextcloud Native/Home Workspace/preferences.lock"), + desktopHomeWorkspaceLockFile("Windows 11", home, mapOf("LOCALAPPDATA" to home.resolve("local").path)), + ) + assertEquals( + home.resolve("Library/Application Support/Nextcloud Native/Home Workspace/preferences.lock"), + desktopHomeWorkspaceLockFile("Mac OS X", home, emptyMap()), + ) + } finally { + home.deleteRecursively() + } + } +} From 1bfc06ab508f9c96411e680f9bb861f053072392 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 13:58:02 +0200 Subject: [PATCH 16/24] fix(workspaces): preserve migration authority --- .../nextcloudnative/app/AppWorkspacePins.kt | 33 +++++++++++-- .../app/DashboardStatusScreens.kt | 24 +++++----- .../app/HomeWorkspacePersistence.kt | 48 ++++++++++++++++--- .../app/WorkspaceLegacyMigrationEffects.kt | 33 +++++++++---- .../app/AppWorkspacePinsTest.kt | 17 +++++++ .../app/HomeWorkspaceLayoutTest.kt | 24 +++++++++- 6 files changed, 146 insertions(+), 33 deletions(-) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt index a27b2d449..6c8c9a405 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt @@ -95,16 +95,41 @@ internal class AppWorkspacePinsRepository( } /** Promotes legacy pins only while the canonical account key is still absent. */ - fun saveIfAbsent(accountScopeDigest: String, appIds: List): Boolean { - val validated = validatedAppWorkspacePinnedIds(appIds) ?: return false + fun promoteIfAbsent( + accountScopeDigest: String, + appIds: List, + ): PersistencePromotionResult { + val validated = validatedAppWorkspacePinnedIds(appIds) ?: return PersistencePromotionResult.Failed return try { val encoded = appWorkspacePinsJson.encodeToString(AppWorkspacePinsSnapshot(appIds = validated)) check(encoded.length <= MAX_APP_WORKSPACE_PINS_CHARACTERS) - storage.writeIfAbsent(persistenceKey(accountScopeDigest), encoded) + if (storage.writeIfAbsent(persistenceKey(accountScopeDigest), encoded)) { + PersistencePromotionResult.Saved + } else { + PersistencePromotionResult.CanonicalAlreadyPresent + } } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { - false + PersistencePromotionResult.Failed + } + } + + fun saveIfAbsent(accountScopeDigest: String, appIds: List): Boolean = + promoteIfAbsent(accountScopeDigest, appIds) == PersistencePromotionResult.Saved + + fun resolveLegacyMigration( + accountScopeDigest: String, + loaded: AppWorkspacePinsLoad, + ): AppWorkspacePinsLoad { + if (!loaded.legacyMigrationRequired) return loaded + return when (promoteIfAbsent(accountScopeDigest, loaded.appIds)) { + PersistencePromotionResult.Saved -> loaded.copy( + storageAuthoritative = true, + legacyMigrationRequired = false, + ) + PersistencePromotionResult.CanonicalAlreadyPresent -> loadWithProvenance(accountScopeDigest) + PersistencePromotionResult.Failed -> loaded.copy(storageAuthoritative = false) } } 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 5474e1368..fe12f7908 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt @@ -235,20 +235,19 @@ internal fun NativeDashboardScreen( } val workspacePersistenceScopes = remember(session) { accountPersistenceScopeDigests(session) } val workspaceScope = remember(workspacePersistenceScopes.current, formFactor) { - HomeWorkspaceScope( - accountScopeDigest = workspacePersistenceScopes.current, - formFactor = formFactor, - ) + HomeWorkspaceScope(workspacePersistenceScopes.current, formFactor) } - var workspaceLayout by rememberMigratedHomeWorkspaceLayout( + val workspaceLayoutState = rememberMigratedHomeWorkspaceLayoutState( workspaceRepository, workspaceScope, workspacePersistenceScopes.legacy, ) + var workspaceLayout by workspaceLayoutState.layout NativeDashboardPresentation( state = state, installedApps = installedApps, pinnedAppIds = pinnedAppIds, workspaceLayout = workspaceLayout, + workspaceLayoutAuthoritative = workspaceLayoutState.storageAuthoritative.value, onWorkspaceLayoutChanged = { updated -> workspaceLayout = updated workspaceRepository.save(updated) @@ -275,6 +274,7 @@ internal fun NativeDashboardPresentation( installedApps: List, pinnedAppIds: List = defaultAppWorkspacePinnedIds(), workspaceLayout: HomeWorkspaceLayout, + workspaceLayoutAuthoritative: Boolean = true, onWorkspaceLayoutChanged: (HomeWorkspaceLayout) -> Boolean, onOpenApp: (NextcloudAppEntry) -> Unit, onOpenStatus: (() -> Unit)?, @@ -293,13 +293,13 @@ internal fun NativeDashboardPresentation( if (workspaceLayout != activeWorkspaceLayout) activeWorkspaceLayout = workspaceLayout } val widgetsAuthoritative = (state as? DashboardSurfaceState.Available)?.widgetsAuthoritative != false - LaunchedEffect(widgetsAuthoritative) { - if (!widgetsAuthoritative) { + val workspaceWritesEnabled = widgetsAuthoritative && workspaceLayoutAuthoritative + LaunchedEffect(workspaceWritesEnabled) { + if (!workspaceWritesEnabled) { customizeWorkspace = false workspacePersistenceError = null } } - Column(modifier = Modifier.fillMaxSize()) { DashboardHeader( title = "Home", @@ -310,7 +310,7 @@ internal fun NativeDashboardPresentation( }, onBack = onBack, onRefresh = onRefresh, - onCustomize = if (widgetsAuthoritative) { + onCustomize = if (workspaceWritesEnabled) { { customizeWorkspace = true } } else { null @@ -339,8 +339,8 @@ internal fun NativeDashboardPresentation( val effectiveLayout = remember(activeWorkspaceLayout, availableSectionIds) { activeWorkspaceLayout.reconcileAvailableSections(availableSectionIds) } - LaunchedEffect(effectiveLayout, current.widgetsAuthoritative) { - if (current.widgetsAuthoritative && effectiveLayout != activeWorkspaceLayout) { + LaunchedEffect(effectiveLayout, workspaceWritesEnabled) { + if (workspaceWritesEnabled && effectiveLayout != activeWorkspaceLayout) { activeWorkspaceLayout = effectiveLayout onWorkspaceLayoutChanged(effectiveLayout) } @@ -365,7 +365,7 @@ internal fun NativeDashboardPresentation( } val updateWorkspaceLayout: (HomeWorkspaceLayout, Boolean) -> Unit = { updated, persist -> activeWorkspaceLayout = updated - if (persist && current.widgetsAuthoritative) { + if (persist && workspaceWritesEnabled) { workspacePersistenceError = if (onWorkspaceLayoutChanged(updated)) { null } else { 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 8c1f0387b..c17df75e9 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt @@ -20,9 +20,16 @@ internal interface HomeWorkspaceLayoutStorage { internal data class HomeWorkspaceLayoutLoad( val layout: HomeWorkspaceLayout, + val storageAuthoritative: Boolean = true, val legacyMigrationRequired: Boolean = false, ) +internal enum class PersistencePromotionResult { + Saved, + CanonicalAlreadyPresent, + Failed, +} + internal class HomeWorkspaceLayoutRepository( private val storage: HomeWorkspaceLayoutStorage, private val encodeSnapshot: (HomeWorkspaceLayout) -> String = @@ -41,7 +48,10 @@ internal class HomeWorkspaceLayoutRepository( } catch (failure: CancellationException) { throw failure } catch (_: Exception) { - return HomeWorkspaceLayoutLoad(defaultHomeWorkspaceLayout(scope)) + return HomeWorkspaceLayoutLoad( + defaultHomeWorkspaceLayout(scope), + storageAuthoritative = false, + ) } if (encoded != null) return HomeWorkspaceLayoutLoad(decodeHomeWorkspaceLayoutSnapshot(scope, encoded)) val legacyScope = legacyAccountScopeDigest?.let { digest -> @@ -52,11 +62,18 @@ internal class HomeWorkspaceLayoutRepository( } catch (failure: CancellationException) { throw failure } catch (_: Exception) { - null + return HomeWorkspaceLayoutLoad( + defaultHomeWorkspaceLayout(scope), + storageAuthoritative = false, + ) } ?: return HomeWorkspaceLayoutLoad(defaultHomeWorkspaceLayout(scope)) val legacyLayout = decodeHomeWorkspaceLayoutSnapshot(legacyScope, legacyEncoded) val migrated = HomeWorkspaceLayout(scope, legacyLayout.sections) - return HomeWorkspaceLayoutLoad(migrated, legacyMigrationRequired = true) + return HomeWorkspaceLayoutLoad( + layout = migrated, + storageAuthoritative = false, + legacyMigrationRequired = true, + ) } /** @@ -76,14 +93,33 @@ internal class HomeWorkspaceLayoutRepository( } /** Promotes a legacy layout only while the canonical key is still absent. */ - fun saveIfAbsent(layout: HomeWorkspaceLayout): Boolean { + fun promoteIfAbsent(layout: HomeWorkspaceLayout): PersistencePromotionResult { return try { val encoded = encodeSnapshot(layout) - storage.writeIfAbsent(layout.scope.persistenceKey, encoded) + if (storage.writeIfAbsent(layout.scope.persistenceKey, encoded)) { + PersistencePromotionResult.Saved + } else { + PersistencePromotionResult.CanonicalAlreadyPresent + } } catch (failure: CancellationException) { throw failure } catch (_: Exception) { - false + PersistencePromotionResult.Failed + } + } + + fun saveIfAbsent(layout: HomeWorkspaceLayout): Boolean = + promoteIfAbsent(layout) == PersistencePromotionResult.Saved + + fun resolveLegacyMigration(loaded: HomeWorkspaceLayoutLoad): HomeWorkspaceLayoutLoad { + if (!loaded.legacyMigrationRequired) return loaded + return when (promoteIfAbsent(loaded.layout)) { + PersistencePromotionResult.Saved -> loaded.copy( + storageAuthoritative = true, + legacyMigrationRequired = false, + ) + PersistencePromotionResult.CanonicalAlreadyPresent -> loadWithMigration(loaded.layout.scope) + PersistencePromotionResult.Failed -> loaded.copy(storageAuthoritative = false) } } } diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt index 9f2b6d086..831ca07cf 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt @@ -29,32 +29,47 @@ internal fun rememberAppWorkspacePinsCompositionState( val authoritative = remember(accountScopeDigest) { mutableStateOf(false) } val loaded = remember(accountScopeDigest) { mutableStateOf(null) } LaunchedEffect(coordinator) { - val result = coordinator.load() - appIds.value = result.appIds - authoritative.value = if (result.legacyMigrationRequired) { - withContext(Dispatchers.Default) { repository.saveIfAbsent(accountScopeDigest, result.appIds) } + val initial = coordinator.load() + val result = if (initial.legacyMigrationRequired) { + withContext(Dispatchers.Default) { + repository.resolveLegacyMigration(accountScopeDigest, initial) + } } else { - result.storageAuthoritative + initial } + appIds.value = result.appIds + authoritative.value = result.storageAuthoritative loaded.value = result } return AppWorkspacePinsCompositionState(appIds, authoritative, loaded.value != null) } +internal data class HomeWorkspaceLayoutCompositionState( + val layout: MutableState, + val storageAuthoritative: MutableState, +) + @Composable -internal fun rememberMigratedHomeWorkspaceLayout( +internal fun rememberMigratedHomeWorkspaceLayoutState( repository: HomeWorkspaceLayoutRepository, scope: HomeWorkspaceScope, legacyAccountScopeDigest: String?, -): MutableState { +): HomeWorkspaceLayoutCompositionState { val loaded = remember(scope, legacyAccountScopeDigest) { repository.loadWithMigration(scope, legacyAccountScopeDigest) } val layout = remember(scope, legacyAccountScopeDigest) { mutableStateOf(loaded.layout) } + val authoritative = remember(scope, legacyAccountScopeDigest) { + mutableStateOf(loaded.storageAuthoritative) + } LaunchedEffect(scope, loaded.legacyMigrationRequired) { if (loaded.legacyMigrationRequired) { - withContext(Dispatchers.Default) { repository.saveIfAbsent(loaded.layout) } + val resolved = withContext(Dispatchers.Default) { + repository.resolveLegacyMigration(loaded) + } + layout.value = resolved.layout + authoritative.value = resolved.storageAuthoritative } } - return layout + return HomeWorkspaceLayoutCompositionState(layout, authoritative) } diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt index fb7d0d38b..8daaba9a9 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt @@ -75,6 +75,23 @@ class AppWorkspacePinsTest { assertEquals(listOf("files", "calendar"), repository.load(current)) } + @Test + fun `losing legacy promotion reloads canonical pins and restores authority`() { + val storage = MemoryStorage() + val repository = AppWorkspacePinsRepository(storage) + val current = "c".repeat(64) + val legacy = "d".repeat(64) + assertTrue(repository.save(legacy, listOf("files", "deck"))) + val staleLegacy = repository.loadWithProvenance(current, legacy) + assertTrue(repository.save(current, listOf("files", "calendar"))) + + val resolved = repository.resolveLegacyMigration(current, staleLegacy) + + assertEquals(listOf("files", "calendar"), resolved.appIds) + assertTrue(resolved.storageAuthoritative) + assertFalse(resolved.legacyMigrationRequired) + } + @Test fun `legacy pin read cancellation remains control flow`() { val current = "e".repeat(64) 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 9f465ce83..c0f21847e 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt @@ -304,6 +304,7 @@ class HomeWorkspaceLayoutTest { assertEquals(currentScope, loaded.layout.scope) assertEquals(legacyLayout.sections, loaded.layout.sections) + assertFalse(loaded.storageAuthoritative) assertTrue(loaded.legacyMigrationRequired) assertEquals(defaultHomeWorkspaceLayout(currentScope), repository.load(currentScope)) assertTrue(repository.save(loaded.layout)) @@ -323,8 +324,11 @@ class HomeWorkspaceLayoutTest { val newer = defaultHomeWorkspaceLayout(currentScope).hide(HomeSectionIds.Activity) assertTrue(repository.save(newer)) - assertFalse(repository.saveIfAbsent(loaded.layout)) - assertEquals(newer, repository.load(currentScope)) + val resolved = repository.resolveLegacyMigration(loaded) + + assertEquals(newer, resolved.layout) + assertTrue(resolved.storageAuthoritative) + assertFalse(resolved.legacyMigrationRequired) } @Test @@ -378,6 +382,22 @@ class HomeWorkspaceLayoutTest { assertEquals(null, storage.lastKey) } + @Test + fun `failed legacy read leaves default layout non authoritative`() { + val storage = RecordingHomeWorkspaceStorage() + val repository = HomeWorkspaceLayoutRepository(storage) + val currentScope = scope(HomeFormFactor.Phone, digit = 'a') + val legacyScope = scope(HomeFormFactor.Phone, digit = 'b') + storage.failedReadKey = legacyScope.persistenceKey + + val loaded = repository.loadWithMigration(currentScope, legacyScope.accountScopeDigest) + + assertEquals(defaultHomeWorkspaceLayout(currentScope), loaded.layout) + assertFalse(loaded.storageAuthoritative) + assertFalse(loaded.legacyMigrationRequired) + assertEquals(null, storage.value(currentScope.persistenceKey)) + } + @Test fun `repository reports snapshot encoding failures without touching storage`() { val storage = RecordingHomeWorkspaceStorage() From daa7bfad86535a760c58f75ab91c0210b6d76e5b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 14:30:58 +0200 Subject: [PATCH 17/24] fix(accounts): verify Android registry migration --- .../AndroidNextcloudServices.kt | 2 +- .../AndroidPersistedSession.kt | 11 +++--- .../AndroidPersistedSessionTest.kt | 36 ++++++++++++++++--- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 21e30d03e..a78d3032e 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt @@ -927,7 +927,7 @@ internal class AndroidNextcloudServices( restoreAndroidPersistedSession( encoded = sessionCipher.decrypt(encrypted), persistMigrated = { migrated -> - preferences.edit().putString(KEY_SESSION, sessionCipher.encrypt(migrated)).apply() + preferences.edit().putString(KEY_SESSION, sessionCipher.encrypt(migrated)).commit() }, recordDiagnostic = ::recordSupportDiagnostic, ) diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt index 1dfd04ae4..e577ad0ca 100644 --- a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -12,7 +12,7 @@ import org.json.JSONObject internal fun restoreAndroidPersistedSession( encoded: String, - persistMigrated: (String) -> Unit, + persistMigrated: (String) -> Boolean, recordDiagnostic: (SupportDiagnosticEventDraft) -> Unit, ): NextcloudSession { val json = JSONObject(encoded) @@ -40,9 +40,12 @@ internal fun restoreAndroidPersistedSession( } if (restored.needsPersistence) { runCatching { - persistMigrated( - json.put(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(restored.registry)).toString(), - ) + 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( diff --git a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt index c23ae8034..57c5e0b72 100644 --- a/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -20,7 +20,10 @@ class AndroidPersistedSessionTest { val first = restoreAndroidPersistedSession( encoded = legacyPayload(), - persistMigrated = { encoded -> migrated = encoded }, + persistMigrated = { encoded -> + migrated = encoded + true + }, recordDiagnostic = diagnostics::add, ) val migratedPayload = requireNotNull(migrated) @@ -34,7 +37,10 @@ class AndroidPersistedSessionTest { var unexpectedSecondMigration = false val restarted = restoreAndroidPersistedSession( encoded = migratedPayload, - persistMigrated = { unexpectedSecondMigration = true }, + persistMigrated = { + unexpectedSecondMigration = true + true + }, recordDiagnostic = diagnostics::add, ) assertEquals(first, restarted) @@ -52,7 +58,10 @@ class AndroidPersistedSessionTest { val session = restoreAndroidPersistedSession( encoded = malformed, - persistMigrated = { encoded -> migrated = encoded }, + persistMigrated = { encoded -> + migrated = encoded + true + }, recordDiagnostic = diagnostics::add, ) val restoredRegistry = restoreNextcloudAccountRegistry( @@ -80,7 +89,10 @@ class AndroidPersistedSessionTest { val session = restoreAndroidPersistedSession( encoded = payload, - persistMigrated = { migrated = true }, + persistMigrated = { + migrated = true + true + }, recordDiagnostic = diagnostics::add, ) @@ -93,7 +105,7 @@ class AndroidPersistedSessionTest { fun savedPayloadKeepsCredentialsOutsideTheRegistry() { val session = restoreAndroidPersistedSession( encoded = legacyPayload(), - persistMigrated = {}, + persistMigrated = { true }, recordDiagnostic = {}, ) @@ -126,6 +138,20 @@ class AndroidPersistedSessionTest { assertFalse(rendered.contains("alice")) } + @Test + fun rejectedMigrationCommitIsReportedWithoutDiscardingTheLegacySession() { + val diagnostics = mutableListOf() + + val session = restoreAndroidPersistedSession( + encoded = legacyPayload(), + persistMigrated = { false }, + recordDiagnostic = diagnostics::add, + ) + + assertEquals("alice", session.loginName) + assertEquals(listOf("ACCOUNT_REGISTRY_MIGRATION_FAILED"), diagnostics.mapNotNull { it.code }) + } + private fun legacyPayload(): String = JSONObject() .put("serverUrl", "https://cloud.example.test") .put("loginName", "alice") From fc74247ecb444343857b5cce357b340e22f215ff Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Fri, 4 Sep 2026 21:56:09 +0200 Subject: [PATCH 18/24] fix(workspaces): defer home layout storage reads --- .../app/HomeWorkspacePersistence.kt | 16 +++++++++++ .../app/WorkspaceLegacyMigrationEffects.kt | 27 ++++++++++++------- .../app/HomeWorkspaceLayoutTest.kt | 23 ++++++++++++++++ 3 files changed, 56 insertions(+), 10 deletions(-) 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 c17df75e9..dfa532a84 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt @@ -2,6 +2,9 @@ package dev.obiente.nextcloudnative.app import androidx.compose.runtime.Composable import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -30,6 +33,19 @@ internal enum class PersistencePromotionResult { Failed, } +internal class HomeWorkspaceLayoutLoadCoordinator( + private val loadLayout: () -> HomeWorkspaceLayoutLoad, +) { + var state: HomeWorkspaceLayoutLoad? = null + private set + + suspend fun load(dispatcher: CoroutineDispatcher = Dispatchers.Default): HomeWorkspaceLayoutLoad { + val loaded = withContext(dispatcher) { loadLayout() } + state = loaded + return loaded + } +} + internal class HomeWorkspaceLayoutRepository( private val storage: HomeWorkspaceLayoutStorage, private val encodeSnapshot: (HomeWorkspaceLayout) -> String = diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt index 831ca07cf..a4a0cd4aa 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt @@ -55,21 +55,28 @@ internal fun rememberMigratedHomeWorkspaceLayoutState( scope: HomeWorkspaceScope, legacyAccountScopeDigest: String?, ): HomeWorkspaceLayoutCompositionState { - val loaded = remember(scope, legacyAccountScopeDigest) { - repository.loadWithMigration(scope, legacyAccountScopeDigest) + val coordinator = remember(repository, scope, legacyAccountScopeDigest) { + HomeWorkspaceLayoutLoadCoordinator { + repository.loadWithMigration(scope, legacyAccountScopeDigest) + } + } + val layout = remember(scope, legacyAccountScopeDigest) { + mutableStateOf(defaultHomeWorkspaceLayout(scope)) } - val layout = remember(scope, legacyAccountScopeDigest) { mutableStateOf(loaded.layout) } val authoritative = remember(scope, legacyAccountScopeDigest) { - mutableStateOf(loaded.storageAuthoritative) + mutableStateOf(false) } - LaunchedEffect(scope, loaded.legacyMigrationRequired) { - if (loaded.legacyMigrationRequired) { - val resolved = withContext(Dispatchers.Default) { - repository.resolveLegacyMigration(loaded) + LaunchedEffect(coordinator) { + val initial = coordinator.load() + val result = if (initial.legacyMigrationRequired) { + withContext(Dispatchers.Default) { + repository.resolveLegacyMigration(initial) } - layout.value = resolved.layout - authoritative.value = resolved.storageAuthoritative + } else { + initial } + layout.value = result.layout + authoritative.value = result.storageAuthoritative } return HomeWorkspaceLayoutCompositionState(layout, authoritative) } 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 c0f21847e..4b640519f 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspaceLayoutTest.kt @@ -3,6 +3,8 @@ package dev.obiente.nextcloudnative.app import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -12,6 +14,25 @@ import kotlin.test.assertSame import kotlin.test.assertTrue class HomeWorkspaceLayoutTest { + @Test + fun `coordinator defers preference reads until its effect runs`() = runBlocking { + val storage = RecordingHomeWorkspaceStorage() + val currentScope = scope(HomeFormFactor.Phone) + val legacyDigest = "b".repeat(64) + val coordinator = HomeWorkspaceLayoutLoadCoordinator { + HomeWorkspaceLayoutRepository(storage).loadWithMigration(currentScope, legacyDigest) + } + + assertEquals(0, storage.readCount) + assertEquals(null, coordinator.state) + + val loaded = coordinator.load(Dispatchers.Unconfined) + + assertEquals(defaultHomeWorkspaceLayout(currentScope), loaded.layout) + assertEquals(2, storage.readCount) + assertEquals(loaded, coordinator.state) + } + @Test fun `defaults are useful and distinct for each form factor`() { val phone = defaultHomeWorkspaceLayout(scope(HomeFormFactor.Phone)) @@ -454,8 +475,10 @@ class HomeWorkspaceLayoutTest { var lastValue: String? = null var failedReadKey: String? = null var readFailure: Throwable = IllegalStateException("synthetic canonical read failure") + var readCount: Int = 0 override fun read(persistenceKey: String): String? { + readCount += 1 if (persistenceKey == failedReadKey) throw readFailure return values[persistenceKey] } From bd8c2ed7d4b3f6582a723f017392be2afa248e6e Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 01:33:52 +0200 Subject: [PATCH 19/24] chore: refresh account stack validation From 8c727202a13d19ba644ed155590512967a0d0b47 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 02:23:03 +0200 Subject: [PATCH 20/24] docs(accounts): clarify persistent identity ownership --- .../obiente/nextcloudnative/app/NextcloudAccountIdentity.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt index fbf26100a..dae9752fb 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt @@ -5,8 +5,8 @@ package dev.obiente.nextcloudnative.app * * The value is a one-way digest of the normalized server address and login name. Callers keep the * type intact so account-scoped state cannot accidentally use a path, username, or password as its - * owner. A future persistent account registry can replace the derivation without changing cache - * owners again. + * owner. The persistent account registry stores the same identity, so account-scoped state keeps a + * stable owner across process restarts. */ @JvmInline value class NextcloudAccountId internal constructor(val storageKey: String) { From 9fdd9f54b3dc34ec96887cefc128a8f5a9339c25 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sat, 5 Sep 2026 08:25:10 +0200 Subject: [PATCH 21/24] fix(dashboard): install loaded layout before reconciliation --- tools/kotlin-file-size-baseline.txt | 2 +- .../app/DashboardStatusScreens.kt | 5 +- .../app/DashboardWorkspaceLayoutLoadTest.kt | 90 +++++++++++++++++++ 3 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DashboardWorkspaceLayoutLoadTest.kt diff --git a/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index eb446783d..663553149 100644 --- a/tools/kotlin-file-size-baseline.txt +++ b/tools/kotlin-file-size-baseline.txt @@ -8,7 +8,7 @@ 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|1841 +ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt|1838 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 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 fe12f7908..58b5e8d26 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt @@ -288,10 +288,7 @@ internal fun NativeDashboardPresentation( mutableStateOf(false) } var workspacePersistenceError by remember(workspaceLayout.scope) { mutableStateOf(null) } - var activeWorkspaceLayout by remember(workspaceLayout.scope) { mutableStateOf(workspaceLayout) } - LaunchedEffect(workspaceLayout) { - if (workspaceLayout != activeWorkspaceLayout) activeWorkspaceLayout = workspaceLayout - } + var activeWorkspaceLayout by remember(workspaceLayout) { mutableStateOf(workspaceLayout) } val widgetsAuthoritative = (state as? DashboardSurfaceState.Available)?.widgetsAuthoritative != false val workspaceWritesEnabled = widgetsAuthoritative && workspaceLayoutAuthoritative LaunchedEffect(workspaceWritesEnabled) { diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DashboardWorkspaceLayoutLoadTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DashboardWorkspaceLayoutLoadTest.kt new file mode 100644 index 000000000..e2cab4cf2 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DashboardWorkspaceLayoutLoadTest.kt @@ -0,0 +1,90 @@ +package dev.obiente.nextcloudnative.app + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import java.util.concurrent.Executors +import kotlin.test.Test +import kotlin.test.assertTrue +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.yield + +@OptIn(ExperimentalComposeUiApi::class) +class DashboardWorkspaceLayoutLoadTest { + @Test + fun loadedLayoutIsInstalledBeforeWidgetReconciliationCanPersist() { + Executors.newSingleThreadExecutor().asCoroutineDispatcher().use { dispatcher -> + runBlocking(dispatcher) { + val scope = HomeWorkspaceScope("a".repeat(64), HomeFormFactor.Desktop) + var layout by mutableStateOf(defaultHomeWorkspaceLayout(scope)) + var storageAuthoritative by mutableStateOf(false) + val persisted = mutableListOf() + val availableSections = buildList { + add(HomeSectionIds.QuickActions) + addAll( + homeDashboardWidgetBindings(marketingDashboardSnapshot.widgets) + .map(HomeDashboardWidgetBinding::sectionId), + ) + } + val loadedLayout = HomeWorkspaceLayout( + scope = scope, + sections = availableSections.reversed().mapIndexed { index, sectionId -> + HomeWorkspaceSection( + id = sectionId, + visible = index != 1, + size = HomeSectionSize.Compact, + ) + }, + ) + val scene = ImageComposeScene( + width = 1280, + height = 800, + density = Density(1f), + coroutineContext = coroutineContext, + ) { + MaterialTheme { + NativeDashboardPresentation( + state = DashboardSurfaceState.Available(marketingDashboardSnapshot, status = null), + installedApps = emptyList(), + workspaceLayout = layout, + workspaceLayoutAuthoritative = storageAuthoritative, + onWorkspaceLayoutChanged = { updated -> + persisted += updated + true + }, + onOpenApp = {}, + onOpenStatus = null, + onOpenLink = {}, + onBack = null, + onRefresh = {}, + ) + } + } + var frameTime = 0L + suspend fun settle() { + repeat(8) { + frameTime += 100_000_000L + scene.render(frameTime).close() + yield() + } + } + + try { + settle() + layout = loadedLayout + storageAuthoritative = true + settle() + + assertTrue(persisted.isEmpty(), "Loading a current layout must not persist stale defaults: $persisted") + } finally { + scene.close() + } + } + } + } +} From 8ea34482a0b2321010c0c32f4282b0e720306f0b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 06:00:00 +0200 Subject: [PATCH 22/24] 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 663553149..108a1c5d0 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|4245 +androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|4241 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 1fdc8b1389f7c4c6eea098f636572027277af26d Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Sun, 6 Sep 2026 06:37:48 +0200 Subject: [PATCH 23/24] refactor(desktop): compact session restoration --- .../app/DesktopNextcloudServices.kt | 47 +++++++++---------- 1 file changed, 23 insertions(+), 24 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 576f13409..694516d1d 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt @@ -3659,32 +3659,31 @@ class DesktopNextcloudServices( ) } - override fun loadSession(): NextcloudSession? { - return 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) { - 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) - } + 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) { + 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) } } + override suspend fun saveSession(session: NextcloudSession) = withContext(Dispatchers.IO) { sessionPublicationGuard.serialize { val encodedRegistry = prepareDesktopAccountRegistry(session) From 5acce088f077fffd7d7b2a0c1ceb251b47079aec 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:48:21 +0000 Subject: [PATCH 24/24] chore(website): refresh marketing captures --- .../public/screenshots/capture-manifest.json | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 2e0043272..0df95708e 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -202,6 +202,8 @@ "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/NextcloudAccountIdentity.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudChunkUploadPolicy.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudDocumentPreview.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudFileListing.kt", @@ -216,6 +218,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudStatusMessages.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesApi.kt", @@ -285,6 +288,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualFolderRetention.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualStorageHydrationPolling.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualStoragePresentation.kt", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceUpdateBanners.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/AdaptiveShell.kt", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShell.kt", @@ -434,7 +438,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateChannels.kt": "f8aef5ec39978ef0d80ff6cbab00a9a4af34d73c2f3a759eb483a5cadfb8170a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppUpdateSettings.kt": "f41c20a9e0a91d917c746998ffa1e0061af1f7086eb55aedf495eb2604fbc71f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspaceNavigationMemory.kt": "2a48ec4fda7ca47657253891e880a4169b16dbc83c197808532a92169961d569", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt": "e2e9e8f823353180f0aaa7d4a5c4fc824591f0878845cfc6d19a0210645342fc", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt": "91842afe8be45598eca9f23da7012401c53c6571737d1a9b38e82332ce84123c", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePresentation.kt": "e5655f80ca80cdb88ac49a54554e7d4cf684293efb2af4e291f0ef4b34529594", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppsWorkspace.kt": "87b5ee4b871c85d55a8f43f3cb07ee619672def16fade42f819d67c913ac5295", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/CalendarDateTimeFields.kt": "dbd41b992d9ebf24f76e6e66b03c8de23cf76267eca831eee318e5a582231d81", @@ -452,9 +456,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": "4a4408bdaa088f1ac4e24ce55c5269906da84262602b574398c4c61ff71393a2", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatus.kt": "427dd6352a5958a5fd31b9b8ed8cd0f8d1eac1b25800ea33b8bad171125e8f5e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusPresentation.kt": "96eb3aa478be8932e695e2dfe2067cc7b8dccf5ffa6cc1370f2db27b8119e0ff", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt": "318a9181974c1f94d5689d00fa417cc57fa5669f0de8d87e1ad0d5a8f89344af", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DashboardStatusScreens.kt": "cf84b77e3d239161c1c84eae7ae949caeb5206c3f09346e61ead6a3ec99533c1", "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", @@ -482,7 +486,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": "0cd94f93c0b5fcaa21d33a71e43e651eb56c75771a7f0c86a3a3be90f65c256f", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/DynamicNativeMemoryCache.kt": "21a9dbe66b1ad067c10df887bd97034b8d13b6e9dfe14cfced89b02f53575ff4", "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", @@ -535,11 +539,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": "7e2f815391373593077ee1a863f0039e778087a23e158d65cd66a38470a9f221", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareCalendarScreen.kt": "86d8e7b52c78d6008a84fc1ba9c901659f1b657405358c4e5d9f027361373b8a", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactDetailDialog.kt": "ab6826a14ac9fbf62677bed2f6e93ef0fb1a57f1f074bb1a9e79fef212f715b3", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsLoading.kt": "b71a1bf898ad016af4e800d8c59778a844c1310a535ef0faf1f3f180bc0152d2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsScreen.kt": "096a154e9d6169da4cc82c0f87d2c572a1a5e97e396cae9eb739d05ce05b0d82", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareContactsState.kt": "257c413e859fb1fbf1a26b5c372a36288c81779cf2e6dc6620ff4d7456e38cee", + "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", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/GroupwareDavResourceStatus.kt": "816453d49fb983cf4eca6c93335d102570f037a6e064188f89f9b734ec1ebea0", @@ -554,7 +558,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": "a6f1e2919f4b68f2301105f8b7919ca1b62b1810bcc4f550d307ea9f127bb55c", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt": "3c89436210b7c93d5970cf65b97ad5893adf6b12d485ab160831c3946770bfc2", "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", @@ -613,6 +617,8 @@ "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/NextcloudAccountIdentity.kt": "99315e08ee2d9abbdcee0527abd61e614201a2ac81e39c180c34f2ff23480afe", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt": "b95f2863c026b25e545af677720d7f81cf57b1bd4a58bfbf2d99935b7499366b", "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", @@ -620,14 +626,15 @@ "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": "0434daff47c75ae69f0b8a4c0a4e0f32cc5ba4343c0bc16835f5f9acf696653b", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "14d43a632afa7c5bf970d90d1387285624182be00569b57c0955670de017cc6c", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "9223dd455c6a1c35a10769616fceb9dfb2d92ef4d43b0a52c2c29e40f1a196cf", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt": "923cae9286489364664dfbf28bdf7e7c12ce1cc8e3a7d1a1a51a0397950cbcd2", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotes.kt": "b53e2505663f0957dea9ad231b006d03d08f0405dc459974d85eb4768ef03484", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNotesCache.kt": "ac2206703b224364c1a3ff4097026c9c81d856042c20d31e5c28358016c3062d", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPeople.kt": "cff910ea2cc77211ef81779c49ee0c957851f2b4a3ed32b857b12ded1cee643b", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPhotoEditor.kt": "34d9b43cf3bbfc8342958dc40f2df7b4573f30a2ae1bd9bcb8bb470151313a3d", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "0d86a2eb73cdd0ea3e0990936f456a5590e052fcab02e4f290e4df5facfff920", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudPlatform.kt": "873201e412de895571b4982ec1afe029afe348950afd7c1b2491904f015f2068", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudResponseLimits.kt": "fead6cbf4f723ea46f99c9155b190967205d24f198687b7bfd5a213c474374b8", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "0abcfe2da22b8e49f6ee292d8b340cc36dafd2cf0658cbb8ca847481a351fe19", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSession.kt": "792a381fd5eefc44e13eb73ee95a80f8d52e8dcec9d3876ba06ec5392b5c1f81", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudSessionLoading.kt": "c96941c7582218754243f780a0bb0954ba31484719d8045f1e609f5d8c04a7c5", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudStatusMessages.kt": "03d7d79e38bdd9ac2e6a90e7172d2e9b7ea361df4d3e3d1a75d64584adc590ec", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesApi.kt": "e3c685b02592ea791b0c9a478a098fc5bf428893aa4764110c793a78040f341e", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NotesFolderOperations.kt": "db6a04cfcd3b25b17c996cdc6dba21847d14f5970e06e6c24a3e2c85139dbe1d", @@ -659,7 +666,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": "19d2205dc43f8a245f5fb9cfc3e65cf55430f13fb091eb9d6812d4d05946180f", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/PreviewMemoryCache.kt": "a30e7fc55f40b13883d2ceb56de0ca72b67e8fb1489d2d261eb66b0a05daef48", "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", @@ -667,7 +674,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RecognizedFaceRemovalPicker.kt": "790361162a60502f2db49218bbb524cdeb9a43403281e9a8ae25ef1354275558", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RecognizedFaceSelection.kt": "a41835093c50db3b4c8414525dafe7542aa71aef295b480f352c7d5afc7abc24", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPicker.kt": "e78c0dccb27918465f2d9d72e555e07c7996a06721f3afef80911b7392b6f746", - "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt": "6c1cb2aef1762b459e23691e8babb2f07c2bac9dfb334d09919b61cecb89d089", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RemoteFolderPickerOperations.kt": "d01f7fe50933f3d3a7303a04788e8c3c839e461bf6cf551592c92ce7a8d35201", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/RetainedContentNotice.kt": "77bcf477e60d7e3022abea07ada109c92e57b4a3c2dd57e61de7bdf53d242b4f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/ServerCertificateReviewDialog.kt": "f7c4d2489596788486893ed746eb762060b5080f64dae5659eca8b48feabf0ea", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/SettingsSectionContent.kt": "3ce0895751bc569843ffabfcdae6ca11c68b4c700a954cb5e1370391cd87e472", @@ -696,6 +703,7 @@ "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualFolderRetention.kt": "598fa756e16d25552d783db55c144008d87289521821fc962544fdb29db496f0", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualStorageHydrationPolling.kt": "2c2ce4e17038ab8f0262d95f1671e0daec8d4567b848e1fc17c1a7e7e21d93e6", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/VirtualStoragePresentation.kt": "bbd155bcf900a8d5470c974a1e3a5733de1fefe6bd320d449d566f6ce7b9b627", + "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt": "d6bbaa8758978f97941617fb525bbbd49622c01361d85a5a0015ab6f3b5077fd", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceUpdateBanners.kt": "0404109434d93ebd1bf801d2d7a95abc9f69b7a471ec55cc7197e6702997a978", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/AdaptiveShell.kt": "f8005f52e2d83106bb78a4230539c3dd5bd80a1d6db24a850c8b69e03df4687f", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/DesktopShell.kt": "bf3018bb72e652dd86c39f6a7a855032b7b002ec3059b66a8300c7044b5df91e",