diff --git a/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt index 54a3b4d5e..a78d3032e 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)).commit() + }, + 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..e577ad0ca --- /dev/null +++ b/androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSession.kt @@ -0,0 +1,72 @@ +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 dev.obiente.nextcloudnative.app.toNonSecretSupportDiagnosticExceptionDraft +import org.json.JSONObject + +internal fun restoreAndroidPersistedSession( + encoded: String, + persistMigrated: (String) -> Boolean, + 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 { + val migrated = json + .put(KEY_ACCOUNT_REGISTRY, encodeNextcloudAccountRegistry(restored.registry)) + .toString() + check(persistMigrated(migrated)) { + "Could not persist the migrated account registry." + } + }.onFailure { failure -> + recordDiagnostic( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Authentication, + operation = "account-registry.migrate", + outcome = "failed", + code = "ACCOUNT_REGISTRY_MIGRATION_FAILED", + exception = failure.toNonSecretSupportDiagnosticExceptionDraft(), + ), + ) + } + } + return session +} + +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..57c5e0b72 --- /dev/null +++ b/androidApp/src/test/kotlin/dev/obiente/nextcloudnative/AndroidPersistedSessionTest.kt @@ -0,0 +1,164 @@ +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.assertNull +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 + true + }, + 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 + 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 + true + }, + 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 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 + 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( + encoded = legacyPayload(), + persistMigrated = { true }, + recordDiagnostic = {}, + ) + + val payload = JSONObject(encodeAndroidPersistedSession(session)) + val encodedRegistry = payload.getString(ACCOUNT_REGISTRY_KEY) + + assertEquals("private-app-password", payload.getString("appPassword")) + assertFalse(encodedRegistry.contains("private-app-password")) + assertFalse(encodedRegistry.contains("appPassword")) + } + + @Test + fun migrationFailureAttachesABoundedCauseWithoutPrivateValues() { + 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")) + } + + @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") + .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..da40afafd --- /dev/null +++ b/changes/unreleased/172-account-identity-foundation.md @@ -0,0 +1,7 @@ +category: internal +issue: 172 +pull: 429 +platforms: android, desktop +user-facing: no + +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/tools/kotlin-file-size-baseline.txt b/tools/kotlin-file-size-baseline.txt index a1f954c3b..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 @@ -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 @@ -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/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/AppWorkspacePins.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePins.kt index 1753943e1..6c8c9a405 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,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 @@ -14,44 +18,119 @@ internal data class AppWorkspacePinsSnapshot( internal data class AppWorkspacePinsLoad( val appIds: List, val storageAuthoritative: Boolean, + 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, ) { - 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 { - val read = runCatching { storage.read(persistenceKey(accountScopeDigest)) } - if (read.isFailure) { + fun loadWithProvenance( + accountScopeDigest: String, + legacyAccountScopeDigest: String? = null, + ): AppWorkspacePinsLoad { + val encoded = try { + storage.read(persistenceKey(accountScopeDigest)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { return AppWorkspacePinsLoad(defaultAppWorkspacePinnedIds(), storageAuthoritative = false) } - val encoded = read.getOrNull() + 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) + }?.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() return AppWorkspacePinsLoad( - validatedAppWorkspacePinnedIds(snapshot.appIds) ?: defaultAppWorkspacePinnedIds(), - storageAuthoritative = true, + 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 + } + } + + /** Promotes legacy pins only while the canonical account key is still absent. */ + 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) + if (storage.writeIfAbsent(persistenceKey(accountScopeDigest), encoded)) { + PersistencePromotionResult.Saved + } else { + PersistencePromotionResult.CanonicalAlreadyPresent + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + 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) + } } private fun persistenceKey(accountScopeDigest: String): String { 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..58b5e8d26 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 { @@ -233,21 +233,21 @@ internal fun NativeDashboardScreen( val workspaceRepository = remember(workspaceStorage) { HomeWorkspaceLayoutRepository(workspaceStorage) } - val workspaceScope = remember(session.serverUrl, session.loginName, formFactor) { - HomeWorkspaceScope( - accountScopeDigest = previewCacheDigest(session), - formFactor = formFactor, - ) - } - var workspaceLayout by remember(workspaceScope) { - mutableStateOf(workspaceRepository.load(workspaceScope)) + val workspacePersistenceScopes = remember(session) { accountPersistenceScopeDigests(session) } + val workspaceScope = remember(workspacePersistenceScopes.current, formFactor) { + HomeWorkspaceScope(workspacePersistenceScopes.current, formFactor) } + 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) @@ -274,6 +274,7 @@ internal fun NativeDashboardPresentation( installedApps: List, pinnedAppIds: List = defaultAppWorkspacePinnedIds(), workspaceLayout: HomeWorkspaceLayout, + workspaceLayoutAuthoritative: Boolean = true, onWorkspaceLayoutChanged: (HomeWorkspaceLayout) -> Boolean, onOpenApp: (NextcloudAppEntry) -> Unit, onOpenStatus: (() -> Unit)?, @@ -287,18 +288,15 @@ 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 - LaunchedEffect(widgetsAuthoritative) { - if (!widgetsAuthoritative) { + val workspaceWritesEnabled = widgetsAuthoritative && workspaceLayoutAuthoritative + LaunchedEffect(workspaceWritesEnabled) { + if (!workspaceWritesEnabled) { customizeWorkspace = false workspacePersistenceError = null } } - Column(modifier = Modifier.fillMaxSize()) { DashboardHeader( title = "Home", @@ -309,7 +307,7 @@ internal fun NativeDashboardPresentation( }, onBack = onBack, onRefresh = onRefresh, - onCustomize = if (widgetsAuthoritative) { + onCustomize = if (workspaceWritesEnabled) { { customizeWorkspace = true } } else { null @@ -338,8 +336,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) } @@ -364,7 +362,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 { @@ -1410,7 +1408,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 +1422,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/HomeWorkspacePersistence.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/HomeWorkspacePersistence.kt index bac3b5cf9..dfa532a84 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,10 @@ 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 @@ -9,6 +13,37 @@ 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( + val layout: HomeWorkspaceLayout, + val storageAuthoritative: Boolean = true, + val legacyMigrationRequired: Boolean = false, +) + +internal enum class PersistencePromotionResult { + Saved, + CanonicalAlreadyPresent, + 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( @@ -16,10 +51,45 @@ internal class HomeWorkspaceLayoutRepository( private val encodeSnapshot: (HomeWorkspaceLayout) -> String = ::encodeHomeWorkspaceLayoutSnapshot, ) { - fun load(scope: HomeWorkspaceScope): HomeWorkspaceLayout { - val encoded = runCatching { storage.read(scope.persistenceKey) }.getOrNull() - ?: return defaultHomeWorkspaceLayout(scope) - return decodeHomeWorkspaceLayoutSnapshot(scope, encoded) + 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 HomeWorkspaceLayoutLoad( + defaultHomeWorkspaceLayout(scope), + storageAuthoritative = false, + ) + } + if (encoded != null) return HomeWorkspaceLayoutLoad(decodeHomeWorkspaceLayoutSnapshot(scope, encoded)) + val legacyScope = legacyAccountScopeDigest?.let { digest -> + HomeWorkspaceScope(digest, scope.formFactor) + } ?: return HomeWorkspaceLayoutLoad(defaultHomeWorkspaceLayout(scope)) + val legacyEncoded = try { + storage.read(legacyScope.persistenceKey) + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + return HomeWorkspaceLayoutLoad( + defaultHomeWorkspaceLayout(scope), + storageAuthoritative = false, + ) + } ?: return HomeWorkspaceLayoutLoad(defaultHomeWorkspaceLayout(scope)) + val legacyLayout = decodeHomeWorkspaceLayoutSnapshot(legacyScope, legacyEncoded) + val migrated = HomeWorkspaceLayout(scope, legacyLayout.sections) + return HomeWorkspaceLayoutLoad( + layout = migrated, + storageAuthoritative = false, + legacyMigrationRequired = true, + ) } /** @@ -27,10 +97,46 @@ 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 + } + } + + /** Promotes a legacy layout only while the canonical key is still absent. */ + fun promoteIfAbsent(layout: HomeWorkspaceLayout): PersistencePromotionResult { + return try { + val encoded = encodeSnapshot(layout) + if (storage.writeIfAbsent(layout.scope.persistenceKey, encoded)) { + PersistencePromotionResult.Saved + } else { + PersistencePromotionResult.CanonicalAlreadyPresent + } + } catch (failure: CancellationException) { + throw failure + } catch (_: Exception) { + 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/NextcloudAccountIdentity.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentity.kt new file mode 100644 index 000000000..dae9752fb --- /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. 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) { + 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..e9c6a28ed --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistry.kt @@ -0,0 +1,294 @@ +package dev.obiente.nextcloudnative.app + +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** 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"), + UnsupportedRegistryVersion("ACCOUNT_REGISTRY_VERSION_UNSUPPORTED"), + 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 && + recoveryReason != NextcloudAccountRegistryRecoveryReason.UnsupportedRegistryVersion +} + +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 decoded = encoded?.let(::decodeNextcloudAccountRegistryResult) + val persisted = (decoded as? NextcloudAccountRegistryDecodeResult.Valid)?.registry + 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.reconcileLegacyActiveAccount(legacyAccount), + source = NextcloudAccountRegistrySource.LegacySession, + 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), + source = NextcloudAccountRegistrySource.LegacySession, + recoveryReason = encoded?.let { NextcloudAccountRegistryRecoveryReason.MalformedRegistry }, + ) + } + return RestoredNextcloudAccountRegistry( + registry = NextcloudAccountRegistry.Empty, + source = NextcloudAccountRegistrySource.Empty, + recoveryReason = encoded?.let { NextcloudAccountRegistryRecoveryReason.MalformedRegistry }, + ) +} + +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 = accounts + .filterNot { account -> account.id == activeAccountId } + .maxBy { account -> account.id.storageKey } + .id + return remove(displacedId).upsertAndSelect(legacyAccount) +} + +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? = + (decodeNextcloudAccountRegistryResult(encoded) as? NextcloudAccountRegistryDecodeResult.Valid)?.registry + +private fun decodeNextcloudAccountRegistryResult(encoded: String): NextcloudAccountRegistryDecodeResult { + val envelopeVersionToken = accountRegistryVersionEnvelope + .find(encoded.take(MAX_ACCOUNT_REGISTRY_VERSION_ENVELOPE_CHARACTERS)) + ?.groupValues + ?.get(1) + val envelopeVersion = envelopeVersionToken?.let(::classifyAccountRegistryVersion) + if (envelopeVersion == AccountRegistryVersionClassification.Unsupported) { + return NextcloudAccountRegistryDecodeResult.UnsupportedVersion + } + if (encoded.encodeToByteArray().size > MAX_ACCOUNT_REGISTRY_BYTES) { + return if (envelopeVersionToken != null) { + NextcloudAccountRegistryDecodeResult.Malformed + } else { + NextcloudAccountRegistryDecodeResult.UnsupportedVersion + } + } + val versionToken = runCatching { + accountRegistryJson.parseToJsonElement(encoded).jsonObject["version"] + ?.jsonPrimitive + ?.takeUnless { version -> version.isString } + ?.content + }.getOrNull() ?: 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( + accounts = persisted.accounts.map { account -> + NextcloudAccountRecord( + id = NextcloudAccountId(account.id), + serverUrl = account.serverUrl, + loginName = account.loginName, + ) + }, + activeAccountId = persisted.activeAccountId?.let(::NextcloudAccountId), + ) + }.fold( + onSuccess = NextcloudAccountRegistryDecodeResult::Valid, + onFailure = { NextcloudAccountRegistryDecodeResult.Malformed }, + ) +} + +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 + + data object Malformed : NextcloudAccountRegistryDecodeResult + + data object UnsupportedVersion : NextcloudAccountRegistryDecodeResult +} + +@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 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 +private const val MAX_ACCOUNT_REGISTRY_BYTES = 256 * 1024 +private const val MAX_ACCOUNT_REGISTRY_VERSION_ENVELOPE_CHARACTERS = 512 +internal const val MAX_ACCOUNT_SERVER_URL_LENGTH = 8 * 1024 +internal const val MAX_ACCOUNT_LOGIN_NAME_LENGTH = 1024 diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index be3910e7f..31c7202dc 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, @@ -1258,14 +1257,13 @@ 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) - } - var pinnedAppIds by remember(appPinsAccountScope) { mutableStateOf(loadedAppPins.appIds) } - var appPinsStorageAuthoritative by remember(appPinsAccountScope) { - mutableStateOf(loadedAppPins.storageAuthoritative) - } + val appPinsPersistenceScopes = remember(session) { accountPersistenceScopeDigests(session) } + val appPinsAccountScope = appPinsPersistenceScopes.current + 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() @@ -7118,7 +7117,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 +7131,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 +11888,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..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,12 +46,6 @@ data class PlatformCapabilityStatus( val state: PlatformCapabilityState, ) -data class NextcloudSession( - val serverUrl: String, - val loginName: String, - val appPassword: String, -) - 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=)" +} 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/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/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/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..a4a0cd4aa --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/WorkspaceLegacyMigrationEffects.kt @@ -0,0 +1,82 @@ +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 + +internal data class AppWorkspacePinsCompositionState( + val appIds: MutableState>, + val storageAuthoritative: MutableState, + val loadComplete: Boolean, +) + +@Composable +internal fun rememberAppWorkspacePinsCompositionState( + repository: AppWorkspacePinsRepository, + accountScopeDigest: String, + 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 initial = coordinator.load() + val result = if (initial.legacyMigrationRequired) { + withContext(Dispatchers.Default) { + repository.resolveLegacyMigration(accountScopeDigest, initial) + } + } else { + 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 rememberMigratedHomeWorkspaceLayoutState( + repository: HomeWorkspaceLayoutRepository, + scope: HomeWorkspaceScope, + legacyAccountScopeDigest: String?, +): HomeWorkspaceLayoutCompositionState { + val coordinator = remember(repository, scope, legacyAccountScopeDigest) { + HomeWorkspaceLayoutLoadCoordinator { + repository.loadWithMigration(scope, legacyAccountScopeDigest) + } + } + val layout = remember(scope, legacyAccountScopeDigest) { + mutableStateOf(defaultHomeWorkspaceLayout(scope)) + } + val authoritative = remember(scope, legacyAccountScopeDigest) { + mutableStateOf(false) + } + LaunchedEffect(coordinator) { + val initial = coordinator.load() + val result = if (initial.legacyMigrationRequired) { + withContext(Dispatchers.Default) { + repository.resolveLegacyMigration(initial) + } + } 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/AppWorkspacePinsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt index bd2db7470..8daaba9a9 100644 --- a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/AppWorkspacePinsTest.kt @@ -1,12 +1,33 @@ package dev.obiente.nextcloudnative.app +import kotlinx.coroutines.CancellationException import kotlin.test.Test 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() @@ -21,6 +42,72 @@ class AppWorkspacePinsTest { assertTrue(storage.values.keys.single().endsWith(firstAccount)) } + @Test + fun `legacy account key returns a migration plan without writing during 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) + assertFalse(loaded.storageAuthoritative) + assertTrue(loaded.legacyMigrationRequired) + assertEquals(defaultAppWorkspacePinnedIds(), repository.load(current)) + 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 `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) + 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")) @@ -93,8 +180,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 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..4b640519f 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,9 @@ 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 @@ -11,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)) @@ -288,6 +310,115 @@ class HomeWorkspaceLayoutTest { ) } + @Test + 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') + 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.loadWithMigration(currentScope, legacyScope.accountScopeDigest) + + 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)) + assertEquals(loaded.layout, repository.load(currentScope)) + 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)) + + val resolved = repository.resolveLegacyMigration(loaded) + + assertEquals(newer, resolved.layout) + assertTrue(resolved.storageAuthoritative) + assertFalse(resolved.legacyMigrationRequired) + } + + @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 `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 `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() @@ -342,13 +473,28 @@ class HomeWorkspaceLayoutTest { private val values = mutableMapOf() var lastKey: String? = null var lastValue: String? = null - - override fun read(persistenceKey: String): String? = values[persistenceKey] + 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] + } override fun write(persistenceKey: String, encodedSnapshot: String) { lastKey = persistenceKey lastValue = encodedSnapshot 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/NextcloudAccountIdentityTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt new file mode 100644 index 000000000..ab5a1ceda --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountIdentityTest.kt @@ -0,0 +1,123 @@ +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 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() + + 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..cd79c165a --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/NextcloudAccountRegistryTest.kt @@ -0,0 +1,295 @@ +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 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 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") + 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 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") + 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") + + 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") + 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 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), + ) + + 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) + assertTrue(staleActive in restored.registry.accounts) + assertFalse(displaced 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") + 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/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/DesktopAccountRegistryPersistence.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt new file mode 100644 index 000000000..24d20eb4c --- /dev/null +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistence.kt @@ -0,0 +1,63 @@ +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 { + persistDesktopAccountRegistry(preferences, prepareDesktopAccountRegistry(restored.registry)) + }.onFailure { failure -> + recordDiagnostic( + SupportDiagnosticEventDraft( + severity = SupportDiagnosticSeverity.Warning, + component = SupportDiagnosticComponent.Authentication, + operation = "account-registry.migrate", + outcome = "failed", + code = "ACCOUNT_REGISTRY_MIGRATION_FAILED", + exception = failure.toNonSecretSupportDiagnosticExceptionDraft(), + ), + ) + } + } +} + +internal fun persistDesktopAccountRegistry(preferences: Preferences, session: NextcloudSession) { + 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) { + 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/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/DesktopNextcloudServices.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopNextcloudServices.kt index c3fec8ece..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,34 +3659,34 @@ 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 -> - 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) listOf(session.serverUrl, session.loginName, session.appPassword) .forEach(supportDiagnostics::registerPrivateValue) try { @@ -3712,6 +3712,7 @@ class DesktopNextcloudServices( ) throw failure } + persistDesktopAccountRegistry(preferences, encodedRegistry) preferences.put(KEY_SERVER, session.serverUrl) preferences.put(KEY_LOGIN, session.loginName) val accountIdentity = desktopFileCacheAccountId(session) @@ -3721,7 +3722,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 +3854,7 @@ class DesktopNextcloudServices( sessionPublicationGuard.serialize { preferences.remove(KEY_SERVER) preferences.remove(KEY_LOGIN) + clearDesktopAccountRegistry(preferences) supportDiagnostics.setActiveAccountIdentity(null) supportIntake.setActiveAccountIdentity(null) } @@ -3865,7 +3866,6 @@ class DesktopNextcloudServices( } } } - override suspend fun loadDeckCardDraft( session: NextcloudSession, key: DeckCardDraftKey, 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..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,15 +7,7 @@ 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) { - preferences.put(persistenceKey, encodedSnapshot) - preferences.flush() - } - } + DesktopHomeWorkspaceLayoutStorage(preferences, desktopHomeWorkspaceLockFile()) } @Composable 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() + } + } + } + } +} 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..ff547f349 --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopAccountRegistryPersistenceTest.kt @@ -0,0 +1,129 @@ +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.assertFailsWith +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 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() + + 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/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() + } + } +} 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..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,57 @@ 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( + 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 `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 0ddcc74af..4d689f70f 100644 --- a/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt +++ b/ui/src/jvmMain/kotlin/dev/obiente/nextcloudnative/app/JvmLoginFlowHttpPolicy.kt @@ -82,7 +82,9 @@ fun interpretLoginPollHttpResponse( val resultServerUrl = normalizeServerUrl(json.getString("server"), challenge.transportSecurity) val loginName = json.getString("loginName") val appPassword = json.getString("appPassword") - require(loginName.isNotEmpty()) { "The login name is empty." } + require(resultServerUrl.length <= MAX_ACCOUNT_SERVER_URL_LENGTH) { "The server URL is too long." } + 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( result = LoginPollResult.Approved(NextcloudSession(resultServerUrl, loginName, appPassword)), 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 49447cfd9..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 @@ -1,9 +1,29 @@ package dev.obiente.nextcloudnative.app import java.security.MessageDigest +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -internal actual fun previewCacheDigest(session: NextcloudSession): String { +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 + +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." } + 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('/') +} 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",