Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
de92b09
feat(accounts): add account identity registry foundation
veryCrunchy Sep 1, 2026
c72ccc0
chore(changelog): link pull request
veryCrunchy Sep 1, 2026
f8cce0b
fix(accounts): harden registry persistence and migration
veryCrunchy Sep 1, 2026
c732b2d
fix(accounts): bound registry and layout recovery
veryCrunchy Sep 1, 2026
8d8ab82
test(accounts): expose registry capacity to tests
veryCrunchy Sep 1, 2026
c1ddf79
fix(accounts): preserve registry recovery state
veryCrunchy Sep 4, 2026
799f5c1
fix(accounts): validate persisted identity boundaries
veryCrunchy Sep 4, 2026
1a2f05f
fix(workspaces): defer legacy preference migration
veryCrunchy Sep 4, 2026
bf1ab52
fix(auth): defer and bound account migrations
veryCrunchy Sep 4, 2026
953ed32
fix(auth): reject blank login approvals
veryCrunchy Sep 4, 2026
ef2f9a6
refactor(auth): split session model from platform contracts
veryCrunchy Sep 4, 2026
a6ca356
fix(workspaces): defer pinned app preference reads
veryCrunchy Sep 4, 2026
5a86654
fix(workspaces): make legacy pin promotion conditional
veryCrunchy Sep 4, 2026
2921cb1
fix(accounts): preserve arbitrary future registry versions
veryCrunchy Sep 4, 2026
b7cbf44
fix(workspaces): serialize desktop preference promotion
veryCrunchy Sep 4, 2026
1bfc06a
fix(workspaces): preserve migration authority
veryCrunchy Sep 4, 2026
daa7bfa
fix(accounts): verify Android registry migration
veryCrunchy Sep 4, 2026
fc74247
fix(workspaces): defer home layout storage reads
veryCrunchy Sep 4, 2026
bd8c2ed
chore: refresh account stack validation
veryCrunchy Sep 4, 2026
8c72720
docs(accounts): clarify persistent identity ownership
veryCrunchy Sep 5, 2026
9fdd9f5
fix(dashboard): install loaded layout before reconciliation
veryCrunchy Sep 5, 2026
8ea3448
chore(architecture): lower Android service baseline
veryCrunchy Sep 6, 2026
1fdc8b1
refactor(desktop): compact session restoration
veryCrunchy Sep 6, 2026
5acce08
chore(website): refresh marketing captures
obiente-automations[bot] Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -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<SupportDiagnosticEventDraft>()
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<SupportDiagnosticEventDraft>()
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<SupportDiagnosticEventDraft>()
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<SupportDiagnosticEventDraft>()

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<SupportDiagnosticEventDraft>()

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"
}
}
7 changes: 7 additions & 0 deletions changes/unreleased/172-account-identity-foundation.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions tools/kotlin-file-size-baseline.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidFileSyncEngine.kt|851
androidApp/src/main/kotlin/dev/obiente/nextcloudnative/AndroidNextcloudServices.kt|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
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
}
Expand All @@ -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()
Loading